Metadata-Version: 2.5
Name: chongming-task
Version: 0.1.0
Summary: 精细化状态控制的 Python 管道任务控制工具
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Python: >=3.11
Provides-Extra: postgres
Requires-Dist: psycopg-pool>=3.2; extra == 'postgres'
Requires-Dist: psycopg[binary]>=3.2; extra == 'postgres'
Description-Content-Type: text/markdown

# chongming-task

精细化状态控制的 Python 管道任务控制工具。

任务在这里不是一个"跑着 / 没跑"的布尔值，而是一条**可审计的状态轨迹**：每个中间态
（排队、启动、运行、暂停中、重试等待、停止中）都能被表达、被观察、被干预，而且
**结果永远如实上报**。

---

## 当前状态（务必先读）

这是一个**正在实现中的库**。已经能用的部分与还没做的部分如下：

| 能力 | 状态 |
|---|---|
| 任务状态模型与合法转移表（15 个状态） | ✅ 可用 |
| 单任务受控执行（`run_task` / `TaskRun`） | ✅ 可用 |
| 停止 / 暂停 / 恢复 | ✅ 可用（协作式） |
| 失败重试与退避 | ✅ 可用 |
| 三种超时（启动 / 单次尝试 / 静默） | ✅ 可用 |
| 线程执行器（含 `async def` 任务） | ✅ 可用 |
| 内联执行器（同步、零开销） | ✅ 可用 |
| 进程执行器（硬性停止 / 绕开 GIL / 崩溃隔离） | ✅ 可用 |
| 状态迁移历史（可审计） | ✅ 可用 |
| 任务注册表 + 统一管理门面（`Controller`） | ✅ 可用 |
| 并发额度与排队、具名资源信号量（按标签）、优先级、文本表格渲染 | ✅ 可用 |
| 任务分桶：稳定哈希 + 每桶独立限流 + 按桶查询（为分布式预留分配单位） | ✅ 可用 |
| 节点桶归属：`owned_buckets` 划界 + 提交校验 + `reconcile` 范围 | ✅ 可用 |
| 持久层契约 `TaskStore`（内存 / SQLite / Postgres 实现 + 契约测试套件） | ✅ 可用 |
| 跨机持久层 `PostgresTaskStore`（`SKIP LOCKED` 认领 + 连接池 + 跨进程并发用例） | ✅ 可用 |
| 存储接线：写穿 / 读穿 / 跨进程查询与控制 | ✅ 可用 |
| 重启恢复：监督者租约 + `reconcile()` 清理僵尸任务 | ✅ 可用 |
| DAG 管道：依赖、扇出/扇入、三种汇合语义、fail_fast 连坐 | ✅ 可用 |
| `@task` 装饰器、`TaskContext`（进度 / 日志 / 上游数据） | ✅ 可用 |
| 存储与 `Controller` 接线、跨进程查询、重启恢复 | ✅ 全部可用 |
| CLI（`run` / `ps` / `status` / `stop` / `pause` / `resume` / `stats` / `buckets`） | ✅ 可用 |

> **默认线程执行器，另备进程执行器**（外加测试用的内联执行器）。
>
> 线程共享内存，可以直接复用连接池、缓存、已加载的模型，也没有参数必须可 pickle 的
> 约束——闭包、`lambda`、`@task` 装饰器产物都能用。代价是**强制停止做不到**：优雅
> 窗口用尽后只能放弃等待并记 `lost`（结果未知），绝不谎报"已停止"。
>
> 需要**硬性停止保证、绕开 GIL、或崩溃隔离**时用 `ProcessExecutor()`——任务跑在独立
> 子进程里，优雅窗口用尽会 `terminate()` → `kill()`，段错误也只带走子进程。代价是
> 三条结构性约束：参数与返回值必须可 pickle、目标函数必须在可导入的模块里（且调用方
> 脚本要有 `if __name__ == "__main__":` 守卫）、停止与暂停有约 20ms 的轮询延迟。
> 详见[用户指南 §7](docs/user-guide.md#7-执行器)。
>
> 两种执行器下都请让任务调 `ctx.checkpoint()`，并写成幂等或可断点续传。

完整设计（状态表、分层、里程碑、风险）见 [设计文档](docs/task-control-design.md)；
面向使用者的完整说明见 [用户指南](docs/user-guide.md)。

---

## 安装

```bash
uv sync              # 开发环境
# 或
pip install -e .
```

核心包**零第三方运行时依赖**，只用标准库。

跨机部署共用一套持久层时，再装一个可选 extra：

```bash
pip install "chongming-task[postgres]"
```

它只带来 `psycopg`（Postgres 驱动）。**不装也能正常 `import chongming_task`**——
`PostgresTaskStore` 是惰性取的，不装时取它会报出安装命令而不是一个光秃秃的
`ModuleNotFoundError`（见 [持久层抽象](#持久层抽象)）。

---

## 快速上手

`examples/01_quickstart.py`：

```python
import time
from chongming_task.run import TaskRun


def extract(ctx, source: str) -> list[int]:
    """长耗时循环里必须周期性地调用 ctx.checkpoint()。"""
    rows: list[int] = []
    for index in range(5):
        ctx.checkpoint()  # 响应停止/暂停，并刷新心跳
        time.sleep(0.05)
        rows.append(index)
    return rows


run = TaskRun(extract, ("s3://demo/rows",), task_id="demo-1", name="抽取")
run.start()
outcome = run.wait(timeout=30)

for record in run.transitions:
    print(f"{record.from_status.value:>10} -> {record.to_status.value:<10} by {record.trigger}")

print(outcome.status.value, outcome.value)
```

真实输出：

```
  抽取完成：s3://demo/rows -> 5 行

状态迁移历史（只追加、可审计）：
     pending -> queued     by orchestrator:dependencies_resolved
      queued -> starting   by scheduler:dispatch
    starting -> running    by executor:launched
     running -> succeeded  by executor:completed

最终状态：succeeded
返回值  ：[0, 1, 2, 3, 4]
耗时    ：0.25s
```

只想跑一个任务、不需要中途干预时，用一行：

```python
from chongming_task.run import run_task

outcome = run_task(extract, "s3://demo/rows", task_id="demo-1", name="抽取")
if outcome.ok:
    print(outcome.value)
```

---

## 任务怎么写

**唯一约定**：长耗时循环里周期性调用 `ctx.checkpoint()`。

```python
def my_task(ctx, arg1, *, kwarg1=1):
    for row in source:
        ctx.checkpoint()  # ← 停止、暂停、心跳都在这里生效
        process(row)
    return result
```

`ctx` 由执行器注入为**第一个位置参数**，包含：

| 成员 | 说明 |
|---|---|
| `ctx.checkpoint()` | 协作点：响应停止（抛 `TaskCancelled`）、响应暂停（挂起）、刷新心跳 |
| `ctx.acheckpoint()` | 同上，在 `async def` 任务里 `await`（执行器会把协程真的跑完） |
| `ctx.is_stop_requested` | 只读，是否已请求停止（用于清理逻辑等不便阻塞处） |
| `ctx.is_pause_requested` | 只读，是否已请求暂停 |
| `ctx.task_id` / `ctx.name` / `ctx.attempt` | 身份与重试次数 |
| `ctx.heartbeat` | 心跳对象，`idle` 超时依据它判定 |
| `ctx.artifact_dir` | 本次尝试的产物目录（按尝试隔离，重试不污染上一次） |

**不调用 `checkpoint()` 的任务，既无法优雅停止，也无法暂停。** 这是协作式设计的
硬约束——见 [用户指南](docs/user-guide.md#为什么必须调用-checkpoint)。

---

## 控制任务

```python
run = TaskRun(my_task, task_id="job-1")
run.start()

run.pause()  # 任务在下一个 checkpoint 挂起 → paused
run.resume()  # → running
run.stop(reason="发布窗口")  # → stopping → stopped
outcome = run.wait(timeout=60)
```

几个刻意设计的语义，都是"不骗人"的结果：

- **`pause()` 返回 `False` = 暂停没生效**（任务没到达检查点），此时状态**回滚为
  `running`**，绝不假装已暂停。
- **`stop()` 停不掉时不会说"已停止"**。线程无法强杀，因此优雅窗口用尽后任务落
  `lost`（结果未知），而不是 `stopped`：

  ```
    == 停不掉的任务：如实上报 ==
    终态          ：lost
    失败归类      ：lost
    含义          ：线程无法强杀，任务可能仍在后台产生副作用，因此不敢说'已停止'
  ```

- **`wait(timeout=...)` 超时只抛 `TimeoutError`，不改变任务状态**。把"我没等到"
  当成"任务失败"是最常见的误用。
- **`pause()` 不释放并发槽位**。我们无法把任意的 Python 调用栈换出内存再换回来，
  所以暂停是就地挂起；暂停大量任务可能耗尽并发额度。

---

## 管理一批任务

`Controller` 是统一的管理入口：它持有**任务定义白名单**、**运行中的任务**与
**并发额度**。将来的 CLI（以及任何看板）都只是这一层的薄适配。

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

controller.submit("transform", 100, task_id="t0", label="转换-1")
controller.submit("transform", 200, task_id="t1", label="转换-2")
controller.submit("transform", 300, task_id="t2", label="转换-3")
controller.submit("transform", 400, task_id="t3", label="转换-4")

print(render_table(controller.list_tasks()))  # 查询
controller.stop("t0", reason="手工干预")  # 控制
controller.wait_all(timeout=60)
```

真实输出（`examples/04_controller.py`，节选）：

```
--- 查询：运行中的视图 ---
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      -        -         -
    未结束 4（其中排队 2，占用执行额度 2）
```

三个必须知道的设计点：

1. **只接受名字，绝不接受代码。** `submit("extract", ...)` 里的 `extract` 必须已在
   启动时 `register` 过，否则抛 `ConfigurationError`。一个能启动任意可调用对象的
   入口等于**远程代码执行**——这条在接 CLI 之后同样不能破。
2. **超限是排队，不是拒绝，也不是无限起线程。** 排队的任务状态为 `queued`
   （已被受理、等待执行槽），与 `pending`（尚未受理）不同。排队中的任务 `ELAPSED`
   显示 `-`，因为它还没执行过。
3. **`stop_all()` 返回的是"已发出请求"的数量，不是"已停止"。** 请求发出后任务先进入
   `stopping`，落终态是异步的；要等就接着 `wait_all()`。

> **跨进程限制**：`Controller` 管理的是**当前进程内**的任务。另一个进程里的
> `chongming-task ps` 看不到它们——内存不共享。跨进程查询与持久化需要共享媒介：
> 同机用 `sqlite3`（**它在标准库里**，不引入任何第三方依赖），跨机用
> `PostgresTaskStore`（可选 extra）。

---

## 任务分桶

桶既是**本地并发隔离**的单位，也是将来**分布式分配**的单位（一台机器拥有一组桶）。

```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")
```

真实输出（`examples/05_buckets.py`，节选）——A 桶积压，B 桶照常并行：

```
分桶策略：64 个桶
并发限制：全局 10，但每个桶最多 1 个

业务键 -> 桶号（同一个键在任何进程、任何机器上都会落到同一个桶）：
  tenant-A   -> bucket 7
  tenant-B   -> bucket 28

--- 查询：A 桶积压，但不影响 B 桶 ---
TASK_ID  NAME    BUCKET  STATUS     ATTEMPT  ELAPSED  PROGRESS  FAILURE
-------  ------  ------  ---------  -------  -------  --------  -------
a0       A-同步  7       succeeded  1/1      0.4s     -         -
a1       A-同步  7       running    1/1      0.1s     -         -
a2       A-同步  7       queued     0/1      -        -         -
a3       A-同步  7       queued     0/1      -        -         -
b0       B-同步  28      succeeded  1/1      0.4s     -         -
b1       B-同步  28      running    1/1      0.1s     -         -
    占用额度 2，排队 2，A 桶在用 1
```

### 两条硬约束

**一、桶数一旦开始用就不能随便改。** `bucket = hash(key) % N`，改 N 会让几乎所有键
改换桶位。所以任务会**同时记下 `bucket_key`**——将来真要改桶数，只有拿得到原始键
才能重新分桶。

**二、绝不能用内置 `hash()`。** CPython 对 `str` 的 `hash()` 带**进程级随机盐**
（`PYTHONHASHSEED`）：同一个键在不同进程里会落到不同的桶，于是"同桶任务在同一台
机器上"会**静默**失效——没有任何报错，只是本地性与缓存复用全都不成立。
`BucketPolicy` 用 `blake2b`，并有真实子进程 + 不同 `PYTHONHASHSEED` 的用例守着。

### 多节点部署：自治节点 + 统一平面

**每个节点有自己的任务定义与触发方式；节点之间不互相执行对方的任务；但共用同一套存储与
控制层，靠桶区分"哪一份是我的"。**

```python
# 节点 A：负责 0..15 号桶（按业务划分，可以多于一个桶）
controller = Controller(
    store=SqliteTaskStore("tasks.db"),
    owner="node-a",
    owned_buckets=range(0, 16),
)
```

| 维度 | 自治（每节点自有） | 统一（全局共享） |
|---|---|---|
| 任务定义、触发方式、负责的桶 | ✅ | — |
| 存储（任务状态、审计轨迹、指令）、控制层 | — | ✅ 一套 |

`owned_buckets` 的作用是**划界**：

- **提交时校验**——任务落进别人的桶会在提交那一刻报错。否则它会永远停在 `queued`，
  而且**没有任何报错**；
- **`reconcile()` 只看自己的桶**——不去动别的节点的任务。

```bash
# 运维：只看"我这一份"
chongming-task --db tasks.db --num-buckets 64 --owned-buckets 0-15 ps --all --mine

# 每个桶多少任务；星号标出本节点负责的桶
chongming-task --db tasks.db --num-buckets 64 --owned-buckets 0-15 buckets
```

统一平面给的是**看得见**与**管得着**：一个看板看全部、跨节点 `stop`/`pause`（走已有的指令
通道）、统一审计轨迹、以及**发现"某个桶没人管、任务在静默堆积"**——这是分散存储做不到的。

**跨机器**用 Postgres：SQLite 文件不能跨机共享，把 `store` 换成 `PostgresTaskStore` 即可，
其余代码（`owner`、`owned_buckets`、`reconcile()`、跨节点 `stop`）一行都不用改。

```python
from chongming_task import PostgresTaskStore  # 需要 pip install "chongming-task[postgres]"

controller = Controller(
    store=PostgresTaskStore("postgresql://user:pw@db.internal:5432/chongming"),
    owner="node-a",
    owned_buckets=range(0, 16),
)
```

CLI 同样认连接串，运维不必在每个节点上找那个 `.db` 文件：

```bash
chongming-task --db 'postgresql://user:pw@db.internal:5432/chongming' --num-buckets 64 ps --all
```

它实现的是**同一个 `TaskStore` 契约**，通过 `StoreContract` 全绿，并额外带着自己的并发证据
（`SKIP LOCKED` 认领、并发 `save` 恰好一个赢家、持有者被强杀后租约照常到期）。细节与建表
语句见[持久层抽象](#持久层抽象)与[持久层实现指南](docs/store-guide.md) §6。

这个架构的代价是**没有东西替你检查配置**：两个节点配了同一个桶（可能重复执行）或某个桶
没人配（任务静默堆积）都不会自动报错。缓解手段与其它风险见
[分布式设计说明](docs/distributed-design.md) §11。

> 早先设计过一个真正的集群方案（任意节点可跑任意任务：配方落库、跨节点原子认领、成员表、
> rendezvous 分配、再均衡、fencing）。它被否决——定时任务是**有归属**的，不是无差别算力池。
> 被否决的理由与仍然成立的结论见该文 §17。

---

## 嵌入到你自己的应用

**它是库优先的**：`Controller` 就是集成点，CLI 只是可选外壳。不需要单独进程、不需要
网络层、不需要先起服务。

```python
from chongming_task import Controller, ProcessExecutor, ThreadExecutor

controller = Controller(max_parallel=4, executor=ThreadExecutor())  # 默认：线程
# 需要硬性停止 / 绕开 GIL / 崩溃隔离时换成子进程：
# controller = Controller(max_parallel=4, executor=ProcessExecutor())

controller.register("extract", extract)  # 启动时登记一次
run = controller.submit("extract", "s3://bucket/x", task_id="job-1")

# 你的 Web 框架 / 定时任务 / 消息消费者里都可以直接用：
controller.list_tasks()
controller.get("job-1")
controller.stop("job-1", reason="用户取消")
controller.stats()
```

完整的一条集成主线（含多节点桶划分、启动恢复、跨节点控制、优雅退出）见
**[examples/11_best_practice.py](examples/11_best_practice.py)** —— 照抄它比照抄本节的
片段更省事。

公开 API 一行导入，`__all__` 里就是全部受支持的表面：

```python
from chongming_task import (  # noqa: F401
    Controller,
    TaskRegistry,
    TaskRun,
    run_task,
    render_table,
    TaskStatus,
    TaskSnapshot,
    TaskStore,
    InMemoryTaskStore,
    SqliteTaskStore,
    RetryPolicy,
    ExponentialBackoff,
    Timeout,
    ThreadExecutor,
)
```

## 持久层抽象

`TaskStore` 是持久层的**唯一契约**，第三方后端（Redis、对象存储……）只需满足它。内置三个
实现：

| 实现 | 适用 | 依赖 |
|---|---|---|
| `InMemoryTaskStore` | 单进程嵌入、单元测试、作为其他实现的**参照物** | 标准库 |
| `SqliteTaskStore` | 跨进程可见 + 持久化（**默认**） | 标准库 `sqlite3` |
| `PostgresTaskStore` | **跨机器**共用一套存储与指令通道 | 可选 `chongming-task[postgres]` |

```python
from chongming_task import InMemoryTaskStore, 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))  # 高频旁路
```

跨机器时换成 DSN 就完了——**同一个契约，同一套语义**：

```python
from chongming_task import PostgresTaskStore

store = PostgresTaskStore("postgresql://user:pw@db.internal:5432/chongming")
```

它建表所需的三张表（`tasks` / `transitions` / `commands`）在首次连接时自动创建；
若当前角色没有 DDL 权限，它检测到表已存在就直接使用——只读运维账号因此也能连。
`close()` 幂等，关闭后再用会抛 `StoreError` 而不是未定义行为。

四类数据刻意分开，因为它们的写入频次与一致性要求完全不同：

| 类别 | 方法 | 特点 |
|---|---|---|
| 任务快照 | `create` / `save` / `get` / `query` | 参与**乐观并发**（`status_version`） |
| 审计轨迹 | `append` / `history` | **只追加**，重复写入幂等 |
| 高频旁路 | `touch` | 进度与心跳，**不碰版本号**（否则会与状态写入持续冲突） |
| 控制指令 | `submit_command` / `claim_commands` / `complete_command` | 跨进程控制的媒介，带**租约** |

### 自证契约

写一个新后端时别靠读文档猜语义——跑契约套件。**完整的接口规格见
[持久层实现指南](docs/store-guide.md)**（14 个方法的逐条语义、并发要求、陷阱清单，
以及一个可执行的第三方后端范例）：

```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，没有就干净跳过）。

---

## 跨进程：一个进程跑，另一个进程看和管

`Controller(store=...)` 一旦给出，本实例就成为**可跨进程的一环**：

| 方向 | 行为 |
|---|---|
| 写 | 每次状态迁移**写穿**到存储（乐观并发，带迁移历史） |
| 读 | `get` / `list_tasks` / `stats` 走存储，因此**能看到别的进程的任务** |
| 控制 | 对不属于本进程的任务，`stop` / `pause` / `resume` 写成一条**指令**，由持有者轮询取走 |

真实输出（`examples/06_cross_process.py`，节选）：

```
--- ops-console 看到的状态（它并不持有这些任务）---
TASK_ID  NAME    BUCKET  STATUS   ATTEMPT  ELAPSED  PROGRESS  FAILURE
-------  ------  ------  -------  -------  -------  --------  -------
job-1    A-同步  7       running  1/1      0.5s     -         -
job-2    B-同步  28      running  1/1      0.5s     -         -
    它本地的任务句柄数：0

>>> ops-console 跨进程停止 job-1
    下发结果：True
    worker-1 已取走指令并停止该任务

--- job-1 的迁移历史（从存储读，可审计）---
     1     pending -> queued     by orchestrator:dependencies_resolved
     2      queued -> starting   by scheduler:dispatch
     3    starting -> running    by executor:launched
     4     running -> stopping   by user:stop
     5    stopping -> stopped    by runtime:stop_confirmed
```

### 三个刻意的设计点

1. **纯查询客户端不轮询指令。** 轮询线程只在**提交过任务**之后才启动。否则一个
   只读的看板会把别人的停止请求抢走——即便它把指令交还，持续轮询也会让真正的持有者
   反复错过。
2. **取到不属于自己的指令是"交还"，不是"完成"。** 存储契约里有
   `release_command`：标记完成会让真正的持有者永远收不到这次请求（一个静默的丢指令）；
   什么都不做、只等租约过期又会被持续续租饿死。所以需要第三种结局。
3. **存储故障不让任务失败。** 任务本身没问题，坏的只是可观测性。但也绝不静默——
   写穿失败记进 `controller.persist_failures`，让"看板上的状态是旧的"有迹可循。

### 两个使用注意

- **跨进程的 `pause()` 返回 `False`。** 那不是失败，是**未确认**——跨进程没有回执
  通道。要观测结果请轮询 `get()`。
- **用完一定要关存储。** `controller.close(close_store=True)` 或自己 `store.close()`。
  没关的 SQLite 连接会泄漏文件句柄，在 Windows 上还会让数据库文件无法删除。

---

## 命令行控制台

CLI 是**另一个进程**，所以它只能通过共享存储看和管——这正是持久层存在的理由。

```bash
# 一个终端里跑（前台执行到结束）
chongming-task --db tasks.db run --tasks myapp.tasks sync --arg tenant=A

# 另一个终端里看和管
chongming-task --db tasks.db ps
chongming-task --db tasks.db status demo-1
chongming-task --db tasks.db stop demo-1 --reason "发布窗口"
```

真实输出（`examples/tasks_demo.py` + `PYTHONPATH=examples`，节选）：

```
$ chongming-task --db tasks.db ps
TASK_ID  NAME    BUCKET  STATUS   ATTEMPT  ELAPSED  PROGRESS  FAILURE
-------  ------  ------  -------  -------  -------  --------  -------
demo-1   A-同步  7       running  1/1      7.4s     -         -

$ chongming-task --db tasks.db history demo-1
VER  FROM      TO        AT                         TRIGGER                             ACTOR   REASON
---  --------  --------  -------------------------  ----------------------------------  ------  ------
1    pending   queued    2026-10-02T14:01:23+00:00  orchestrator:dependencies_resolved  system  -
2    queued    starting  2026-10-02T14:01:23+00:00  scheduler:dispatch                  system  -
3    starting  running   2026-10-02T14:01:23+00:00  executor:launched                   system  -
4    running   pausing   2026-10-02T14:01:40+00:00  user:pause                          user    -
5    pausing   paused    2026-10-02T14:01:40+00:00  runtime:pause_reached               system  -
6    paused    running   2026-10-02T14:01:42+00:00  user:resume                         user    -
7    running   stopping  2026-10-02T14:01:44+00:00  user:stop                           user    发布窗口
8    stopping  stopped   2026-10-02T14:01:44+00:00  runtime:stop_confirmed              system  发布窗口
```

第 4–6 行是一次完整的暂停往返，第 7–8 行是停止——**谁按的、为什么按，都在轨迹里**。

### 退出码分三档

| 码 | 含义 |
|---|---|
| `0` | 成功 |
| `1` | 命令本身失败（参数错、任务不存在、存储打不开……） |
| `3` | 命令成功，但它查询/执行的**任务没有成功** |

`3` 是给脚本判分支用的——把"运维脚本写错了"和"任务本身失败了"分开，脚本才可能做对
判断。

### 任务定义来自模块，CLI 只传名字

```bash
chongming-task tasks --tasks myapp.tasks
```

模块必须定义 `register(controller)`。**一个能启动任意可调用对象的命令行入口等于任意
代码执行**，所以这条约定必须从第一天立好。

子命令、错误路径与完整走查见 **[命令行控制台](docs/cli.md)**。

---

## DAG 管道

把多个任务按依赖关系串起来，由一个调度器推进：节点先停在 `blocked` 等上游，上游满足后
才放行；注定跑不了的下游会被 `skipped`。

```python
from chongming_task import Controller, Pipeline
from chongming_task.policies import AggregationPolicy, JoinMode

pipeline = Pipeline("nightly-etl")

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)  # succeeded / failed / cancelled
```

真实输出（`examples/07_pipeline.py`）：

```
--- 刚提交时：下游都停在 blocked ---
NODE    TASK       STATUS   DEPENDS_ON            JOIN
------  ---------  -------  --------------------  -------------
fetch   fetch      running  -                     all_succeeded
shard0  transform  blocked  fetch                 all_succeeded
shard1  transform  blocked  fetch                 all_succeeded
shard2  transform  blocked  fetch                 all_succeeded
load    load       blocked  shard0,shard1,shard2  all_succeeded
audit   audit      blocked  load                  all_settled
```

### 依赖由状态机表达，不是调度器自造的

`blocked` 是一个**真正的任务状态**（早已在转移表里），不是管道额外发明的标记。所以：

* `PENDING → BLOCKED`（等待上游）→ `QUEUED`（上游满足）——每一步都走状态机、都写穿到
  存储、都留下审计轨迹；
* 等待中的节点**不占并发额度**（它不在待执行队列里），一条长管道不会把额度锁死。

### 两个不同的轴，别混

| 概念 | 回答的问题 |
|---|---|
| `JoinMode`（逐节点） | **这一个节点何时该跑**：`ALL_SUCCEEDED` / `ALL_SETTLED` / `ANY_SUCCEEDED` |
| `AggregationPolicy`（整条管道） | **管道何时算结束、要不要连坐**：`FAIL_FAST` / `ALL_SETTLED` |

### 三种结局必须分得清

| 状态 | 含义 |
|---|---|
| `skipped` | 上游没成功，它**本来就不该跑** |
| `cancelled` | 本来要跑，被 fail_fast **连坐**了 |
| `stopped` | 值班同学**手动停的** |

把它们混成一个状态，事后就再也分不出"上游挂了"、"被连坐了"和"人停的"。

`CANCELLED` 刻意**不作为** fail_fast 的触发条件——它是连坐的*结果*，不是原因。否则用户
主动取消整条管道会被记成"失败"，被连坐的节点还会再触发一轮连坐。

### 诚实的边界

* **管道结构在内存里，不落库。** 任务的函数与参数从未持久化，所以进程重启后一条跑到
  一半的管道无法续跑——它的节点会被 `reconcile()` 如实标记，而不是被假装恢复。
* **不支持 `quorum` 结算。** quorum 描述的是"同一件事的 N 份副本成功 k 份"，它属于扇出
  分组而不是一条节点各异的 DAG。硬套上去只能得到一个没人能预测含义的数字，所以
  `submit_pipeline` 会明确拒绝并说明原因。

---

## 写一个任务：`@task` 与 `TaskContext`

```python
from chongming_task import RetryPolicy, task


@task(tags={"io"}, retry=RetryPolicy(max_attempts=3))
def extract(ctx, day: str) -> list[str]:
    """按天抽取分区列表。"""
    partitions = [f"{day}/part{i}" for i in range(6)]
    for index, part in enumerate(partitions, start=1):
        ctx.checkpoint()  # 响应停止/暂停 + 刷新心跳
        ctx.progress(index, total=len(partitions), message=part)
        ctx.log(f"处理 {part}")
    return partitions
```

一次登记整个模块：

```python
controller.register_module(myapp.tasks)
```

`ctx` 是任务与运行时之间**唯一**的协作入口：

| 成员 | 作用 |
|---|---|
| `ctx.checkpoint()` | 协作点：响应停止与暂停，并刷新心跳。**不调用它就不响应停止** |
| `ctx.progress(done, total=, unit=, message=)` | 上报进度（高频旁路，不碰版本号） |
| `ctx.log(msg)` / `.warning` / `.error` | 走标准库 `logging`，logger 名 `chongming_task.task.<task_id>` |
| `ctx.input(节点id, 默认值)` / `ctx.upstream` | 取**上游节点**的返回值（管道数据传递） |
| `ctx.artifact_dir` | 本次尝试的产物目录（按 attempt 隔离） |

**返回值必须可 JSON 序列化**（它会写入存储）。违反时任务以 `result_invalid` **失败**——
不会静默地变成"存储里停在 running、重启后被判 LOST"。大数据请传指针。

进度会进快照、进存储，并出现在 `ps` 的 `PROGRESS` 列：

```
TASK_ID                 NAME                 STATUS     ATTEMPT  ELAPSED  PROGRESS  FAILURE
----------------------  -------------------  ---------  -------  -------  --------  -------
2b4e5e326776:extract    daily-etl/extract    succeeded  1/2      0.3s     6/6       -
2b4e5e326776:transform  daily-etl/transform  succeeded  1/1      0.3s     6/6       -
2b4e5e326776:load       daily-etl/load       succeeded  1/1      0.2s     4/4       -
```

### 三个刻意的设计点

1. **`@task` 不产生全局注册表。** 它只把规格挂在函数对象上（`__chongming_task__`），
   谁注册、注册到哪个控制器由你决定。全局注册表意味着 **import 即注册**，而 import
   可能发生在任何地方（包括测试收集阶段），那种隐式行为极难排查。
2. **`progress()` 不刷新心跳。** 心跳是"我还在推进"的整体信号，进度是"推进到哪了"。
   把心跳藏进进度里，会让一个只报进度、不做实际工作的任务看起来永远健康——那正是
   `idle` 超时要抓的东西。
3. **失败的上游不传 `None`。** 只有成功的上游才出现在 `ctx.upstream` 里。塞一个
   `None` 会让下游以为"上游返回了 None"，而不是"上游没有值"。

---

## 重启恢复

进程意外退出后，存储里会留下一批永远停在 `running` 的任务。**新进程启动时先跑一次
`reconcile()`**：

```python
controller = Controller(store=SqliteTaskStore("tasks.db"), owner="worker-2")
report = controller.reconcile()
if report.lost:
    log.warning("上次有 %d 个任务失去监督者", len(report.lost))
```

```bash
chongming-task --db crash.db reconcile --dry-run     # 先看一眼，不写任何状态
chongming-task --db crash.db reconcile --reason "worker-old 崩溃"
```

```
$ chongming-task --db crash.db reconcile --dry-run
OUTCOME      TASK_ID      WAS      DETAIL
-----------  -----------  -------  -----------------
adoptable    job-queued   queued   没有执行载体，等待重新提交
marked-lost  job-crashed  running  监督者已失联（进程重启后未再续租）
```

### 判据是租约，不是心跳

每个监督进程都会给自己负责的任务**续租**。租约过期才说明"没人在管了"：

| 情况 | 处置 |
|---|---|
| 租约未过期 | 有活着的进程在管，**一动不动** |
| 租约过期 + 任务**占着执行资源** | 载体已消失，判 `LOST` |
| 租约过期 + 任务**不占资源**（排队/暂停/退避） | 只是没人接手，**不改状态** |
| 原本的监督者又活过来抢先写入 | 检测到版本冲突，**放手** |

**绝不能用任务心跳当判据。** `last_heartbeat_at` 由任务自己的 `checkpoint()` 驱动，
一个长时间不调 checkpoint 的健康任务（纯 CPU 计算）心跳同样停滞——拿它判断进程存活，
会把一个正常任务判成 `LOST` 并**杀掉**。这就是租约与心跳必须分成两个字段的原因，也是
持久层契约里 `save` 不许写租约列的原因。

### 它不会重新执行任务

这是能力边界，不是遗漏：**存储里记的是状态，不是配方**——任务的函数与参数从未落库。
所以 `reconcile()` 做的是**如实标记**，让看板不再显示一个假的"运行中"；真正重跑要靠
调用方自己重新 `submit`。

---

## 重试与超时

```python
from chongming_task.policies import ExponentialBackoff, RetryPolicy, Timeout

outcome = run_task(
    flaky_task,
    retry=RetryPolicy(max_attempts=5, backoff=ExponentialBackoff(base=0.05, factor=2)),
    timeout=Timeout(per_attempt=30, idle=10),
)
```

三档超时相互独立，取最早到期者：

| 作用域 | 含义 |
|---|---|
| `start` | 从提交到真正开始执行的等待上限（一旦开始即失效） |
| `per_attempt` | 单次尝试的总时长 |
| `idle` | 距最近一次 `checkpoint()` 的静默时长——唯一能发现"卡住但没报错"的机制 |

真实输出（`examples/03_retry_and_timeout.py`）：

```
== 重试：失败两次后成功 ==
  终态      ：succeeded
  返回值    ：第 3 次尝试成功
  尝试次数  ：3/5

== 重试：预算耗尽 ==
  终态      ：failed
  尝试次数  ：2/2
  失败归类  ：exception

== 超时：单次尝试不超过 0.5 秒 ==
  终态      ：failed
  失败归类  ：timeout_attempt
  可重试    ：True
```

---

## 状态模型速览

生命周期状态有 15 个，分为非终态与终态：

```
非终态  pending  blocked  queued  starting  running  pausing  paused  retry_wait  stopping
终态    succeeded  failed  stopped  cancelled  skipped  lost
```

关键区分：

- `stopped`（人工主动停止）与 `cancelled`（编排层级联取消）**责任方不同**，分开记录。
- `lost` 表示**结果未知**（线程被放弃、载体失联），与"已停止"是两回事。
- **超时不是状态**，是失败原因（`failure.kind = timeout_attempt` 等）。否则会逼出
  "已超时但正在重试"这类单枚举表达不了的情况。
- 状态**只由状态机写入**，每次变更都递增 `status_version` 并留下一条带触发源的
  `Transition` 记录。

完整转移表与理由见 [设计文档 §2](docs/task-control-design.md)。

---

## 已知限制

1. **不调用 `ctx.checkpoint()` 就无法优雅停止或暂停**，只能等超时：线程执行器下那
   个线程停不掉，进程执行器下则要等强杀（任务会被真的停掉，但已经产生的那部分副作用
   无法撤销）。
2. **线程无法被强制终止**。优雅窗口用尽后任务落 `lost`，且可能仍在后台产生副作用。
   需要"停得住"的保证时换 `ProcessExecutor()`，见[用户指南 §7.4](docs/user-guide.md#74-进程执行器硬性停止--绕开-gil--崩溃隔离)。
3. **暂停不释放执行槽位**（就地挂起）。
4. **`idle` 超时衡量的是 `checkpoint()` 间隔**，不是"CPU 是否在干活"。长时间不
   checkpoint 的重计算会被判定为疑似卡死——这要求任务作者让 checkpoint 频率与
   `idle` 阈值相匹配。
5. **跨机之后仍没有自动告警**：两个节点配了同一个桶（会重复执行）或某个桶没人管
   （任务静默堆积）都不会报错——缓解手段见
   [分布式设计说明 §11](docs/distributed-design.md)。
6. **重试总是从头开始，任务不跨节点迁移**：没有用户态检查点 / 断点续传。请把任务写成
   幂等或可断点续传。

---

## 文档

| 文档 | 内容 |
|---|---|
| [最佳实践](docs/best-practices.md) | **照什么做生产上不会踩坑**：选型、任务写法、并发与资源、排障、反模式清单、上线检查清单 |
| [用户指南](docs/user-guide.md) | 概念、任务编写、控制语义、超时、执行器、FAQ |
| [命令行控制台](docs/cli.md) | 子命令、退出码、任务模块约定、完整走查（真实输出） |
| [持久层实现指南](docs/store-guide.md) | **换掉默认持久层要实现哪些接口**、逐方法语义、并发要求、陷阱清单 |
| [分布式设计说明](docs/distributed-design.md) | 多节点部署：边界表、自治节点 + 统一平面架构、风险、Postgres 落地状态 |
| [设计文档](docs/task-control-design.md) | 状态模型、转移表、分层架构、里程碑、风险 |
| [示例](examples/) | 十二个可直接运行的脚本 + 两个 CLI 任务模块（**多节点集成模板见 11 号**） |

```bash
uv run python examples/11_best_practice.py   # ← 想接入自己的应用，从这个开始
uv run python examples/01_quickstart.py
uv run python examples/02_control.py
uv run python examples/03_retry_and_timeout.py
uv run python examples/04_controller.py
uv run python examples/05_buckets.py
uv run python examples/06_cross_process.py
uv run python examples/07_pipeline.py
uv run python examples/08_task_context.py
uv run python examples/09_long_running.py
uv run python examples/10_worker_walkthrough.py
uv run python examples/12_process_executor.py   # 硬性停止 / 绕开 GIL / 崩溃隔离
```

其中 **11 号是"最佳实践"主模板**：一个可以直接照抄的多节点任务服务 ——
部署参数与桶划分（`buckets_for_node` 连续分区）→ 任务定义 → `build_node()`
接线 → 启动 / 提交 / 观测 / 跨节点控制 / 收尾，全流程自带断言校验。
**12 号**演示进程执行器：不响应停止的任务被真的杀掉、崩溃隔离、以及不可 pickle
的目标在提交那一刻被拦下。其余示例是单点机制演示，按需查阅即可。

`examples/tasks_demo.py` 不是可运行脚本，而是**给 CLI 用的任务模块**（演示
`register(controller)` 约定）：

```bash
PYTHONPATH=examples uv run chongming-task tasks --tasks tasks_demo
```

---

## 开发

```bash
uv run pytest -q        # 测试
uv run ruff check .     # 静态检查
uv run ruff format .    # 格式化
uv run mypy             # 类型检查（strict）
```

质量门禁：测试全绿、ruff 无告警、mypy strict 在全部源文件上零问题。
