1158 lines
72 KiB
Markdown
1158 lines
72 KiB
Markdown
# 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` 有值语义、可比较,便于启动时断言各处
|
|||
|
|
一致。
|