# chongming-task 精细化状态控制的 Python 管道任务控制工具。 任务在这里不是一个"跑着 / 没跑"的布尔值,而是一条**可审计的状态轨迹**:每个中间态 (排队、启动、运行、暂停中、重试等待、停止中)都能被表达、被观察、被干预,而且 **结果永远如实上报**。 --- ## 当前状态(务必先读) 这是一个**正在实现中的库**。已经能用的部分与还没做的部分如下: | 能力 | 状态 | |---|---| | 任务状态模型与合法转移表(15 个状态) | ✅ 可用 | | 单任务受控执行(`run_task` / `TaskRun`) | ✅ 可用 | | 停止 / 暂停 / 恢复 | ✅ 可用(协作式) | | 失败重试与退避 | ✅ 可用 | | 三种超时(启动 / 单次尝试 / 静默) | ✅ 可用 | | 线程执行器(含 `async def` 任务) | ✅ 可用 | | 内联执行器(同步、零开销) | ✅ 可用 | | 进程执行器(硬性停止 / 绕开 GIL / 崩溃隔离) | ✅ 可用 | | 状态迁移历史(可审计) | ✅ 可用 | | 任务注册表 + 统一管理门面(`Controller`) | ✅ 可用 | | 并发额度与排队、具名资源信号量(按标签)、优先级、文本表格渲染 | ✅ 可用 | | 任务分桶:稳定哈希 + 每桶独立限流 + 按桶查询(为分布式预留分配单位) | ✅ 可用 | | 节点桶归属:`owned_buckets` 划界 + 提交校验 + `reconcile` 范围 | ✅ 可用 | | 持久层契约 `TaskStore`(内存 / SQLite / Postgres 实现 + 契约测试套件) | ✅ 可用 | | 跨机持久层 `PostgresTaskStore`(`SKIP LOCKED` 认领 + 连接池 + 跨进程并发用例) | ✅ 可用 | | 存储接线:写穿 / 读穿 / 跨进程查询与控制 | ✅ 可用 | | 重启恢复:监督者租约 + `reconcile()` 清理僵尸任务 | ✅ 可用 | | DAG 管道:依赖、扇出/扇入、三种汇合语义、fail_fast 连坐 | ✅ 可用 | | `@task` 装饰器、`TaskContext`(进度 / 日志 / 上游数据) | ✅ 可用 | | 存储与 `Controller` 接线、跨进程查询、重启恢复 | ✅ 全部可用 | | CLI(`run` / `ps` / `status` / `stop` / `pause` / `resume` / `stats` / `buckets`) | ✅ 可用 | > **默认线程执行器,另备进程执行器**(外加测试用的内联执行器)。 > > 线程共享内存,可以直接复用连接池、缓存、已加载的模型,也没有参数必须可 pickle 的 > 约束——闭包、`lambda`、`@task` 装饰器产物都能用。代价是**强制停止做不到**:优雅 > 窗口用尽后只能放弃等待并记 `lost`(结果未知),绝不谎报"已停止"。 > > 需要**硬性停止保证、绕开 GIL、或崩溃隔离**时用 `ProcessExecutor()`——任务跑在独立 > 子进程里,优雅窗口用尽会 `terminate()` → `kill()`,段错误也只带走子进程。代价是 > 三条结构性约束:参数与返回值必须可 pickle、目标函数必须在可导入的模块里(且调用方 > 脚本要有 `if __name__ == "__main__":` 守卫)、停止与暂停有约 20ms 的轮询延迟。 > 详见[用户指南 §7](docs/user-guide.md#7-执行器)。 > > 两种执行器下都请让任务调 `ctx.checkpoint()`,并写成幂等或可断点续传。 完整设计(状态表、分层、里程碑、风险)见 [设计文档](docs/task-control-design.md); 面向使用者的完整说明见 [用户指南](docs/user-guide.md)。 --- ## 安装 ```bash uv sync # 开发环境 # 或 pip install -e . ``` 核心包**零第三方运行时依赖**,只用标准库。 跨机部署共用一套持久层时,再装一个可选 extra: ```bash pip install "chongming-task[postgres]" ``` 它只带来 `psycopg`(Postgres 驱动)。**不装也能正常 `import chongming_task`**—— `PostgresTaskStore` 是惰性取的,不装时取它会报出安装命令而不是一个光秃秃的 `ModuleNotFoundError`(见 [持久层抽象](#持久层抽象))。 --- ## 快速上手 `examples/01_quickstart.py`: ```python import time from chongming_task.run import TaskRun def extract(ctx, source: str) -> list[int]: """长耗时循环里必须周期性地调用 ctx.checkpoint()。""" rows: list[int] = [] for index in range(5): ctx.checkpoint() # 响应停止/暂停,并刷新心跳 time.sleep(0.05) rows.append(index) return rows run = TaskRun(extract, ("s3://demo/rows",), task_id="demo-1", name="抽取") run.start() outcome = run.wait(timeout=30) for record in run.transitions: print(f"{record.from_status.value:>10} -> {record.to_status.value:<10} by {record.trigger}") print(outcome.status.value, outcome.value) ``` 真实输出: ``` 抽取完成:s3://demo/rows -> 5 行 状态迁移历史(只追加、可审计): pending -> queued by orchestrator:dependencies_resolved queued -> starting by scheduler:dispatch starting -> running by executor:launched running -> succeeded by executor:completed 最终状态:succeeded 返回值 :[0, 1, 2, 3, 4] 耗时 :0.25s ``` 只想跑一个任务、不需要中途干预时,用一行: ```python from chongming_task.run import run_task outcome = run_task(extract, "s3://demo/rows", task_id="demo-1", name="抽取") if outcome.ok: print(outcome.value) ``` --- ## 任务怎么写 **唯一约定**:长耗时循环里周期性调用 `ctx.checkpoint()`。 ```python def my_task(ctx, arg1, *, kwarg1=1): for row in source: ctx.checkpoint() # ← 停止、暂停、心跳都在这里生效 process(row) return result ``` `ctx` 由执行器注入为**第一个位置参数**,包含: | 成员 | 说明 | |---|---| | `ctx.checkpoint()` | 协作点:响应停止(抛 `TaskCancelled`)、响应暂停(挂起)、刷新心跳 | | `ctx.acheckpoint()` | 同上,在 `async def` 任务里 `await`(执行器会把协程真的跑完) | | `ctx.is_stop_requested` | 只读,是否已请求停止(用于清理逻辑等不便阻塞处) | | `ctx.is_pause_requested` | 只读,是否已请求暂停 | | `ctx.task_id` / `ctx.name` / `ctx.attempt` | 身份与重试次数 | | `ctx.heartbeat` | 心跳对象,`idle` 超时依据它判定 | | `ctx.artifact_dir` | 本次尝试的产物目录(按尝试隔离,重试不污染上一次) | **不调用 `checkpoint()` 的任务,既无法优雅停止,也无法暂停。** 这是协作式设计的 硬约束——见 [用户指南](docs/user-guide.md#为什么必须调用-checkpoint)。 --- ## 控制任务 ```python run = TaskRun(my_task, task_id="job-1") run.start() run.pause() # 任务在下一个 checkpoint 挂起 → paused run.resume() # → running run.stop(reason="发布窗口") # → stopping → stopped outcome = run.wait(timeout=60) ``` 几个刻意设计的语义,都是"不骗人"的结果: - **`pause()` 返回 `False` = 暂停没生效**(任务没到达检查点),此时状态**回滚为 `running`**,绝不假装已暂停。 - **`stop()` 停不掉时不会说"已停止"**。线程无法强杀,因此优雅窗口用尽后任务落 `lost`(结果未知),而不是 `stopped`: ``` == 停不掉的任务:如实上报 == 终态 :lost 失败归类 :lost 含义 :线程无法强杀,任务可能仍在后台产生副作用,因此不敢说'已停止' ``` - **`wait(timeout=...)` 超时只抛 `TimeoutError`,不改变任务状态**。把"我没等到" 当成"任务失败"是最常见的误用。 - **`pause()` 不释放并发槽位**。我们无法把任意的 Python 调用栈换出内存再换回来, 所以暂停是就地挂起;暂停大量任务可能耗尽并发额度。 --- ## 管理一批任务 `Controller` 是统一的管理入口:它持有**任务定义白名单**、**运行中的任务**与 **并发额度**。将来的 CLI(以及任何看板)都只是这一层的薄适配。 ```python from chongming_task.controller import Controller, render_table controller = Controller(max_parallel=2) # 最多同时跑 2 个 controller.register("extract", extract, tags={"io"}) # 先登记,之后只传名字 controller.register("transform", transform) controller.submit("transform", 100, task_id="t0", label="转换-1") controller.submit("transform", 200, task_id="t1", label="转换-2") controller.submit("transform", 300, task_id="t2", label="转换-3") controller.submit("transform", 400, task_id="t3", label="转换-4") print(render_table(controller.list_tasks())) # 查询 controller.stop("t0", reason="手工干预") # 控制 controller.wait_all(timeout=60) ``` 真实输出(`examples/04_controller.py`,节选): ``` --- 查询:运行中的视图 --- TASK_ID NAME STATUS ATTEMPT ELAPSED PROGRESS FAILURE ------- ------ ------- ------- ------- -------- ------- t0 转换-1 running 1/1 0.4s - - t1 转换-2 running 1/1 0.4s - - t2 转换-3 queued 0/1 - - - t3 转换-4 queued 0/1 - - - 未结束 4(其中排队 2,占用执行额度 2) ``` 三个必须知道的设计点: 1. **只接受名字,绝不接受代码。** `submit("extract", ...)` 里的 `extract` 必须已在 启动时 `register` 过,否则抛 `ConfigurationError`。一个能启动任意可调用对象的 入口等于**远程代码执行**——这条在接 CLI 之后同样不能破。 2. **超限是排队,不是拒绝,也不是无限起线程。** 排队的任务状态为 `queued` (已被受理、等待执行槽),与 `pending`(尚未受理)不同。排队中的任务 `ELAPSED` 显示 `-`,因为它还没执行过。 3. **`stop_all()` 返回的是"已发出请求"的数量,不是"已停止"。** 请求发出后任务先进入 `stopping`,落终态是异步的;要等就接着 `wait_all()`。 > **跨进程限制**:`Controller` 管理的是**当前进程内**的任务。另一个进程里的 > `chongming-task ps` 看不到它们——内存不共享。跨进程查询与持久化需要共享媒介: > 同机用 `sqlite3`(**它在标准库里**,不引入任何第三方依赖),跨机用 > `PostgresTaskStore`(可选 extra)。 --- ## 任务分桶 桶既是**本地并发隔离**的单位,也是将来**分布式分配**的单位(一台机器拥有一组桶)。 ```python controller = Controller(max_parallel=10, max_parallel_per_bucket=1) controller.submit("sync", "租户A", task_id="a0", bucket_key="tenant-A") controller.submit("sync", "租户B", task_id="b0", bucket_key="tenant-B") ``` 真实输出(`examples/05_buckets.py`,节选)——A 桶积压,B 桶照常并行: ``` 分桶策略:64 个桶 并发限制:全局 10,但每个桶最多 1 个 业务键 -> 桶号(同一个键在任何进程、任何机器上都会落到同一个桶): tenant-A -> bucket 7 tenant-B -> bucket 28 --- 查询:A 桶积压,但不影响 B 桶 --- TASK_ID NAME BUCKET STATUS ATTEMPT ELAPSED PROGRESS FAILURE ------- ------ ------ --------- ------- ------- -------- ------- a0 A-同步 7 succeeded 1/1 0.4s - - a1 A-同步 7 running 1/1 0.1s - - a2 A-同步 7 queued 0/1 - - - a3 A-同步 7 queued 0/1 - - - b0 B-同步 28 succeeded 1/1 0.4s - - b1 B-同步 28 running 1/1 0.1s - - 占用额度 2,排队 2,A 桶在用 1 ``` ### 两条硬约束 **一、桶数一旦开始用就不能随便改。** `bucket = hash(key) % N`,改 N 会让几乎所有键 改换桶位。所以任务会**同时记下 `bucket_key`**——将来真要改桶数,只有拿得到原始键 才能重新分桶。 **二、绝不能用内置 `hash()`。** CPython 对 `str` 的 `hash()` 带**进程级随机盐** (`PYTHONHASHSEED`):同一个键在不同进程里会落到不同的桶,于是"同桶任务在同一台 机器上"会**静默**失效——没有任何报错,只是本地性与缓存复用全都不成立。 `BucketPolicy` 用 `blake2b`,并有真实子进程 + 不同 `PYTHONHASHSEED` 的用例守着。 ### 多节点部署:自治节点 + 统一平面 **每个节点有自己的任务定义与触发方式;节点之间不互相执行对方的任务;但共用同一套存储与 控制层,靠桶区分"哪一份是我的"。** ```python # 节点 A:负责 0..15 号桶(按业务划分,可以多于一个桶) controller = Controller( store=SqliteTaskStore("tasks.db"), owner="node-a", owned_buckets=range(0, 16), ) ``` | 维度 | 自治(每节点自有) | 统一(全局共享) | |---|---|---| | 任务定义、触发方式、负责的桶 | ✅ | — | | 存储(任务状态、审计轨迹、指令)、控制层 | — | ✅ 一套 | `owned_buckets` 的作用是**划界**: - **提交时校验**——任务落进别人的桶会在提交那一刻报错。否则它会永远停在 `queued`, 而且**没有任何报错**; - **`reconcile()` 只看自己的桶**——不去动别的节点的任务。 ```bash # 运维:只看"我这一份" chongming-task --db tasks.db --num-buckets 64 --owned-buckets 0-15 ps --all --mine # 每个桶多少任务;星号标出本节点负责的桶 chongming-task --db tasks.db --num-buckets 64 --owned-buckets 0-15 buckets ``` 统一平面给的是**看得见**与**管得着**:一个看板看全部、跨节点 `stop`/`pause`(走已有的指令 通道)、统一审计轨迹、以及**发现"某个桶没人管、任务在静默堆积"**——这是分散存储做不到的。 **跨机器**用 Postgres:SQLite 文件不能跨机共享,把 `store` 换成 `PostgresTaskStore` 即可, 其余代码(`owner`、`owned_buckets`、`reconcile()`、跨节点 `stop`)一行都不用改。 ```python from chongming_task import PostgresTaskStore # 需要 pip install "chongming-task[postgres]" controller = Controller( store=PostgresTaskStore("postgresql://user:pw@db.internal:5432/chongming"), owner="node-a", owned_buckets=range(0, 16), ) ``` CLI 同样认连接串,运维不必在每个节点上找那个 `.db` 文件: ```bash chongming-task --db 'postgresql://user:pw@db.internal:5432/chongming' --num-buckets 64 ps --all ``` 它实现的是**同一个 `TaskStore` 契约**,通过 `StoreContract` 全绿,并额外带着自己的并发证据 (`SKIP LOCKED` 认领、并发 `save` 恰好一个赢家、持有者被强杀后租约照常到期)。细节与建表 语句见[持久层抽象](#持久层抽象)与[持久层实现指南](docs/store-guide.md) §6。 这个架构的代价是**没有东西替你检查配置**:两个节点配了同一个桶(可能重复执行)或某个桶 没人配(任务静默堆积)都不会自动报错。缓解手段与其它风险见 [分布式设计说明](docs/distributed-design.md) §11。 > 早先设计过一个真正的集群方案(任意节点可跑任意任务:配方落库、跨节点原子认领、成员表、 > rendezvous 分配、再均衡、fencing)。它被否决——定时任务是**有归属**的,不是无差别算力池。 > 被否决的理由与仍然成立的结论见该文 §17。 --- ## 嵌入到你自己的应用 **它是库优先的**:`Controller` 就是集成点,CLI 只是可选外壳。不需要单独进程、不需要 网络层、不需要先起服务。 ```python from chongming_task import Controller, ProcessExecutor, ThreadExecutor controller = Controller(max_parallel=4, executor=ThreadExecutor()) # 默认:线程 # 需要硬性停止 / 绕开 GIL / 崩溃隔离时换成子进程: # controller = Controller(max_parallel=4, executor=ProcessExecutor()) controller.register("extract", extract) # 启动时登记一次 run = controller.submit("extract", "s3://bucket/x", task_id="job-1") # 你的 Web 框架 / 定时任务 / 消息消费者里都可以直接用: controller.list_tasks() controller.get("job-1") controller.stop("job-1", reason="用户取消") controller.stats() ``` 完整的一条集成主线(含多节点桶划分、启动恢复、跨节点控制、优雅退出)见 **[examples/11_best_practice.py](examples/11_best_practice.py)** —— 照抄它比照抄本节的 片段更省事。 公开 API 一行导入,`__all__` 里就是全部受支持的表面: ```python from chongming_task import ( # noqa: F401 Controller, TaskRegistry, TaskRun, run_task, render_table, TaskStatus, TaskSnapshot, TaskStore, InMemoryTaskStore, SqliteTaskStore, RetryPolicy, ExponentialBackoff, Timeout, ThreadExecutor, ) ``` ## 持久层抽象 `TaskStore` 是持久层的**唯一契约**,第三方后端(Redis、对象存储……)只需满足它。内置三个 实现: | 实现 | 适用 | 依赖 | |---|---|---| | `InMemoryTaskStore` | 单进程嵌入、单元测试、作为其他实现的**参照物** | 标准库 | | `SqliteTaskStore` | 跨进程可见 + 持久化(**默认**) | 标准库 `sqlite3` | | `PostgresTaskStore` | **跨机器**共用一套存储与指令通道 | 可选 `chongming-task[postgres]` | ```python from chongming_task import InMemoryTaskStore, Progress, SqliteTaskStore, TaskQuery, TaskStatus store = SqliteTaskStore("tasks.db") store.create(snapshot) store.save(next_snapshot, expect_version=snapshot.status_version) # 乐观并发 store.query(TaskQuery(statuses=frozenset({TaskStatus.RUNNING}), tag="io")) store.touch("job-1", progress=Progress(done=120, total=500)) # 高频旁路 ``` 跨机器时换成 DSN 就完了——**同一个契约,同一套语义**: ```python from chongming_task import PostgresTaskStore store = PostgresTaskStore("postgresql://user:pw@db.internal:5432/chongming") ``` 它建表所需的三张表(`tasks` / `transitions` / `commands`)在首次连接时自动创建; 若当前角色没有 DDL 权限,它检测到表已存在就直接使用——只读运维账号因此也能连。 `close()` 幂等,关闭后再用会抛 `StoreError` 而不是未定义行为。 四类数据刻意分开,因为它们的写入频次与一致性要求完全不同: | 类别 | 方法 | 特点 | |---|---|---| | 任务快照 | `create` / `save` / `get` / `query` | 参与**乐观并发**(`status_version`) | | 审计轨迹 | `append` / `history` | **只追加**,重复写入幂等 | | 高频旁路 | `touch` | 进度与心跳,**不碰版本号**(否则会与状态写入持续冲突) | | 控制指令 | `submit_command` / `claim_commands` / `complete_command` | 跨进程控制的媒介,带**租约** | ### 自证契约 写一个新后端时别靠读文档猜语义——跑契约套件。**完整的接口规格见 [持久层实现指南](docs/store-guide.md)**(14 个方法的逐条语义、并发要求、陷阱清单, 以及一个可执行的第三方后端范例): ```python from chongming_task.testing.store_contract import StoreContract class TestMyStore(StoreContract): def make_store(self): return MyStore(...) ``` 套件**随包发布**且不依赖 pytest;内置实现跑的正是同一套用例(内存 / SQLite 文件 / SQLite 内存 / Postgres,四份实例共用同一批断言——最后一份需要 `CHONGMING_TEST_POSTGRES` 给出 DSN,没有就干净跳过)。 --- ## 跨进程:一个进程跑,另一个进程看和管 `Controller(store=...)` 一旦给出,本实例就成为**可跨进程的一环**: | 方向 | 行为 | |---|---| | 写 | 每次状态迁移**写穿**到存储(乐观并发,带迁移历史) | | 读 | `get` / `list_tasks` / `stats` 走存储,因此**能看到别的进程的任务** | | 控制 | 对不属于本进程的任务,`stop` / `pause` / `resume` 写成一条**指令**,由持有者轮询取走 | 真实输出(`examples/06_cross_process.py`,节选): ``` --- ops-console 看到的状态(它并不持有这些任务)--- TASK_ID NAME BUCKET STATUS ATTEMPT ELAPSED PROGRESS FAILURE ------- ------ ------ ------- ------- ------- -------- ------- job-1 A-同步 7 running 1/1 0.5s - - job-2 B-同步 28 running 1/1 0.5s - - 它本地的任务句柄数:0 >>> ops-console 跨进程停止 job-1 下发结果:True worker-1 已取走指令并停止该任务 --- job-1 的迁移历史(从存储读,可审计)--- 1 pending -> queued by orchestrator:dependencies_resolved 2 queued -> starting by scheduler:dispatch 3 starting -> running by executor:launched 4 running -> stopping by user:stop 5 stopping -> stopped by runtime:stop_confirmed ``` ### 三个刻意的设计点 1. **纯查询客户端不轮询指令。** 轮询线程只在**提交过任务**之后才启动。否则一个 只读的看板会把别人的停止请求抢走——即便它把指令交还,持续轮询也会让真正的持有者 反复错过。 2. **取到不属于自己的指令是"交还",不是"完成"。** 存储契约里有 `release_command`:标记完成会让真正的持有者永远收不到这次请求(一个静默的丢指令); 什么都不做、只等租约过期又会被持续续租饿死。所以需要第三种结局。 3. **存储故障不让任务失败。** 任务本身没问题,坏的只是可观测性。但也绝不静默—— 写穿失败记进 `controller.persist_failures`,让"看板上的状态是旧的"有迹可循。 ### 两个使用注意 - **跨进程的 `pause()` 返回 `False`。** 那不是失败,是**未确认**——跨进程没有回执 通道。要观测结果请轮询 `get()`。 - **用完一定要关存储。** `controller.close(close_store=True)` 或自己 `store.close()`。 没关的 SQLite 连接会泄漏文件句柄,在 Windows 上还会让数据库文件无法删除。 --- ## 命令行控制台 CLI 是**另一个进程**,所以它只能通过共享存储看和管——这正是持久层存在的理由。 ```bash # 一个终端里跑(前台执行到结束) chongming-task --db tasks.db run --tasks myapp.tasks sync --arg tenant=A # 另一个终端里看和管 chongming-task --db tasks.db ps chongming-task --db tasks.db status demo-1 chongming-task --db tasks.db stop demo-1 --reason "发布窗口" ``` 真实输出(`examples/tasks_demo.py` + `PYTHONPATH=examples`,节选): ``` $ chongming-task --db tasks.db ps TASK_ID NAME BUCKET STATUS ATTEMPT ELAPSED PROGRESS FAILURE ------- ------ ------ ------- ------- ------- -------- ------- demo-1 A-同步 7 running 1/1 7.4s - - $ chongming-task --db tasks.db history demo-1 VER FROM TO AT TRIGGER ACTOR REASON --- -------- -------- ------------------------- ---------------------------------- ------ ------ 1 pending queued 2026-10-02T14:01:23+00:00 orchestrator:dependencies_resolved system - 2 queued starting 2026-10-02T14:01:23+00:00 scheduler:dispatch system - 3 starting running 2026-10-02T14:01:23+00:00 executor:launched system - 4 running pausing 2026-10-02T14:01:40+00:00 user:pause user - 5 pausing paused 2026-10-02T14:01:40+00:00 runtime:pause_reached system - 6 paused running 2026-10-02T14:01:42+00:00 user:resume user - 7 running stopping 2026-10-02T14:01:44+00:00 user:stop user 发布窗口 8 stopping stopped 2026-10-02T14:01:44+00:00 runtime:stop_confirmed system 发布窗口 ``` 第 4–6 行是一次完整的暂停往返,第 7–8 行是停止——**谁按的、为什么按,都在轨迹里**。 ### 退出码分三档 | 码 | 含义 | |---|---| | `0` | 成功 | | `1` | 命令本身失败(参数错、任务不存在、存储打不开……) | | `3` | 命令成功,但它查询/执行的**任务没有成功** | `3` 是给脚本判分支用的——把"运维脚本写错了"和"任务本身失败了"分开,脚本才可能做对 判断。 ### 任务定义来自模块,CLI 只传名字 ```bash chongming-task tasks --tasks myapp.tasks ``` 模块必须定义 `register(controller)`。**一个能启动任意可调用对象的命令行入口等于任意 代码执行**,所以这条约定必须从第一天立好。 子命令、错误路径与完整走查见 **[命令行控制台](docs/cli.md)**。 --- ## DAG 管道 把多个任务按依赖关系串起来,由一个调度器推进:节点先停在 `blocked` 等上游,上游满足后 才放行;注定跑不了的下游会被 `skipped`。 ```python from chongming_task import Controller, Pipeline from chongming_task.policies import AggregationPolicy, JoinMode pipeline = Pipeline("nightly-etl") fetch = pipeline.node("fetch", "2026-10-02", node_id="fetch") shards = [ pipeline.node("transform", index, node_id=f"shard{index}", depends_on=[fetch]) for index in range(3) ] load = pipeline.node("load", node_id="load", depends_on=shards) # 默认屏障:全部分片成功 pipeline.node( "audit", node_id="audit", depends_on=[load], join=JoinMode.ALL_SETTLED ) # 不管成败都要跑 run = controller.submit_pipeline(pipeline) outcome = run.wait(timeout=60) print(outcome.status) # succeeded / failed / cancelled ``` 真实输出(`examples/07_pipeline.py`): ``` --- 刚提交时:下游都停在 blocked --- NODE TASK STATUS DEPENDS_ON JOIN ------ --------- ------- -------------------- ------------- fetch fetch running - all_succeeded shard0 transform blocked fetch all_succeeded shard1 transform blocked fetch all_succeeded shard2 transform blocked fetch all_succeeded load load blocked shard0,shard1,shard2 all_succeeded audit audit blocked load all_settled ``` ### 依赖由状态机表达,不是调度器自造的 `blocked` 是一个**真正的任务状态**(早已在转移表里),不是管道额外发明的标记。所以: * `PENDING → BLOCKED`(等待上游)→ `QUEUED`(上游满足)——每一步都走状态机、都写穿到 存储、都留下审计轨迹; * 等待中的节点**不占并发额度**(它不在待执行队列里),一条长管道不会把额度锁死。 ### 两个不同的轴,别混 | 概念 | 回答的问题 | |---|---| | `JoinMode`(逐节点) | **这一个节点何时该跑**:`ALL_SUCCEEDED` / `ALL_SETTLED` / `ANY_SUCCEEDED` | | `AggregationPolicy`(整条管道) | **管道何时算结束、要不要连坐**:`FAIL_FAST` / `ALL_SETTLED` | ### 三种结局必须分得清 | 状态 | 含义 | |---|---| | `skipped` | 上游没成功,它**本来就不该跑** | | `cancelled` | 本来要跑,被 fail_fast **连坐**了 | | `stopped` | 值班同学**手动停的** | 把它们混成一个状态,事后就再也分不出"上游挂了"、"被连坐了"和"人停的"。 `CANCELLED` 刻意**不作为** fail_fast 的触发条件——它是连坐的*结果*,不是原因。否则用户 主动取消整条管道会被记成"失败",被连坐的节点还会再触发一轮连坐。 ### 诚实的边界 * **管道结构在内存里,不落库。** 任务的函数与参数从未持久化,所以进程重启后一条跑到 一半的管道无法续跑——它的节点会被 `reconcile()` 如实标记,而不是被假装恢复。 * **不支持 `quorum` 结算。** quorum 描述的是"同一件事的 N 份副本成功 k 份",它属于扇出 分组而不是一条节点各异的 DAG。硬套上去只能得到一个没人能预测含义的数字,所以 `submit_pipeline` 会明确拒绝并说明原因。 --- ## 写一个任务:`@task` 与 `TaskContext` ```python from chongming_task import RetryPolicy, task @task(tags={"io"}, retry=RetryPolicy(max_attempts=3)) def extract(ctx, day: str) -> list[str]: """按天抽取分区列表。""" partitions = [f"{day}/part{i}" for i in range(6)] for index, part in enumerate(partitions, start=1): ctx.checkpoint() # 响应停止/暂停 + 刷新心跳 ctx.progress(index, total=len(partitions), message=part) ctx.log(f"处理 {part}") return partitions ``` 一次登记整个模块: ```python controller.register_module(myapp.tasks) ``` `ctx` 是任务与运行时之间**唯一**的协作入口: | 成员 | 作用 | |---|---| | `ctx.checkpoint()` | 协作点:响应停止与暂停,并刷新心跳。**不调用它就不响应停止** | | `ctx.progress(done, total=, unit=, message=)` | 上报进度(高频旁路,不碰版本号) | | `ctx.log(msg)` / `.warning` / `.error` | 走标准库 `logging`,logger 名 `chongming_task.task.` | | `ctx.input(节点id, 默认值)` / `ctx.upstream` | 取**上游节点**的返回值(管道数据传递) | | `ctx.artifact_dir` | 本次尝试的产物目录(按 attempt 隔离) | **返回值必须可 JSON 序列化**(它会写入存储)。违反时任务以 `result_invalid` **失败**—— 不会静默地变成"存储里停在 running、重启后被判 LOST"。大数据请传指针。 进度会进快照、进存储,并出现在 `ps` 的 `PROGRESS` 列: ``` TASK_ID NAME STATUS ATTEMPT ELAPSED PROGRESS FAILURE ---------------------- ------------------- --------- ------- ------- -------- ------- 2b4e5e326776:extract daily-etl/extract succeeded 1/2 0.3s 6/6 - 2b4e5e326776:transform daily-etl/transform succeeded 1/1 0.3s 6/6 - 2b4e5e326776:load daily-etl/load succeeded 1/1 0.2s 4/4 - ``` ### 三个刻意的设计点 1. **`@task` 不产生全局注册表。** 它只把规格挂在函数对象上(`__chongming_task__`), 谁注册、注册到哪个控制器由你决定。全局注册表意味着 **import 即注册**,而 import 可能发生在任何地方(包括测试收集阶段),那种隐式行为极难排查。 2. **`progress()` 不刷新心跳。** 心跳是"我还在推进"的整体信号,进度是"推进到哪了"。 把心跳藏进进度里,会让一个只报进度、不做实际工作的任务看起来永远健康——那正是 `idle` 超时要抓的东西。 3. **失败的上游不传 `None`。** 只有成功的上游才出现在 `ctx.upstream` 里。塞一个 `None` 会让下游以为"上游返回了 None",而不是"上游没有值"。 --- ## 重启恢复 进程意外退出后,存储里会留下一批永远停在 `running` 的任务。**新进程启动时先跑一次 `reconcile()`**: ```python controller = Controller(store=SqliteTaskStore("tasks.db"), owner="worker-2") report = controller.reconcile() if report.lost: log.warning("上次有 %d 个任务失去监督者", len(report.lost)) ``` ```bash chongming-task --db crash.db reconcile --dry-run # 先看一眼,不写任何状态 chongming-task --db crash.db reconcile --reason "worker-old 崩溃" ``` ``` $ chongming-task --db crash.db reconcile --dry-run OUTCOME TASK_ID WAS DETAIL ----------- ----------- ------- ----------------- adoptable job-queued queued 没有执行载体,等待重新提交 marked-lost job-crashed running 监督者已失联(进程重启后未再续租) ``` ### 判据是租约,不是心跳 每个监督进程都会给自己负责的任务**续租**。租约过期才说明"没人在管了": | 情况 | 处置 | |---|---| | 租约未过期 | 有活着的进程在管,**一动不动** | | 租约过期 + 任务**占着执行资源** | 载体已消失,判 `LOST` | | 租约过期 + 任务**不占资源**(排队/暂停/退避) | 只是没人接手,**不改状态** | | 原本的监督者又活过来抢先写入 | 检测到版本冲突,**放手** | **绝不能用任务心跳当判据。** `last_heartbeat_at` 由任务自己的 `checkpoint()` 驱动, 一个长时间不调 checkpoint 的健康任务(纯 CPU 计算)心跳同样停滞——拿它判断进程存活, 会把一个正常任务判成 `LOST` 并**杀掉**。这就是租约与心跳必须分成两个字段的原因,也是 持久层契约里 `save` 不许写租约列的原因。 ### 它不会重新执行任务 这是能力边界,不是遗漏:**存储里记的是状态,不是配方**——任务的函数与参数从未落库。 所以 `reconcile()` 做的是**如实标记**,让看板不再显示一个假的"运行中";真正重跑要靠 调用方自己重新 `submit`。 --- ## 重试与超时 ```python from chongming_task.policies import ExponentialBackoff, RetryPolicy, Timeout outcome = run_task( flaky_task, retry=RetryPolicy(max_attempts=5, backoff=ExponentialBackoff(base=0.05, factor=2)), timeout=Timeout(per_attempt=30, idle=10), ) ``` 三档超时相互独立,取最早到期者: | 作用域 | 含义 | |---|---| | `start` | 从提交到真正开始执行的等待上限(一旦开始即失效) | | `per_attempt` | 单次尝试的总时长 | | `idle` | 距最近一次 `checkpoint()` 的静默时长——唯一能发现"卡住但没报错"的机制 | 真实输出(`examples/03_retry_and_timeout.py`): ``` == 重试:失败两次后成功 == 终态 :succeeded 返回值 :第 3 次尝试成功 尝试次数 :3/5 == 重试:预算耗尽 == 终态 :failed 尝试次数 :2/2 失败归类 :exception == 超时:单次尝试不超过 0.5 秒 == 终态 :failed 失败归类 :timeout_attempt 可重试 :True ``` --- ## 状态模型速览 生命周期状态有 15 个,分为非终态与终态: ``` 非终态 pending blocked queued starting running pausing paused retry_wait stopping 终态 succeeded failed stopped cancelled skipped lost ``` 关键区分: - `stopped`(人工主动停止)与 `cancelled`(编排层级联取消)**责任方不同**,分开记录。 - `lost` 表示**结果未知**(线程被放弃、载体失联),与"已停止"是两回事。 - **超时不是状态**,是失败原因(`failure.kind = timeout_attempt` 等)。否则会逼出 "已超时但正在重试"这类单枚举表达不了的情况。 - 状态**只由状态机写入**,每次变更都递增 `status_version` 并留下一条带触发源的 `Transition` 记录。 完整转移表与理由见 [设计文档 §2](docs/task-control-design.md)。 --- ## 已知限制 1. **不调用 `ctx.checkpoint()` 就无法优雅停止或暂停**,只能等超时:线程执行器下那 个线程停不掉,进程执行器下则要等强杀(任务会被真的停掉,但已经产生的那部分副作用 无法撤销)。 2. **线程无法被强制终止**。优雅窗口用尽后任务落 `lost`,且可能仍在后台产生副作用。 需要"停得住"的保证时换 `ProcessExecutor()`,见[用户指南 §7.4](docs/user-guide.md#74-进程执行器硬性停止--绕开-gil--崩溃隔离)。 3. **暂停不释放执行槽位**(就地挂起)。 4. **`idle` 超时衡量的是 `checkpoint()` 间隔**,不是"CPU 是否在干活"。长时间不 checkpoint 的重计算会被判定为疑似卡死——这要求任务作者让 checkpoint 频率与 `idle` 阈值相匹配。 5. **跨机之后仍没有自动告警**:两个节点配了同一个桶(会重复执行)或某个桶没人管 (任务静默堆积)都不会报错——缓解手段见 [分布式设计说明 §11](docs/distributed-design.md)。 6. **重试总是从头开始,任务不跨节点迁移**:没有用户态检查点 / 断点续传。请把任务写成 幂等或可断点续传。 --- ## 文档 | 文档 | 内容 | |---|---| | [用户指南](docs/user-guide.md) | 概念、任务编写、控制语义、超时、执行器、FAQ | | [命令行控制台](docs/cli.md) | 子命令、退出码、任务模块约定、完整走查(真实输出) | | [持久层实现指南](docs/store-guide.md) | **换掉默认持久层要实现哪些接口**、逐方法语义、并发要求、陷阱清单 | | [分布式设计说明](docs/distributed-design.md) | 多节点部署:边界表、自治节点 + 统一平面架构、风险、Postgres 落地状态 | | [设计文档](docs/task-control-design.md) | 状态模型、转移表、分层架构、里程碑、风险 | | [示例](examples/) | 十二个可直接运行的脚本 + 两个 CLI 任务模块(**多节点集成模板见 11 号**) | ```bash uv run python examples/11_best_practice.py # ← 想接入自己的应用,从这个开始 uv run python examples/01_quickstart.py uv run python examples/02_control.py uv run python examples/03_retry_and_timeout.py uv run python examples/04_controller.py uv run python examples/05_buckets.py uv run python examples/06_cross_process.py uv run python examples/07_pipeline.py uv run python examples/08_task_context.py uv run python examples/09_long_running.py uv run python examples/10_worker_walkthrough.py uv run python examples/12_process_executor.py # 硬性停止 / 绕开 GIL / 崩溃隔离 ``` 其中 **11 号是"最佳实践"主模板**:一个可以直接照抄的多节点任务服务 —— 部署参数与桶划分(`buckets_for_node` 连续分区)→ 任务定义 → `build_node()` 接线 → 启动 / 提交 / 观测 / 跨节点控制 / 收尾,全流程自带断言校验。 **12 号**演示进程执行器:不响应停止的任务被真的杀掉、崩溃隔离、以及不可 pickle 的目标在提交那一刻被拦下。其余示例是单点机制演示,按需查阅即可。 `examples/tasks_demo.py` 不是可运行脚本,而是**给 CLI 用的任务模块**(演示 `register(controller)` 约定): ```bash PYTHONPATH=examples uv run chongming-task tasks --tasks tasks_demo ``` --- ## 开发 ```bash uv run pytest -q # 测试 uv run ruff check . # 静态检查 uv run ruff format . # 格式化 uv run mypy # 类型检查(strict) ``` 质量门禁:测试全绿、ruff 无告警、mypy strict 在全部源文件上零问题。