- docs/best-practices.md:选型、任务写法、并发与资源、执行器选择、持久化与重启、
多节点与桶划分、排障、反模式清单、上线检查清单(文档里的 API 调用形状已实跑核对)
- README:文档表加入口
- .gitignore:忽略示例库文件 *.db、被强杀跑批留下的临时目录与文件
(Windows 上 Path.replace 偶发 WinError 5 的测试修复在 f4de43c 里。)
908 lines
38 KiB
Markdown
908 lines
38 KiB
Markdown
# 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/best-practices.md) | **照什么做生产上不会踩坑**:选型、任务写法、并发与资源、排障、反模式清单、上线检查清单 |
|
||
| [用户指南](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 在全部源文件上零问题。
|