Files
chongming-utils-task/docs/task-control-design.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

1158 lines
72 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# chongming-task:Python 管道任务控制工具 — API 规划与架构方案
> 状态:设计草案 v1
> 目标读者:本项目的实现者与使用方
> 起点:仓库当前仅有 `src/chongming-task/enum.py`(6 个状态雏形)
---
## 0. 先决问题(必须先解决,否则无法进入实现)
| # | 问题 | 现状 | 结论 |
|---|---|---|---|
| 1 | 包目录名含连字符 | `src/chongming-task` | **必须改为 `src/chongming_task`**。`import chongming-task` 是语法错误,任何 `from chongming_task import ...` 都不可用。`pyproject.toml` 的 `packages = ["src/chongming-task"]` 同步修改(project name 可保留连字符,那是发行名,不是导入名)。 |
| 2 | 状态枚举语义重叠 | `FINISHED` / `STOPPED` 含义模糊 | `FINISHED` 无法区分"成功结束"与"任意终止",需拆为 `SUCCEEDED` / `FAILED` / `STOPPED` / `CANCELLED`。详见 §2。 |
| 3 | Python 版本下限 | `requires-python = ">=3.14"` | 3.14 过于激进:可选依赖(数据库驱动、HTTP 框架、观测后端)的 wheel 覆盖不确定。**建议放宽到 `>=3.11`**,除非明确无第三方依赖。 |
| 4 | `enum.py` 文件名 | 与标准库同名 | 虽因绝对导入而可用,但 `chongming_task.enum` 易误读。建议改名为 `states.py`,`enum.py` 保留为兼容 re-export。 |
---
## 1. 设计原则
1. **状态是唯一的真相来源,且只有一个写者。** 所有状态变更必须经过状态机(`StateMachine.apply(event)`),禁止业务代码直接改 `snapshot.status`。每次变更携带 `trigger` + `actor` + `reason`,天然可审计。
2. **"请求的动作"与"当前状态"分离。** `stop()` / `pause()` 是**请求**,不是瞬时赋值。运行中的任务需要时间响应,因此快照同时暴露 `status`(现在在哪)和 `requested_action`(想让它去哪),UI 才能显示"停止中/暂停中"。
3. **一个枚举只表达一件事。** 生命周期(`status`)、重试计数(`attempt`)、进度(`progress`)、健康(`last_heartbeat_at`)、结果(`failure`/`outcome`)是**正交维度**,绝不塞进同一个枚举。这是同类工具最常见的建模错误,也是"精细化控制"能否落地的前提。
4. **取消是协作式的,停止是有层级的。** 停止 = 请求 + 优雅窗口 + 如实收场;线程**不可强杀**,所以窗口用尽只能放弃并记 `LOST`,绝不谎报已停止(见 §5.4、§13.6)。
5. **内核无 I/O。** 领域层不 import `asyncio`、不碰数据库、不认识日志库。这样状态机可以用属性测试穷举验证。
6. **可插拔优于可配置。** 存储、执行器、事件出口都用 `Protocol` 定义,v1 只提供最小实现。
7. **v1 单进程。** 分布式调度是明确的非目标(§9),但接口预留(store / executor 抽象足以支撑后续远程化)。
---
## 2. 状态模型
### 2.1 `TaskStatus` 枚举
```python
from enum import StrEnum
class TaskStatus(StrEnum):
# ---- 非终态 ----
PENDING = "pending" # 已创建,依赖尚未解析
BLOCKED = "blocked" # 依赖未满足,等待上游
QUEUED = "queued" # 就绪,等待执行槽 / 暂停后待恢复
STARTING = "starting" # 执行器启动中(spawn / 建连 / 加载)
RUNNING = "running"
PAUSING = "pausing" # 已请求暂停,等待静默点
PAUSED = "paused" # 已静止,不再消耗资源
RETRY_WAIT = "retry_wait" # 本次尝试失败,退避等待中
STOPPING = "stopping" # 已请求停止,优雅窗口内
# ---- 终态 ----
SUCCEEDED = "succeeded" # 正常完成
FAILED = "failed" # 出错且重试耗尽 / 不可重试
STOPPED = "stopped" # 人工主动停止(不是错误)
CANCELLED = "cancelled" # 编排层级联取消(上游失败 / 管道取消)
SKIPPED = "skipped" # 条件为假 / 依策略跳过
LOST = "lost" # 执行载体失联(进程消失、心跳超时)
```
设计说明:
- **`STOPPED` 与 `CANCELLED` 分开**:两者都"没跑完",但责任方不同。`STOPPED` = 人对单个任务显式干预;`CANCELLED` = 编排层因上游失败/整体取消而级联触发。这对排障和统计("这次跑批失败是人工干预还是真故障")价值很高。
- **不设 `TIMED_OUT` 状态**。超时是**失败原因**而非生命周期阶段,放在 `Failure.kind = TIMEOUT` 里。否则 `TIMEOUT → RETRY_WAIT` 这种组合会逼出"超时但正在重试"这类无法用单一枚举表达的状态,导致枚举爆炸。
- **`LOST` 是终态但可被 `retry` 复活**:进程崩溃、心跳丢失时,系统无法确知结果,标记 `LOST` 而非 `FAILED` 是诚实的做法。
- 兼容旧枚举:`FINISHED` / `STOPPED` 旧名保留为别名,`FINISHED = "succeeded"`。
### 2.2 终止态与"可复活"
```python
TERMINAL = frozenset(
{
TaskStatus.SUCCEEDED,
TaskStatus.FAILED,
TaskStatus.STOPPED,
TaskStatus.CANCELLED,
TaskStatus.SKIPPED,
TaskStatus.LOST,
}
)
# 允许通过 retry 重新进入执行流程的终态
REVIVABLE = frozenset(
{
TaskStatus.FAILED,
TaskStatus.STOPPED,
TaskStatus.CANCELLED,
TaskStatus.LOST,
}
)
```
复活**不修改历史**:`retry()` 递增 `attempt`,写入新的 `AttemptRecord`,状态回到 `QUEUED`。历史尝试永远只追加、不覆盖。
### 2.3 合法转移表("精细控制"的核心资产)
以**数据**而非 `if/else` 表达,可测试、可内省、可生成文档:
```python
TRANSITIONS: Mapping[TaskStatus, frozenset[TaskStatus]] = {
PENDING: {BLOCKED, QUEUED, SKIPPED, CANCELLED, STOPPED},
BLOCKED: {QUEUED, SKIPPED, CANCELLED, STOPPED},
QUEUED: {STARTING, PAUSED, SKIPPED, STOPPED, CANCELLED},
STARTING: {RUNNING, RETRY_WAIT, PAUSING, STOPPING, FAILED, LOST},
RUNNING: {SUCCEEDED, FAILED, RETRY_WAIT, PAUSING, STOPPING, LOST},
PAUSING: {PAUSED, RUNNING, FAILED, STOPPING, LOST}, # RUNNING = 暂停被撤销
PAUSED: {QUEUED, RUNNING, STOPPED, CANCELLED}, # 恢复目标见下方 v1.3 说明
RETRY_WAIT: {STARTING, QUEUED, PAUSED, STOPPED, FAILED, CANCELLED},
STOPPING: {STOPPED, SUCCEEDED, FAILED, LOST}, # SUCCEEDED = 恰好跑完
# 终态:出边为空集。TERMINAL 由本表推导(出边为空者)得出,避免两处维护而不一致。
SUCCEEDED: frozenset(),
FAILED: frozenset(),
STOPPED: frozenset(),
CANCELLED: frozenset(),
SKIPPED: frozenset(),
LOST: frozenset(),
}
```
> **统一规则(实现期修正 v1.2)**:控制动作是否需要中间态,取决于任务是否
> **已占用执行资源**。
>
> - 未占用(`PENDING` / `BLOCKED` / `QUEUED` / `PAUSED` / `RETRY_WAIT`)→ 立即生效:
> `stop()` 直接落 `STOPPED`,`pause()` 直接落 `PAUSED`。
> - 已占用(`STARTING` / `RUNNING` / `PAUSING`)→ 经中间态缓冲:
> `stop()` 先 `STOPPING`,`pause()` 先 `PAUSING`。
> 这条规则替代了 v1.0 的做法。v1.0 只允许经 `CANCELLED` 离开未启动状态,导致
> "停止一个排队中的任务"无合法路径——而 `stop()` 是人为主动停止,语义上应落
> `STOPPED` 而非级联取消的 `CANCELLED`。同时 v1.0 允许 `QUEUED → PAUSING`,
> 会让暂停中的排队任务经 `abort_pause` 变成 `RUNNING`,从而产出**没有
> `started_at` 的 RUNNING 快照**(`validate()` 能抓到这个不一致)。
>
> 让未启动的任务显示"停止中/暂停中"是虚假信息,会让运维误以为需要等待;因此
> 宁可让表更精确,也不保留语义空洞的边。`started_at` 改由 `mark_starting()`
> 写入,使任何到达 `RUNNING` 的路径都必然带有开始时间。
> **暂停恢复语义修正(v1.3)**:`PAUSED` 增加了到 `RUNNING` 的边,恢复目标由
> `StateMachine.resume()` 按**暂停发生在哪一层**判定:
>
> | 暂停路径 | 恢复目标 | 理由 |
> |---|---|---|
> | `STARTING`/`RUNNING → PAUSING → PAUSED` | `RUNNING` | 就地暂停,执行槽从未释放,调用栈还在内存里 |
> | `QUEUED`/`RETRY_WAIT → PAUSED` | `QUEUED` | 从未启动过,需要重新排队 |
>
> v1.0–v1.2 主张"恢复必须重新争取执行槽,否则暂停会成为绕过并发限制的后门"。
> 这个理由**建立在一个不成立的假设上**:它假定暂停会释放槽位。而我们的实现是
> 就地协作暂停(任务阻塞在 `checkpoint()` 里),槽位从未释放,也不存在可用于
> 换出/换回调用栈的用户态检查点。因此强制回到 `QUEUED` 只会让状态与实际执行
> 情况脱节——任务仍在运行,状态却显示在排队,而且 `QUEUED → SUCCEEDED` 根本
> 不在转移表里。
>
> 这条修正连带暴露了两个必须用**守卫**(而非转移表)表达的区别:
>
> - `mark_running()` 要求已有 `started_at`。`PAUSED → RUNNING` 对就地恢复合法,
> 对"排队期间被暂停、从未启动"却毫无意义。
> - `abort_pause()` 只允许从 `PAUSING` 回滚。否则它会借 `PAUSED → RUNNING` 这条
> 边产出没有 `started_at` 的 `RUNNING` 快照。
>
> 后者是**属性测试**跑出来的:随机事件序列 `mark_queued → request_pause →
> abort_pause` 精确命中了这个组合,手写用例时完全没想到。
规则:
- 终态**没有**出边。任何"TERMINAL → 非终态"的写入都会被状态机拒绝,唯一的合法路径是 `retry()` / `reset()`(前者保留尝试历史,后者清空)。
- 非法转移抛 `InvalidTransition(current, requested, allowed)`,而不是静默忽略或返回 `False`。同时提供只读预检 `StateMachine.can()/can_stop/can_pause/can_resume/can_retry`,让 UI 能在发指令前就置灰按钮。
- 幂等性(对已处于目标态的任务重复 `stop()` 不报错)由**控制面**(P4 的 `Controller`)负责,不在状态机内处理——状态机只做"本次转移是否合法"的判断。
- 有些规则**无法**用"状态 → 状态"表达,它们是**守卫**:`mark_running()` 要求已有 `started_at`;`abort_pause()` 要求当前处于 `PAUSING`;`mark_starting()` 要求 `attempt + 1 <= max_attempts`(否则抛 `AttemptsExhausted`);`force_transition()` 要求非空 `actor` 与 `reason`。
### 2.4 快照模型(正交维度)
```python
@dataclass(frozen=True, slots=True)
class TaskSnapshot:
# 身份
task_id: str
pipeline_run_id: str | None
name: str
tags: frozenset[str]
# 生命周期
status: TaskStatus
status_version: int # 乐观并发版本号,每次转移 +1
requested_action: RequestedAction | None # STOP / PAUSE / None
# 尝试
attempt: int
max_attempts: int
# 时间
created_at: datetime
queued_at: datetime | None
started_at: datetime | None
ended_at: datetime | None
last_heartbeat_at: datetime | None
# 进度
progress: Progress | None # done/total/unit/message/updated_at
# 结果
failure: Failure | None # kind/message/traceback/retryable
outcome: Mapping[str, Any] | None
# 执行载体
executor: str | None # "inline" | "thread"
```
`Failure.kind` 枚举:`EXCEPTION` / `NONZERO_EXIT` / `TIMEOUT_START` / `TIMEOUT_ATTEMPT` / `TIMEOUT_IDLE` / `CANCELLED` / `LOST` / `DEPENDENCY_FAILED` / `CONDITION_FALSE`。
---
## 3. 控制面 API
API 分三层,职责不混:**声明层**(描述要做什么)、**控制层**(运行期干预)、**查询层**(观察)。
### 3.1 控制动词语义表
这是"精细化控制"的契约,实现前必须先冻结:
| 动词 | 合法前置状态 | 瞬时效果 | 最终状态 | 幂等 | 强制语义 |
|---|---|---|---|---|---|
| `start()` | PENDING, BLOCKED | 解析依赖 → QUEUED | QUEUED | 是 | — |
| `stop(graceful, timeout)` | 任意非终态 | `requested_action=STOP`,status→STOPPING;向取消令牌发信号 | STOPPED / LOST | 是 | 超 `timeout` 未退 → 线程不可强杀:放弃等待并记 `LOST`(结果未知) |
| `pause(timeout)` | RUNNING, QUEUED | status→PAUSING;令牌置暂停位 | PAUSED | 是 | 超时未静默 → 回滚为 RUNNING 并告警 |
| `resume()` | PAUSED | status→QUEUED | RUNNING | 是 | — |
| `cancel(reason)` | 任意非终态 | 编排层级联 | CANCELLED | 是 | 上游失败时的自动行为 |
| `retry(force)` | FAILED, STOPPED, CANCELLED, LOST | `attempt += 1`,新 AttemptRecord | QUEUED | 是 | `force=True` 可越过 `max_attempts` |
| `skip(reason)` | PENDING, BLOCKED, FAILED | — | SKIPPED | 是 | — |
| `reset()` | 任意 | 清空 attempts,`attempt=0` | PENDING | 是 | 丢失全部历史(危险,需二次确认) |
| `wait(timeout)` | 任意 | 阻塞至终态 | — | 是 | 超时抛 `TimeoutError`,**不改变任务状态** |
| `force_transition(to, reason, actor)` | 任意 | 绕过转移表,写入 `AdminOverride` 审计事件 | 指定状态 | 否 | 唯一逃生舱,必须带 `actor` 与 `reason` |
要点:
- **`wait()` 必须有超时且不产生副作用**。这是最常见的误用点(无限阻塞的 `wait()` 会拖死调用方)。
- **`force_transition` 是必需的,不是妥协**。现实运维需要"这个任务卡住了,人工标记为成功/失败"的能力。但必须:写审计事件、要求非空 `reason`、在快照上打 `overridden` 标记。
- 停止语义分三层:`requested_action` 记录意图 → `STOPPING` 表示优雅窗口 → 窗口用尽后**如实收场**:配合的任务落 `STOPPED`,不配合的落 `LOST`(`Failure.kind = LOST`,结果未知),不留任何假装已停止的空档。
### 3.2 控制层代码示例
```python
from chongming_task import RetryPolicy, Timeout
handle = controller.submit(
my_task,
args=(...),
retry=RetryPolicy(max_attempts=3),
timeout=Timeout(per_attempt=300, idle=60),
)
handle.status # TaskStatus.RUNNING
handle.progress # Progress(done=120, total=500, unit="rows")
controller.can(handle.id, "pause") # (True, None)
handle.pause(timeout=30) # -> PAUSED
handle.resume() # -> RUNNING
handle.stop(graceful=True, timeout=10) # -> STOPPED
controller.retry(handle.id) # -> QUEUED (attempt=2)
snap = controller.get(handle.id)
controller.force_transition(handle.id, TaskStatus.SUCCEEDED, reason="已人工核验", actor="ops@x")
```
异步版本完全对应:`await controller.submit(...)`、`await handle.wait()`。同步外观只是 async 内核对 `asyncio.run`/thread-local loop 的薄封装,**不是两套实现**。
### 3.3 声明层:`@task` 与 `Pipeline`
```python
from chongming_task import task, TaskContext
@task(
retry=RetryPolicy(max_attempts=3, backoff="exponential"),
timeout=Timeout(start=30, per_attempt=600),
tags={"io"},
)
def extract(ctx: TaskContext, source: str) -> list[Row]:
ctx.log.info("开始抽取", source=source)
rows = read(source)
ctx.progress(total=len(rows))
for i, row in enumerate(rows):
ctx.checkpoint() # 协作取消/暂停点;被请求停止时抛 TaskCancelled
ctx.progress(done=i + 1)
ctx.emit("row", row) # 可选:中途产出(流式结果)
return rows
```
`TaskContext` 是运行期唯一被注入的对象,职责明确:
| 成员 | 作用 |
|---|---|
| `task_id` / `pipeline_run_id` / `attempt` / `name` | 身份与重试可见性 |
| `log` | 已绑定 task 上下文字段的结构化 logger |
| `progress(done=None, total=None, unit=None, message=None)` | 进度上报(自动节流) |
| `checkpoint()` | 协作式取消/暂停点。收到停止请求时抛 `TaskCancelled`;请求暂停时阻塞至恢复 |
| `upstream(name)` | 读取上游步骤结果(类型标注用 `cast`) |
| `artifacts` | 该次尝试的产物目录(`Path`),随 attempt 隔离,重试不污染 |
| `emit(kind, payload)` | 发出自定义事件(进事件总线) |
| `deadline` | 本次尝试的绝对截止时间,便于长循环内部自行收敛 |
**约定:长耗时循环必须调用 `checkpoint()`。** 不调用 = 无法优雅停止,只能等超时(`idle`)并被判 `LOST`——线程没有强杀这条后路。这条要写进文档并在 `--strict` 模式下用静态检查辅助(检测 `@task` 函数体内是否存在无 `checkpoint` 的循环)。
管道(DAG):
```python
pipe = Pipeline("nightly-etl", on_failure=AggregationPolicy.fail_fast)
a = pipe.add(extract, args=("s3://bucket/x",))
b = pipe.add(transform, deps=[a], retry=RetryPolicy(max_attempts=5))
c = pipe.add(load, deps=[b], when=lambda up: up[a].row_count > 0) # 条件执行 -> 否则 SKIPPED
d = pipe.map(transform_one, items=up[a].rows, deps=[a], max_parallel=8) # 扇出
run = pipe.submit(controller) # -> PipelineRun
run.status # PipelineRunStatus,由子任务状态按策略聚合
run.wait(timeout=3600)
run.summary() # 各状态计数 + 首个失败链
```
管道级补充控制:`run.pause()` / `run.stop()` 级联到所有非终态子任务;`on_failure` 策略决定上游失败时下游是 `CANCELLED` 还是 `SKIPPED`。
### 3.4 查询层
```python
controller.get(task_id) -> TaskSnapshot
controller.list_tasks(status={RUNNING, RETRY_WAIT}, tag="io", name_prefix=..., limit=100)
controller.stats(pipeline_run_id=...) -> StatusCounts
controller.can(task_id, action) -> tuple[bool, str | None]
controller.events(task_id, since=..., types={...}) -> Iterator[Event]
controller.attempts(task_id) -> list[AttemptRecord]
controller.watch(filter, callback) / async for ev in controller.stream(filter) # 实时订阅
```
---
## 4. 事件与可观测性
### 4.1 事件模型
```python
@dataclass(frozen=True, slots=True)
class TaskStatusChanged(Event):
task_id: str
from_status: TaskStatus
to_status: TaskStatus
trigger: str # "user:stop" | "scheduler:dispatch" | "retry:exhausted" | ...
actor: str
reason: str | None
at: datetime
```
事件类型全集:`TaskCreated`、`TaskStatusChanged`、`AttemptStarted`、`AttemptFailed`、`ProgressUpdated`、`TaskHeartbeat`、`TaskOutput`、`TaskLogLine`、`PipelineRunStatusChanged`、`AdminOverride`。
三条工程约束:
1. **进度事件默认节流**(≥500ms 或 ≥1% 变化才发)。不加节流,10 万行数据的管道会用进度事件把存储打爆。
2. **`TaskStatusChanged` 先落存储、后发总线**(write-ahead)。跨进程场景用 outbox 表保证至少一次投递;消费者需幂等(事件带 `event_id`)。
3. 总线只做分发,**订阅者异常不得影响任务执行**(捕获并降级记录),否则一个坏订阅者能搞挂整个管道。
### 4.2 观测挂点
内核只定义 `Protocol`,不绑定任何后端:
- 日志:标准 `logging` + 结构化字段注入(`task_id`/`run_id`/`attempt`)。JSON formatter 作为可选实现。
- 指标:`MetricsSink.incr(name, tags)` / `observe(name, value, tags)`。默认 `NullSink`,可选 Prometheus/OTel 适配器(独立模块,不进核心依赖)。
- 追踪:`Tracer.span(name, attrs)`,每个 attempt 一个 span。
**核心包零第三方运行时依赖**是可维护性的关键。
---
## 5. 架构方案
### 5.1 分层
```
┌──────────────────────────────────────────────────────┐
│ cli/ run | ps | status | stop | pause | │ 可选
│ resume | retry | logs | events │
├──────────────────────────────────────────────────────┤
│ api/ 装饰器 · Pipeline · Handle · │
│ Controller(门面) · TaskContext │
├──────────────────────────────────────────────────────┤
│ runtime/ 调度器 · 状态机 · 取消令牌 · │
│ 心跳 · 执行器适配 · 事件总线 │
├──────────────────────────────────────────────────────┤
│ stores/ memory | sqlite | (redis) │
├──────────────────────────────────────────────────────┤
│ core/ 状态枚举 · 转移表 · 快照 · 策略 · │ 零 I/O
│ 错误体系 · 事件类型 · Protocol │
└──────────────────────────────────────────────────────┘
```
依赖方向严格单向 `core ← stores/runtime ← api ← cli`。用 `import-linter` 在 CI 中强制,防止内核被 I/O 污染。
### 5.2 数据流(一次任务的生命周期)
```
submit() ──► Store.create(PENDING) ──► Event(TaskCreated)
│
▼
Scheduler.tick: 依赖就绪? ──► BLOCKED/QUEUED ──► 取并发槽
│
▼
状态机: QUEUED → STARTING ──► Executor.launch(ctx, token) ──► RUNNING
│ │
│ Heartbeat(周期) ──► last_heartbeat_at
│ │
│ ctx.progress / ctx.emit ──► 事件(节流)
▼
返回 / 抛异常 / 被取消 / 超时 / 心跳丢失
│
▼
状态机: RUNNING → SUCCEEDED | FAILED | RETRY_WAIT | STOPPED | LOST
│
▼
Store 写终态 ──► Event ──► 唤醒下游依赖 / 触发 Pipeline 聚合
```
### 5.3 并发模型
> **本节记录最初的设计意图,其中的 asyncio 内核没有被采纳。** 最终落地的是
> **线程执行器 + 同步调度器**(§13):调度器用一个专职派发线程与一把锁管理就绪队列,
> 执行器每个任务起一个守护线程。要监督的本来就是我方**阻塞式业务代码**(数据库、
> HTTP、文件),先搬进事件循环再 `to_thread` 卸载只会多一层;而取消、暂停、超时
> 在"每个任务一个线程"下已经足够干净。
>
> 下面的分析与选型作为**决策记录**保留。最终落地的执行器有三个(`Inline` / `Thread` /
> `Process`):`SubprocessExecutor` 从未实现,`AsyncExecutor` 不做(异步能力落在执行器里:
> 协程在本线程跑完,见 §13.5)。`ProcessExecutor` 的取舍与决策史见 §13.10。
**asyncio 单事件循环为内核**(未采纳),理由:DAG 调度、超时、优雅取消、心跳在 asyncio 下都远比线程池干净;线程池方案无法取消、无法精确超时。
- 同步阻塞型任务:`AsyncExecutor` 内用 `asyncio.to_thread` 卸载,配**独立的有界线程池**(默认 `min(32, cpu*4)`),避免耗尽默认执行器影响整个进程。(未实现)
- 调度器**事件驱动**,不做忙轮询:依赖满足、槽释放、心跳、超时都通过事件/定时器唤醒。(部分成立:现在的调度器是事件唤醒 + 固定间隔兜底重扫,见 `Controller._dispatch_loop`)
`ConcurrencyPolicy` 支持:全局 `max_parallel`、按 `tags` 的具名信号量(如 `db=4`、`gpu=1`)、优先级(高优先进就绪队列)——**已交付**,见 §13.5。
### 5.4 取消与停止(最容易被低估的部分)
三层机制,逐层兜底:
1. **协作式**:`CancellationToken` → `ctx.checkpoint()` 抛 `TaskCancelled`。干净、可回滚。
2. **优雅窗口**:`stop(graceful=True, timeout=T)`,给任务 T 秒收尾(写检查点、关连接)。
3. **强制兜底**:超时后强杀。**这里必须诚实**:
- 子进程 → `terminate()` → 宽限 → `kill()`(`ProcessExecutor`,见 §13.10)。
- 线程 → **Python 无法安全强杀线程**。因此 `ThreadExecutor` 的强制停止**只能**放弃等待,并返回 `abandoned=True`(结果未知,落 `LOST`)。这是架构级限制,不是实现偷懒。
- 结论:**硬性强杀是执行器的可选能力,不是默认前提**。默认走线程 + 协作式取消即可停止;只有确实需要"停止保证"或需要绕开 GIL 的任务才升级到进程。选型矩阵见 §13.2。
**暂停的诚实定义**:`pause()` = ① 不再向该任务的下游派发新工作;② 运行中的任务在下一个 `checkpoint()` 处挂起。对于**不调用 `checkpoint()` 的不可中断同步代码,暂停无法生效**,超时后回滚为 `RUNNING` 并告警。我们**不做**抢占式暂停(POSIX 的 `SIGSTOP` 不跨平台,且会冻结持锁线程)。这个限制必须明说,否则用户会误以为 `pause()` 是万能的。
### 5.5 重试、超时与恢复
- `RetryPolicy(max_attempts, backoff=Exponential(base=1, factor=2, max=300, jitter="full"), retry_on=(TransientError,), give_up_on=(ValueError,))`。默认 `jitter="full"`,避免重试风暴同步化。
- `Timeout(start=, per_attempt=, idle=)`:`idle` 基于心跳判定"卡死",是唯一能发现死锁的机制,三者独立可组合。
- 恢复:进程启动时 `controller.reconcile()` 扫描非终态任务,心跳超 `idle_timeout` 者按 `on_orphan` 策略处理:`fail`(默认,保守)| `requeue` | `manual`。**不做静默续跑**——无法确知上次副作用是否已提交时,猜错比报错更贵。
### 5.6 持久化
契约见 [stores/protocol.py](src/chongming_task/stores/protocol.py)。**四类数据刻意
分开**,因为它们的写入频次与一致性要求完全不同:
```python
class TaskStore(Protocol):
# 1. 任务快照——查询面,参与乐观并发
def create(self, snapshot: TaskSnapshot) -> None: ...
def save(
self, snapshot: TaskSnapshot, *, expect_version: int
) -> None: ... # 冲突抛 ConcurrencyConflict
def get(self, task_id: str) -> TaskSnapshot | None: ...
def query(self, query: TaskQuery | None = None) -> list[TaskSnapshot]: ...
def counts(self) -> dict[TaskStatus, int]: ...
# 2. 审计轨迹——只追加,重复写入幂等
def append(self, transition: Transition) -> None: ...
def history(self, task_id: str, *, since_version: int = 0) -> list[Transition]: ...
# 3. 高频旁路——不校验也不递增 status_version
def touch(self, task_id, *, progress=None, heartbeat_at=None) -> None: ...
# 4. 控制指令——跨进程控制的媒介,带租约
def submit_command(self, command: Command) -> None: ...
def claim_commands(self, *, now, lease_seconds, limit=50) -> list[Command]: ...
def complete_command(self, command_id, *, now, outcome=None) -> None: ...
# 运维
def prune(self, *, keep_terminal: int) -> int: ...
def close(self) -> None: ...
```
三个取舍值得记下来:
* **`touch` 不碰版本号。** 进度与心跳可能每秒几十次;若参与乐观并发,会与状态写入
持续冲突,把 `status_version` 变成噪声。
* **指令带租约而不是用 IPC 推送。** 租约到期即可重取:既不会重复下发,也不会因为
取走指令的进程崩溃而永久卡住。存储层**只传递、不解释、不执行**——执行者是持有该
任务的进程。
* **查询与写入分成两套方法,而不是一个通用 `update`。** 状态写入必须严格按版本,
高频旁路必须绕过版本;混在一个方法里,迟早会有人在错误的路径上用了错误的语义。
实现:`InMemoryTaskStore`(测试 / 单进程 / 其他实现的参照物)、`SqliteTaskStore`
(标准库 `sqlite3`,WAL + `BEGIN IMMEDIATE` 实现跨进程原子取指令)。并发正确性靠
`status_version` 乐观锁;每个任务的写入者唯一,冲突即代表 bug,抛
`ConcurrencyConflict` 而非重试掩盖。
**契约测试随包发布**:`chongming_task.testing.store_contract.StoreContract`。第三方
后端只要通过这一套用例就可以替换内置实现——这是"持久层可扩展"能成立的前提。
面向后端作者的完整规格(14 个方法的逐条语义、并发与原子性要求、序列化保真、可执行
范例、陷阱清单)见 **[持久层实现指南](store-guide.md)**。
分布式部分的现状、边界与落地清单见 **[分布式设计说明](distributed-design.md)**。选定架构是
**多个自治的定时任务节点 + 一套统一的存储与控制平面**:任务定义与触发方式各节点自有,
靠 `owned_buckets` 区分"哪一份是我的";节点之间**不互相执行对方的任务**(被否决的集群方案
及其原因见该文 §17)。
### 5.7 目录结构
```
src/chongming_task/
__init__.py # 收敛公开 API(__all__ 显式声明)
clock.py # Clock / utcnow:可注入时钟
errors.py # 错误体系
states.py # TaskStatus / TRANSITIONS / TERMINAL(终态由表推导)
models.py # TaskSnapshot / Progress / Failure / AttemptRecord
policies.py # RetryPolicy / Timeout / ConcurrencyPolicy / AggregationPolicy
controller.py # TaskRegistry / Controller / StatusCounts / render_table(已可用)
run.py # run_task() / TaskRun:单任务受控执行(已可用)
core/
state_machine.py # 唯一写者:apply(event) -> Transition
graph.py # DAG 构建、环检测、拓扑就绪判定(P3)
runtime/
cancellation.py # CancellationToken:协作式停止/暂停
context.py # RuntimeContext:checkpoint / 心跳的唯一入口
heartbeat.py # Heartbeat
timeouts.py # 三档超时的纯计算判定
scheduler.py # 调度器(P3)
reconciler.py # 重启恢复(P5)
executors/
base.py # ExecutionRequest / Outcome / Handle / LocalExecutionHandle
thread.py # 线程执行器(默认)
inline.py # 内联执行器
events.py # 事件类型 + EventBus(P4)
stores/ # 持久层(已可用)
protocol.py # TaskStore 契约 / TaskQuery / Command / CommandKind
_codec.py # SQLite 行编解码(快照 / 迁移 / 指令)
memory.py # InMemoryTaskStore
sqlite.py # SqliteTaskStore(标准库 sqlite3)
api/ # decorators / pipeline / handles / controller(P4)
observability/ # logging / metrics / tracing(P6)
testing/
clock.py # FakeClock,随包发布,使用者的测试也能用
store_contract.py # TaskStore 契约套件,第三方后端据此自证(零 pytest 依赖)
cli/ # main.py(P6)
tests/
unit/ property/ integration/ e2e/ contract/
docs/
task-control-design.md # 本文件:设计动机、状态模型、架构、里程碑
user-guide.md # 面向使用者的完整说明
store-guide.md # 面向持久层实现者的接口规格与陷阱清单
examples/
01_quickstart.py # 跑一个任务并观察状态轨迹
02_control.py # 暂停 / 恢复 / 停止,以及"停不掉时如实上报"
03_retry_and_timeout.py # 重试、预算耗尽、超时
04_controller.py # 注册 / 提交 / 并发排队 / 查询 / 控制
```
---
## 6. 错误体系
```python
class TaskError(Exception): ...
class InvalidTransition(TaskError): # current / requested / allowed
class DependencyFailed(TaskError): # upstream_ids, their Failure
class CircularDependency(TaskError): # cycle path
class TaskCancelled(TaskError): # 协作取消信号(在任务内部抛出)
class TaskTimeout(TaskError): # scope: start | attempt | idle
class TaskExecutionError(TaskError): # 包装用户异常,保留 __cause__
class ConcurrencyConflict(TaskError): # expected_version / actual_version
class StoreError(TaskError): ...
class ConfigurationError(TaskError): ... # 如循环依赖、无效策略组合
```
约定:**用户代码的异常必须被包装并保留 `__cause__`**,绝不吞掉 traceback(任务失败后 `Failure.traceback` 必须完整可读)。
---
## 7. 实现计划
### 7.1 里程碑
| 里程碑 | 阶段 | 交付物 | 验收标准 | 估时 |
|---|---|---|---|---|
| **M1 行走骨架** | P0 | 包重命名、ruff/mypy/pytest/hypothesis 配置、CI | `uv run pytest` 绿;`import chongming_task` 成功 | 0.5d |
| | P1 | `states.py` `models.py` `policies.py` `errors.py` + 状态机 | 转移表 100% 分支覆盖;hypothesis 属性测试通过(无终态逃逸) | 1.5d |
| | P2 | 取消令牌、超时、心跳、`Inline`/`Thread`/`Process` 执行器 | 可跑通"带重试+超时+取消"的单个任务;不配合的线程记 `LOST` 且不阻塞退出,不配合的子进程被真的杀掉 | 2d |
| **M2 控制面** | P3 | 调度器、DAG、依赖、重试退避、并发限流、管道聚合 | 100 节点随机 DAG 压力测试无死锁;`max_parallel` 与具名信号量被遵守 | 2.5d |
| | P4 | `Controller` 门面、句柄、事件总线、钩子、`force_transition` + 审计 | §3.1 语义表逐条有测试;每个动词都有幂等性与非法前置状态用例 | 1.5d |
| **M3 可运维** | P5 | `SqliteStore`、版本冲突、`reconcile()` 恢复 | 杀进程后重启,非终态任务被正确判定为 LOST 并按策略处理 | 2d |
| | P6 | CLI(`run/ps/status/stop/pause/resume/retry/logs/events`)、日志与指标 sink | CLI 端到端演示:启动 → 观察 → 暂停 → 恢复 → 停止 → 重试 | 1.5d |
| **M4 服务化(可选)** | P7 | HTTP/ASGI 控制面、远程执行器草案 | OpenAPI 覆盖全部控制动词 | 待评估 |
到 P6 合计约 **11.5 人日**。
### 7.2 分阶段任务细目
**P1 领域内核**:`TaskStatus` + 别名 → `TRANSITIONS` 表 + `TERMINAL`/`REVIVABLE` → `Transition` 对象(携带 trigger/actor/reason/version)→ `StateMachine.apply()` → 快照与策略数据类 → 错误体系。
*验收*:`tests/property/test_state_machine.py` 用 hypothesis 随机生成事件序列,断言:终态不可离开、`status_version` 单调递增、任何被接受的转移都在表中、被拒绝的转移状态零变化。
**P2 执行运行时**:`CancellationToken`(支持 stop/pause 两种信号,`checkpoint()` 语义)→ `Deadline`/`Timeout` 监视器 → `HeartbeatReporter` → `Executor` Protocol(`launch` / `signal_graceful` / `force_kill` / `join`)→ 实现(**最终只有两个**:`ThreadExecutor` 与 `InlineExecutor`)。
*验收*:任务在协作路径上能被停止/暂停;不配合的线程被**如实**记 `LOST`(`abandoned=True`)且不阻塞进程退出。原计划的 `ProcessExecutor` 验收项已作废(§13.10)。
**P3 调度与 DAG**:`Graph`(建图、环检测、就绪队列)→ `Scheduler`(事件驱动 + 并发槽 + 优先级)→ 退避重试 → `AggregationPolicy`(`fail_fast` / `all_settled` / `quorum(n)`)→ 扇出 `map`。
*验收*:随机 DAG(含菱形、长链、宽扇出)压力测试无死锁、无重复执行;`fail_fast` 下上游失败后下游正确进入 `CANCELLED`/`SKIPPED`。
**P4 控制面 API**:`TaskContext` → `@task` 装饰器(签名解析、参数校验)→ `Pipeline` DSL → `TaskHandle`/`PipelineRun` → `Controller` 门面 → `EventBus` → 生命周期钩子(`on_start/on_success/on_failure/on_retry/on_cancel`)。
*验收*:§3.1 每个动词 × 每个前置状态 的参数化测试矩阵全绿。
**P5 持久化**:`TaskStore` Protocol → `InMemoryStore` → `SqliteStore`(WAL + 索引:`status`、`pipeline_run_id`、`created_at`)→ `AttemptRecord` 追加 → `Reconciler`。
*验收*:契约测试套件对两个 store 实现同样通过;进程被 `kill -9` 后重启,孤儿任务按 `on_orphan` 策略正确落终态。
**P6 CLI 与观测**:`typer`(或 argparse,保持零依赖则用 argparse)CLI → 结构化日志 formatter → `MetricsSink` 挂点 → `NullSink` 默认。
*验收*:演示脚本完整走一遍控制流,`--json` 输出可被脚本消费。
### 7.3 测试策略
| 层次 | 手段 | 目标 |
|---|---|---|
| 属性测试 | hypothesis 状态机测试 | 转移表是唯一真相,穷举非法序列不可破坏不变量 |
| 时间相关 | `FakeClock` 注入 | 重试退避/超时/心跳测试在毫秒内完成,不 `sleep` |
| 故障注入 | 会抛异常 / 会挂起 / 会丢心跳的假执行器 | 失败路径覆盖,而非只测 happy path |
| 契约测试 | 同一套 suite 跑所有 `TaskStore` / `Executor` 实现 | 新实现不偏离语义 |
| 集成 | 真实子进程的 `SIGTERM`/`kill`→Windows `CTRL_BREAK_EVENT`/`taskkill` | 强杀路径真的能工作 |
| 端到端 | 小规模真实管道(抽取→转换→加载,含一次失败重试) | 用户视角可用性 |
CI 门槛:ruff(lint+format)、mypy strict(`core/` 必须零豁免)、pytest + 覆盖率 ≥85%(`core/` ≥95%)。
### 7.4 建议的起步动作(本周可做)
1. 改包目录 `src/chongming-task` → `src/chongming_task`,同步 `pyproject.toml`;确认 `uv sync` 与 `import chongming_task` 通过。
2. 冻结 §3.1 的动词语义表与 §2.3 的转移表(**这是最该先评审的产物**,改它的成本随时间指数上升)。
3. 完成 P1 + P2 的最小切片,跑通一个带重试与取消的 `asyncio` 任务,作为可演示骨架。
---
## 8. 风险与对策
| # | 风险 | 影响 | 对策 |
|---|---|---|---|
| 8.1 | 用户同步代码不调 `checkpoint()` | `stop()`/`pause()` 只能等超时(`idle`)并落 `LOST`,体验落差 | 文档强约定(README §已知限制、用户指南 §2.2、`ctx.checkpoint()` 的 docstring);后续可做 `--strict` 静态提示 |
| 8.2 | 线程不可强杀 | 无法保证停止 | **接受**(架构级限制):如实标记 `LOST`;用 `timeout` + 幂等设计兜底;不再提供进程执行器(§13.10) |
| 8.3 | Windows 进程终止语义不同 | 优雅停止可能拿不到信号处理机会 | 平台分支:POSIX `SIGTERM`;Windows `CTRL_BREAK_EVENT`(需 console group)或直接 `taskkill /F`,并把 `forced=True` 如实记录 |
| 8.4 | Python 3.14 过新 | 可选依赖 wheel 缺失,阻塞 P5/P6 | 放宽到 `>=3.11`;核心包零依赖,把风险限制在可选适配器 |
| 8.5 | 状态并发写 | 状态错乱、事件重复 | 单写者 + `status_version` 乐观锁;冲突即抛错 |
| 8.6 | 进度/日志事件洪水 | 存储与总线被压垮 | 默认节流 + 采样 + 有界队列(满则丢弃并计数,不阻塞任务) |
| 8.7 | 范围膨胀(想做分布式/Web UI) | 里程碑失控 | §9 非目标写死;P7 之前不引入网络与多进程调度 |
| 8.8 | 暂停语义被误解为抢占式 | 用户预期落空 | 文档显式声明"边界暂停 + 协作挂起,非抢占" |
---
## 9. 非目标(v1 明确不做)
- 分布式调度、跨机器 worker、调度高可用
- 抢占式暂停(含 POSIX `SIGSTOP`)
- Web UI / 图形化编排
- 任意 Python 代码的自动检查点与图重算(Dask/Ray 式)
- 数据血缘、列级血缘
- 多租户隔离与权限体系
这些留作 v2+ 的扩展点:`Executor` 与 `TaskStore` 抽象已为其预留位置。
---
## 10. 待确认的决策点
| # | 问题 | 结论 |
|---|---|---|
| D1 | 主场景是什么? | **(c) 两者都要。** 执行器与 DAG 在 v1 即解耦:`Executor` 抽象同时覆盖进程内函数与外部长驻命令,调度层不认识执行器差异。 |
| D2 | v1 是否需要重启恢复(持久化)? | **需要。** `TaskStore` 语义在 M1 就冻结,P5(SQLite + `reconcile()`)留在 M3 但不可砍。工期维持 ~11.5d。 |
| D3 | Python 版本下限与第三方依赖策略? | **`requires-python = ">=3.11"`**(已改)。核心包零第三方运行时依赖,观测/存储适配器独立。已设 `ruff`/`mypy` 的 `target-version = py311`,防止误用 3.12+ 语法。 |
| D4 | 是否同意重命名包目录为 `chongming_task`? | **已同意且已生效**,`pyproject.toml` 的 `packages` 同步为 `["src/chongming_task"]`。 |
| D5 | 控制面形态? | **纯库 + CLI。** v1 不做 HTTP 服务;P6 落地 CLI,P7(服务化)保持可选。 |
| D6 | 暂停能力期望? | **已定案:协作式暂停(边界暂停 + 协作挂起),不做抢占式。** 详细语义与取舍见 §11。P2 的 `CancellationToken` 已按此实现。 |
---
## 11. 暂停语义详解(对应 D6)
> **结论(已定案)**:采用**协作式暂停**,不做抢占式。以下解释保留,用于说明
> 为什么这样选,以及使用者需要遵守哪些约束。
### 11.1 两种截然不同的"暂停"
`pause()` 有两种可能实现,能力边界差别很大,必须先分清:
| | 抢占式暂停 | 协作式暂停 |
|---|---|---|
| 实现手段 | POSIX `SIGSTOP` 冻结进程 | 任务在检查点主动检查暂停标志并挂起 |
| 生效时机 | 立即(任意指令处) | 下一个 `ctx.checkpoint()` |
| 跨平台 | **否**(Windows 无 `SIGSTOP` 等价物) | 是 |
| 持锁风险 | **高**:冻结时若持有锁/事务,会阻塞其他任务 | 无(任务自己挑安全点) |
| 对不检查标志的同步代码 | 有效 | **无效** |
| 实现复杂度 | 低(发信号) | 中(令牌 + 检查点 + 状态往返) |
### 11.2 本方案的选择:协作式 + 边界暂停
`controller.pause(task_id)` 的实际语义是**两件事**:
1. **不再派发新工作**——该任务的下游不会获得新的执行槽;管道级 `pause()` 停止向所有未开始的任务派发。
2. **运行中的任务在下一个检查点挂起**——`ctx.checkpoint()` 检测到暂停请求后阻塞,直到 `resume()` 或 `stop()`。
状态往返:`RUNNING → PAUSING`(已请求、尚未静默)→ `PAUSED`(真正停住)。
恢复目标取决于**暂停发生在哪一层**(详见 §2.3 的 v1.3 修正):就地暂停的任务回到
`RUNNING`——它从未释放执行槽,调用栈还在内存里;从未启动就被暂停的任务回到
`QUEUED`。**暂停不释放并发槽位**,因此暂停大量任务可能耗尽并发额度。
若任务在暂停窗口内没到达检查点(例如一段不调用 `checkpoint()` 的紧密循环),状态**回滚为 `RUNNING`** 并记录告警。这是有意为之:假装已暂停会让用户以为资源已释放,比如实报"没能暂停"更危险。
### 11.3 为什么不做抢占式
- **跨平台**:Windows 没有可用的 `SIGSTOP` 等价物(`SuspendThread` 需注入且不安全)。D3 已把支持范围定为含 Windows 的 `>=3.11`,抢占式会在 Windows 上直接失效。
- **持锁冻结**:冻结一个持有数据库连接或锁的任务,会让其他任务一起卡死——把"暂停一个任务"变成"暂停整条管道"。
- **线程本就不能安全强杀**(见 §8.2):抢占式在线程执行器上本就无法保证。
### 11.4 由此产生的三条使用约束(必须写进用户文档)
1. **长耗时循环必须调用 `ctx.checkpoint()`。** 不调用 = 只能靠超时强杀,`pause()` 与优雅 `stop()` 都会失效。
2. **需要"停止保证"的任务才升级到进程执行器。** 线程的优雅停止完全可用(走 `checkpoint()`),只是优雅窗口超时后无法强杀,只能放弃并记 `LOST`。选型见 §13。
3. **`pause()` 不是资源隔离手段。** 它不保证立即释放 CPU 或连接。
### 11.5 如果你确实需要抢占式
可行但受限:只有走进程/子进程的方案才能做抢占式暂停(POSIX `SIGSTOP`/`SIGCONT`;Windows 无对应能力,只能降级为协作式),而进程执行器已被删除(§13.10)。**结论:不提供**——若将来真的需要,它与 §13.10 的取舍要一起重新评估,因为抢占式暂停正是进程方案唯一不可替代的能力。
---
## 12. 实现进度
| 阶段 | 状态 | 产物 |
|---|---|---|
| P0 项目基线 | ✅ 完成 | 包目录 `src/chongming_task`;`requires-python >= 3.11`;`[dependency-groups] dev`;ruff / mypy strict / pytest + hypothesis 配置 |
| P1 领域内核 | ✅ 完成 | [states.py](src/chongming_task/states.py)(15 态 + 转移表 + 派生终态集)、[models.py](src/chongming_task/models.py)(快照与正交维度)、[errors.py](src/chongming_task/errors.py)、[policies.py](src/chongming_task/policies.py)、[state_machine.py](src/chongming_task/core/state_machine.py)(唯一写者,含审计与版本号) |
| P1 测试 | ✅ 完成 | `tests/unit/` + `tests/property/`:转移表结构性不变量、状态机全生命周期、停止/暂停/恢复语义、重试预算与 `force` 路径、人工改写审计、快照一致性;hypothesis 跑 500 组随机事件序列验证五条不变量 |
| P1 待补 | ✅ 完成 | `FakeClock` 已提升为随包发布的 [testing/clock.py](src/chongming_task/testing/clock.py),测试不再各写一份 |
| P2 控制原语 | ✅ 完成 | [clock.py](src/chongming_task/clock.py)(可注入时钟)、[cancellation.py](src/chongming_task/runtime/cancellation.py)(协作式取消/暂停令牌,含异步路径)、[heartbeat.py](src/chongming_task/runtime/heartbeat.py)、[timeouts.py](src/chongming_task/runtime/timeouts.py)(三档超时的纯计算判定)、[context.py](src/chongming_task/runtime/context.py)(`RuntimeContext`,协作点与心跳的唯一入口) |
| P2 执行器 | ✅ 完成 | [base.py](src/chongming_task/runtime/executors/base.py)(请求/结果/句柄协议 + `LocalExecutionHandle` 三段式停止 + `run_target`:`async def` 任务在本线程把协程跑完)、[thread.py](src/chongming_task/runtime/executors/thread.py)、[inline.py](src/chongming_task/runtime/executors/inline.py)、[process.py](src/chongming_task/runtime/executors/process.py)(子进程:硬性停止、绕开 GIL、崩溃隔离;决策史见 §13.10)。`subprocess` 与 `AsyncExecutor` 不做 |
| P2 可用入口 | ✅ 完成 | [run.py](src/chongming_task/run.py):`run_task()` 与 `TaskRun`,把状态机 / 令牌 / 心跳 / 超时 / 执行器串成可用的单任务受控执行 |
| 管理门面 | ✅ 完成 | [controller.py](src/chongming_task/controller.py):`TaskRegistry`(定义白名单)、`Controller`(提交 / 查询 / 控制 / 并发额度排队)、`StatusCounts`、`render_table`(CLI 与看板共用) |
| 并发策略 | ✅ 完成 | `ConcurrencyPolicy` 由调度器执行:全局 `max_parallel`、按标签的**具名资源信号量**、**优先级**(`submit(priority=...)`,默认值来自策略)。三条都在 `tests/unit/test_controller.py` 有用例;`concurrency` 与 `max_parallel` 同时给出会报错,避免"谁说了算"靠猜 |
| 持久层 | ✅ 完成 | [stores/](src/chongming_task/stores/):`TaskStore` 契约、`_codec`(SQLite 行编解码)、`InMemoryTaskStore`、`SqliteTaskStore`(标准库 `sqlite3`,WAL + 乐观并发 + 指令租约 + 交还);[store_contract.py](src/chongming_task/testing/store_contract.py) 随包发布,第三方后端据此自证 |
| 存储接线 | ✅ 完成 | `Controller(store=...)`:状态迁移**写穿**(乐观并发 + 迁移历史)、查询**读穿**(跨进程可见)、控制**指令化**、进度心跳经 `touch` 高频旁路回写;存储故障记入 `persist_failures` 而不让任务失败 |
| 公开 API | ✅ 完成 | [__init__.py](src/chongming_task/__init__.py) 收敛全部受支持的表面,便于嵌入其他应用 |
| DAG 管道 | ✅ 完成 | [pipeline.py](src/chongming_task/pipeline.py):静态定义(`Pipeline` / `Node`)+ 运行时推进(`PipelineRun`)。依赖复用**状态机里已有的** `blocked` 状态,不另造标记;等待中的节点不占并发额度。逐节点 `JoinMode` 与整条管道 `AggregationPolicy` 是两个独立的轴。`CANCELLED` 不作 fail_fast 触发条件(它是结果不是原因) |
| 任务编写 API | ✅ 完成 | [decorators.py](src/chongming_task/decorators.py) 的 `@task`(**不产生全局注册表**,规格挂在函数上)+ [runtime/context.py](src/chongming_task/runtime/context.py) 的 `TaskContext`:`checkpoint` / `progress` / `log` / `input`。进度走 `touch` 旁路(进快照与存储,不碰版本号);上游数据在放行下游前注入 |
| 重启恢复 | ✅ 完成 | **监督者租约**(`lease_owner` / `lease_until`,经 `touch` 续租)+ `Controller.reconcile()`:判据是"还有人在管它吗"而非"它还在推进吗",因此不会误杀心跳停滞的健康任务。只如实标记,**不重新执行**(存储里没有参数) |
| CLI | ✅ 完成 | [cli/main.py](src/chongming_task/cli/main.py):纯标准库 `argparse`,11 个子命令。**只传任务名、绝不接受代码**;输出契约与退出码见 [cli.md](cli.md) |
| 文档 | ✅ 完成 | [README.md](../README.md)(快速上手 + 真实输出)、[user-guide.md](user-guide.md)(面向使用者)、[cli.md](cli.md)(命令行)、[store-guide.md](store-guide.md)(持久层实现者)、[examples/](../examples/)(十二个可直接运行的脚本 + 两个 CLI 任务模块,输出均为实跑结果) |
当前质量门禁(均通过):
```
uv run pytest -q → 563 passed, 44 skipped (跳过的是需要真实 Postgres 的那批)
CHONGMING_TEST_POSTGRES=... uv run pytest -q → 607 passed (一台可连的库,零跳过)
uv run ruff check . → All checks passed
uv run ruff format . → unchanged
uv run mypy → Success: no issues found in 34 source files
```
> 属性测试在首次运行时就抓到两处真实缺陷:一是 `_PAUSE_IS_DEFERRED` 包含 `STARTING`
> 但转移表缺少 `STARTING → PAUSING` 边;二是 `QUEUED → PAUSING → abort_pause`
> 会产出没有 `started_at` 的 `RUNNING` 快照。这两处都不是靠读代码发现的,而是
> 随机事件序列的组合跑出来的——这正是把不变量写成可执行断言的价值。
>
> P2 期间同类问题又出现一次:`idle` 超时原本不要求任务已开始执行,于是一个
> **在队列里排了很久但从未启动**的任务会被判定为"卡死"。已修正为 `idle` 仅在
> `started_at` 存在时参与竞争——"卡住"是运行中才有的概念。
>
> 同一阶段还修掉一处**契约级**缺陷:执行请求原本携带捕获了上下文的闭包,而令牌
> 不可 pickle,导致这类任务永远无法迁移到进程执行器。修正为"上下文由执行器注入"
> 之后,同一份任务定义才能自由迁移(详见 §13.3)。这类问题在只写线程执行器时
> 完全看不出来,是**为了写进程执行器**才被迫暴露的——那份实现后来被删掉了
> (§13.10),但它逼出来的这条契约留了下来,而且理由比当初更硬。
>
> 进程执行器落地时又抓到一处(该模块现已删除,见 §13.10,但这条原则留下了):
> 强杀之后 `handle.running` 一度仍返回 `True`——
> 因为判据是"读取线程是否收工",而从子进程被杀死到读取线程观察到 EOF 之间存在
> 窗口。改为直接判断"载体是否还活着"后消失。这个 bug 只在断言 `running is False`
> 的用例里才会暴露,而写那条断言的理由是**上一轮刚定下的诚实性原则**。
>
> **收口线程模式时的同一类清理**:进程执行器一度连模块带测试一起删掉,只服务于它的
> 字段(`Failure.exit_code`、快照上的 `pid`)也一并删掉——没有任何代码路径会写它们,
> 留着只会让"存了但永远是 NULL"看起来像有意义的字段。同时把 `ConcurrencyPolicy` 真正
> 接进调度器:`limits`(按标签的具名信号量)与 `priority` 此前只是声明、校验、写进
> 文档,调度器从不读它们,设了等于没设且**不报任何错**;现在由 `Controller` 执行,
> 用例钉在 `tests/unit/test_controller.py`(§13.5)。
>
> 后来进程执行器被**重新实现并纳入支持范围**(理由与决策史见 §13.10),那两个字段也
> 随之回来——这次它们有生产者、有测试:`pid` 由 `ProcessExecutionHandle` 写入快照
> (`ps` 对得上号的那个进程号),`exit_code` 由子进程的非零退出/崩溃路径写入 `Failure`。
---
## 13. 执行器选型(线程优先)
### 13.1 为什么线程是细粒度管道的默认选择
一个常见误解是"任务控制工具应该用进程,因为进程能被杀掉"。**强杀是一项可选能力,
不是默认前提。** 默认应当用线程,理由:
| 维度 | 线程 | 进程 |
|---|---|---|
| 创建开销 | 几十微秒 | 毫秒~几十毫秒 |
| 参数/返回值 | 直接传递对象 | **必须 pickle** |
| 可用的任务写法 | 闭包、lambda、局部函数、装饰器产物 | 上述**全部不可用** |
| 共享状态 | 连接池、缓存、已加载模型可直接复用 | 各自一份,内存翻倍 |
| 优雅停止(协作式) | ✅ 完全可用 | ✅ 可用 |
| **强制停止** | ❌ 只能放弃 | ✅ `terminate` → `kill` |
| CPU 并行 | ❌ 受 GIL 限制 | ✅ 真并行 |
| 崩溃隔离 | ❌ 段错误会带走整个进程 | ✅ |
对一条由 `@task` 装饰函数组成的管道来说,"必须可 pickle"是实实在在的负担:
用装饰器、闭包或偏函数包装过的任务在进程执行器下直接不可用。而绝大多数管道步骤
是 I/O 密集(数据库、HTTP、文件),线程完全够用。
### 13.2 选型矩阵
| 场景 | 选择 | 状态 |
|---|---|---|
| 阻塞式 I/O(DB / HTTP / 文件)、普通业务函数 | **`ThreadExecutor`(默认)** | ✅ 支持范围内 |
| 微秒级、确定性的纯计算步骤(同步运行路径 / 测试) | `InlineExecutor` | ✅ 支持范围内 |
| 原生 `async` 任务(`async def` + `await ctx.acheckpoint()`) | **线程 / 内联执行器在本线程把协程跑完** | ✅ 支持范围内 |
| CPU 密集计算、需要绕开 GIL | **`ProcessExecutor`** | ✅ 支持范围内 |
| 需要"停止保证"(优雅窗口用尽后必须真的停住) | **`ProcessExecutor`** | ✅ 支持范围内 |
| 运行不可信代码 / 依赖原生扩展可能段错误(崩溃隔离) | **`ProcessExecutor`** | ✅ 支持范围内 |
| 长驻外部命令、需要明确的退出码与信号控制 | 在任务里用 `subprocess` 自己管 | ℹ️ 不做执行器 |
> **`AsyncExecutor` 不做**:多余。执行器只需要在本线程把协程跑完(`asyncio.run`),
> 再包一层执行器只会让"取消令牌该由谁持有"多出一个需要同步的副本。
>
> **`ProcessExecutor` 的取舍**:它服务上面三种场景,代价是三条结构性约束(可 pickle、
> 模块级目标 + `__main__` 守卫、停止/暂停有轮询延迟)。默认仍是线程执行器——
> 强杀是**可选能力,不是默认前提**(§5.4)。完整决策史见 §13.10。
### 13.3 令牌必须由执行器**注入**,不能被任务捕获
这是 P2 实现期发现并修正的一处契约缺陷,值得单独记录。
最初的设计里,执行请求携带一个**无参闭包**:
```python
# 修正前的写法:闭包捕获 ctx,而 ctx 里装着令牌
ExecutionRequest(task_id=..., call=lambda: user_func(ctx, source))
```
闭包天然会捕获 `ctx`,而 `ctx` 里装着 `CancellationToken`——它的内部是
`threading.Condition`,**根本不可 pickle**。当时的后果是:凡是用了闭包、`lambda` 或
`functools.partial` 的任务,**永远无法迁移到进程执行器**——而进程执行器现在是**在用的**
(§13.10),这条约束也就照旧生效,`ProcessExecutor` 在 `launch()` 时就会把这类目标
挡在门外并给出提示。
修正后的契约是:请求携带 `(target, args, kwargs)`,上下文由执行器构造后**注入**
为第一个位置参数:
```python
def extract(ctx, source: str) -> list[Row]: ...
```
**这个约定在只剩线程执行器之后依然成立**,理由换了一个但同样硬:上下文属于**某一次
尝试**,在启动那一刻才由执行器造出来。若靠闭包捕获,任务定义里握住的只可能是上一次
尝试留下的令牌——停止指令会发给一个已经结束的执行,而且完全静默。
代价是要写成 `def f(ctx, ...)`;收益是任务定义不持有任何执行期状态,因此既能被线程
执行器驱动,也能被内联执行器同步跑完,还能被测试直接调用。
### 13.4 控制能力长在句柄上
`ExecutionHandle` 同时持有令牌、心跳与执行载体,因此 `stop` / `pause` / `resume`
都是句柄方法,控制面不必自己拼装这三者,也不会出现"令牌与载体对不上号"。
语义完全一致的三件事收敛在基类 `LocalExecutionHandle`,子类只需回答两个问题:
还在跑吗(`running`),以及优雅窗口用尽后怎么办(`_on_stop_timeout`)。
`stop()` 的三段式因此只写一遍:
```
请求停止 → 等待优雅窗口 → 兜底(线程不可强杀:放弃并如实报告)
```
兜底结论一旦产生就是**粘性**的:已记录的 `LOST` 不会被后台载体日后返回的结果
悄悄改写,否则审计记录会自相矛盾。
### 13.5 线程执行器的两条实现约束
**一、必须用守护线程,不能用 `ThreadPoolExecutor`。** `ThreadPoolExecutor` 的工作
线程会在解释器退出时被 `join`,于是一个不响应取消的任务会让**整个进程无法退出**。
对一个专门用来监督"可能不配合"的任务的工具来说,这是致命缺陷;此外池子也无法
放弃卡住的线程。因此 `ThreadExecutor` 每个任务起一个守护线程。
代价是:被放弃的线程会**泄漏**,并且它可能仍在后台产生副作用。所以放弃的结果记为
`LOST`(未知),绝不记成 `STOPPED`。
**二、并发上限由调度器统一负责。** 执行器内部不再叠线程池,否则两层限制互相掩盖。
`ConcurrencyPolicy.max_parallel` 与具名资源信号量都在调度层实现——**这一条已交付**:
`Controller(concurrency=ConcurrencyPolicy(...))` 的全局额度、按标签的具名信号量与
优先级都由调度器执行(`Controller._can_start_locked` / `_take_runnable_locked`),
用例钉在 `tests/unit/test_controller.py` 的"具名资源信号量与优先级"一节。
执行器仍然一层池都没有:需要限流时改策略,不要改执行器。
顺带把 async 也收在这一层:`async def` 任务由执行器在**本线程**把协程跑完
(`run_target`),因此不需要 `AsyncExecutor`,`await ctx.acheckpoint()` 照常工作。
### 13.6 放弃语义的诚实性要求
线程执行器的 `stop(graceful_timeout=T)` 有两种结局,绝不能混淆:
- 任务在 T 秒内响应停止 → `abandoned=False`,`Failure.kind = CANCELLED`,
`retryable=False`(取消不是失败,重试它等于无视停止指令)。
- 超时未响应 → `abandoned=True`,`Failure.kind = LOST`,且 `handle.running`
**仍然返回 `True`**——它确实还在跑。谎报为 `False` 会让上层以为资源已释放。
同一个诚实性原则也用在暂停上:`handle.pause(timeout=T)` 在任务未到达检查点时
返回 `False`。控制面据此把状态回滚为 `RUNNING` 并告警,而不是记成 `PAUSED`。
`InlineExecutor` 同样如此——它无法被暂停,就直接返回 `False`,不假装成功。
### 13.7 线程与进程的停止结局完全不同
这是选型时最该看清的一点:两种载体的"停了"含义不同——一个是**未知**,一个是**已知**。
| | `ThreadExecutor`(任务不配合) | `ProcessExecutor`(任务不配合) |
|---|---|---|
| 优雅窗口用尽后 | 放弃等待 | `terminate()` → 宽限 → `kill()` |
| `abandoned` | `True` | `False` |
| `Failure.kind` | `LOST`(**结果未知**) | `CANCELLED`(**结果已知**:任务未完成) |
| `handle.running` | 仍为 `True`(它确实还在跑) | `False`(确已死) |
| 后台副作用 | 可能继续产生 | 已终止 |
也就是说:**线程的"停不掉"是一个未知状态,进程的"杀掉"是一个已知状态。**
把两者都简化成"已停止"是不诚实的,所以 `LOST` 与 `CANCELLED` 必须分开。
### 13.8 进程执行器的四处跨进程困难
实现进程执行器时逐一撞上的问题,都记在这里以免后人重踩(任何跨进程方案都会撞上
这四条;`ProcessExecutor` 的每一处实现都对应其中一条):
1. **令牌传不过去。** `CancellationToken` 内部是 `threading.Condition`,不可
pickle。子进程自建本地令牌,由轮询线程把 `multiprocessing.Event` 映射到它上面。
代价:**进程的协作式停止/暂停有轮询延迟**(当前 50ms),不像线程那样即时。
2. **心跳传不过来。** 子进程的 `Heartbeat` 对象父进程看不见。若不管它,`idle`
超时对进程执行器就完全失效——而它是唯一能发现"卡住但没报错"的机制。做法是让
每次 `checkpoint()` 顺带经管道回传一次心跳(节流),暂停期间由镜像线程代为回传
(被暂停的任务不是卡死的)。
3. **管道不是线程安全的。** 任务线程(心跳)与镜像线程(暂停期心跳)会并发写入
同一条管道,必须加锁,否则内容交错损坏。
4. **父进程必须持续排空管道。** 若只在 `wait()` 里读,心跳就只在调用方主动轮询时
推进,第 2 点的努力又白费。因此父进程起一个读取线程,与是否有人 `wait` 无关。
### 13.9 进程执行器的两条使用限制
> 这两条限制**现在就生效**(`ProcessExecutor` 已纳入支持范围,见 §13.10)。它们不是
> 实现缺陷,而是进程方案的固有成本——当年正是因为它们,这个执行器被搁置过一次。
**一、target / args / kwargs 必须可 pickle。** 即不能是 `lambda`、闭包或局部函数。
这条限制在 `launch()` 时就会以 `ConfigurationError` 抛出,并附带"这类任务请改用
线程执行器"的提示——而不是等子进程里神秘失败。
**二、子进程是守护进程,不能再创建自己的子进程。** 这是为了解释器退出安全:
`multiprocessing` 在退出时会先 `terminate()` 守护子进程再 `join()`,因此不响应
停止的任务不会让整个进程卡住。代价是 `daemon=True` 的进程不允许再有子进程。
若任务本身需要多进程,请用 `ThreadExecutor` 或把并行放在任务内部自行管理。
### 13.10 进程执行器:删过一次,又请回来了
**当前状态:已实现并纳入支持范围**(`runtime/executors/process.py`),公开 API 导出
`ProcessExecutor`,用户指南 §7.4 有使用说明,测试在 `tests/unit/test_process_executor.py`
与 `tests/integration/test_process_execution.py`。
这段决策史值得完整留着,因为它是一个**反复动摇过**的决定,而每次动摇的理由都成立:
| 阶段 | 决定 | 当时的理由 |
|---|---|---|
| 最初 | 实现、测试,但**搁置**:不导出、不宣传 | 线程更贴合以 `@task` + 闭包 + 共享连接池为主的写法,而进程要求可 pickle、参数可传输 |
| 收口线程模式时 | **删除**(模块、测试、只服务于它的 `Failure.exit_code` 与快照 `pid` 一起删) | "不再投入"的代码留着就是负债:没人维护的实现比没有实现更危险 |
| 现在 | **重新实现并纳入支持范围** | 三种场景只有它能覆盖:**硬性停止保证**(线程只能放弃并记 `lost`)、**绕开 GIL**、**崩溃隔离**(段错误只带走子进程) |
重新引入时按这份文档里记着的约束逐条落地,没有重蹈覆辙:
* **可 pickle 的失败要当场报**(§13.9 第一条):`launch()` 先试 pickling,失败抛
`ConfigurationError` 并附"这类任务请改用线程执行器"——不留"子进程里神秘失败";
* **不给"取不到结论就杀"留下含糊空间**:子进程没给结论就消失时,按"是否报告过
started"分成两种失败:从未开始(`nonzero_exit`,重试是干净的)与跑一半没了
(`nonzero_exit` + "结果未知,可能有部分副作用");
* **父进程必须持续排空管道**(§13.8 第四条):心跳就在那条管道上,只在 `wait()` 里读
会让 `idle` 超时在任务健康时误触发,所以句柄起一个独立读取线程;
* **判活以 `Process.is_alive()` 为准,不靠管道 EOF**:子进程若在反序列化参数时就死掉,
父进程那侧的写端句柄从未交出去,管道**永远不给 EOF**(实测),只等 EOF 会一直阻塞;
* **停止/暂停的语义一条都没有重写**:父进程的 `Event` 由子进程里的轮询线程映射到
本地令牌上,于是"暂停中的任务也要停得掉"这类语义跨进程与同进程共用同一份实现。
三条代价依旧成立,并且**写在用户能看见的地方**(README 状态表、用户指南 §7.4、
`ProcessExecutor` 的模块文档):可 pickle 约束、模块级目标 + `__main__` 守卫、
约 20ms 的停止/暂停轮询延迟。默认执行器仍然是线程。
**这段历史给出的判据**:进程执行器不是"更好的执行器",而是一组**用约束换能力**的
取舍。需要那三种能力时它不可替代;不需要时它的约束纯是负担。所以选型看场景
(§13.2 的矩阵),不要看"哪个更强大"。
---
## 14. 管理后台:CLI 形态与跨进程约束
原计划(D5)是"纯库 + CLI",HTTP 服务放到 P7;非目标里还写着"Web UI / 图形化
编排"。后来提出的"统一管理后台(启动 + 查询运行情况)"确认了同样的方向并去掉了网络层:
**只做 CLI,不引入网络层**。
### 14.1 一个绕不过去的约束
CLI 的 `ps` / `status` / `stop` 是**另一个进程**,它看不到任务进程的内存。所以
"跨进程查询"必须有共享媒介:
| 方案 | 引入网络层 | 说明 |
|---|---|---|
| 共享存储(`sqlite3`) | 否 | 标准库自带,无 IPC,天然跨平台,**顺带就是持久化** |
| 本地 IPC(Unix socket / 命名管道) | 否 | 仍需写 IPC 与心跳,且平台分叉 |
| 不跨进程 | 否 | CLI 只能管自己启动的任务,不成其为"后台" |
结论:**`sqlite3` 属于标准库**,因此第一条路既满足"不引入网络层",也满足"核心包
零第三方依赖",还一并交付了持久化。于是"持久化随后"实际上是**提前**了——它不是
额外工作,它**就是 CLI 的媒介本身**。
### 14.2 分层
```
CLI(P6) ← 薄适配:解析参数、渲染 render_table
│
共享存储 sqlite3(下一步) ← 跨进程媒介 + 持久化
│
Controller / TaskRegistry ← 已完成:提交、查询、控制、并发额度
│
TaskRun(run.py) ← 已完成:状态机 + 令牌 + 心跳 + 超时 + 执行器
```
CLI **不直接操作 `TaskRun`**,只经 `Controller`。这样内存实现与存储实现可以互换,
CLI 不必知道数据从哪来。
### 14.3 安全底线(无论形态如何都不能破)
控制面**只接受任务名字,绝不接受代码**。`Controller.submit()` 要求名字先在
`TaskRegistry` 注册过,未注册即抛 `ConfigurationError`。
一个能启动任意可调用对象的入口等于**远程代码执行**。现在只有 CLI 时风险尚可控
(本机用户本来就能跑代码),但这条规则必须从第一天就立好——将来任何形态的控制面
(本地 socket、HTTP、看板)都建立在它之上。
### 14.4 已知限制
* `Controller` 只管**当前进程内**的任务;跨进程查询要等共享存储。
* `prune()` 丢弃终态记录以防内存增长;当前**丢弃即永久丢失**。存储落地后会改为
"从内存淘汰、存储仍可查"。
* 控制器级的 `retry`(复活一个已失败的任务)**尚未实现**:它需要跨运行保留尝试
历史,而那依赖存储。任务内部的 `RetryPolicy` 已覆盖常见的重试场景。
---
## 15. 任务分桶与分布式预留
### 15.1 为什么现在就要做
分桶涉及的两件事里,**一件便宜、一件昂贵**:
| 事项 | 事后补做的代价 |
|---|---|
| 给任务加上桶号与业务键 | 低——但**已跑过的任务补不回来**:没有记录原始键就无法重新分桶 |
| 桶数(`num_buckets`) | **高**——它一改,几乎所有键改换桶位,局部性与隔离假设同时失效,必须迁移 |
所以策略是:**现在就把桶变成任务的一等属性并记下原始键**,而把真正的分布式分配
(所有权、租约、再均衡)留到真有第二个进程时再做。前者不做,后者将来会卡在
"历史数据没有桶"上。
### 15.2 桶的两种用途
1. **本地并发隔离**——每个桶各自限流,一个租户的任务积压不会饿死其他租户。
这是**今天就有价值**的部分,不需要任何分布式机制。
2. **分布式分配单位**——一台机器拥有一组桶,只处理自己那组。桶数应明显大于机器
数量(经验上 4~16 倍),这样某台机器退出时它的桶能较均匀地分给其余机器。
### 15.3 两条硬约束(都有测试钉住)
**一、桶数一旦开始使用就不能随便改。** `bucket = hash(key) % N`。因此 `N` 是创建时
确定的常量、部署范围内的约定;要改就得迁移——这也正是任务会同时记下 `bucket_key`
的原因:迁移需要原始键。
**二、绝不能用内置 `hash()`。** CPython 对 `str` 的 `hash()` 带**进程级随机盐**
(`PYTHONHASHSEED`),同一个键在不同进程里会落到不同的桶。这不只是"可能不一致",
而是确实不一致——测试里留了一条反例直接演示:
```python
# 两段脚本分别在 PYTHONHASHSEED=0 / 1 下运行
hash("k0") % 64 ... hash("k19") % 64 # → 两次输出不同
BucketPolicy(64).bucket_for("k0") ... # → 两次输出完全相同
```
这条性质如果搞错,"同桶任务在同一台机器上"会**静默**失效:本地性、顺序性、缓存
复用全部不成立,却没有任何报错。因此 `BucketPolicy` 用 `blake2b`,并有跨进程用例
(真实子进程 + 不同 `PYTHONHASHSEED`)守着它。
### 15.4 已实现 vs 预留
**已实现**(今天就能用):
| 能力 | 位置 |
|---|---|
| 稳定的分桶策略(`blake2b`,跨进程/跨机器一致) | `policies.BucketPolicy` |
| 桶号与业务键作为任务的一等属性(创建时即记录) | `TaskSnapshot.bucket` / `.bucket_key` |
| 每桶独立限流,且**不引入队头阻塞** | `Controller(max_parallel_per_bucket=...)` |
| 按桶查询**下推到存储层**,SQLite 侧有索引 | `TaskQuery.buckets` / `idx_tasks_bucket` |
| 每桶任务数统计 | `Controller.bucket_counts()` |
调度器取下一个可跑任务时是**扫描**而不是取队首:某个桶满时,队首可能正好被它挡住,
而后面属于其他桶的任务本可以先跑。直接取队首会让一个桶的积压阻塞所有桶——那正是
分桶要解决的问题,不能在调度器里又原样引入一次。
**刻意预留、尚未实现**(没有消费者之前不写死代码):
```python
# 将来的 TaskStore 扩展:桶的所有权与租约
def claim_buckets(
self, *, owner: str, buckets: Iterable[int], now: datetime, lease_seconds: float
) -> list[int]: ...
def renew_buckets(self, *, owner: str, now: datetime, lease_seconds: float) -> list[int]: ...
def owned_buckets(self, *, now: datetime) -> dict[int, str]: ...
def release_buckets(self, *, owner: str) -> int: ...
```
为什么是这个形状——它复用了**已经存在且已被验证**的四块:
| 将来需要的 | 现在已有的 |
|---|---|
| "谁拥有哪些桶"的租约 | `Command` 的租约机制(同一套语义:到期可重取,既防重复也不怕持有者崩溃) |
| 只取自己那组桶的任务 | `TaskQuery.buckets`,已下推到存储层并在 SQLite 建了索引 |
| 每桶公平性 | `Controller` 已按桶记账(`_active: task_id -> bucket`),远端公平用同一套账 |
| 改桶数时的迁移 | `bucket_key` 已经留在每条任务上 |
所有权表将来大致长这样:
```sql
CREATE TABLE bucket_owners (
bucket INTEGER PRIMARY KEY, -- 主键本身即保证"一个桶同时只有一个所有者"
owner TEXT NOT NULL,
lease_until TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
**不需要额外的分布式锁**:`bucket` 作主键 + 租约,就足以表达"这个桶此刻归谁"。
### 15.5 明确**不**预留的部分
避免把"预留"做成"过度设计":
* **没有跨节点派发/传输层。** 远程执行器是另一套设计(序列化、网络、结果回收),
不该借分桶顺手带出来。
* **没有再均衡算法。** 分摊策略取决于部署形态(一致性哈希?按负载?人工指定?),
现在拍板只会拍错。
* **桶数不是运行时协商出来的**,而是部署范围内的配置常量。多节点各配不同的桶数
是最危险的一类错误——所以 `BucketPolicy` 有值语义、可比较,便于启动时断言各处
一致。