# 用户指南 面向使用者的完整说明。设计动机与取舍见 [设计文档](task-control-design.md),可直接 运行的代码在 [examples/](../examples/)。 > **适用范围**:单任务执行、DAG 管道、并发额度、持久化与重启恢复、CLI 均已可用 > (状态表见 [README](../README.md#当前状态务必先读))。真正的边界是语义上的,不是功能 > 缺失:不调 `checkpoint()` 停不下来、暂停不释放槽位、任务不跨节点迁移——见 §11。 --- ## 目录 1. [核心概念](#1-核心概念) 2. [写一个任务](#2-写一个任务) 3. [跑一个任务](#3-跑一个任务) 4. [控制任务](#4-控制任务) 5. [重试](#5-重试) 6. [超时](#6-超时) 7. [执行器](#7-执行器) 8. [管理一批任务(Controller)](#8-管理一批任务controller) 9. [状态模型参考](#9-状态模型参考) 10. [错误与异常](#10-错误与异常) 11. [限制与 FAQ](#11-限制与-faq) 12. [测试自己的任务](#12-测试自己的任务) --- ## 1. 核心概念 ### 1.1 任务是一条轨迹,不是一个布尔值 大多数"任务控制"只是把任务记成"跑着 / 成功 / 失败"。这里不是。每个任务是一条 **只追加的状态轨迹**,每个中间态都可表达、可观察、可干预: ``` pending → queued → starting → running → succeeded ↓ ↓ retry_wait pausing → paused → running ↓ ↓ starting stopping → stopped ``` 每次状态变更都会产生一条 `Transition` 记录,带**触发源**、**操作者**、**原因**和 **版本号**: ```python for record in run.transitions: print(record.from_status, "->", record.to_status, "by", record.trigger) ``` ### 1.2 状态只表达"生命周期阶段" `status` 是一个枚举,只回答"它现在处于哪个阶段"。其余信息属于**正交维度**,绝不 塞进同一个枚举: | 维度 | 字段 | 为什么必须分开 | |---|---|---| | 生命周期 | `status` | 单一枚举,便于穷举校验 | | 重试计数 | `attempt` / `max_attempts` | "第 3 次尝试"与"正在运行"是两件事 | | 进度 | `progress` | 运行中的任务可能 0% 也可能 99% | | 健康 | `last_heartbeat_at` | 用于发现"没报错但也不动" | | 结果 | `failure` / `outcome` | 失败原因是技术细节,不是生命周期 | 最典型的反例是**把超时做成状态**。那样会立刻逼出"已超时但正在重试"这种单一枚举 表达不了的情况。因此超时是 `failure.kind = timeout_attempt`。 ### 1.3 状态机是唯一写者 只有 `StateMachine` 能改 `status`。业务代码、执行器、控制面都不能直接赋值。这条 纪律换来三件事:非法转移会被立刻拒绝、状态历史必然完整、并发写入可检测 (`status_version` 乐观锁)。 ```python from chongming_task.core.state_machine import StateMachine from chongming_task.states import TaskStatus machine = StateMachine.create("t1", "抽取", max_attempts=3) machine.mark_queued() machine.mark_starting(executor="thread") machine.mark_running() machine.mark_succeeded(outcome={"rows": 42}) # 非法转移会抛异常,而不是静默忽略 machine.mark_running() # InvalidTransition: 非法状态转移:succeeded -> running;动作 'executor:launched';当前允许:无(终态) ``` 非法转移**抛异常**而不返回 `False`:静默失败会让"停止指令没生效"这类问题极难排查。 需要预检时用 `machine.can(target)` / `can_stop` / `can_pause` / `can_resume` / `can_retry`。 ### 1.4 终态与复活 终态**没有出边**,这是转移表里显式声明的空集。复活只能走两个显式 API: - `retry()`:从 `failed` / `stopped` / `cancelled` / `lost` 复活,**保留**尝试历史。 - `reset()`:回到 `pending`,**丢弃**全部尝试历史(危险,需二次确认)。 --- ## 2. 写一个任务 ### 2.1 签名 任务的第一个位置参数由执行器**注入**为执行期上下文: ```python def my_task(ctx, source: str, *, batch_size: int = 100) -> list[Row]: ... ``` 调用时**不要自己传 `ctx`**: ```python run_task(my_task, "s3://bucket/x", batch_size=500) ``` > **为什么 `ctx` 必须注入而不是闭包捕获?** 因为上下文属于**某一次尝试**:取消令牌 > 在启动那一刻才由执行器造出来,任务不可能提前攥着一个。若靠闭包捕获(`lambda: f(ctx, arg)`), > 任务定义里握住的只可能是上一次尝试留下的令牌——停止指令会发给一个已经结束的执行, > 而且完全静默。注入式签名让同一份任务定义既能被线程执行器驱动,也能被内联执行器 > 同步跑完,语义只有一份。 ### 2.2 为什么必须调用 `checkpoint()` ```python def my_task(ctx): for row in rows: ctx.checkpoint() # ← 就这一行 process(row) ``` `ctx.checkpoint()` 同时做三件事: 1. 若已请求**停止** → 抛 `TaskCancelled`; 2. 若已请求**暂停** → 在此挂起,直到恢复或停止; 3. **刷新心跳**(`idle` 超时的依据)。 **不调用它的任务既停不掉也暂停不了。** Python 不能安全地终止线程,也没有跨平台的 抢占式暂停手段,所以停止与暂停只能是协作式的。这是硬约束,不是可以绕过的实现细节。 长循环里每轮都调用即可,开销可以忽略(无请求时它只是两次布尔判断)。 ### 2.3 清理逻辑里的 `is_stop_requested` 在 `except` / `finally` 块里不要再调用 `checkpoint()`——它会阻塞,导致清理永远 完不成。改用只读属性: ```python def my_task(ctx): conn = connect() try: for row in rows: ctx.checkpoint() write(conn, row) finally: conn.commit() # 无论如何都要收尾 if ctx.is_stop_requested: print("任务被中断,已提交部分结果") # log/progress 属于 P4,此处暂用 print conn.close() ``` ### 2.4 产物目录 ```python def my_task(ctx): target = ctx.artifact_dir / "part-0.parquet" ... ``` `ctx.artifact_dir` 按 `attempt` 隔离,重试不会污染上一次的产物。传 `artifact_dir` 给 `TaskRun` / `run_task` 即可启用。 --- ## 3. 跑一个任务 ### 3.1 一行版:`run_task` ```python from chongming_task.run import run_task outcome = run_task(my_task, "s3://bucket/x", task_id="job-1", name="抽取") if outcome.ok: print(outcome.value) else: print(outcome.status.value, outcome.failure.kind.value) ``` `run_task` 会阻塞到任务落终态。**不要在需要中途干预时用它**。 ### 3.2 完整版:`TaskRun` ```python from chongming_task.run import TaskRun run = TaskRun(my_task, ("s3://bucket/x",), task_id="job-1", name="抽取") run.start() # 立即返回 ... # 这里可以做任何事:暂停、停止、轮询状态 outcome = run.wait(timeout=60) ``` | 成员 | 说明 | |---|---| | `start()` | 启动受监督执行,立即返回;重复调用报错 | | `wait(timeout=None)` | 等待终态;超时抛 `TimeoutError` 且**不改变任务状态** | | `stop(reason=...)` | 请求停止,不阻塞 | | `pause(timeout=...)` | 请求暂停并等待确认,返回 `bool` | | `resume()` | 撤销暂停 | | `snapshot` | 当前 `TaskSnapshot`,运行期可安全读取 | | `status` | 当前 `TaskStatus` | | `transitions` | 完整迁移历史 | `TaskRun` 是线程安全的:上述方法可从不同线程调用。所有状态机写入都在同一把锁下 进行,因此"唯一写者"这条不变量仍然成立。 ### 3.3 结果对象 ```python outcome.ok # bool:是否 succeeded outcome.status # TaskStatus outcome.value # 任务返回值(未成功时为 None) outcome.failure # Failure | None outcome.snapshot # 终态快照:attempt、时间戳、progress、failure 等 ``` --- ## 4. 控制任务 ### 4.1 停止的三段式 ``` 请求停止 → 优雅窗口(graceful_stop 秒)→ 兜底 ``` - 任务在窗口内响应 `checkpoint()` → 正常收尾,落 `stopped`。 - 窗口用尽 → 线程无法强杀,**放弃等待**并落 `lost`。 ```python run.stop(reason="发布窗口") # 不阻塞 outcome = run.wait(timeout=30) ``` **停不掉时不会说"已停止"**: | | 任务配合 | 任务不配合(线程) | |---|---|---| | `outcome.status` | `stopped` | **`lost`** | | `outcome.failure.kind` | `cancelled` | `lost` | | `failure.retryable` | `False` | `True` | | 后台副作用 | 已收尾 | **可能仍在产生** | 把两者都简化成"已停止"不只是不准确,而是会让运维误判资源已释放。 ### 4.2 暂停与恢复 ```python run.pause() # → True 表示已确认挂起,状态变为 paused run.resume() # → running ``` **`pause()` 返回 `False` 不是错误,是"暂停没生效"**:任务在窗口内没到达检查点。 此时状态**回滚为 `running`**,并留下一条 `runtime:pause_aborted` 记录,绝不假装 已暂停。 两条必须知道的性质: 1. **暂停不释放并发槽位。** 就地挂起——任务的调用栈还在内存里。我们没有办法把 任意的 Python 调用栈换出再换回,所以暂停大量任务可能耗尽并发额度。 2. **暂停期间不判超时。** 被暂停的任务本来就不会推进心跳,若照常判 `idle` 超时, 暂停会被误判成卡死。 ### 4.3 恢复的目标状态取决于暂停层级 这是实现期修正过的一处语义,容易踩: | 暂停发生在 | 恢复后回到 | 理由 | |---|---|---| | 运行中(经 `pausing`) | `running` | 就地暂停,槽位从未释放,调用栈还在 | | 排队/重试等待中(未启动) | `queued` | 从未启动过,重新排队 | `TaskRun.resume()` 会自动判定,无需你关心。 ### 4.4 停止请求发出后任务恰好跑完 这是合法结果:状态走 `stopping → succeeded`,不判为失败。停止是请求,不是判决。 ### 4.5 无限循环(常驻)任务:怎么启、怎么停 一个"永远跑下去"的任务,任务体每轮**必须**调 `ctx.checkpoint()`: ```python @task def worker(ctx, source: str) -> int: processed = 0 while not ctx.is_stop_requested: # ← 优雅收尾的写法 ctx.checkpoint() # ← 不调用它 = 停不掉 processed += 1 ctx.progress(processed, unit="batch") time.sleep(0.02) ctx.log(f"收到停止请求,本次共处理 {processed} 批") return processed # 正常返回 → 终态 succeeded,返回值保留 ``` #### 停止请求的三种收尾方式,终态不同 | 任务体的写法 | 终态 | 返回值 | 含义 | |---|---|---|---| | `while True: ctx.checkpoint()`(让它抛 `TaskCancelled`) | `stopped` | 无 | **立刻中止** | | 检查 `ctx.is_stop_requested` 后正常 `return` | `succeeded` | ✅ 有 | **请收工**——把已处理的结果留下来 | | 完全不调 `checkpoint()` | `lost` | 无 | 载体被放弃,**线程仍在后台跑** | 第二行不是笔误:状态机允许 `stopping → succeeded`,因为"停止"是请求而不是判决。对"跑到 下一个安全点再退出"这类需求,它比 `stopped` 更有用——已经做好的结果不会丢。 #### 日常"先停一下"应该用 `pause` / `resume`,不是 `stop` `stop` 是**终态且不可逆**:当前没有 `retry`,停止之后想再跑必须换一个 `task_id` 重新提交 (`store.create` 会拒绝重复 id)。所以: ```python controller.pause("orders-worker") # → True:已确认挂起;False:未确认(状态回滚 running) controller.resume("orders-worker") # 继续跑 controller.stop("orders-worker", reason="发布窗口") # 彻底结束 ``` > `pause` 是**就地挂起**:任务阻塞在 `checkpoint()` 里,**继续占用执行槽**。暂停大量常驻 > 任务可能耗尽并发额度。 #### 启停的两种方式 ```python # 方式一:同进程(需要这个进程一直活着) controller = Controller(store=store, owner="node-a") run = controller.submit("worker", "kafka://orders", task_id="orders-worker") controller.stop("orders-worker", reason="发布窗口") outcome = controller.wait("orders-worker", timeout=10) # → succeeded,value=已处理批数 ``` ```bash # 方式二:命令行(前台执行,交给 systemd / nohup;停止从另一个终端发起) # --bucket-key 在限定了 --owned-buckets 时是**必需**的:没有归属桶的任务不会有任何 # 节点认领它,所以 CLI 会在提交时报错,而不是留下一个永远排队的任务。 chongming-task --db tasks.db --num-buckets 64 --owned-buckets 0-15 \ run --tasks myapp.tasks worker --arg source=kafka://orders \ --task-id orders-worker --bucket-key orders chongming-task --db tasks.db --owned-buckets 0-15 status orders-worker chongming-task --db tasks.db --owned-buckets 0-15 stop orders-worker --reason 发布窗口 ``` **`run` 是前台进程**:它退出时任务就没人管了(监督线程是守护线程)。要长期跑请交给 `systemd` / `nohup`。`Ctrl-C` 是安全的——CLI 会走 `finally` 里的 `controller.close()`, 把在跑的任务停干净。 #### 如果任务不调 `checkpoint()` 它不是"停得慢",而是**停不掉**:Python 无法安全终止线程,所以系统只能如实报 `lost` (结果未知),而任务体**仍在后台继续执行**——真正终止它只能靠结束进程。这正是"诚实优先于 便利"的那条设计原则:与其假装已停止,不如报"结果未知"。 完整可运行演示见 `examples/09_long_running.py`。 --- ## 5. 重试 ```python from chongming_task.policies import ExponentialBackoff, RetryPolicy RetryPolicy( max_attempts=5, # 总尝试次数(含首次) backoff=ExponentialBackoff(base=1, factor=2, max_delay=300), retry_on=(Exception,), # 哪些异常值得重试 give_up_on=(ValueError, KeyError), # 哪些立刻放弃 ) ``` | 字段 | 说明 | |---|---| | `max_attempts` | **总**尝试次数,因此 `1` 表示不重试 | | `backoff` | `FixedBackoff(delay)` 或 `ExponentialBackoff(base, factor, max_delay, jitter)` | | `retry_on` | 值得重试的异常类型 | | `give_up_on` | 命中即放弃,优先级高于 `retry_on` | 退避默认带**抖动**(`Jitter.FULL`,在 `[0, delay)` 内均匀取值)。这不是花哨:没有 抖动时,一整批同时失败的任务会在同一时刻集体重试,形成风暴。 ### 5.1 取消永远不会被重试 `TaskCancelled` 继承自 `BaseException`,而 `retry_on` 默认是 `(Exception,)`,因此 **取消信号天然不匹配任何重试条件**。同时 `Failure.retryable` 对取消置为 `False`。 重试一个已被取消的任务,等于无视停止指令。 ### 5.2 失败分类 每次失败都会带一个 `FailureKind`: | kind | 含义 | |---|---| | `exception` | 任务抛出了异常 | | `nonzero_exit` | 外部命令返回非零退出码 | | `timeout_start` / `timeout_attempt` / `timeout_idle` | 三档超时 | | `cancelled` | 被取消 | | `lost` | 结果未知 | | `dependency_failed` | 上游失败 | | `condition_false` | 条件不满足(配合 `skipped`) | --- ## 6. 超时 ```python from chongming_task.policies import Timeout Timeout(start=30, per_attempt=600, idle=60) ``` 三档相互独立,取**最早**到期者: | 作用域 | 含义 | 生效条件 | |---|---|---| | `start` | 从提交到真正开始执行 | 一旦开始执行即失效 | | `per_attempt` | 单次尝试的总时长 | 本次尝试已开始 | | `idle` | 距最近一次 `checkpoint()` 的静默时长 | 本次尝试已开始 | 超时到期后系统会**请求停止**该任务,并把结果记成对应的 `timeout_*` 失败,而不是 一个"超时状态"。超时默认**可重试**(多半是暂时性的)。 ### 6.1 `idle` 的语义与陷阱 `idle` 衡量的是 **`checkpoint()` 间隔**,不是"CPU 是否在干活"。这是唯一能发现 "卡住但没报错"(死锁、连接挂起)的机制,但它有个前提: > 如果你有一段几分钟不调用 `checkpoint()` 的重计算,它会被判定为疑似卡死。 所以 `idle` 阈值必须与任务里 `checkpoint()` 的频率相匹配。如果某段计算确实无法 中途打断,要么把它调大,要么在进入该段之前手动 `ctx.heartbeat.beat()`。 另外两条边界:`idle` **只在本次尝试已经开始后才参与判定**,因此排队等待的时长不会 被误判成静默超时;**暂停期间同样不判 `idle`**,因为被暂停的任务本来就不会推进心跳。 --- ## 7. 执行器 ### 7.1 线程执行器(默认) ```python from chongming_task.runtime.executors.thread import ThreadExecutor run = TaskRun(my_task, executor=ThreadExecutor()) ``` 适合阻塞式 I/O(数据库、HTTP、文件)与普通业务函数。特点: - 创建开销几十微秒,共享内存,可复用连接池与缓存; - 无参数序列化要求,闭包、lambda、局部函数都能用; - `async def` 任务同样能跑:执行器在本线程内把协程跑完,`await ctx.acheckpoint()` 照常响应停止与暂停; - **无法被强制停止**(Python 不能安全终止线程)。 ### 7.2 内联执行器 ```python from chongming_task.runtime.executors.inline import InlineExecutor run_task(trivial_step, executor=InlineExecutor()) ``` 在调用线程内同步跑完,零调度开销,适合微秒级的纯计算步骤与确定性测试。 它**刻意违反**"`launch` 不得阻塞"的约定(这就是它存在的意义),因此**异步调度器 不得使用它**。它也无法被暂停——`pause()` 如实返回 `False`,不假装成功。 ### 7.3 为什么不用 `ThreadPoolExecutor` `ThreadPoolExecutor` 的工作线程会在**解释器退出时被 `join`**。一个不响应取消的 任务会让整个进程无法退出——对一个专门监督"可能不配合"的任务的工具来说这是致命 缺陷;此外池子也无法放弃卡住的线程。 因此 `ThreadExecutor` 每个任务起一个**守护线程**,代价是被放弃的线程会泄漏(所以 结果记 `lost`)。并发上限由调度层统一负责,执行器不再叠一层池。 ### 7.4 进程执行器:硬性停止 / 绕开 GIL / 崩溃隔离 ```python from chongming_task import ProcessExecutor, run_task outcome = run_task(fetch_all, executor=ProcessExecutor()) # 单个任务 controller = Controller(executor=ProcessExecutor()) # 或整批任务 ``` 任务跑在**独立子进程**里。它换来三件线程给不了的事: | 能力 | 线程执行器 | 进程执行器 | |---|---|---| | 优雅窗口用尽后 | 只能放弃等待,记 `lost`(结果未知) | `terminate()` → `kill()`,**真的停掉** | | CPU 密集 | 受 GIL 限制 | 真并行 | | 原生扩展段错误 | 带走**整个监督进程** | 只带走子进程,任务如实记一次失败 | 代码里的体现是:超时或停止不下来的任务会被真的终止,最终状态是 `failed` (超时/崩溃)或 `stopped`(停止请求),而不是 `lost`。快照里的 `pid` 就是那个子进程的 进程号,`ps` 能直接对上号。 **代价是三条结构性的约束**,不是实现偷懒: 1. **参数、返回值、上游数据必须可 pickle**:`lambda`、闭包、局部函数一概不行——它们 在子进程里找不到。违反时提交那一刻就报 `ConfigurationError` 并告诉你改用什么。 返回值的约束也比线程执行器严(那边只要求可 JSON 序列化)。 2. **目标函数必须在可导入的模块里**,且调用方脚本要有 `if __name__ == "__main__":` 守卫(`multiprocessing` 的 spawn 会重新 import 主模块)。用 `@task` 装饰过的 **模块级**函数没问题;写在函数体里的临时函数不行。 3. **停止与暂停有约 20ms 的轮询延迟**,信号要过进程边界;线程执行器是即时的。 另外子进程是守护进程,**不能再创建自己的子进程**——任务内部要并行请开线程。 ```python @task def resize(ctx, path: str) -> str: # ✅ 模块级、参数可 pickle ... async def _inline(): # ❌ 闭包 / 局部函数 ... ``` **什么时候该用它**:需要"停得住"的保证(例:任务里跑着别人写的、可能不响应取消的 代码)、CPU 密集、或跑第三方原生扩展(段错误隔离)。**什么时候不该**:绝大多数 阻塞式 I/O 任务——线程更轻、约束更少,而且能直接复用连接池与缓存。 --- ## 8. 管理一批任务(Controller) `Controller` 是**统一的管理入口**:它持有任务定义的**白名单**、**运行中的任务** 与**并发额度**。将来的 CLI 与任何看板都只是这一层的薄适配。 ### 8.1 基本用法 ```python from chongming_task.controller import Controller, render_table controller = Controller(max_parallel=2) # 最多同时跑 2 个 controller.register("extract", extract, tags={"io"}) # 先登记,之后只传名字 controller.register("transform", transform) run = controller.submit("transform", 100, task_id="t0", label="转换-1") controller.submit("transform", 200, task_id="t1", label="转换-2") print(render_table(controller.list_tasks())) # 查询 controller.stop("t0", reason="手工干预") # 控制 controller.wait_all(timeout=60) # 等全部落终态 ``` ### 8.2 查询接口 | 方法 | 作用 | |---|---| | `get(task_id)` | 单个快照;不存在返回 `None` | | `list_tasks(status=, name_prefix=, tag=, limit=)` | 按条件查询 | | `stats()` | 各状态计数 | | `runs()` | 全部 `TaskRun` 句柄(可按 id 继续控制) | | `active_count` / `queued_count` | 真的在占用执行额度 / 正在排队 | | `render_table(snapshots)` | 渲染等宽文本表,CLI 与看板共用同一份数据 | > 方法名是 `list_tasks` 而不是 `list`:后者会在类作用域里遮蔽内置的 `list`, > 使类内所有 `list[...]` 类型注解失效(mypy 会报 "Function is not valid as a type")。 ### 8.3 控制接口 ```python controller.stop(task_id, reason=...) # → bool(是否找到该任务) controller.pause(task_id, timeout=...) # → bool(是否**确认**已挂起) controller.resume(task_id) # → bool controller.stop_all(reason=...) # → int(本轮**发出**的请求数) controller.close(reason=..., timeout=...) # 停止一切并收尾 ``` 两个容易误读的返回值: - `stop_all()` 返回的是**已发出请求**的数量,不是"已停止"。请求发出后任务先进入 `stopping`,落终态是异步的;要等就接着 `wait_all()`。 - `pause()` 返回 `False` 表示暂停**没生效**(状态已回滚为 `running`)。 `close()` 会停止一切,且**排队中的任务不会真的执行任务体**——它们被"启动后立刻 停止",监督循环第一轮就落 `stopped`。 ### 8.4 并发额度 `max_parallel` 限制**同时执行**的任务数;超出部分排队,状态为 `queued`: ``` TASK_ID NAME STATUS ATTEMPT ELAPSED PROGRESS FAILURE ------- ------ ------- ------- ------- -------- ------- t0 转换-1 running 1/1 0.4s - - t1 转换-2 running 1/1 0.4s - - t2 转换-3 queued 0/1 - - - t3 转换-4 queued 0/1 - - - ``` `queued` 是"已被受理、等待执行槽",与 `pending`(尚未受理)不同。排队中的任务 `ELAPSED` 显示 `-`——它还没执行过,显示一个时长会被误读成"已经跑了这么久"。 注意 `stats().unfinished` 是**非终态**数量,含排队与暂停;要知道"真的在占用执行 资源"的有几个,用 `controller.active_count`。 #### 具名资源信号量 保护外部稀缺资源(连接数、配额、GPU)用 `ConcurrencyPolicy.limits`——**按标签** 记账,与全局额度叠加: ```python from chongming_task import ConcurrencyPolicy, Controller controller = Controller(concurrency=ConcurrencyPolicy(max_parallel=20, limits={"db": 4, "gpu": 1})) controller.register("sync", sync_tenant) controller.register("train", train_model) controller.submit("sync", "tenant-A", tags=["db"]) # 占一个 db 名额 controller.submit("sync", "tenant-B", tags=["db"]) # 再占一个 controller.submit("train", "v3", tags=["gpu"]) # 占那个唯一的 gpu 名额 ``` 规则只有三条,都好记: - **没写进 `limits` 的标签不受限**,打标签不会自动限流; - 一个任务带多个受限期标签时,**每个资源都要有余量**才放行; - 名额算的是**活跃**任务(已占执行槽的),**含被暂停的**——暂停不释放执行槽, 也就不该释放资源名额。 额度是**本进程**的账,不做跨进程协调:多节点各自配自己那一份(例如每个节点到库里 的连接数)。 #### 优先级 `priority` 决定**排队顺序**:数值越大越先被执行,同优先级仍按提交顺序(先进先出)。 ```python controller.submit("sync", "tenant-A", priority=10) # 用户等着的那次同步 controller.submit("sync", "tenant-B") # 默认优先级 0:批量回填 ``` 默认值来自 `ConcurrencyPolicy.priority`,`submit(priority=...)` 可逐条覆盖。它只在 **本进程的就绪队列**里比较——优先让"同一批排队任务里的急件"先走,不做跨节点抢占。 ### 8.5 安全底线 **只接受名字,绝不接受代码。** `submit("extract", ...)` 里的 `extract` 必须在启动时 `register` 过,否则抛 `ConfigurationError`。一个能启动任意可调用对象的入口等于 **远程代码执行**——将来接 CLI 之后这条同样不能破。 重名注册也会抛错,不静默覆盖:静默覆盖会让"我明明改了实现,跑的还是旧的"这类问题 极难发现。 ### 8.6 跨进程限制(重要) `Controller` 管理的是**当前进程内**的任务。另一个进程里执行 `chongming-task ps` **看不到它们**——内存不共享。 跨进程查询与持久化需要**共享媒介**: | 方案 | 引入网络层 | 说明 | |---|---|---| | 共享存储(`sqlite3`) | 否 | 标准库自带,无 IPC,天然跨平台,顺带就是持久化 | | 本地 IPC(Unix socket / 命名管道) | 否 | 仍需写 IPC 与心跳,且平台分叉 | | 不跨进程 | 否 | CLI 只能管自己启动的任务,不成其为"后台" | 已选定第一条。`prune(keep_terminal=N)` 会丢弃最旧的终态记录以防内存无限增长。 ### 8.7 持久层抽象 持久层契约是 `TaskStore`,内置三个实现: | 实现 | 适用 | 依赖 | |---|---|---| | `InMemoryTaskStore` | 单进程嵌入、单元测试、作为其他实现的**参照物** | 标准库 | | `SqliteTaskStore` | 跨进程可见 + 持久化(**默认**) | 标准库 `sqlite3` | | `PostgresTaskStore` | **跨机器**共用一套存储与指令通道 | 可选 `chongming-task[postgres]` | ```python from chongming_task import Progress, SqliteTaskStore, TaskQuery, TaskStatus store = SqliteTaskStore("tasks.db") store.create(snapshot) store.save(next_snapshot, expect_version=snapshot.status_version) # 乐观并发 store.query(TaskQuery(statuses=frozenset({TaskStatus.RUNNING}), tag="io")) store.touch("job-1", progress=Progress(done=120, total=500)) ``` 同机跨进程用 SQLite 就够;**跨机器**换成连接串即可,语义与 API 完全一致: ```python from chongming_task import PostgresTaskStore # pip install "chongming-task[postgres]" store = PostgresTaskStore("postgresql://user:pw@db.internal:5432/chongming") ``` 核心包不因此多出任何依赖:`PostgresTaskStore` 是**惰性取用**的,没装驱动时 `import chongming_task` 照常工作(只有真正取它才会给出安装命令)。建表在首次连接时 自动完成;角色没有 DDL 权限时,它检测到表已存在就直接用,所以只读运维账号也能连。 四类数据的写入频次与一致性要求不同,因此刻意分开: | 类别 | 方法 | 特点 | |---|---|---| | 任务快照 | `create` / `save` / `get` / `query` | 参与乐观并发(`status_version`) | | 审计轨迹 | `append` / `history` | **只追加**,重复写入幂等 | | 高频旁路 | `touch` | 进度与心跳,**不碰版本号**(否则会与状态写入持续冲突) | | 控制指令 | `submit_command` / `claim_commands` / `complete_command` | 跨进程控制的媒介 | `claim_commands` 的**租约**解决的是"取走指令的进程崩了怎么办":租约到期后指令可被 重新取走,因此既不会重复下发,也不会因为一次崩溃而永久卡住。 #### 写自己的后端 别靠读文档猜语义——跑契约套件: ```python from chongming_task.testing.store_contract import StoreContract class TestMyStore(StoreContract): def make_store(self): return MyStore(...) ``` 套件**随包发布**且不依赖 pytest;内置实现跑的正是同一套用例(内存 / SQLite 文件 / SQLite 内存 / Postgres——最后一份需要 `CHONGMING_TEST_POSTGRES` 给出 DSN,没有就跳过)。 **要换掉默认持久层,请看 [持久层实现指南](store-guide.md)**——那里有 14 个方法的逐条 语义、并发与原子性要求、序列化保真要求,以及一个可执行的第三方后端范例和陷阱清单。 `PostgresTaskStore` 本身就是照这份规格写出来的第二个真实后端:它的模块文档记了 "与 SQLite 的三点差异"(连接池、`SKIP LOCKED`、原生类型)与跨进程并发用例, 可以当成比文件后端更接近生产的参照。 > **已经接线**:`Controller(store=...)` 可用——每次状态迁移写穿到存储,查询走存储 > (因此能看到别的进程的任务),对不属于本进程的任务会把控制动作写成指令。详见 §8.9。 ### 8.8 任务分桶 桶既是**本地并发隔离**的单位,也是将来**分布式分配**的单位(一台机器拥有一组桶)。 ```python controller = Controller(max_parallel=10, max_parallel_per_bucket=1) controller.submit("sync", "租户A", task_id="a0", bucket_key="tenant-A") controller.submit("sync", "租户B", task_id="b0", bucket_key="tenant-B") ``` `bucket_key` 是业务键(租户 id、数据源、日期分区……)。同一个键在**任何进程、任何 机器**上都落到同一个桶。 | 成员 | 作用 | |---|---| | `submit(..., bucket_key=...)` | 提交时给出业务键 | | `snapshot.bucket` / `.bucket_key` | 桶号与原始键都记在任务上 | | `max_parallel_per_bucket=` | 每桶独立限流;一个桶积压不会饿死其他桶 | | `list_tasks(bucket=...)` / `(buckets=[...])` | 按桶查询(`buckets` 是为分布式预留的:只取自己拥有的那组) | | `bucket_counts()` | 每桶任务数,用于决定把桶分给谁 | | `active_in_bucket(bucket)` | 某桶当前占用的执行额度 | | `controller.bucket_policy` | 分桶策略(`num_buckets` 等) | `render_table` 也带上了 `BUCKET` 列。 **两条硬约束**(详见 [设计文档 §15](task-control-design.md)): 1. **桶数一旦开始用就不能随便改。** `bucket = hash(key) % N`,改 N 会让几乎所有键 改换桶位。所以任务会同时记下 `bucket_key`——将来真要改桶数,只有拿得到原始键 才能重新分桶。 2. **绝不能用内置 `hash()`。** CPython 对 `str` 的 `hash()` 带进程级随机盐 (`PYTHONHASHSEED`):同一个键在不同进程里会落到不同的桶,于是"同桶任务在同一台 机器上"会**静默**失效。`BucketPolicy` 用 `blake2b`,跨进程一致。 调度器取下一个可跑任务时是**扫描**而不是取队首:某个桶满时队首可能正好被它挡住, 而后面属于其他桶的任务本可以先跑。 ### 8.9 跨进程:一个进程跑,另一个进程看和管 ```python from chongming_task import Controller, SqliteTaskStore worker = Controller(store=SqliteTaskStore("tasks.db"), max_parallel=2, owner="worker-1") worker.register("sync", sync_tenant) worker.submit("sync", "租户A", task_id="job-1", bucket_key="tenant-A") # 另一个进程(同一个 SQLite 文件,各自一条连接) console = Controller(store=SqliteTaskStore("tasks.db"), owner="ops-console") console.list_tasks() # 看得到 job-1,尽管它并不持有 console.stop("job-1", reason="运维停止") # 写成一条指令 ``` | 方向 | 行为 | |---|---| | 写 | 每次状态迁移**写穿**到存储(乐观并发,附迁移历史) | | 读 | `get` / `list_tasks` / `stats` / `bucket_counts` 走存储 | | 控制 | 不属于本进程的任务 → 写一条指令,由持有者轮询取走 | #### 三个必须知道的行为 1. **跨进程的 `pause()` 返回 `False`。** 那不是失败,是**未确认**——跨进程没有回执 通道。要观测结果请轮询 `get()`。`stop()` / `resume()` 返回的是"请求已受理"。 2. **纯查询客户端不会抢指令。** 轮询线程只在提交过任务之后才启动;即便取到了不属于 自己的指令,也会通过 `release_command` 立即交还,而不是标记完成(那会让真正的 持有者永远收不到)。 3. **存储故障不让任务失败**,但会记进 `controller.persist_failures`(最多 20 条)。 任务本身没问题,坏的只是可观测性——但绝不静默。 #### 两个使用注意 * **用完一定要关存储**:`controller.close(close_store=True)`,或自己 `store.close()`。 没关的 SQLite 连接会泄漏文件句柄,在 Windows 上还会让数据库文件无法删除。 * `stop_all()` 只作用于**本进程持有**的任务。"全部停止"跨进程需要一份全局任务清单, 那是分布式调度该决定的事。 `controller.owner` 是写进指令里的发起者标识(默认 `主机名:pid`),便于事后追溯是谁 按的停止。 ### 8.10 命令行控制台 `chongming-task` 是 `Controller` 之上的一层**薄适配**:解析参数、调用库、渲染输出。 它不引入任何库没有的概念,也不绕过任何不变量。 ```bash chongming-task --db tasks.db ps chongming-task --db tasks.db status demo-1 chongming-task --db tasks.db stop demo-1 --reason "发布窗口" ``` | 命令 | 作用 | |---|---| | `tasks --tasks MODULE` | 列出模块里注册的任务定义 | | `run TASK --tasks MODULE` | 提交并**前台执行**到落终态 | | `ps` / `status` / `history` / `stats` / `buckets` | 查询 | | `stop` / `pause` / `resume` | 控制 | 退出码分三档:`0` 成功、`1` 命令失败、`3` **命令成功但任务没成功**——最后一档是给 脚本判分支用的。 三点与库行为一致、但在命令行里更容易踩到的地方: * **`run` 是前台进程**,退出时任务就没人管了;长期运行请交给 `nohup` / `systemd`。 * **`pause` 跨进程只报"未确认"**(退出码仍是 0),要看结果得 `status`。 * **任务定义必须来自模块的 `register(controller)`**,CLI 只传名字——一个能启动任意 可调用对象的入口等于任意代码执行。 完整走查(含真实输出)见 **[命令行控制台](cli.md)**。 ### 8.11 重启恢复(reconcile) 进程意外退出后,存储里会留下一批永远停在 `running` 的任务——它们看着像在跑,其实 载体早就随进程消失了。**进程启动时先跑一次 `reconcile()` 把它们清理掉**: ```python controller = Controller(store=SqliteTaskStore("tasks.db"), owner="worker-2") report = controller.reconcile() if report.lost: log.warning("上次有 %d 个任务失去监督者", len(report.lost)) ``` #### 判据是"还有人在管它吗",不是"它还在推进吗" 每个监督进程都会给自己负责的任务**续租**(`lease_owner` / `lease_until`,由 `touch` 写入)。于是: | 情况 | 处置 | |---|---| | 租约未过期 | 有活着的进程在管,**一动不动** | | 租约过期 + 任务**占着执行资源**(`starting`/`running`/`pausing`/`stopping`) | 载体已消失,判 `LOST` | | 租约过期 + 任务**不占资源**(`queued`/`paused`/`retry_wait`) | 只是没人接手,**不改状态**,报告为 `adoptable` | | 原本的监督者又活过来抢先写入 | 检测到版本冲突,**放手**(报 `skipped`) | **绝不能用任务心跳当判据。** `last_heartbeat_at` 由任务自己的 `checkpoint()` 驱动, 一个长时间不调 checkpoint 的健康任务(纯 CPU 计算)心跳同样停滞——拿它判断进程存活, 会把一个正常任务判成 `LOST` 并**杀掉**。这正是租约与心跳必须分成两个字段的原因。 ```python report = controller.reconcile(dry_run=True) # 先看一眼,不写任何状态 report.lost # 会被(或已被)判为丢失的 report.adoptable # 需要重新提交的 report.skipped # 有人正在写、被跳过的 ``` #### 它**不会**重新执行任务 这是能力边界,不是遗漏:**存储里记的是状态,不是配方**。任务的函数与参数从未落库 (序列化任意参数会带来不可反序列化、版本漂移等一堆问题),所以恢复一个失去载体的 任务在原理上不可能。 `reconcile()` 能做的、也是它应当做的,是**如实标记**——让看板不再永远显示一个假的 "运行中"。真正"重跑"要靠调用方自己重新 `submit`,而那需要调用方知道参数。 `lease_ttl`(默认 30 秒,可调)决定崩溃多久之后会被发现。 ### 8.12 DAG 管道 把多个任务按依赖关系串起来: ```python from chongming_task import Pipeline from chongming_task.policies import AggregationPolicy, JoinMode pipeline = Pipeline("nightly-etl", aggregation=AggregationPolicy.all_settled()) fetch = pipeline.node("fetch", "2026-10-02", node_id="fetch") shards = [ pipeline.node("transform", index, node_id=f"shard{index}", depends_on=[fetch]) for index in range(3) ] load = pipeline.node("load", node_id="load", depends_on=shards) pipeline.node("audit", node_id="audit", depends_on=[load], join=JoinMode.ALL_SETTLED) run = controller.submit_pipeline(pipeline) outcome = run.wait(timeout=60) print(outcome.status, outcome.nodes) ``` > **第一个位置参数是已注册的任务名**,其后才是传给任务的位置参数。节点 id 由 > `node_id=` 指定;省略时默认取任务名,同名多次则自动加 `#2` / `#3` 后缀。 > `p.node("mark", "after")` 里的 `"after"` 是**传给 mark 的参数**,不是节点名—— > 这正是那个参数叫 `node_id` 而不是 `name` 的原因。 #### 三种汇合语义(逐节点) | `JoinMode` | 何时放行 | |---|---| | `ALL_SUCCEEDED`(默认) | 全部上游成功——最常见的屏障 | | `ALL_SETTLED` | 全部上游落终态即可,**不管成败**——用于清理、通知、汇总 | | `ANY_SUCCEEDED` | 任一上游成功即可——多源冗余取数的先到先得 | #### 两种结算方式(整条管道) | `AggregationPolicy` | 行为 | |---|---| | `fail_fast()`(默认) | 首个真失败即**取消其余已经放行的节点** | | `all_settled()` | 等所有节点落终态,再统一判定 | `quorum` 会被**明确拒绝**:它描述的是"同一件事的 N 份副本成功 k 份",属于扇出分组而不是 一条节点各异的 DAG。与其发明一个没人能预测的语义,不如说清楚。 #### 等待上游的节点不占并发额度 依赖由一个**真正的任务状态** `blocked` 表达,而且等待中的节点不进待执行队列: ```python assert controller.active_count == 1 # 只有根节点占额度 assert controller.queued_count == 0 # 下游还在 blocked,不是 queued ``` 一条 20 个节点的串行管道不会因为"等待"而把额度全锁死。 #### 三种"没成功"必须分得清 | 状态 | 含义 | |---|---| | `skipped` | 上游没成功,它**本来就不该跑** | | `cancelled` | 本来要跑,被 fail_fast **连坐**了 | | `stopped` | 值班同学**手动停的** | `CANCELLED` 刻意不作为 fail_fast 的触发条件,否则用户主动取消整条管道会被记成"失败", 被连坐的节点也会再触发一轮连坐,因果就搅成一团了。 #### 限制 管道**结构在内存里**,不落库(任务的函数与参数从未持久化)。进程重启后一条跑到一半的 管道无法续跑——节点会被 `reconcile()` 如实标记为丢失。 ### 8.13 写一个任务:`@task` 与 `TaskContext` #### `@task` 装饰器 ```python from chongming_task import RetryPolicy, Timeout, task @task(tags={"io"}, retry=RetryPolicy(max_attempts=3), timeout=Timeout(per_attempt=60)) def extract(ctx, source: str) -> list[Row]: """从 source 读取数据。""" ... ``` 描述默认取 **docstring 第一行**,名字默认取函数名。裸 `@task`(不带括号)也支持。 一次登记整个模块: ```python controller.register_module(myapp.tasks) # 模块里所有 @task 都被登记 controller.register_all(extract, load) # 或者逐个登记 ``` **它不产生全局注册表。** 规格只挂在函数对象上(`__chongming_task__`),谁注册、注册到 哪个控制器由调用方决定。全局注册表意味着 **import 即注册**,而 import 可能发生在任何 地方(包括测试收集阶段),那种隐式行为极难排查。 #### `TaskContext` 任务作者面对的就是这一个对象(公开名字 `TaskContext`,与内部的 `RuntimeContext` 是 **同一个类**): | 成员 | 作用 | |---|---| | `ctx.checkpoint()` | 协作点:响应停止与暂停 + 刷新心跳。**不调用它就不响应停止**,也会被 `idle` 超时判为疑似卡死 | | `ctx.acheckpoint()` | 异步版本,`async def` 任务里 `await` 它(执行器会把协程真的跑完) | | `ctx.progress(done, *, total=, unit=, message=)` | 上报进度 | | `ctx.log(msg, *, level=)` / `.warning` / `.error` | 记日志 | | `ctx.logs` | 本任务最近 200 条日志(内存中) | | `ctx.input(节点id, 默认值)` / `ctx.upstream` | 取上游节点的返回值 | | `ctx.artifact_dir` | 本次尝试的产物目录(按 attempt 隔离) | | `ctx.is_stop_requested` / `ctx.is_pause_requested` | 只想探一下、不想抛异常时用 | #### 进度:进快照、进存储、不碰版本号 ```python for index, part in enumerate(partitions, start=1): ctx.checkpoint() ctx.progress(index, total=len(partitions), unit="partitions", message=part) ``` 进度走**高频旁路**:不参与状态机、不递增 `status_version`,由 `Controller` 经 `touch` 定期回写到存储。所以它出现在 `ps` 的 `PROGRESS` 列,而不会把版本号变成噪声。 > **`progress()` 刻意不刷新心跳。** 心跳是"我还在推进"的整体信号,进度是"推进到哪了"。 > 把心跳藏进进度里,会让一个只报进度、不做实际工作的任务看起来永远健康——那正是 > `idle` 超时要抓的东西。 #### 日志:走标准库 ```python ctx.log("开始处理") # INFO ctx.warning("分区为空,跳过") ctx.error("写入失败") ``` logger 名是 `chongming_task.task.`,并且 `task_id` / `task_name` / `attempt` 会作为 `extra` 附在 `LogRecord` 上,因此可以按任务维度配 handler、过滤或加字段: ```python logging.getLogger("chongming_task.task").setLevel(logging.DEBUG) ``` `ctx.logs` 保留最近 200 条(内存中,进程退出即消失)。**它不是日志存储方案**——那属于 应用自己的事,挂个 handler 就能落盘或入库。 #### 上游数据 管道的下游节点可以直接取上游的返回值: ```python @task def transform(ctx, day: str) -> int: partitions = ctx.input("extract", []) # extract 节点的返回值 ... ``` 只有**成功**的上游才会出现在 `ctx.upstream` 里。失败或跳过的上游没有值可传——塞一个 `None` 会让下游以为"上游返回了 None",而不是"上游没有值"。 #### 返回值必须可 JSON 序列化 返回值会**写入存储**(`outcome` 列),所以它必须是 JSON 可序列化的: ```python @task def good(ctx) -> dict[str, Any]: return {"rows": 3, "path": "s3://bucket/x.parquet"} # ✅ @task def bad(ctx) -> set[int]: return {1, 2, 3} # ❌ set 不可序列化 ``` 违反时任务会**失败**,失败类型是 `result_invalid`,消息写明原因。大数据请传**指针** (对象存储 key、表名 + 主键、文件路径)而不是内容。 > 为什么这么严:返回值不落库的话,进程内会显示 `succeeded` 而存储里停在 `running` > ——随后重启恢复会把一个**真正跑完**的任务判成 `LOST`。把一个成功过的任务报成"结果 > 未知"会让人放心地重跑,而副作用已经发生过一次。所以宁可让它在返回时就响亮地失败。 标签同理:`tags` 必须是字符串(类型契约是 `frozenset[str]`),否则在提交时就会被拒绝。 **参数**暂时不强制可序列化——参数今天不落库,所以 `Path`、连接对象这类本地专用参数 仍然合法。(节点之间不互相执行对方的任务,所以也不存在"参数要跨机器传输"的问题。) ### 8.14 节点桶归属:多节点部署 多个节点各跑各的任务时,靠桶区分"哪一份是我的": ```python controller = Controller( store=SqliteTaskStore("tasks.db"), owner="node-a", owned_buckets=range(0, 16), # 本节点负责 0..15 号桶(按业务划分,可多于一个) ) ``` | 成员 | 说明 | |---|---| | `owned_buckets` | 本节点负责的桶;`None`(默认)表示不限定 | | `owns_all_buckets` | 是否**不构成划分**:不限定,或显式覆盖了全部桶 | | `owns_bucket(bucket)` | 某个桶是否归本节点 | 限定时有两条行为: 1. **提交时校验**——任务必须落在自己的桶里: ```python controller.submit("sync", bucket_key="tenant-B") # ConfigurationError: 桶 42(来自 bucket_key='tenant-B')不属于本节点; # 本节点负责 [0, 1, ..., 15]。任务若写进别人的桶,不会有任何节点执行它。 ``` 这是**硬错误**而不是警告:落在别人桶里的任务不会有任何节点去跑它,它会永远停在 `queued`,而且**没有任何报错**。与其让它静默堆积,不如在提交那一刻就说清楚。 2. **`reconcile()` 只清理自己桶里的僵尸任务**,不去动别的节点的任务。 **覆盖全部桶不算划分。** 如果显式列举了全部桶(例如生成出来的"负责 0-63"配置),此时没有 任何"别的节点",无桶任务同样会且只会由本节点执行——因此不再强制 `bucket_key`。 **启动时会校验桶号范围**,越界立刻报错:放过去的症状同样是"任务永远排队且没有报错"。 > 声明了归属的节点会在**构造那一刻**就启动指令轮询,而不是等第一次提交。否则别人 `stop` > 一个还排在队列里的任务时,指令要等到本节点第一次提交才被取走——对定时任务节点来说可能 > 是几小时之后。没有声明归属的纯查询客户端仍然不轮询,避免抢走别人的指令。 多节点部署的完整说明(能做什么、不能做什么、新增的风险、跨机部署)见 **[分布式设计说明](distributed-design.md)**。跨机只需换存储后端:把 `SqliteTaskStore("tasks.db")` 换成 `PostgresTaskStore("postgresql://...")`, `owner` / `owned_buckets` / `reconcile()` / 跨节点控制全部照旧(见 §8.7)。 一份**可以直接照抄的完整模板**(部署参数与桶划分 `buckets_for_node()`、任务定义、 `build_node()` 接线、启动恢复、按桶提交、跨节点停止、收尾校验,全流程带断言)见 **[examples/11_best_practice.py](../examples/11_best_practice.py)**。 --- ## 9. 状态模型参考 ### 9.1 状态一览 **非终态**(9 个): | 状态 | 含义 | |---|---| | `pending` | 已创建,依赖尚未解析 | | `blocked` | 依赖未满足,等待上游 | | `queued` | 就绪,等待执行槽;未启动的暂停也在恢复时回到这里 | | `starting` | 执行器启动中 | | `running` | 正在执行 | | `pausing` | 已请求暂停,等待到达静默点 | | `paused` | 已就地挂起 | | `retry_wait` | 本次尝试失败,正在退避等待 | | `stopping` | 已请求停止,处于优雅窗口内 | **终态**(6 个,无出边): | 状态 | 含义 | |---|---| | `succeeded` | 正常完成 | | `failed` | 出错且重试耗尽,或遇到不可重试的错误 | | `stopped` | **人工主动停止**(不是错误) | | `cancelled` | **编排层级联取消**(如兄弟任务失败) | | `skipped` | 条件为假或依策略跳过 | | `lost` | 载体失联或被放弃,**结果未知** | `stopped` 与 `cancelled` 分开,是因为两者责任方不同。排障时"这次跑批失败是人工 干预还是真故障"是个必须能回答的问题。 ### 9.2 完整转移表 ```python PENDING: {BLOCKED, QUEUED, SKIPPED, CANCELLED, STOPPED} BLOCKED: {QUEUED, SKIPPED, CANCELLED, STOPPED} QUEUED: {STARTING, PAUSED, SKIPPED, STOPPED, CANCELLED} STARTING: {RUNNING, RETRY_WAIT, PAUSING, STOPPING, FAILED, CANCELLED, LOST} RUNNING: {SUCCEEDED, FAILED, RETRY_WAIT, PAUSING, STOPPING, CANCELLED, LOST} PAUSING: {PAUSED, RUNNING, STOPPING, FAILED, CANCELLED, LOST} PAUSED: {QUEUED, RUNNING, STOPPED, CANCELLED} RETRY_WAIT: {STARTING, QUEUED, PAUSED, STOPPED, FAILED, CANCELLED} STOPPING: {STOPPED, SUCCEEDED, FAILED, LOST} SUCCEEDED / FAILED / STOPPED / CANCELLED / SKIPPED / LOST: {} ``` 一条贯穿全表的规则:**控制动作是否需要中间态,取决于任务是否已占用执行资源**。 未占用(`pending`/`blocked`/`queued`/`paused`/`retry_wait`)→ 立即生效;已占用 (`starting`/`running`/`pausing`)→ 经中间态缓冲。让未启动的任务显示"停止中/暂停中" 是虚假信息,会让运维误以为需要等待。 ### 9.3 转移表之外的守卫 有些规则无法用"状态 → 状态"表达,它们是**守卫**: | 动作 | 守卫 | 理由 | |---|---|---| | `mark_running()` | 必须已有 `started_at` | `paused → running` 对就地恢复合法,对"排队期间被暂停、从未启动"却毫无意义 | | `abort_pause()` | 必须处于 `pausing` | `paused → running` 是给*恢复*用的;把"回滚"也放进来会产出没有开始时间的 `running` 快照 | | `mark_starting()` | `attempt + 1 <= max_attempts` | 否则抛 `AttemptsExhausted`(预算问题,不是状态表问题) | | `force_transition()` | 必须给出非空 `actor` 与 `reason` | 这是唯一能任意跳转的入口,没有审计信息就无法事后追溯 | | `mark_lost()` | 建议带 `failure` | `lost` 的全部意义是"不知道结果",不记录原因就无从判断该重跑还是人工核对 | --- ## 10. 错误与异常 ``` TaskError ├─ InvalidTransition 非法状态转移(含守卫不通过) ├─ DependencyFailed 上游未成功 ├─ CircularDependency 依赖成环 ├─ TaskTimeout 超时 ├─ TaskExecutionError 用户代码异常的统一包装(保留 __cause__) ├─ AttemptsExhausted 重试预算耗尽 ├─ ConcurrencyConflict 乐观并发写冲突 ├─ StoreError 存储层故障 └─ ConfigurationError 配置错误 TaskCancelled ← 独立:继承 BaseException,不是 TaskError ``` ### 9.1 `TaskCancelled` 为什么继承 `BaseException` 因为它可能出现在用户代码的任何一行,而下面这种写法极其常见: ```python try: while True: ctx.checkpoint() work() except Exception as exc: # 想捕获业务异常 log(exc) # 但会把停止信号一起吃掉 ``` 若 `TaskCancelled` 继承 `Exception`,任务将**永远无法被停止**。因此它和 `asyncio.CancelledError` 一样继承 `BaseException`: ```python try: ... except TaskCancelled: raise # 取消要放行 except Exception as exc: ... # 只处理真正的业务异常 ``` 测试里有一条用例专门用 `except Exception` 包裹循环来验证取消不会被吞掉。 --- ## 11. 限制与 FAQ **Q:任务停不下来?** 检查它是否调用了 `ctx.checkpoint()`。不调用的任务只能等超时,然后落 `lost`。 **Q:为什么我的任务被判"疑似卡死"(`timeout_idle`)?** `idle` 衡量的是 `checkpoint()` 间隔。长段无 `checkpoint()` 的计算会被误判——调大 `idle` 阈值,或提高 `checkpoint()` 频率。 **Q:`pause()` 返回 `False` 是什么情况?** 任务在窗口内没到达检查点,暂停**没生效**,状态已回滚为 `running`。这是如实告知, 不是 bug。 **Q:暂停会不会释放并发额度?** 不会。暂停是就地挂起,槽位仍被占用。 **Q:任务被判 `lost` 之后怎么办?** `lost` 表示结果未知。系统不会替你猜——你可以 `state_machine.retry()` 重跑,也可以 人工核对副作用后 `force_transition()` 标记终态。 **Q:`wait(timeout=...)` 超时了,任务会被杀掉吗?** 不会。它只抛 `TimeoutError`,任务状态完全不变。要停止请显式 `stop()`。 **Q:能让任务跑到一半重启而不重跑吗?** 不能。没有用户态检查点机制(那属于 Dask/Ray 式的图重算,是明确的非目标)。重试 总是从头开始,请把任务设计成幂等或可断点续传。 --- ## 12. 测试自己的任务 确定性时钟随包发布,你的测试不必 `sleep`: ```python from chongming_task.testing import FakeClock from chongming_task.run import run_task from chongming_task.policies import Timeout def test_idle_timeout_fires_without_waiting(): clock = FakeClock() # 通过 clock 注入控制时间,配合 handle.heartbeat 即可在毫秒内验证超时逻辑 ``` 常用做法: - `FakeClock` 注入 `TaskRun(..., clock=clock)`,用 `clock.advance(n)` 推进时间; - `InlineExecutor` 让执行同步化,测试不必等线程; - `RetryPolicy(backoff=FixedBackoff(0))` 去掉重试等待; - 断言 `run.transitions`,而不是只断言最终状态——中间态才是本项目的价值所在。 --- ## 下一步 已实现的能力以 [README 状态表](../README.md#当前状态务必先读)为准:单任务与 DAG 管道执行、 并发额度与排队、SQLite / Postgres 持久化与重启恢复、`@task` 装饰器与 `TaskContext` (`log` / `progress` / `upstream`)、CLI 控制台、节点桶归属。 仍在计划中的部分:跨机的**运维告警**(权威桶声明、桶重复/空洞、孤儿任务,见 [分布式设计说明](distributed-design.md) §11.2–11.3)与 `transitions` 的归档策略 (规模上 `prune()` 不够用,见 [持久层实现指南](store-guide.md))。 设计细节与理由见 [设计文档](task-control-design.md)。