Files
chongming-utils-task/docs/task-control-design.md
T
chakcy f4de43c743 初始提交:chongming-task 任务控制库
单任务受控执行(15 态状态机 + 审计轨迹)、DAG 管道、Controller 门面。
执行器三个:线程(默认,含 async def 任务)、内联(同步 / 测试)、进程
(spawn 子进程:硬性停止、绕开 GIL、崩溃隔离)。
调度:并发额度 + 每桶限流 + 具名资源信号量(按标签)+ 优先级排队。
持久层:TaskStore 契约 + 内存 / SQLite / Postgres 实现,契约测试随包发布。
跨进程与多节点:写穿 / 读穿、指令通道与租约、owned_buckets 划界、reconcile。
CLI、12 个可直接运行的示例、docs/ 下 5 份文档。

门禁:ruff / ruff format / mypy(strict) 干净;
      pytest 563 passed(44 skipped 是需要真实 Postgres 的那批);
      带一台可连的 Postgres 时 607 passed。
2026-10-08 23:47:50 +08:00

72 KiB
Raw Blame History

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 枚举

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 终止态与"可复活"

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 表达,可测试、可内省、可生成文档:

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 快照模型(正交维度)

@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 控制层代码示例

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

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):

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 查询层

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 事件模型

@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。四类数据刻意 分开,因为它们的写入频次与一致性要求完全不同:

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 个方法的逐条语义、并发与原子性要求、序列化保真、可执行 范例、陷阱清单)见 持久层实现指南。

分布式部分的现状、边界与落地清单见 分布式设计说明。选定架构是 多个自治的定时任务节点 + 一套统一的存储与控制平面:任务定义与触发方式各节点自有, 靠 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. 错误体系

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(15 态 + 转移表 + 派生终态集)、models.py(快照与正交维度)、errors.py、policies.py、state_machine.py(唯一写者,含审计与版本号)
P1 测试 ✅ 完成 tests/unit/ + tests/property/:转移表结构性不变量、状态机全生命周期、停止/暂停/恢复语义、重试预算与 force 路径、人工改写审计、快照一致性;hypothesis 跑 500 组随机事件序列验证五条不变量
P1 待补 ✅ 完成 FakeClock 已提升为随包发布的 testing/clock.py,测试不再各写一份
P2 控制原语 ✅ 完成 clock.py(可注入时钟)、cancellation.py(协作式取消/暂停令牌,含异步路径)、heartbeat.py、timeouts.py(三档超时的纯计算判定)、context.py(RuntimeContext,协作点与心跳的唯一入口)
P2 执行器 ✅ 完成 base.py(请求/结果/句柄协议 + LocalExecutionHandle 三段式停止 + run_target:async def 任务在本线程把协程跑完)、thread.py、inline.py、process.py(子进程:硬性停止、绕开 GIL、崩溃隔离;决策史见 §13.10)。subprocess 与 AsyncExecutor 不做
P2 可用入口 ✅ 完成 run.py:run_task() 与 TaskRun,把状态机 / 令牌 / 心跳 / 超时 / 执行器串成可用的单任务受控执行
管理门面 ✅ 完成 controller.py:TaskRegistry(定义白名单)、Controller(提交 / 查询 / 控制 / 并发额度排队)、StatusCounts、render_table(CLI 与看板共用)
并发策略 ✅ 完成 ConcurrencyPolicy 由调度器执行:全局 max_parallel、按标签的具名资源信号量、优先级(submit(priority=...),默认值来自策略)。三条都在 tests/unit/test_controller.py 有用例;concurrency 与 max_parallel 同时给出会报错,避免"谁说了算"靠猜
持久层 ✅ 完成 stores/:TaskStore 契约、_codec(SQLite 行编解码)、InMemoryTaskStore、SqliteTaskStore(标准库 sqlite3,WAL + 乐观并发 + 指令租约 + 交还);store_contract.py 随包发布,第三方后端据此自证
存储接线 ✅ 完成 Controller(store=...):状态迁移写穿(乐观并发 + 迁移历史)、查询读穿(跨进程可见)、控制指令化、进度心跳经 touch 高频旁路回写;存储故障记入 persist_failures 而不让任务失败
公开 API ✅ 完成 __init__.py 收敛全部受支持的表面,便于嵌入其他应用
DAG 管道 ✅ 完成 pipeline.py:静态定义(Pipeline / Node)+ 运行时推进(PipelineRun)。依赖复用状态机里已有的 blocked 状态,不另造标记;等待中的节点不占并发额度。逐节点 JoinMode 与整条管道 AggregationPolicy 是两个独立的轴。CANCELLED 不作 fail_fast 触发条件(它是结果不是原因)
任务编写 API ✅ 完成 decorators.py 的 @task(不产生全局注册表,规格挂在函数上)+ runtime/context.py 的 TaskContext:checkpoint / progress / log / input。进度走 touch 旁路(进快照与存储,不碰版本号);上游数据在放行下游前注入
重启恢复 ✅ 完成 监督者租约(lease_owner / lease_until,经 touch 续租)+ Controller.reconcile():判据是"还有人在管它吗"而非"它还在推进吗",因此不会误杀心跳停滞的健康任务。只如实标记,不重新执行(存储里没有参数)
CLI ✅ 完成 cli/main.py:纯标准库 argparse,11 个子命令。只传任务名、绝不接受代码;输出契约与退出码见 cli.md
文档 ✅ 完成 README.md(快速上手 + 真实输出)、user-guide.md(面向使用者)、cli.md(命令行)、store-guide.md(持久层实现者)、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 实现期发现并修正的一处契约缺陷,值得单独记录。

最初的设计里,执行请求携带一个无参闭包:

# 修正前的写法:闭包捕获 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),上下文由执行器构造后注入 为第一个位置参数:

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),同一个键在不同进程里会落到不同的桶。这不只是"可能不一致", 而是确实不一致——测试里留了一条反例直接演示:

# 两段脚本分别在 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()

调度器取下一个可跑任务时是扫描而不是取队首:某个桶满时,队首可能正好被它挡住, 而后面属于其他桶的任务本可以先跑。直接取队首会让一个桶的积压阻塞所有桶——那正是 分桶要解决的问题,不能在调度器里又原样引入一次。

刻意预留、尚未实现(没有消费者之前不写死代码):

# 将来的 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 已经留在每条任务上

所有权表将来大致长这样:

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