Files
chongming-utils-task/docs/user-guide.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

1314 lines
54 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.
# 用户指南
面向使用者的完整说明。设计动机与取舍见 [设计文档](task-control-design.md),可直接
运行的代码在 [examples/](../examples/)。
> **适用范围**:单任务执行、DAG 管道、并发额度、持久化与重启恢复、CLI 均已可用
> (状态表见 [README](../README.md#当前状态务必先读))。真正的边界是语义上的,不是功能
> 缺失:不调 `checkpoint()` 停不下来、暂停不释放槽位、任务不跨节点迁移——见 §11。
---
## 目录
1. [核心概念](#1-核心概念)
2. [写一个任务](#2-写一个任务)
3. [跑一个任务](#3-跑一个任务)
4. [控制任务](#4-控制任务)
5. [重试](#5-重试)
6. [超时](#6-超时)
7. [执行器](#7-执行器)
8. [管理一批任务(Controller)](#8-管理一批任务controller)
9. [状态模型参考](#9-状态模型参考)
10. [错误与异常](#10-错误与异常)
11. [限制与 FAQ](#11-限制与-faq)
12. [测试自己的任务](#12-测试自己的任务)
---
## 1. 核心概念
### 1.1 任务是一条轨迹,不是一个布尔值
大多数"任务控制"只是把任务记成"跑着 / 成功 / 失败"。这里不是。每个任务是一条
**只追加的状态轨迹**,每个中间态都可表达、可观察、可干预:
```
pending → queued → starting → running → succeeded
↓ ↓
retry_wait pausing → paused → running
↓ ↓
starting stopping → stopped
```
每次状态变更都会产生一条 `Transition` 记录,带**触发源**、**操作者**、**原因**和
**版本号**:
```python
for record in run.transitions:
print(record.from_status, "->", record.to_status, "by", record.trigger)
```
### 1.2 状态只表达"生命周期阶段"
`status` 是一个枚举,只回答"它现在处于哪个阶段"。其余信息属于**正交维度**,绝不
塞进同一个枚举:
| 维度 | 字段 | 为什么必须分开 |
|---|---|---|
| 生命周期 | `status` | 单一枚举,便于穷举校验 |
| 重试计数 | `attempt` / `max_attempts` | "第 3 次尝试"与"正在运行"是两件事 |
| 进度 | `progress` | 运行中的任务可能 0% 也可能 99% |
| 健康 | `last_heartbeat_at` | 用于发现"没报错但也不动" |
| 结果 | `failure` / `outcome` | 失败原因是技术细节,不是生命周期 |
最典型的反例是**把超时做成状态**。那样会立刻逼出"已超时但正在重试"这种单一枚举
表达不了的情况。因此超时是 `failure.kind = timeout_attempt`。
### 1.3 状态机是唯一写者
只有 `StateMachine` 能改 `status`。业务代码、执行器、控制面都不能直接赋值。这条
纪律换来三件事:非法转移会被立刻拒绝、状态历史必然完整、并发写入可检测
(`status_version` 乐观锁)。
```python
from chongming_task.core.state_machine import StateMachine
from chongming_task.states import TaskStatus
machine = StateMachine.create("t1", "抽取", max_attempts=3)
machine.mark_queued()
machine.mark_starting(executor="thread")
machine.mark_running()
machine.mark_succeeded(outcome={"rows": 42})
# 非法转移会抛异常,而不是静默忽略
machine.mark_running()
# InvalidTransition: 非法状态转移:succeeded -> running;动作 'executor:launched';当前允许:无(终态)
```
非法转移**抛异常**而不返回 `False`:静默失败会让"停止指令没生效"这类问题极难排查。
需要预检时用 `machine.can(target)` / `can_stop` / `can_pause` / `can_resume` / `can_retry`。
### 1.4 终态与复活
终态**没有出边**,这是转移表里显式声明的空集。复活只能走两个显式 API:
- `retry()`:从 `failed` / `stopped` / `cancelled` / `lost` 复活,**保留**尝试历史。
- `reset()`:回到 `pending`,**丢弃**全部尝试历史(危险,需二次确认)。
---
## 2. 写一个任务
### 2.1 签名
任务的第一个位置参数由执行器**注入**为执行期上下文:
```python
def my_task(ctx, source: str, *, batch_size: int = 100) -> list[Row]: ...
```
调用时**不要自己传 `ctx`**:
```python
run_task(my_task, "s3://bucket/x", batch_size=500)
```
> **为什么 `ctx` 必须注入而不是闭包捕获?** 因为上下文属于**某一次尝试**:取消令牌
> 在启动那一刻才由执行器造出来,任务不可能提前攥着一个。若靠闭包捕获(`lambda: f(ctx, arg)`),
> 任务定义里握住的只可能是上一次尝试留下的令牌——停止指令会发给一个已经结束的执行,
> 而且完全静默。注入式签名让同一份任务定义既能被线程执行器驱动,也能被内联执行器
> 同步跑完,语义只有一份。
### 2.2 为什么必须调用 `checkpoint()`
```python
def my_task(ctx):
for row in rows:
ctx.checkpoint() # ← 就这一行
process(row)
```
`ctx.checkpoint()` 同时做三件事:
1. 若已请求**停止** → 抛 `TaskCancelled`;
2. 若已请求**暂停** → 在此挂起,直到恢复或停止;
3. **刷新心跳**(`idle` 超时的依据)。
**不调用它的任务既停不掉也暂停不了。** Python 不能安全地终止线程,也没有跨平台的
抢占式暂停手段,所以停止与暂停只能是协作式的。这是硬约束,不是可以绕过的实现细节。
长循环里每轮都调用即可,开销可以忽略(无请求时它只是两次布尔判断)。
### 2.3 清理逻辑里的 `is_stop_requested`
在 `except` / `finally` 块里不要再调用 `checkpoint()`——它会阻塞,导致清理永远
完不成。改用只读属性:
```python
def my_task(ctx):
conn = connect()
try:
for row in rows:
ctx.checkpoint()
write(conn, row)
finally:
conn.commit() # 无论如何都要收尾
if ctx.is_stop_requested:
print("任务被中断,已提交部分结果") # log/progress 属于 P4,此处暂用 print
conn.close()
```
### 2.4 产物目录
```python
def my_task(ctx):
target = ctx.artifact_dir / "part-0.parquet"
...
```
`ctx.artifact_dir` 按 `attempt` 隔离,重试不会污染上一次的产物。传 `artifact_dir`
给 `TaskRun` / `run_task` 即可启用。
---
## 3. 跑一个任务
### 3.1 一行版:`run_task`
```python
from chongming_task.run import run_task
outcome = run_task(my_task, "s3://bucket/x", task_id="job-1", name="抽取")
if outcome.ok:
print(outcome.value)
else:
print(outcome.status.value, outcome.failure.kind.value)
```
`run_task` 会阻塞到任务落终态。**不要在需要中途干预时用它**。
### 3.2 完整版:`TaskRun`
```python
from chongming_task.run import TaskRun
run = TaskRun(my_task, ("s3://bucket/x",), task_id="job-1", name="抽取")
run.start() # 立即返回
... # 这里可以做任何事:暂停、停止、轮询状态
outcome = run.wait(timeout=60)
```
| 成员 | 说明 |
|---|---|
| `start()` | 启动受监督执行,立即返回;重复调用报错 |
| `wait(timeout=None)` | 等待终态;超时抛 `TimeoutError` 且**不改变任务状态** |
| `stop(reason=...)` | 请求停止,不阻塞 |
| `pause(timeout=...)` | 请求暂停并等待确认,返回 `bool` |
| `resume()` | 撤销暂停 |
| `snapshot` | 当前 `TaskSnapshot`,运行期可安全读取 |
| `status` | 当前 `TaskStatus` |
| `transitions` | 完整迁移历史 |
`TaskRun` 是线程安全的:上述方法可从不同线程调用。所有状态机写入都在同一把锁下
进行,因此"唯一写者"这条不变量仍然成立。
### 3.3 结果对象
```python
outcome.ok # bool:是否 succeeded
outcome.status # TaskStatus
outcome.value # 任务返回值(未成功时为 None)
outcome.failure # Failure | None
outcome.snapshot # 终态快照:attempt、时间戳、progress、failure 等
```
---
## 4. 控制任务
### 4.1 停止的三段式
```
请求停止 → 优雅窗口(graceful_stop 秒)→ 兜底
```
- 任务在窗口内响应 `checkpoint()` → 正常收尾,落 `stopped`。
- 窗口用尽 → 线程无法强杀,**放弃等待**并落 `lost`。
```python
run.stop(reason="发布窗口") # 不阻塞
outcome = run.wait(timeout=30)
```
**停不掉时不会说"已停止"**:
| | 任务配合 | 任务不配合(线程) |
|---|---|---|
| `outcome.status` | `stopped` | **`lost`** |
| `outcome.failure.kind` | `cancelled` | `lost` |
| `failure.retryable` | `False` | `True` |
| 后台副作用 | 已收尾 | **可能仍在产生** |
把两者都简化成"已停止"不只是不准确,而是会让运维误判资源已释放。
### 4.2 暂停与恢复
```python
run.pause() # → True 表示已确认挂起,状态变为 paused
run.resume() # → running
```
**`pause()` 返回 `False` 不是错误,是"暂停没生效"**:任务在窗口内没到达检查点。
此时状态**回滚为 `running`**,并留下一条 `runtime:pause_aborted` 记录,绝不假装
已暂停。
两条必须知道的性质:
1. **暂停不释放并发槽位。** 就地挂起——任务的调用栈还在内存里。我们没有办法把
任意的 Python 调用栈换出再换回,所以暂停大量任务可能耗尽并发额度。
2. **暂停期间不判超时。** 被暂停的任务本来就不会推进心跳,若照常判 `idle` 超时,
暂停会被误判成卡死。
### 4.3 恢复的目标状态取决于暂停层级
这是实现期修正过的一处语义,容易踩:
| 暂停发生在 | 恢复后回到 | 理由 |
|---|---|---|
| 运行中(经 `pausing`) | `running` | 就地暂停,槽位从未释放,调用栈还在 |
| 排队/重试等待中(未启动) | `queued` | 从未启动过,重新排队 |
`TaskRun.resume()` 会自动判定,无需你关心。
### 4.4 停止请求发出后任务恰好跑完
这是合法结果:状态走 `stopping → succeeded`,不判为失败。停止是请求,不是判决。
### 4.5 无限循环(常驻)任务:怎么启、怎么停
一个"永远跑下去"的任务,任务体每轮**必须**调 `ctx.checkpoint()`:
```python
@task
def worker(ctx, source: str) -> int:
processed = 0
while not ctx.is_stop_requested: # ← 优雅收尾的写法
ctx.checkpoint() # ← 不调用它 = 停不掉
processed += 1
ctx.progress(processed, unit="batch")
time.sleep(0.02)
ctx.log(f"收到停止请求,本次共处理 {processed} 批")
return processed # 正常返回 → 终态 succeeded,返回值保留
```
#### 停止请求的三种收尾方式,终态不同
| 任务体的写法 | 终态 | 返回值 | 含义 |
|---|---|---|---|
| `while True: ctx.checkpoint()`(让它抛 `TaskCancelled`) | `stopped` | 无 | **立刻中止** |
| 检查 `ctx.is_stop_requested` 后正常 `return` | `succeeded` | ✅ 有 | **请收工**——把已处理的结果留下来 |
| 完全不调 `checkpoint()` | `lost` | 无 | 载体被放弃,**线程仍在后台跑** |
第二行不是笔误:状态机允许 `stopping → succeeded`,因为"停止"是请求而不是判决。对"跑到
下一个安全点再退出"这类需求,它比 `stopped` 更有用——已经做好的结果不会丢。
#### 日常"先停一下"应该用 `pause` / `resume`,不是 `stop`
`stop` 是**终态且不可逆**:当前没有 `retry`,停止之后想再跑必须换一个 `task_id` 重新提交
(`store.create` 会拒绝重复 id)。所以:
```python
controller.pause("orders-worker") # → True:已确认挂起;False:未确认(状态回滚 running)
controller.resume("orders-worker") # 继续跑
controller.stop("orders-worker", reason="发布窗口") # 彻底结束
```
> `pause` 是**就地挂起**:任务阻塞在 `checkpoint()` 里,**继续占用执行槽**。暂停大量常驻
> 任务可能耗尽并发额度。
#### 启停的两种方式
```python
# 方式一:同进程(需要这个进程一直活着)
controller = Controller(store=store, owner="node-a")
run = controller.submit("worker", "kafka://orders", task_id="orders-worker")
controller.stop("orders-worker", reason="发布窗口")
outcome = controller.wait("orders-worker", timeout=10) # → succeeded,value=已处理批数
```
```bash
# 方式二:命令行(前台执行,交给 systemd / nohup;停止从另一个终端发起)
# --bucket-key 在限定了 --owned-buckets 时是**必需**的:没有归属桶的任务不会有任何
# 节点认领它,所以 CLI 会在提交时报错,而不是留下一个永远排队的任务。
chongming-task --db 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 tasks.db --owned-buckets 0-15 status orders-worker
chongming-task --db tasks.db --owned-buckets 0-15 stop orders-worker --reason 发布窗口
```
**`run` 是前台进程**:它退出时任务就没人管了(监督线程是守护线程)。要长期跑请交给
`systemd` / `nohup`。`Ctrl-C` 是安全的——CLI 会走 `finally` 里的 `controller.close()`,
把在跑的任务停干净。
#### 如果任务不调 `checkpoint()`
它不是"停得慢",而是**停不掉**:Python 无法安全终止线程,所以系统只能如实报 `lost`
(结果未知),而任务体**仍在后台继续执行**——真正终止它只能靠结束进程。这正是"诚实优先于
便利"的那条设计原则:与其假装已停止,不如报"结果未知"。
完整可运行演示见 `examples/09_long_running.py`。
---
## 5. 重试
```python
from chongming_task.policies import ExponentialBackoff, RetryPolicy
RetryPolicy(
max_attempts=5, # 总尝试次数(含首次)
backoff=ExponentialBackoff(base=1, factor=2, max_delay=300),
retry_on=(Exception,), # 哪些异常值得重试
give_up_on=(ValueError, KeyError), # 哪些立刻放弃
)
```
| 字段 | 说明 |
|---|---|
| `max_attempts` | **总**尝试次数,因此 `1` 表示不重试 |
| `backoff` | `FixedBackoff(delay)` 或 `ExponentialBackoff(base, factor, max_delay, jitter)` |
| `retry_on` | 值得重试的异常类型 |
| `give_up_on` | 命中即放弃,优先级高于 `retry_on` |
退避默认带**抖动**(`Jitter.FULL`,在 `[0, delay)` 内均匀取值)。这不是花哨:没有
抖动时,一整批同时失败的任务会在同一时刻集体重试,形成风暴。
### 5.1 取消永远不会被重试
`TaskCancelled` 继承自 `BaseException`,而 `retry_on` 默认是 `(Exception,)`,因此
**取消信号天然不匹配任何重试条件**。同时 `Failure.retryable` 对取消置为 `False`。
重试一个已被取消的任务,等于无视停止指令。
### 5.2 失败分类
每次失败都会带一个 `FailureKind`:
| kind | 含义 |
|---|---|
| `exception` | 任务抛出了异常 |
| `nonzero_exit` | 外部命令返回非零退出码 |
| `timeout_start` / `timeout_attempt` / `timeout_idle` | 三档超时 |
| `cancelled` | 被取消 |
| `lost` | 结果未知 |
| `dependency_failed` | 上游失败 |
| `condition_false` | 条件不满足(配合 `skipped`) |
---
## 6. 超时
```python
from chongming_task.policies import Timeout
Timeout(start=30, per_attempt=600, idle=60)
```
三档相互独立,取**最早**到期者:
| 作用域 | 含义 | 生效条件 |
|---|---|---|
| `start` | 从提交到真正开始执行 | 一旦开始执行即失效 |
| `per_attempt` | 单次尝试的总时长 | 本次尝试已开始 |
| `idle` | 距最近一次 `checkpoint()` 的静默时长 | 本次尝试已开始 |
超时到期后系统会**请求停止**该任务,并把结果记成对应的 `timeout_*` 失败,而不是
一个"超时状态"。超时默认**可重试**(多半是暂时性的)。
### 6.1 `idle` 的语义与陷阱
`idle` 衡量的是 **`checkpoint()` 间隔**,不是"CPU 是否在干活"。这是唯一能发现
"卡住但没报错"(死锁、连接挂起)的机制,但它有个前提:
> 如果你有一段几分钟不调用 `checkpoint()` 的重计算,它会被判定为疑似卡死。
所以 `idle` 阈值必须与任务里 `checkpoint()` 的频率相匹配。如果某段计算确实无法
中途打断,要么把它调大,要么在进入该段之前手动 `ctx.heartbeat.beat()`。
另外两条边界:`idle` **只在本次尝试已经开始后才参与判定**,因此排队等待的时长不会
被误判成静默超时;**暂停期间同样不判 `idle`**,因为被暂停的任务本来就不会推进心跳。
---
## 7. 执行器
### 7.1 线程执行器(默认)
```python
from chongming_task.runtime.executors.thread import ThreadExecutor
run = TaskRun(my_task, executor=ThreadExecutor())
```
适合阻塞式 I/O(数据库、HTTP、文件)与普通业务函数。特点:
- 创建开销几十微秒,共享内存,可复用连接池与缓存;
- 无参数序列化要求,闭包、lambda、局部函数都能用;
- `async def` 任务同样能跑:执行器在本线程内把协程跑完,`await ctx.acheckpoint()`
照常响应停止与暂停;
- **无法被强制停止**(Python 不能安全终止线程)。
### 7.2 内联执行器
```python
from chongming_task.runtime.executors.inline import InlineExecutor
run_task(trivial_step, executor=InlineExecutor())
```
在调用线程内同步跑完,零调度开销,适合微秒级的纯计算步骤与确定性测试。
它**刻意违反**"`launch` 不得阻塞"的约定(这就是它存在的意义),因此**异步调度器
不得使用它**。它也无法被暂停——`pause()` 如实返回 `False`,不假装成功。
### 7.3 为什么不用 `ThreadPoolExecutor`
`ThreadPoolExecutor` 的工作线程会在**解释器退出时被 `join`**。一个不响应取消的
任务会让整个进程无法退出——对一个专门监督"可能不配合"的任务的工具来说这是致命
缺陷;此外池子也无法放弃卡住的线程。
因此 `ThreadExecutor` 每个任务起一个**守护线程**,代价是被放弃的线程会泄漏(所以
结果记 `lost`)。并发上限由调度层统一负责,执行器不再叠一层池。
### 7.4 进程执行器:硬性停止 / 绕开 GIL / 崩溃隔离
```python
from chongming_task import ProcessExecutor, run_task
outcome = run_task(fetch_all, executor=ProcessExecutor()) # 单个任务
controller = Controller(executor=ProcessExecutor()) # 或整批任务
```
任务跑在**独立子进程**里。它换来三件线程给不了的事:
| 能力 | 线程执行器 | 进程执行器 |
|---|---|---|
| 优雅窗口用尽后 | 只能放弃等待,记 `lost`(结果未知) | `terminate()` → `kill()`,**真的停掉** |
| CPU 密集 | 受 GIL 限制 | 真并行 |
| 原生扩展段错误 | 带走**整个监督进程** | 只带走子进程,任务如实记一次失败 |
代码里的体现是:超时或停止不下来的任务会被真的终止,最终状态是 `failed`
(超时/崩溃)或 `stopped`(停止请求),而不是 `lost`。快照里的 `pid` 就是那个子进程的
进程号,`ps` 能直接对上号。
**代价是三条结构性的约束**,不是实现偷懒:
1. **参数、返回值、上游数据必须可 pickle**:`lambda`、闭包、局部函数一概不行——它们
在子进程里找不到。违反时提交那一刻就报 `ConfigurationError` 并告诉你改用什么。
返回值的约束也比线程执行器严(那边只要求可 JSON 序列化)。
2. **目标函数必须在可导入的模块里**,且调用方脚本要有 `if __name__ == "__main__":`
守卫(`multiprocessing` 的 spawn 会重新 import 主模块)。用 `@task` 装饰过的
**模块级**函数没问题;写在函数体里的临时函数不行。
3. **停止与暂停有约 20ms 的轮询延迟**,信号要过进程边界;线程执行器是即时的。
另外子进程是守护进程,**不能再创建自己的子进程**——任务内部要并行请开线程。
```python
@task
def resize(ctx, path: str) -> str: # ✅ 模块级、参数可 pickle
...
async def _inline(): # ❌ 闭包 / 局部函数
...
```
**什么时候该用它**:需要"停得住"的保证(例:任务里跑着别人写的、可能不响应取消的
代码)、CPU 密集、或跑第三方原生扩展(段错误隔离)。**什么时候不该**:绝大多数
阻塞式 I/O 任务——线程更轻、约束更少,而且能直接复用连接池与缓存。
---
## 8. 管理一批任务(Controller)
`Controller` 是**统一的管理入口**:它持有任务定义的**白名单**、**运行中的任务**
与**并发额度**。将来的 CLI 与任何看板都只是这一层的薄适配。
### 8.1 基本用法
```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)
run = controller.submit("transform", 100, task_id="t0", label="转换-1")
controller.submit("transform", 200, task_id="t1", label="转换-2")
print(render_table(controller.list_tasks())) # 查询
controller.stop("t0", reason="手工干预") # 控制
controller.wait_all(timeout=60) # 等全部落终态
```
### 8.2 查询接口
| 方法 | 作用 |
|---|---|
| `get(task_id)` | 单个快照;不存在返回 `None` |
| `list_tasks(status=, name_prefix=, tag=, limit=)` | 按条件查询 |
| `stats()` | 各状态计数 |
| `runs()` | 全部 `TaskRun` 句柄(可按 id 继续控制) |
| `active_count` / `queued_count` | 真的在占用执行额度 / 正在排队 |
| `render_table(snapshots)` | 渲染等宽文本表,CLI 与看板共用同一份数据 |
> 方法名是 `list_tasks` 而不是 `list`:后者会在类作用域里遮蔽内置的 `list`,
> 使类内所有 `list[...]` 类型注解失效(mypy 会报 "Function is not valid as a type")。
### 8.3 控制接口
```python
controller.stop(task_id, reason=...) # → bool(是否找到该任务)
controller.pause(task_id, timeout=...) # → bool(是否**确认**已挂起)
controller.resume(task_id) # → bool
controller.stop_all(reason=...) # → int(本轮**发出**的请求数)
controller.close(reason=..., timeout=...) # 停止一切并收尾
```
两个容易误读的返回值:
- `stop_all()` 返回的是**已发出请求**的数量,不是"已停止"。请求发出后任务先进入
`stopping`,落终态是异步的;要等就接着 `wait_all()`。
- `pause()` 返回 `False` 表示暂停**没生效**(状态已回滚为 `running`)。
`close()` 会停止一切,且**排队中的任务不会真的执行任务体**——它们被"启动后立刻
停止",监督循环第一轮就落 `stopped`。
### 8.4 并发额度
`max_parallel` 限制**同时执行**的任务数;超出部分排队,状态为 `queued`:
```
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 - - -
```
`queued` 是"已被受理、等待执行槽",与 `pending`(尚未受理)不同。排队中的任务
`ELAPSED` 显示 `-`——它还没执行过,显示一个时长会被误读成"已经跑了这么久"。
注意 `stats().unfinished` 是**非终态**数量,含排队与暂停;要知道"真的在占用执行
资源"的有几个,用 `controller.active_count`。
#### 具名资源信号量
保护外部稀缺资源(连接数、配额、GPU)用 `ConcurrencyPolicy.limits`——**按标签**
记账,与全局额度叠加:
```python
from chongming_task import ConcurrencyPolicy, Controller
controller = Controller(concurrency=ConcurrencyPolicy(max_parallel=20, limits={"db": 4, "gpu": 1}))
controller.register("sync", sync_tenant)
controller.register("train", train_model)
controller.submit("sync", "tenant-A", tags=["db"]) # 占一个 db 名额
controller.submit("sync", "tenant-B", tags=["db"]) # 再占一个
controller.submit("train", "v3", tags=["gpu"]) # 占那个唯一的 gpu 名额
```
规则只有三条,都好记:
- **没写进 `limits` 的标签不受限**,打标签不会自动限流;
- 一个任务带多个受限期标签时,**每个资源都要有余量**才放行;
- 名额算的是**活跃**任务(已占执行槽的),**含被暂停的**——暂停不释放执行槽,
也就不该释放资源名额。
额度是**本进程**的账,不做跨进程协调:多节点各自配自己那一份(例如每个节点到库里
的连接数)。
#### 优先级
`priority` 决定**排队顺序**:数值越大越先被执行,同优先级仍按提交顺序(先进先出)。
```python
controller.submit("sync", "tenant-A", priority=10) # 用户等着的那次同步
controller.submit("sync", "tenant-B") # 默认优先级 0:批量回填
```
默认值来自 `ConcurrencyPolicy.priority`,`submit(priority=...)` 可逐条覆盖。它只在
**本进程的就绪队列**里比较——优先让"同一批排队任务里的急件"先走,不做跨节点抢占。
### 8.5 安全底线
**只接受名字,绝不接受代码。** `submit("extract", ...)` 里的 `extract` 必须在启动时
`register` 过,否则抛 `ConfigurationError`。一个能启动任意可调用对象的入口等于
**远程代码执行**——将来接 CLI 之后这条同样不能破。
重名注册也会抛错,不静默覆盖:静默覆盖会让"我明明改了实现,跑的还是旧的"这类问题
极难发现。
### 8.6 跨进程限制(重要)
`Controller` 管理的是**当前进程内**的任务。另一个进程里执行 `chongming-task ps`
**看不到它们**——内存不共享。
跨进程查询与持久化需要**共享媒介**:
| 方案 | 引入网络层 | 说明 |
|---|---|---|
| 共享存储(`sqlite3`) | 否 | 标准库自带,无 IPC,天然跨平台,顺带就是持久化 |
| 本地 IPC(Unix socket / 命名管道) | 否 | 仍需写 IPC 与心跳,且平台分叉 |
| 不跨进程 | 否 | CLI 只能管自己启动的任务,不成其为"后台" |
已选定第一条。`prune(keep_terminal=N)` 会丢弃最旧的终态记录以防内存无限增长。
### 8.7 持久层抽象
持久层契约是 `TaskStore`,内置三个实现:
| 实现 | 适用 | 依赖 |
|---|---|---|
| `InMemoryTaskStore` | 单进程嵌入、单元测试、作为其他实现的**参照物** | 标准库 |
| `SqliteTaskStore` | 跨进程可见 + 持久化(**默认**) | 标准库 `sqlite3` |
| `PostgresTaskStore` | **跨机器**共用一套存储与指令通道 | 可选 `chongming-task[postgres]` |
```python
from chongming_task import 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))
```
同机跨进程用 SQLite 就够;**跨机器**换成连接串即可,语义与 API 完全一致:
```python
from chongming_task import PostgresTaskStore # pip install "chongming-task[postgres]"
store = PostgresTaskStore("postgresql://user:pw@db.internal:5432/chongming")
```
核心包不因此多出任何依赖:`PostgresTaskStore` 是**惰性取用**的,没装驱动时
`import chongming_task` 照常工作(只有真正取它才会给出安装命令)。建表在首次连接时
自动完成;角色没有 DDL 权限时,它检测到表已存在就直接用,所以只读运维账号也能连。
四类数据的写入频次与一致性要求不同,因此刻意分开:
| 类别 | 方法 | 特点 |
|---|---|---|
| 任务快照 | `create` / `save` / `get` / `query` | 参与乐观并发(`status_version`) |
| 审计轨迹 | `append` / `history` | **只追加**,重复写入幂等 |
| 高频旁路 | `touch` | 进度与心跳,**不碰版本号**(否则会与状态写入持续冲突) |
| 控制指令 | `submit_command` / `claim_commands` / `complete_command` | 跨进程控制的媒介 |
`claim_commands` 的**租约**解决的是"取走指令的进程崩了怎么办":租约到期后指令可被
重新取走,因此既不会重复下发,也不会因为一次崩溃而永久卡住。
#### 写自己的后端
别靠读文档猜语义——跑契约套件:
```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,没有就跳过)。
**要换掉默认持久层,请看 [持久层实现指南](store-guide.md)**——那里有 14 个方法的逐条
语义、并发与原子性要求、序列化保真要求,以及一个可执行的第三方后端范例和陷阱清单。
`PostgresTaskStore` 本身就是照这份规格写出来的第二个真实后端:它的模块文档记了
"与 SQLite 的三点差异"(连接池、`SKIP LOCKED`、原生类型)与跨进程并发用例,
可以当成比文件后端更接近生产的参照。
> **已经接线**:`Controller(store=...)` 可用——每次状态迁移写穿到存储,查询走存储
> (因此能看到别的进程的任务),对不属于本进程的任务会把控制动作写成指令。详见 §8.9。
### 8.8 任务分桶
桶既是**本地并发隔离**的单位,也是将来**分布式分配**的单位(一台机器拥有一组桶)。
```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")
```
`bucket_key` 是业务键(租户 id、数据源、日期分区……)。同一个键在**任何进程、任何
机器**上都落到同一个桶。
| 成员 | 作用 |
|---|---|
| `submit(..., bucket_key=...)` | 提交时给出业务键 |
| `snapshot.bucket` / `.bucket_key` | 桶号与原始键都记在任务上 |
| `max_parallel_per_bucket=` | 每桶独立限流;一个桶积压不会饿死其他桶 |
| `list_tasks(bucket=...)` / `(buckets=[...])` | 按桶查询(`buckets` 是为分布式预留的:只取自己拥有的那组) |
| `bucket_counts()` | 每桶任务数,用于决定把桶分给谁 |
| `active_in_bucket(bucket)` | 某桶当前占用的执行额度 |
| `controller.bucket_policy` | 分桶策略(`num_buckets` 等) |
`render_table` 也带上了 `BUCKET` 列。
**两条硬约束**(详见 [设计文档 §15](task-control-design.md)):
1. **桶数一旦开始用就不能随便改。** `bucket = hash(key) % N`,改 N 会让几乎所有键
改换桶位。所以任务会同时记下 `bucket_key`——将来真要改桶数,只有拿得到原始键
才能重新分桶。
2. **绝不能用内置 `hash()`。** CPython 对 `str` 的 `hash()` 带进程级随机盐
(`PYTHONHASHSEED`):同一个键在不同进程里会落到不同的桶,于是"同桶任务在同一台
机器上"会**静默**失效。`BucketPolicy` 用 `blake2b`,跨进程一致。
调度器取下一个可跑任务时是**扫描**而不是取队首:某个桶满时队首可能正好被它挡住,
而后面属于其他桶的任务本可以先跑。
### 8.9 跨进程:一个进程跑,另一个进程看和管
```python
from chongming_task import Controller, SqliteTaskStore
worker = Controller(store=SqliteTaskStore("tasks.db"), max_parallel=2, owner="worker-1")
worker.register("sync", sync_tenant)
worker.submit("sync", "租户A", task_id="job-1", bucket_key="tenant-A")
# 另一个进程(同一个 SQLite 文件,各自一条连接)
console = Controller(store=SqliteTaskStore("tasks.db"), owner="ops-console")
console.list_tasks() # 看得到 job-1,尽管它并不持有
console.stop("job-1", reason="运维停止") # 写成一条指令
```
| 方向 | 行为 |
|---|---|
| 写 | 每次状态迁移**写穿**到存储(乐观并发,附迁移历史) |
| 读 | `get` / `list_tasks` / `stats` / `bucket_counts` 走存储 |
| 控制 | 不属于本进程的任务 → 写一条指令,由持有者轮询取走 |
#### 三个必须知道的行为
1. **跨进程的 `pause()` 返回 `False`。** 那不是失败,是**未确认**——跨进程没有回执
通道。要观测结果请轮询 `get()`。`stop()` / `resume()` 返回的是"请求已受理"。
2. **纯查询客户端不会抢指令。** 轮询线程只在提交过任务之后才启动;即便取到了不属于
自己的指令,也会通过 `release_command` 立即交还,而不是标记完成(那会让真正的
持有者永远收不到)。
3. **存储故障不让任务失败**,但会记进 `controller.persist_failures`(最多 20 条)。
任务本身没问题,坏的只是可观测性——但绝不静默。
#### 两个使用注意
* **用完一定要关存储**:`controller.close(close_store=True)`,或自己 `store.close()`。
没关的 SQLite 连接会泄漏文件句柄,在 Windows 上还会让数据库文件无法删除。
* `stop_all()` 只作用于**本进程持有**的任务。"全部停止"跨进程需要一份全局任务清单,
那是分布式调度该决定的事。
`controller.owner` 是写进指令里的发起者标识(默认 `主机名:pid`),便于事后追溯是谁
按的停止。
### 8.10 命令行控制台
`chongming-task` 是 `Controller` 之上的一层**薄适配**:解析参数、调用库、渲染输出。
它不引入任何库没有的概念,也不绕过任何不变量。
```bash
chongming-task --db tasks.db ps
chongming-task --db tasks.db status demo-1
chongming-task --db tasks.db stop demo-1 --reason "发布窗口"
```
| 命令 | 作用 |
|---|---|
| `tasks --tasks MODULE` | 列出模块里注册的任务定义 |
| `run TASK --tasks MODULE` | 提交并**前台执行**到落终态 |
| `ps` / `status` / `history` / `stats` / `buckets` | 查询 |
| `stop` / `pause` / `resume` | 控制 |
退出码分三档:`0` 成功、`1` 命令失败、`3` **命令成功但任务没成功**——最后一档是给
脚本判分支用的。
三点与库行为一致、但在命令行里更容易踩到的地方:
* **`run` 是前台进程**,退出时任务就没人管了;长期运行请交给 `nohup` / `systemd`。
* **`pause` 跨进程只报"未确认"**(退出码仍是 0),要看结果得 `status`。
* **任务定义必须来自模块的 `register(controller)`**,CLI 只传名字——一个能启动任意
可调用对象的入口等于任意代码执行。
完整走查(含真实输出)见 **[命令行控制台](cli.md)**。
### 8.11 重启恢复(reconcile)
进程意外退出后,存储里会留下一批永远停在 `running` 的任务——它们看着像在跑,其实
载体早就随进程消失了。**进程启动时先跑一次 `reconcile()` 把它们清理掉**:
```python
controller = Controller(store=SqliteTaskStore("tasks.db"), owner="worker-2")
report = controller.reconcile()
if report.lost:
log.warning("上次有 %d 个任务失去监督者", len(report.lost))
```
#### 判据是"还有人在管它吗",不是"它还在推进吗"
每个监督进程都会给自己负责的任务**续租**(`lease_owner` / `lease_until`,由 `touch`
写入)。于是:
| 情况 | 处置 |
|---|---|
| 租约未过期 | 有活着的进程在管,**一动不动** |
| 租约过期 + 任务**占着执行资源**(`starting`/`running`/`pausing`/`stopping`) | 载体已消失,判 `LOST` |
| 租约过期 + 任务**不占资源**(`queued`/`paused`/`retry_wait`) | 只是没人接手,**不改状态**,报告为 `adoptable` |
| 原本的监督者又活过来抢先写入 | 检测到版本冲突,**放手**(报 `skipped`) |
**绝不能用任务心跳当判据。** `last_heartbeat_at` 由任务自己的 `checkpoint()` 驱动,
一个长时间不调 checkpoint 的健康任务(纯 CPU 计算)心跳同样停滞——拿它判断进程存活,
会把一个正常任务判成 `LOST` 并**杀掉**。这正是租约与心跳必须分成两个字段的原因。
```python
report = controller.reconcile(dry_run=True) # 先看一眼,不写任何状态
report.lost # 会被(或已被)判为丢失的
report.adoptable # 需要重新提交的
report.skipped # 有人正在写、被跳过的
```
#### 它**不会**重新执行任务
这是能力边界,不是遗漏:**存储里记的是状态,不是配方**。任务的函数与参数从未落库
(序列化任意参数会带来不可反序列化、版本漂移等一堆问题),所以恢复一个失去载体的
任务在原理上不可能。
`reconcile()` 能做的、也是它应当做的,是**如实标记**——让看板不再永远显示一个假的
"运行中"。真正"重跑"要靠调用方自己重新 `submit`,而那需要调用方知道参数。
`lease_ttl`(默认 30 秒,可调)决定崩溃多久之后会被发现。
### 8.12 DAG 管道
把多个任务按依赖关系串起来:
```python
from chongming_task import Pipeline
from chongming_task.policies import AggregationPolicy, JoinMode
pipeline = Pipeline("nightly-etl", aggregation=AggregationPolicy.all_settled())
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, outcome.nodes)
```
> **第一个位置参数是已注册的任务名**,其后才是传给任务的位置参数。节点 id 由
> `node_id=` 指定;省略时默认取任务名,同名多次则自动加 `#2` / `#3` 后缀。
> `p.node("mark", "after")` 里的 `"after"` 是**传给 mark 的参数**,不是节点名——
> 这正是那个参数叫 `node_id` 而不是 `name` 的原因。
#### 三种汇合语义(逐节点)
| `JoinMode` | 何时放行 |
|---|---|
| `ALL_SUCCEEDED`(默认) | 全部上游成功——最常见的屏障 |
| `ALL_SETTLED` | 全部上游落终态即可,**不管成败**——用于清理、通知、汇总 |
| `ANY_SUCCEEDED` | 任一上游成功即可——多源冗余取数的先到先得 |
#### 两种结算方式(整条管道)
| `AggregationPolicy` | 行为 |
|---|---|
| `fail_fast()`(默认) | 首个真失败即**取消其余已经放行的节点** |
| `all_settled()` | 等所有节点落终态,再统一判定 |
`quorum` 会被**明确拒绝**:它描述的是"同一件事的 N 份副本成功 k 份",属于扇出分组而不是
一条节点各异的 DAG。与其发明一个没人能预测的语义,不如说清楚。
#### 等待上游的节点不占并发额度
依赖由一个**真正的任务状态** `blocked` 表达,而且等待中的节点不进待执行队列:
```python
assert controller.active_count == 1 # 只有根节点占额度
assert controller.queued_count == 0 # 下游还在 blocked,不是 queued
```
一条 20 个节点的串行管道不会因为"等待"而把额度全锁死。
#### 三种"没成功"必须分得清
| 状态 | 含义 |
|---|---|
| `skipped` | 上游没成功,它**本来就不该跑** |
| `cancelled` | 本来要跑,被 fail_fast **连坐**了 |
| `stopped` | 值班同学**手动停的** |
`CANCELLED` 刻意不作为 fail_fast 的触发条件,否则用户主动取消整条管道会被记成"失败",
被连坐的节点也会再触发一轮连坐,因果就搅成一团了。
#### 限制
管道**结构在内存里**,不落库(任务的函数与参数从未持久化)。进程重启后一条跑到一半的
管道无法续跑——节点会被 `reconcile()` 如实标记为丢失。
### 8.13 写一个任务:`@task` 与 `TaskContext`
#### `@task` 装饰器
```python
from chongming_task import RetryPolicy, Timeout, task
@task(tags={"io"}, retry=RetryPolicy(max_attempts=3), timeout=Timeout(per_attempt=60))
def extract(ctx, source: str) -> list[Row]:
"""从 source 读取数据。"""
...
```
描述默认取 **docstring 第一行**,名字默认取函数名。裸 `@task`(不带括号)也支持。
一次登记整个模块:
```python
controller.register_module(myapp.tasks) # 模块里所有 @task 都被登记
controller.register_all(extract, load) # 或者逐个登记
```
**它不产生全局注册表。** 规格只挂在函数对象上(`__chongming_task__`),谁注册、注册到
哪个控制器由调用方决定。全局注册表意味着 **import 即注册**,而 import 可能发生在任何
地方(包括测试收集阶段),那种隐式行为极难排查。
#### `TaskContext`
任务作者面对的就是这一个对象(公开名字 `TaskContext`,与内部的 `RuntimeContext` 是
**同一个类**):
| 成员 | 作用 |
|---|---|
| `ctx.checkpoint()` | 协作点:响应停止与暂停 + 刷新心跳。**不调用它就不响应停止**,也会被 `idle` 超时判为疑似卡死 |
| `ctx.acheckpoint()` | 异步版本,`async def` 任务里 `await` 它(执行器会把协程真的跑完) |
| `ctx.progress(done, *, total=, unit=, message=)` | 上报进度 |
| `ctx.log(msg, *, level=)` / `.warning` / `.error` | 记日志 |
| `ctx.logs` | 本任务最近 200 条日志(内存中) |
| `ctx.input(节点id, 默认值)` / `ctx.upstream` | 取上游节点的返回值 |
| `ctx.artifact_dir` | 本次尝试的产物目录(按 attempt 隔离) |
| `ctx.is_stop_requested` / `ctx.is_pause_requested` | 只想探一下、不想抛异常时用 |
#### 进度:进快照、进存储、不碰版本号
```python
for index, part in enumerate(partitions, start=1):
ctx.checkpoint()
ctx.progress(index, total=len(partitions), unit="partitions", message=part)
```
进度走**高频旁路**:不参与状态机、不递增 `status_version`,由 `Controller` 经 `touch`
定期回写到存储。所以它出现在 `ps` 的 `PROGRESS` 列,而不会把版本号变成噪声。
> **`progress()` 刻意不刷新心跳。** 心跳是"我还在推进"的整体信号,进度是"推进到哪了"。
> 把心跳藏进进度里,会让一个只报进度、不做实际工作的任务看起来永远健康——那正是
> `idle` 超时要抓的东西。
#### 日志:走标准库
```python
ctx.log("开始处理") # INFO
ctx.warning("分区为空,跳过")
ctx.error("写入失败")
```
logger 名是 `chongming_task.task.<task_id>`,并且 `task_id` / `task_name` / `attempt`
会作为 `extra` 附在 `LogRecord` 上,因此可以按任务维度配 handler、过滤或加字段:
```python
logging.getLogger("chongming_task.task").setLevel(logging.DEBUG)
```
`ctx.logs` 保留最近 200 条(内存中,进程退出即消失)。**它不是日志存储方案**——那属于
应用自己的事,挂个 handler 就能落盘或入库。
#### 上游数据
管道的下游节点可以直接取上游的返回值:
```python
@task
def transform(ctx, day: str) -> int:
partitions = ctx.input("extract", []) # extract 节点的返回值
...
```
只有**成功**的上游才会出现在 `ctx.upstream` 里。失败或跳过的上游没有值可传——塞一个
`None` 会让下游以为"上游返回了 None",而不是"上游没有值"。
#### 返回值必须可 JSON 序列化
返回值会**写入存储**(`outcome` 列),所以它必须是 JSON 可序列化的:
```python
@task
def good(ctx) -> dict[str, Any]:
return {"rows": 3, "path": "s3://bucket/x.parquet"} # ✅
@task
def bad(ctx) -> set[int]:
return {1, 2, 3} # ❌ set 不可序列化
```
违反时任务会**失败**,失败类型是 `result_invalid`,消息写明原因。大数据请传**指针**
(对象存储 key、表名 + 主键、文件路径)而不是内容。
> 为什么这么严:返回值不落库的话,进程内会显示 `succeeded` 而存储里停在 `running`
> ——随后重启恢复会把一个**真正跑完**的任务判成 `LOST`。把一个成功过的任务报成"结果
> 未知"会让人放心地重跑,而副作用已经发生过一次。所以宁可让它在返回时就响亮地失败。
标签同理:`tags` 必须是字符串(类型契约是 `frozenset[str]`),否则在提交时就会被拒绝。
**参数**暂时不强制可序列化——参数今天不落库,所以 `Path`、连接对象这类本地专用参数
仍然合法。(节点之间不互相执行对方的任务,所以也不存在"参数要跨机器传输"的问题。)
### 8.14 节点桶归属:多节点部署
多个节点各跑各的任务时,靠桶区分"哪一份是我的":
```python
controller = Controller(
store=SqliteTaskStore("tasks.db"),
owner="node-a",
owned_buckets=range(0, 16), # 本节点负责 0..15 号桶(按业务划分,可多于一个)
)
```
| 成员 | 说明 |
|---|---|
| `owned_buckets` | 本节点负责的桶;`None`(默认)表示不限定 |
| `owns_all_buckets` | 是否**不构成划分**:不限定,或显式覆盖了全部桶 |
| `owns_bucket(bucket)` | 某个桶是否归本节点 |
限定时有两条行为:
1. **提交时校验**——任务必须落在自己的桶里:
```python
controller.submit("sync", bucket_key="tenant-B")
# ConfigurationError: 桶 42(来自 bucket_key='tenant-B')不属于本节点;
# 本节点负责 [0, 1, ..., 15]。任务若写进别人的桶,不会有任何节点执行它。
```
这是**硬错误**而不是警告:落在别人桶里的任务不会有任何节点去跑它,它会永远停在
`queued`,而且**没有任何报错**。与其让它静默堆积,不如在提交那一刻就说清楚。
2. **`reconcile()` 只清理自己桶里的僵尸任务**,不去动别的节点的任务。
**覆盖全部桶不算划分。** 如果显式列举了全部桶(例如生成出来的"负责 0-63"配置),此时没有
任何"别的节点",无桶任务同样会且只会由本节点执行——因此不再强制 `bucket_key`。
**启动时会校验桶号范围**,越界立刻报错:放过去的症状同样是"任务永远排队且没有报错"。
> 声明了归属的节点会在**构造那一刻**就启动指令轮询,而不是等第一次提交。否则别人 `stop`
> 一个还排在队列里的任务时,指令要等到本节点第一次提交才被取走——对定时任务节点来说可能
> 是几小时之后。没有声明归属的纯查询客户端仍然不轮询,避免抢走别人的指令。
多节点部署的完整说明(能做什么、不能做什么、新增的风险、跨机部署)见
**[分布式设计说明](distributed-design.md)**。跨机只需换存储后端:把
`SqliteTaskStore("tasks.db")` 换成 `PostgresTaskStore("postgresql://...")`,
`owner` / `owned_buckets` / `reconcile()` / 跨节点控制全部照旧(见 §8.7)。
一份**可以直接照抄的完整模板**(部署参数与桶划分 `buckets_for_node()`、任务定义、
`build_node()` 接线、启动恢复、按桶提交、跨节点停止、收尾校验,全流程带断言)见
**[examples/11_best_practice.py](../examples/11_best_practice.py)**。
---
## 9. 状态模型参考
### 9.1 状态一览
**非终态**(9 个):
| 状态 | 含义 |
|---|---|
| `pending` | 已创建,依赖尚未解析 |
| `blocked` | 依赖未满足,等待上游 |
| `queued` | 就绪,等待执行槽;未启动的暂停也在恢复时回到这里 |
| `starting` | 执行器启动中 |
| `running` | 正在执行 |
| `pausing` | 已请求暂停,等待到达静默点 |
| `paused` | 已就地挂起 |
| `retry_wait` | 本次尝试失败,正在退避等待 |
| `stopping` | 已请求停止,处于优雅窗口内 |
**终态**(6 个,无出边):
| 状态 | 含义 |
|---|---|
| `succeeded` | 正常完成 |
| `failed` | 出错且重试耗尽,或遇到不可重试的错误 |
| `stopped` | **人工主动停止**(不是错误) |
| `cancelled` | **编排层级联取消**(如兄弟任务失败) |
| `skipped` | 条件为假或依策略跳过 |
| `lost` | 载体失联或被放弃,**结果未知** |
`stopped` 与 `cancelled` 分开,是因为两者责任方不同。排障时"这次跑批失败是人工
干预还是真故障"是个必须能回答的问题。
### 9.2 完整转移表
```python
PENDING: {BLOCKED, QUEUED, SKIPPED, CANCELLED, STOPPED}
BLOCKED: {QUEUED, SKIPPED, CANCELLED, STOPPED}
QUEUED: {STARTING, PAUSED, SKIPPED, STOPPED, CANCELLED}
STARTING: {RUNNING, RETRY_WAIT, PAUSING, STOPPING, FAILED, CANCELLED, LOST}
RUNNING: {SUCCEEDED, FAILED, RETRY_WAIT, PAUSING, STOPPING, CANCELLED, LOST}
PAUSING: {PAUSED, RUNNING, STOPPING, FAILED, CANCELLED, LOST}
PAUSED: {QUEUED, RUNNING, STOPPED, CANCELLED}
RETRY_WAIT: {STARTING, QUEUED, PAUSED, STOPPED, FAILED, CANCELLED}
STOPPING: {STOPPED, SUCCEEDED, FAILED, LOST}
SUCCEEDED / FAILED / STOPPED / CANCELLED / SKIPPED / LOST: {}
```
一条贯穿全表的规则:**控制动作是否需要中间态,取决于任务是否已占用执行资源**。
未占用(`pending`/`blocked`/`queued`/`paused`/`retry_wait`)→ 立即生效;已占用
(`starting`/`running`/`pausing`)→ 经中间态缓冲。让未启动的任务显示"停止中/暂停中"
是虚假信息,会让运维误以为需要等待。
### 9.3 转移表之外的守卫
有些规则无法用"状态 → 状态"表达,它们是**守卫**:
| 动作 | 守卫 | 理由 |
|---|---|---|
| `mark_running()` | 必须已有 `started_at` | `paused → running` 对就地恢复合法,对"排队期间被暂停、从未启动"却毫无意义 |
| `abort_pause()` | 必须处于 `pausing` | `paused → running` 是给*恢复*用的;把"回滚"也放进来会产出没有开始时间的 `running` 快照 |
| `mark_starting()` | `attempt + 1 <= max_attempts` | 否则抛 `AttemptsExhausted`(预算问题,不是状态表问题) |
| `force_transition()` | 必须给出非空 `actor` 与 `reason` | 这是唯一能任意跳转的入口,没有审计信息就无法事后追溯 |
| `mark_lost()` | 建议带 `failure` | `lost` 的全部意义是"不知道结果",不记录原因就无从判断该重跑还是人工核对 |
---
## 10. 错误与异常
```
TaskError
├─ InvalidTransition 非法状态转移(含守卫不通过)
├─ DependencyFailed 上游未成功
├─ CircularDependency 依赖成环
├─ TaskTimeout 超时
├─ TaskExecutionError 用户代码异常的统一包装(保留 __cause__)
├─ AttemptsExhausted 重试预算耗尽
├─ ConcurrencyConflict 乐观并发写冲突
├─ StoreError 存储层故障
└─ ConfigurationError 配置错误
TaskCancelled ← 独立:继承 BaseException,不是 TaskError
```
### 9.1 `TaskCancelled` 为什么继承 `BaseException`
因为它可能出现在用户代码的任何一行,而下面这种写法极其常见:
```python
try:
while True:
ctx.checkpoint()
work()
except Exception as exc: # 想捕获业务异常
log(exc) # 但会把停止信号一起吃掉
```
若 `TaskCancelled` 继承 `Exception`,任务将**永远无法被停止**。因此它和
`asyncio.CancelledError` 一样继承 `BaseException`:
```python
try:
...
except TaskCancelled:
raise # 取消要放行
except Exception as exc:
... # 只处理真正的业务异常
```
测试里有一条用例专门用 `except Exception` 包裹循环来验证取消不会被吞掉。
---
## 11. 限制与 FAQ
**Q:任务停不下来?**
检查它是否调用了 `ctx.checkpoint()`。不调用的任务只能等超时,然后落 `lost`。
**Q:为什么我的任务被判"疑似卡死"(`timeout_idle`)?**
`idle` 衡量的是 `checkpoint()` 间隔。长段无 `checkpoint()` 的计算会被误判——调大
`idle` 阈值,或提高 `checkpoint()` 频率。
**Q:`pause()` 返回 `False` 是什么情况?**
任务在窗口内没到达检查点,暂停**没生效**,状态已回滚为 `running`。这是如实告知,
不是 bug。
**Q:暂停会不会释放并发额度?**
不会。暂停是就地挂起,槽位仍被占用。
**Q:任务被判 `lost` 之后怎么办?**
`lost` 表示结果未知。系统不会替你猜——你可以 `state_machine.retry()` 重跑,也可以
人工核对副作用后 `force_transition()` 标记终态。
**Q:`wait(timeout=...)` 超时了,任务会被杀掉吗?**
不会。它只抛 `TimeoutError`,任务状态完全不变。要停止请显式 `stop()`。
**Q:能让任务跑到一半重启而不重跑吗?**
不能。没有用户态检查点机制(那属于 Dask/Ray 式的图重算,是明确的非目标)。重试
总是从头开始,请把任务设计成幂等或可断点续传。
---
## 12. 测试自己的任务
确定性时钟随包发布,你的测试不必 `sleep`:
```python
from chongming_task.testing import FakeClock
from chongming_task.run import run_task
from chongming_task.policies import Timeout
def test_idle_timeout_fires_without_waiting():
clock = FakeClock()
# 通过 clock 注入控制时间,配合 handle.heartbeat 即可在毫秒内验证超时逻辑
```
常用做法:
- `FakeClock` 注入 `TaskRun(..., clock=clock)`,用 `clock.advance(n)` 推进时间;
- `InlineExecutor` 让执行同步化,测试不必等线程;
- `RetryPolicy(backoff=FixedBackoff(0))` 去掉重试等待;
- 断言 `run.transitions`,而不是只断言最终状态——中间态才是本项目的价值所在。
---
## 下一步
已实现的能力以 [README 状态表](../README.md#当前状态务必先读)为准:单任务与 DAG 管道执行、
并发额度与排队、SQLite / Postgres 持久化与重启恢复、`@task` 装饰器与 `TaskContext`
(`log` / `progress` / `upstream`)、CLI 控制台、节点桶归属。
仍在计划中的部分:跨机的**运维告警**(权威桶声明、桶重复/空洞、孤儿任务,见
[分布式设计说明](distributed-design.md) §11.2–11.3)与 `transitions` 的归档策略
(规模上 `prune()` 不够用,见 [持久层实现指南](store-guide.md))。
设计细节与理由见 [设计文档](task-control-design.md)。