Files
chongming-utils-task/README.md
T
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

907 lines
38 KiB
Markdown
Raw 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.
# 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.<task_id>` |
| `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 在全部源文件上零问题。