单任务受控执行(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。
72 KiB
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. 设计原则
- 状态是唯一的真相来源,且只有一个写者。 所有状态变更必须经过状态机(
StateMachine.apply(event)),禁止业务代码直接改snapshot.status。每次变更携带trigger+actor+reason,天然可审计。 - "请求的动作"与"当前状态"分离。
stop()/pause()是请求,不是瞬时赋值。运行中的任务需要时间响应,因此快照同时暴露status(现在在哪)和requested_action(想让它去哪),UI 才能显示"停止中/暂停中"。 - 一个枚举只表达一件事。 生命周期(
status)、重试计数(attempt)、进度(progress)、健康(last_heartbeat_at)、结果(failure/outcome)是正交维度,绝不塞进同一个枚举。这是同类工具最常见的建模错误,也是"精细化控制"能否落地的前提。 - 取消是协作式的,停止是有层级的。 停止 = 请求 + 优雅窗口 + 如实收场;线程不可强杀,所以窗口用尽只能放弃并记
LOST,绝不谎报已停止(见 §5.4、§13.6)。 - 内核无 I/O。 领域层不 import
asyncio、不碰数据库、不认识日志库。这样状态机可以用属性测试穷举验证。 - 可插拔优于可配置。 存储、执行器、事件出口都用
Protocol定义,v1 只提供最小实现。 - 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 → PAUSEDRUNNING就地暂停,执行槽从未释放,调用栈还在内存里 QUEUED/RETRY_WAIT → PAUSEDQUEUED从未启动过,需要重新排队 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。
三条工程约束:
- 进度事件默认节流(≥500ms 或 ≥1% 变化才发)。不加节流,10 万行数据的管道会用进度事件把存储打爆。
TaskStatusChanged先落存储、后发总线(write-ahead)。跨进程场景用 outbox 表保证至少一次投递;消费者需幂等(事件带event_id)。- 总线只做分发,订阅者异常不得影响任务执行(捕获并降级记录),否则一个坏订阅者能搞挂整个管道。
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 取消与停止(最容易被低估的部分)
三层机制,逐层兜底:
-
协作式:
CancellationToken→ctx.checkpoint()抛TaskCancelled。干净、可回滚。 -
优雅窗口:
stop(graceful=True, timeout=T),给任务 T 秒收尾(写检查点、关连接)。 -
强制兜底:超时后强杀。这里必须诚实:
- 子进程 →
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 建议的起步动作(本周可做)
- 改包目录
src/chongming-task→src/chongming_task,同步pyproject.toml;确认uv sync与import chongming_task通过。 - 冻结 §3.1 的动词语义表与 §2.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) 的实际语义是两件事:
- 不再派发新工作——该任务的下游不会获得新的执行槽;管道级
pause()停止向所有未开始的任务派发。 - 运行中的任务在下一个检查点挂起——
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 由此产生的三条使用约束(必须写进用户文档)
- 长耗时循环必须调用
ctx.checkpoint()。 不调用 = 只能靠超时强杀,pause()与优雅stop()都会失效。 - 需要"停止保证"的任务才升级到进程执行器。 线程的优雅停止完全可用(走
checkpoint()),只是优雅窗口超时后无法强杀,只能放弃并记LOST。选型见 §13。 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 的每一处实现都对应其中一条):
- 令牌传不过去。
CancellationToken内部是threading.Condition,不可 pickle。子进程自建本地令牌,由轮询线程把multiprocessing.Event映射到它上面。 代价:进程的协作式停止/暂停有轮询延迟(当前 50ms),不像线程那样即时。 - 心跳传不过来。 子进程的
Heartbeat对象父进程看不见。若不管它,idle超时对进程执行器就完全失效——而它是唯一能发现"卡住但没报错"的机制。做法是让 每次checkpoint()顺带经管道回传一次心跳(节流),暂停期间由镜像线程代为回传 (被暂停的任务不是卡死的)。 - 管道不是线程安全的。 任务线程(心跳)与镜像线程(暂停期心跳)会并发写入 同一条管道,必须加锁,否则内容交错损坏。
- 父进程必须持续排空管道。 若只在
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 桶的两种用途
- 本地并发隔离——每个桶各自限流,一个租户的任务积压不会饿死其他租户。 这是今天就有价值的部分,不需要任何分布式机制。
- 分布式分配单位——一台机器拥有一组桶,只处理自己那组。桶数应明显大于机器 数量(经验上 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有值语义、可比较,便于启动时断言各处 一致。