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