1314 lines
54 KiB
Markdown
1314 lines
54 KiB
Markdown
# 用户指南
|
||||
|
|
|
|||
|
|
面向使用者的完整说明。设计动机与取舍见 [设计文档](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)。
|