Files
chakcy f4de43c743 初始提交:chongming-task 任务控制库
单任务受控执行(15 态状态机 + 审计轨迹)、DAG 管道、Controller 门面。
执行器三个:线程(默认,含 async def 任务)、内联(同步 / 测试)、进程
(spawn 子进程:硬性停止、绕开 GIL、崩溃隔离)。
调度:并发额度 + 每桶限流 + 具名资源信号量(按标签)+ 优先级排队。
持久层:TaskStore 契约 + 内存 / SQLite / Postgres 实现,契约测试随包发布。
跨进程与多节点:写穿 / 读穿、指令通道与租约、owned_buckets 划界、reconcile。
CLI、12 个可直接运行的示例、docs/ 下 5 份文档。

门禁:ruff / ruff format / mypy(strict) 干净;
      pytest 563 passed(44 skipped 是需要真实 Postgres 的那批);
      带一台可连的 Postgres 时 607 passed。
2026-10-08 23:47:50 +08:00

181 lines
7.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""示例 9:无限循环任务的**启与停**。
一个"永远跑下去"的任务该怎么写、怎么启动、怎么停,以及三种收尾方式的终态差异。
运行::
uv run python examples/09_long_running.py
## 三条规则
1. **任务体每轮必须调 ``ctx.checkpoint()``。** 停止是**协作式**的:只有调用了它,任务
才会看到停止请求。不调用 = 停不掉(见文件最后的对照)。
2. **停止请求有两种收尾方式,终态不同**:
================================================ =========== ==========
写法 终态 返回值
================================================ =========== ==========
``while True: ctx.checkpoint()``(让它抛) ``stopped`` 无
检查 ``ctx.is_stop_requested`` 后正常 ``return`` ``succeeded`` ✅ 有
完全不调 ``checkpoint()`` ``lost`` 无
================================================ =========== ==========
"停止"在第一种写法里是"立刻中止",在第二种里是"**请收工**"——后者会把已经处理好的
结果留下来,对"跑到下一个安全点再退出"这类需求更合适。
3. **停止是终态。** 目前没有 ``retry``,停止/失败之后想再跑要换一个 ``task_id``
重新提交(``create`` 会拒绝重复 id)。所以日常"先停一下"应该用 ``pause`` / ``resume``,
``stop`` 留给"我要它彻底结束"。
"""
from __future__ import annotations
import sys
import threading
import time
from typing import Any
from chongming_task import Controller, InMemoryTaskStore, render_table
def worker(ctx: Any, source: str) -> int:
"""无限循环:持续处理,直到收到停止请求后**优雅收尾**。"""
processed = 0
while not ctx.is_stop_requested:
ctx.checkpoint()
processed += 1
ctx.progress(processed, unit="batch", message=f"{source} 第 {processed} 批")
if processed % 20 == 0:
ctx.log(f"已处理 {processed} 批")
time.sleep(0.02)
# 走到这里说明收到停止请求了:做收尾,然后正常返回——
# 返回值会被保留下来,终态是 succeeded 而不是 stopped。
ctx.log(f"收到停止请求,收尾:本次共处理 {processed} 批")
return processed
def main() -> None:
store = InMemoryTaskStore()
controller = Controller(store=store, owner="node-a")
controller.register("worker", worker)
# ---- 启动 ----
run = controller.submit("worker", "kafka://orders", task_id="orders-worker")
print(f"已启动 {run.snapshot.task_id} 状态={run.status.value}")
time.sleep(0.5)
print("\n--- 运行中 ---")
print(render_table(controller.list_tasks()))
# ---- 日常先停一下:pause / resume(不是 stop) ----
print("\n--- pause(就地挂起,仍占用执行槽)---")
print(f" pause 是否确认: {controller.pause('orders-worker', timeout=5)}")
print(f" 状态: {run.status.value}")
print("\n--- resume ---")
controller.resume("orders-worker")
time.sleep(0.2)
print(f" 状态: {run.status.value}")
# ---- 让它彻底结束:stop ----
print("\n--- stop(终态,不可逆)---")
controller.stop("orders-worker", reason="发布窗口")
outcome = controller.wait("orders-worker", timeout=10)
print(f" 终态: {outcome.status.value}")
print(f" 返回值: {outcome.value} ← 优雅收尾把已处理的数量带了出来")
print("\n--- 结束后 ---")
print(render_table(controller.list_tasks()))
print("\n--- 审计轨迹(谁按的、为什么,都在这里)---")
for record in store.history("orders-worker"):
reason = f" 原因={record.reason}" if record.reason else ""
print(
f" {record.version:>2} {record.from_status.value:>9} -> "
f"{record.to_status.value:<9} {record.trigger}{reason}"
)
# ---- 停止是终态:同一个 task_id 不能复活 ----
print("\n--- 想再跑一次?同一个 task_id 会被拒绝 ---")
try:
controller.submit("worker", "kafka://orders", task_id="orders-worker")
except Exception as exc:
print(f" {type(exc).__name__}: {exc}")
print(" 换一个 task_id 重新提交即可(或让进程一直活着,用 pause/resume 做日常启停)。")
controller.close(timeout=5)
_contrast_uncooperative()
_print_cli_usage()
def _contrast_uncooperative() -> None:
"""对照:不调 ``checkpoint()`` 的任务**停不掉**。
这不是缺陷,是诚实:线程无法被强杀,所以系统只能如实报 ``lost``("结果未知"),
而任务体仍在后台继续跑。真正终止它只能靠结束进程。
"""
print("\n" + "=" * 70)
print("对照:一个从不调 checkpoint() 的无限任务")
print("=" * 70)
release = threading.Event()
ticks = {"n": 0}
def uncooperative(ctx: Any) -> int:
while not release.is_set():
ticks["n"] += 1
time.sleep(0.02)
return ticks["n"]
controller = Controller(store=InMemoryTaskStore(), graceful_stop=0.5)
controller.register("stubborn", uncooperative)
controller.submit("stubborn", task_id="stubborn")
time.sleep(0.3)
print(" 停止请求已发出,等待载体响应……")
controller.stop("stubborn")
outcome = controller.wait("stubborn", timeout=8)
print(f" 终态: {outcome.status.value} ← 不是 stopped,而是 lost(结果未知)")
print(f" 失败类型: {outcome.failure.kind.value if outcome.failure else None}")
before = ticks["n"]
time.sleep(0.4)
alive = [t.name for t in threading.enumerate() if t.is_alive() and t.name.startswith("task-")]
print(f" 任务体的线程还在吗: {alive}")
print(f" 它还在继续干活吗: {ticks['n'] > before}({before} -> {ticks['n']})")
print(" → 线程无法被强杀,只能靠结束进程来真正终止它。")
release.set()
controller.close(timeout=3)
def _print_cli_usage() -> None:
"""运维视角:命令行怎么启、怎么停。"""
print("\n" + "=" * 70)
print("命令行(另一个进程,通过共享存储启停)")
print("=" * 70)
print(
"""
# 启动:前台执行,交给 systemd / nohup 保持存活
# 注意 --bucket-key:限定了 --owned-buckets 时它是**必需**的,否则任务没有归属桶、
# 不会有任何节点认领它(CLI 会在提交时报错)。
chongming-task --db /var/lib/tasks.db --num-buckets 64 --owned-buckets 0-15 \\
run --tasks myapp.tasks worker --arg source=kafka://orders \\
--task-id orders-worker --bucket-key orders
# 另一个终端:看状态、停止
chongming-task --db /var/lib/tasks.db --owned-buckets 0-15 ps --all --mine
chongming-task --db /var/lib/tasks.db --owned-buckets 0-15 status orders-worker
chongming-task --db /var/lib/tasks.db --owned-buckets 0-15 stop orders-worker --reason 发布窗口
# Ctrl-C 也可以:main() 会走 finally 里的 controller.close(),把在跑的任务停干净
""".rstrip()
)
print(" 注意:`run` 是前台进程——它退出时任务就没人管了,请交给进程管理器。")
sys.stdout.write("")
if __name__ == "__main__":
main()