初始提交:chongming-task 任务控制库

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

门禁:ruff / ruff format / mypy(strict) 干净;
      pytest 563 passed(44 skipped 是需要真实 Postgres 的那批);
      带一台可连的 Postgres 时 607 passed。
This commit is contained in:
2026-10-08 23:47:50 +08:00
commit f4de43c743
85 changed files with 25809 additions and 0 deletions
+506
View File
@@ -0,0 +1,506 @@
# 命令行控制台
`chongming-task` 是**另一个进程**:它看不到任务进程的内存,只能通过共享存储查询与
控制。这正是持久层存在的理由——没有它,`ps` 什么也看不到。
```bash
# 一个终端里跑任务(前台执行到结束)
chongming-task --db /var/lib/tasks.db run --tasks myapp.tasks sync --arg tenant=A
# 另一个终端里看和管
chongming-task --db /var/lib/tasks.db ps
chongming-task --db /var/lib/tasks.db status demo-1
chongming-task --db /var/lib/tasks.db stop demo-1 --reason "发布窗口"
```
---
## 1. 命令总览
| 命令 | 作用 |
|---|---|
| `tasks --tasks MODULE` | 列出某个任务模块里注册的定义(名字、标签、描述) |
| `run TASK --tasks MODULE [--executor thread\|process]` | 提交并**前台执行**一个任务,直到落终态 |
| `ps [--all] [--status S] [--bucket N] [--pipeline RUN_ID] [--mine] [--limit N]` | 列出任务。默认只显示**未落终态**的 |
| `status TASK_ID` | 单个任务的详情 + 迁移历史 |
| `history TASK_ID` | 只列迁移历史(可审计轨迹) |
| `stats` | 各状态计数 |
| `buckets [--num-buckets N]` | 每个桶的任务数 |
| `reconcile [--dry-run] [--stale-after S] [--reason R]` | **重启恢复**:清理上次进程留下的僵尸任务 |
| `stop TASK_ID [--reason R]` | 请求停止 |
| `pause TASK_ID [--timeout S]` | 请求暂停 |
| `resume TASK_ID` | 恢复被暂停的任务 |
全局选项:
| 选项 | 说明 |
|---|---|
| `--db PATH_OR_DSN` | 任务存储:SQLite 文件路径,或 `postgresql://` 连接串(跨机部署;需要 `pip install "chongming-task[postgres]"`)。未给出时读环境变量 `CHONGMING_TASK_DB` |
| `--num-buckets N` | 集群的桶数。**必须与部署一致**——它决定 `--owned-buckets` 的合法性与 `buckets` 视图的行数;不给出按默认值 64 |
| `--owned-buckets SPEC` | 本节点负责的桶,如 `0-15,20,30`。给出后 `reconcile` 只清理这些桶、`ps --mine` 只看这些桶 |
| `--json` | 以 JSON 输出,便于脚本消费 |
| `--owner NAME` | 本客户端的标识,会写进它下发的指令里,便于事后追溯是谁按的停止 |
### 退出码
| 码 | 含义 |
|---|---|
| `0` | 命令成功 |
| `1` | 命令本身失败(参数错、任务不存在、存储打不开、目录不存在……) |
| `3` | 命令成功,但它查询/执行的**任务没有成功** |
`3` 是给脚本判分支用的:
```bash
if ! chongming-task --db tasks.db run --tasks myapp.tasks load --arg day=2026-10-02; then
echo "任务没成功,别继续往下走了" >&2
exit 1
fi
```
> 注意 `1` 与 `3` 的区别:`1` 是"我连命令都没跑成",`3` 是"命令跑成了,但任务本身
> 没成功"。混在一起会让脚本无法区分"运维脚本写错了"和"数据质量有问题"。
---
## 2. 任务定义从哪里来
CLI **只传任务名,绝不接受代码**——一个能启动任意可调用对象的命令行入口等于任意
代码执行。所以必须先有一个模块把定义登记进来:
```python
# myapp/tasks.py
def sync_tenant(ctx, tenant: str, batches: int = 120) -> str:
"""同步一个租户的数据。"""
for index in range(batches):
ctx.checkpoint()
...
return f"{tenant} 同步完成"
def register(controller) -> None:
"""约定:CLI 导入模块后会调用它。"""
controller.register("sync", sync_tenant, tags={"io"})
```
然后 `--tasks myapp.tasks`。模块必须在 `PYTHONPATH` 上(或是一个已安装的包)。
仓库里的 `examples/tasks_demo.py` 就是一个可直接用的例子:
```bash
PYTHONPATH=examples chongming-task tasks --tasks tasks_demo
```
```
NAME TAGS MAX_ATTEMPTS DESCRIPTION
------------------------------------------------------------
add cpu 1 两数相加。
flaky - 1 前几次故意失败,用来演示重试。
sync io 1 同步一个租户的数据。
```
模块没有 `register` 时会给一句明确的话,而不是 `AttributeError`:
```
错误:任务模块 'json' 必须定义 register(controller) 函数。
```
---
## 3. 完整走查
以下全部是**真实运行输出**(`examples/tasks_demo.py`,先在一个终端启动,再从另一个
终端查询与控制)。
### 3.1 启动一个长任务(前台)
```bash
chongming-task --db .cli-demo/tasks.db run --tasks tasks_demo sync \
--arg tenant=租户A --arg batches=20000 --bucket-key tenant-A \
--task-id demo-1 --label A-同步
```
`run` 是**前台**的:它自己就是 worker,执行到任务落终态才退出。要后台跑就交给
`nohup`、`systemd` 或你已有的进程管理器——CLI 不假装自己是守护进程管理器。
### 3.2 从另一个进程查询
```bash
chongming-task --db .cli-demo/tasks.db ps
```
```
TASK_ID NAME BUCKET STATUS ATTEMPT ELAPSED PROGRESS FAILURE
------- ------ ------ ------- ------- ------- -------- -------
demo-1 A-同步 7 running 1/1 7.4s - -
```
```bash
chongming-task --db .cli-demo/tasks.db status demo-1
```
```
task_id : demo-1
name : A-同步
status : running
bucket : 7
tags : io
bucket_key : tenant-A
attempt : 1/1
status_version : 3
executor : thread
created_at : 2026-10-02T14:01:23+00:00
started_at : 2026-10-02T14:01:23+00:00
ended_at : -
last_heartbeat_at : 2026-10-02T14:01:31+00:00
elapsed : 7.8s
progress : -
failure : -
retryable : -
overridden : False
迁移历史:
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 -
```
**`last_heartbeat_at` 只有在这里才有意义。** 它由任务自己的 `ctx.checkpoint()` 驱动,
所以一个"卡住但没报错"的任务会显示心跳停滞——这是唯一能发现死锁的信号。
`ps` 的 `PROGRESS` 列来自任务里的 `ctx.progress(...)`。它走**高频旁路**(不参与乐观
并发),由持有任务的进程定期回写,因此可能有最多一个轮询周期的延迟:
```
TASK_ID NAME STATUS PROGRESS
---------------------- ------------------- --------- --------
2b4e5e326776:extract daily-etl/extract succeeded 6/6
2b4e5e326776:load daily-etl/load succeeded 4/4
```
`stats` 与 `buckets`:
```
$ chongming-task --db .cli-demo/tasks.db stats
running 1
--------------------
total 1
unfinished 1
$ chongming-task --db .cli-demo/tasks.db buckets --num-buckets 10
BUCKET TASKS
--------------
0 0
...
7 1
...
```
### 3.3 暂停与恢复
```bash
chongming-task --db .cli-demo/tasks.db --owner ops@console pause demo-1
```
```
已请求暂停 demo-1(用 status 观察是否真的挂起)
暂停**未确认**。若任务由别的进程持有,这是正常的——跨进程没有回执通道,请用 status 观察结果。
```
第一行在 stdout,第二行在 stderr,**退出码仍是 0**。跨进程没有回执通道,所以
`pause` 只能如实说"未确认"——这不是失败,而是它做不到的事就别假装做到。
紧接着 `ps` 能看到它确实停了:
```
TASK_ID NAME BUCKET STATUS ATTEMPT ELAPSED PROGRESS FAILURE
------- ------ ------ ------ ------- ------- -------- -------
demo-1 A-同步 7 paused 1/1 18.4s - -
```
`resume` 之后回到 `running`。
### 3.4 停止,并留下原因
```bash
chongming-task --db .cli-demo/tasks.db --owner ops@console stop demo-1 --reason "发布窗口"
chongming-task --db .cli-demo/tasks.db ps --all
```
```
TASK_ID NAME BUCKET STATUS ATTEMPT ELAPSED PROGRESS FAILURE
------- ------ ------ ------- ------- ------- -------- ---------
demo-1 A-同步 7 stopped 1/1 20.9s - cancelled
```
原因会完整落进审计轨迹,并且**区分了是谁按的**:
```
$ chongming-task --db .cli-demo/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 行是停止。**`REASON` 只在人为动作上出现**——机器自己的
迁移没有原因要记。
### 3.5 `--json`:给脚本用
```bash
chongming-task --db .cli-demo/tasks.db --json ps --all
```
```json
[{"attempt": 1, "bucket": 7, "bucket_key": "tenant-A", "created_at": "2026-10-02T14:01:23.570947+00:00",
"ended_at": "2026-10-02T14:01:44.485082+00:00", "executor": "thread", "failure": "{...}",
"last_heartbeat_at": "2026-10-02T14:01:44.430100+00:00", "max_attempts": 1, "name": "A-同步",
"outcome": null, "overridden": 0, "status": "stopped", "status_version": 8,
"tags": "[\"io\"]", "task_id": "demo-1"}]
```
JSON 与**存储行同构**(全部是原语),因此脚本消费它不需要了解 `datetime` / 枚举的
序列化规则。三点约定:
* 时间是 ISO 字符串(UTC);
* 枚举是 `.value` 字符串;
* 嵌套结构(`failure` / `outcome` / `tags`)是 JSON **字符串**而不是对象——保持与
存储行一致,代价是脚本里要多一次 `json.loads`。
错误走 stderr,stdout 保持干净,所以 `--json` 的输出可以直接管道给 `jq`。
### 3.6 按管道查看
管道里的每个任务都带 `pipeline_run_id`(形如 `ead248368f4a:load` 的 task_id),因此
可以整条一起看:
```bash
chongming-task --db tasks.db ps --all --pipeline ead248368f4a
```
过滤**下推到存储层**(SQLite 侧有 `idx_tasks_pipeline_run` 索引),不是取回全部再筛。
`--pipeline` 也可以与 `--status` 组合:
```bash
# 这条管道里有哪些节点没成功
chongming-task --db tasks.db ps --status failed --pipeline ead248368f4a
```
---
## 4. 多节点:`--owned-buckets` 与 `--num-buckets`
多个节点各跑各的任务时,靠桶区分"哪一份是我的"。两个选项都必须与部署一致:
```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
```
```
BUCKET TASKS
----------------
* 0 3
* 1 0
2 0
```
`reconcile` 会**限定在自己负责的桶内**:
```bash
chongming-task --db tasks.db --owned-buckets 0-15 reconcile --dry-run
```
**`run` 同样受归属约束**:限定了 `--owned-buckets` 时,`--bucket-key` 是必需的——没有归属
桶的任务不会有任何节点认领它,所以 CLI 在提交时就报错,而不是留下一个永远排队的任务:
```
$ chongming-task --db tasks.db --num-buckets 64 --owned-buckets 0-15 run \
--tasks myapp.tasks worker --task-id orders-worker
错误:本节点只负责桶 [0, 1, ..., 15],因此提交任务 'orders-worker' 时必须给出 bucket_key
——否则它没有归属桶,不会有任何节点认领它。
```
这很重要:统一存储里有别的节点的任务,去动它既越界(那是它的责任)也没有意义(它的租约
由它自己续)。
**桶归属的校验在做任何事之前完成**,包括 `ps` / `stats` / `buckets` 这些不构造
`Controller`、直接查存储的命令。否则越界的桶号会被静默接受——筛选条件匹配不到任何东西,
看起来就像"这个桶没有任务":
```
$ chongming-task --db tasks.db --owned-buckets 0-999 ps
错误:桶号越界:64;桶数是 64(合法范围 0..63)。若部署的桶数不是 64,请用 --num-buckets 指明。
```
### 4.1 `run --executor process`:能被真的杀掉的执行方式
默认的线程执行器在"任务不响应停止"时只能放弃等待(记 `lost`,结果未知)。需要**停得住**
的保证时,让任务跑在独立子进程里:
```bash
# 前台跑一个可能不配合的任务
chongming-task --db tasks.db run --tasks tasks_demo sync --arg tenant=acme --executor process --graceful-stop 5
# 另一个终端:优雅窗口用尽后会 terminate() → kill(),任务被真的停掉
chongming-task --db tasks.db stop <task_id> --reason "跑太久"
```
代价写在这里,省得踩了才知道(与库的行为完全一致,见[用户指南 §7.4](user-guide.md#74-进程执行器硬性停止--绕开-gil--崩溃隔离)):
- **参数与返回值必须可 pickle**——`lambda`、闭包、局部函数都不行;违反时提交那一刻就报错;
- **任务函数必须在可导入的模块里**(`--tasks` 指定的那个模块正好满足),且该模块的顶层
不能有副作用;
- 停止与暂停有约 20ms 的轮询延迟(信号要过进程边界)。
`run` 输出里的 `pid` 就是那个子进程的进程号,`ps` 能直接对上号。
### 4.2 跨机部署:`--db` 换成连接串
SQLite 文件不能跨机共享。多台机器共用一套存储时,给 `--db` 一个 Postgres 连接串即可:
```bash
pip install "chongming-task[postgres]"
chongming-task --db 'postgresql://user:pw@db.internal:5432/chongming' \
--num-buckets 64 --owned-buckets 0-15 ps --all --mine
```
**除连接串本身,其余一切都一样**:`--json`、退出码、`--owned-buckets` 校验、`reconcile`
的语义都不变。三张表(`tasks` / `transitions` / `commands`)在首次连接时自动创建,不需要
手工迁移;当前角色没有建表权限时,它检测到表已存在就直接使用——**只读运维账号因此也能连**。
没装可选依赖时给的是安装命令,而不是一个 `ModuleNotFoundError`:
```
$ chongming-task --db postgresql://… ps
错误:--db 给的是 Postgres 连接串,但可选依赖未安装:PostgresTaskStore 需要可选依赖 psycopg 与 psycopg-pool。安装:
pip install "chongming-task[postgres]"
或:pip install "psycopg[binary]" psycopg-pool
```
连不上时同样只说该说的——DSN 里的**密码不会出现在任何错误信息里**:
```
$ chongming-task --db 'postgresql://someuser:s3cret@nope.invalid:5432/x' ps
错误:无法连接 Postgres(someuser@nope.invalid:5432/x):failed to resolve host 'nope.invalid': [Errno 11001] getaddrinfo failed
```
---
## 5. 重启恢复:`reconcile`
进程意外退出后,存储里会留下一批永远停在 `running` 的任务。**新进程启动时先跑一次
`reconcile`**,否则看板会永远显示一个假的"运行中"。
真实输出。先看崩溃后的现场:
```
$ chongming-task --db crash.db ps
TASK_ID NAME BUCKET STATUS ATTEMPT ELAPSED PROGRESS FAILURE
----------- ------------ ------ ------- ------- ------- -------- -------
job-queued 等待中的任务 - queued 0/1 - - -
job-crashed 夜间抽取 - running 1/1 600.4s - -
```
`job-crashed` 已经"运行"了 600 秒——它的进程其实十分钟前就没了。
**先预演**(这类操作会改状态,看一眼是值得的):
```bash
chongming-task --db crash.db reconcile --dry-run
```
```
OUTCOME TASK_ID WAS DETAIL
----------- ----------- ------- -----------------
adoptable job-queued queued 没有执行载体,等待重新提交
marked-lost job-crashed running 监督者已失联(进程重启后未再续租)
预演:有 1 个任务**会被**标记为 lost,1 个等待重新提交。未写入任何状态。
注意:本命令不会、也无法重新执行任务——存储里记的是状态,不是参数。
```
确认无误后真正执行:
```bash
chongming-task --db crash.db reconcile --reason "worker-old 崩溃"
```
```
已把 1 个僵尸任务标记为 lost,1 个任务无人接手(状态未改动,需要重新提交)。
```
之后 `ps --all` 里 `job-crashed` 是 `lost`,而 `job-queued` **仍然是 `queued`**——
它没占执行资源,只是没人接手,把它的排队事实改掉才是错的。原因也进了审计轨迹:
```
$ chongming-task --db crash.db history job-crashed
VER FROM TO AT TRIGGER ACTOR REASON
--- ------- ---- ------------------------- ---------------------- ------ -------------
4 running lost 2026-10-02T14:10:47+00:00 runtime:heartbeat_lost system worker-old 崩溃
```
### 两件必须清楚的事
1. **它不会、也无法重新执行任务。** 存储里记的是状态,不是配方:任务的函数与参数从未
落库。所以"重跑"只能靠你重新 `run`,而那需要你自己知道参数。
2. **判据是租约,不是心跳。** 一个长时间不调 `checkpoint()` 的健康任务心跳也会停滞;
若拿心跳判断进程存活,它会被误判丢失并**杀掉**。所以心跳与租约是两个独立字段。
`--stale-after` 只影响"从未续过租的任务"的宽限期(默认取租约时长的两倍),用来避开
"刚提交、工作线程还没轮到第一次续租"这个窗口。
---
## 6. 错误路径
```
$ chongming-task ps
错误:未指定任务存储。请用 --db PATH,或设置环境变量 CHONGMING_TASK_DB。
CLI 是另一个进程,没有共享存储就看不到任何任务。
$ chongming-task --db .cli-demo/tasks.db stop typo-task-id
错误:没有找到任务 typo-task-id;无法停止。
$ chongming-task --db /nope/tasks.db ps
错误:数据库所在目录不存在:/nope。请先创建它,或改用别的路径。
```
最后一条是刻意加的:SQLite 原本只会报 `unable to open database file`,完全看不出是
目录不存在——这是最容易浪费时间的错误之一。
---
## 7. 桌面上的注意事项
* **`run` 是前台进程。** 它退出时任务就没人管了。要长期跑请交给进程管理器。
* **`pause` 跨进程返回"未确认"。** 想看结果请 `status`;`stop` 则是"请求已受理"。
* **`stop` 一个不存在的任务会失败并留下零条指令。** 拼错的 id 不会制造孤儿指令。
* **`ps` 默认不含已结束的任务。** 想看历史加 `--all`,或按状态过滤 `--status failed`。
* **`--db` 必须显式给出或设环境变量。** 不会静默在当前目录建一个库——那类"我的任务
去哪了"的问题很难查。
---
## 8. 与库的关系
CLI 是 `Controller` 之上的一层**薄适配**:它做的全部事情就是解析参数、调
`Controller` / `TaskStore`、渲染输出。所以:
* 库能做的,CLI 不一定都暴露(比如 `wait_all`、`close_store`、`prune`)——直接在代码里用
[Controller](user-guide.md#8-管理一批任务controller) 更灵活;
* CLI 不引入任何库没有的概念,也不绕过任何不变量(比如它不会直接改状态)。
替换持久层的步骤见 [持久层实现指南](store-guide.md)——CLI 会自动用上你的实现,因为它
只依赖 `TaskStore` 契约。
+566
View File
@@ -0,0 +1,566 @@
# 分布式设计说明
本文说明**多节点部署**的架构、边界与风险。
> **阅读顺序**:§2 的边界表一眼说清今天能做什么、不能做什么;§5 是选定架构;§11 是这个
> 架构**新增的风险**;§17 记录了被否决的另一个方案及原因。
**一句话**:不是分布式集群,而是**多个自治的定时任务节点 + 一套统一的存储与控制平面**,
靠**桶**区分"哪一份是我的"。
---
## 1. 结论速览
| 层次 | 状态 |
|---|---|
| 稳定分片(桶) | ✅ 已实现:同一个键在任何进程、任何机器上落到同一个桶 |
| 节点归属(我负责哪些桶) | ✅ 已实现:`Controller(owned_buckets=...)` |
| 统一存储(多节点共享任务状态) | ✅ 已实现(默认后端是 SQLite **文件**,跨机需要换后端) |
| 统一控制(跨节点停止/暂停/查询) | ✅ 已实现:指令通道 + 统一 CLI |
| 判活与重启恢复 | ✅ 已实现:监督者租约 + `reconcile()` |
| 跨节点**执行**对方的任务 | ❌ **刻意不做**(见 §17) |
| 弹性伸缩 / 负载均衡 | ❌ 不做(定时任务有归属,不是无差别算力) |
---
## 2. 边界表:今天能做什么
### ✅ 能做
| 能力 | 位置 |
|---|---|
| 同一个键稳定映射到同一个桶(跨进程、跨机器一致) | `policies.BucketPolicy`(blake2b,**不是**内置 `hash()`) |
| 桶号与原始业务键记在每条任务上 | `TaskSnapshot.bucket` / `.bucket_key` |
| 声明本节点负责的桶 | `Controller(owned_buckets=...)` |
| 提交时校验归属(防止任务写进别人的桶) | `Controller._check_bucket_ownership()` |
| 每桶独立限流 | `Controller(max_parallel_per_bucket=...)` |
| 按桶查询,**下推到存储层**且有索引 | `TaskQuery.buckets` / `idx_tasks_bucket` |
| 多进程共享任务状态(同一台机器) | `SqliteTaskStore`(WAL + 乐观并发) |
| 一个进程控制另一个进程的任务 | 指令通道:`submit_command` / `claim_commands` / `complete_command` / `release_command` |
| 崩溃后的清理,**限定在自己的桶内** | `reconcile()` + 监督者租约 |
| 换掉持久层而不改业务代码 | `TaskStore` 契约 + 随包发布的 `StoreContract` |
| 写穿失败后自动追上(不永久冻结) | 写穿缓冲(见 §14) |
### ❌ 不能做
| 做不到的事 | 原因 |
|---|---|
| 两台机器共享 SQLite 文件 | 网络文件系统的锁不可靠。跨机必须换网络后端 |
| 节点执行另一个节点定义的任务 | **架构决定**:任务定义各节点自有,见 §5 / §17 |
| 任务在任意节点间漂移/迁移 | 归属是配置,不是调度结果 |
| 自动发现"某个桶没人负责" | 需要一份中心归属声明,见 §11.3 |
| 检测"两个节点配了同一个桶" | 同上 |
**这里的关键区分是"共享"与"分工"**:本文解决的是共享(谁都能看到、谁都不会踩到别人),
分工由**配置**表达——每个节点声明自己的桶,不靠协商。
---
## 3. 今天的三条基础机制
这三条已经跑通,也是后面一切的地基。
### 3.1 乐观并发:一个任务只有一个写入者
`save(snapshot, expect_version=...)` 是**单键 compare-and-set**。冲突就抛
`ConcurrencyConflict`,**绝不自动重试**——因为每个任务的写入者唯一(它的持有进程),
冲突意味着存在第二个写入者,那是设计缺陷。
`reconcile()` 直接依赖这条:发现版本冲突时它**放手**,因为"原来的监督者又活过来了"。
### 3.2 指令通道:控制意图怎么跨进程
一个进程无法给另一个进程的执行载体发信号,所以 `stop` / `pause` / `resume` 变成**指令**:
```
控制方 存储 持有者
stop(T1) ──submit_command──▶ [c1: stop T1] ◀──claim── worker-1
(租约 30s) │
[c1: done] ◀──complete── 执行 run.stop()
```
取走指令时打**租约**:取走者崩溃后租约到期,别人可以重取。
取到**不属于自己**的任务时**交还**(`release_command`)而不是标记完成——后者会静默丢
指令,前者让真正的持有者马上能取到。
**这条通道本来就是跨节点的**,只是单机部署时收发双方碰巧同机。所以"统一控制平面"不需要
任何新机制。
### 3.3 监督者租约:谁还在管这个任务
每个监督进程定期给自己负责的任务续租(`lease_owner` / `lease_until`,经 `touch` 高频
旁路写入)。`reconcile()` 据此判定僵尸任务。
**它与任务心跳是两件事,不能互相推导:**
| 字段 | 谁写 | 回答 |
|---|---|---|
| `last_heartbeat_at` | 任务自己的 `checkpoint()` | **任务在推进吗**(停滞 = 卡死) |
| `lease_until` | 监督进程续租 | **还有人在管它吗**(过期 = 监督者没了) |
用任务心跳判断进程存活,会把一个长时间不调 `checkpoint()` 的**健康**任务误判为失联并
判 `LOST`——把一个正常任务杀掉。这条在持久层契约里也钉死了:`save` **不得**写租约列。
---
## 4. 已经就位的地基里,哪些是**为**分布式而做的
这些今天就有价值,但它们的存在理由包含"将来要多节点":
1. **`blake2b` 而不是内置 `hash()`。** CPython 对 `str` 的 `hash()` 带进程级随机盐,
同一个键在不同进程会落到不同的桶。后果是"同桶任务在同一台机器上"**静默**失效。
有跨进程用例守着(真实子进程 + 不同 `PYTHONHASHSEED`)。
2. **桶号记在任务上,而不是每次重算。** 万一分桶策略或桶数变化,重算会把一个正在跑的
任务挪到别的桶,局部性与隔离假设随之悄悄失效。
3. **`bucket_key` 留存。** 改桶数或改归属时,只有拿得到原始键才能重新分桶。
4. **按桶查询下推 + 索引。** "取我拥有的那组桶"是这套架构里最高频的查询。
5. **持久层契约可换。** 这是跨机部署的第 0 步——SQLite 文件不能跨机共享。
6. **`owner` 标识**(默认 `主机名:pid`)写进指令里,便于事后追溯是谁按的停止。
---
## 5. 选定架构:自治节点 + 统一平面
**每个节点有自己的任务定义与触发方式;节点之间不互相执行对方的任务;但共用同一套存储与
控制层,靠桶区分"哪一份是我的"。**
```
┌──────────────────────────────────────────────┐
│ PostgreSQL(统一存储) │
│ tasks / transitions / commands │
└───┬────────────────┬────────────────┬────────┘
│ │ │
┌────────▼──────┐ ┌──────▼────────┐ ┌────▼──────────┐
│ 节点 A │ │ 节点 B │ │ 运维 / CLI │
│ 自己的任务定义 │ │ 自己的任务定义 │ │ 统一观测与控制 │
│ 自己的定时触发 │ │ 自己的定时触发 │ │ --owned-buckets│
│ 桶 {0-15} │ │ 桶 {16-31} │ │ --num-buckets │
└───────────────┘ └───────────────┘ └───────────────┘
```
| 维度 | 自治(每节点自有) | 统一(全局共享) |
|---|---|---|
| 任务定义(函数、默认重试/超时、标签) | ✅ | — |
| 触发方式(定时、事件、手动) | ✅ | — |
| 负责的桶(按业务划分,可多个) | ✅ 声明 | — |
| 存储(任务状态、审计轨迹、指令) | — | ✅ 一套 |
| 控制层(API、CLI、停止/暂停、reconcile) | — | ✅ 一套 |
**没有调度器进程,也没有跨节点的任务派发。** 每个节点只做自己的事;统一平面提供的是
**看得见**与**管得着**。
---
## 6. 桶在这个架构里的三个作用
1. **划界(核心)**——节点只接受、只清理自己负责的桶:
* 提交时校验:任务落进别人的桶会**在提交那一刻报错**;
* `reconcile()` 只看自己的桶,不去动别的节点的任务。
2. **隔离与公平**——桶内并发限制(`max_parallel_per_bucket`),一个租户的积压不饿死
其他租户。这是分桶在**本地**就有的价值,不必等到多节点。
3. **将来迁移的最小单位**——`bucket_key` 已留存,改桶数或换归属都有依据。
---
## 7. 统一平面给了你什么
既然节点不跑对方的任务,"统一"的全部回报就是**可观测**与**可控制**:
| 回报 | 靠什么 |
|---|---|
| 一个看板看全部,不必逐台登录 | 所有任务在一张表里,`ps` / `stats` 直接查 |
| 跨节点控制 | **已有**的指令通道:`stop` / `pause` / `resume` 别的节点正在跑的任务 |
| 统一的审计轨迹 | 所有状态迁移在一张表里:谁的、什么时候、为什么 |
| 重启恢复各自负责,但**对运维可见** | 每个节点只 `reconcile` 自己的桶,而"哪个桶有僵尸"全局可见 |
| **能发现"没人管"的任务** | 见 §11.2——这是统一平面**唯一能做**而分散存储做不到的事 |
最后一条最重要:分散存储时,"某个桶的节点已经下线,它的任务在静默堆积"是**不可发现**的。
---
## 8. 节点归属:`owned_buckets`(已实现)
```python
# 节点 A:负责 0..15 号桶(按业务划分,可以多于一个桶)
controller = Controller(
store=SqliteTaskStore("tasks.db"),
owner="node-a",
owned_buckets=range(0, 16),
)
# 单机,或不构成划分的节点:不限定(默认)
controller = Controller(store=store) # owned_buckets=None
```
行为:
| 成员 | 说明 |
|---|---|
| `owned_buckets` | 本节点负责的桶;`None` 表示不限定 |
| `owns_all_buckets` | 是否**不构成划分**:不限定,或显式覆盖了全部桶 |
| `owns_bucket(b)` | 某个桶是否归本节点(只回答归属,不回答"无桶任务能否提交") |
| `submit()` | 限定时,任务必须落在自己的桶里,否则 `ConfigurationError` |
| `reconcile()` | 限定时,只清理自己桶里的僵尸任务 |
**两条判据上的细节:**
* **覆盖全部桶 = 不构成划分**,此时无桶任务同样会且只会由本节点执行,因此不再强制
`bucket_key`(否则生成出来的"负责 0-63"配置会带来纯摩擦)。
* **提交时是硬错误而不是警告**:落在别人桶里的任务不会有任何节点去跑它,它会永远停在
`queued`,而且**没有任何报错**。与其让它静默堆积,不如在提交那一刻就说清楚。
启动时校验越界桶号(`0 <= bucket < num_buckets`):放过去的症状同样是"任务永远排队且
没有报错",那是最难查的一类问题。
---
## 9. 命令轮询:声明了归属就从启动开始(已实现)
`Controller` 在**声明了 `owned_buckets`** 时,构造那一刻就启动指令轮询线程。
否则有一个延迟生效的坑:别人 `stop` 一个属于本节点、当前还排在队列里的任务时,指令已经
写进存储了,但本节点要等到**第一次提交**才会启动轮询——对一个定时任务节点来说,那可能是
几分钟甚至几小时之后。
没有声明归属的实例(纯查询客户端)**仍然不轮询**,避免抢走别人的指令——这是早先就定下的
规则,两条一起成立的判据是"你是不是一个真 worker"。
---
## 10. 运维视图(CLI)
```bash
# 声明本节点负责的桶与集群桶数(两者都必须与部署一致)
chongming-task --db tasks.db --num-buckets 64 --owned-buckets 0-15,20 \
ps --all --mine
# 每个桶有多少任务;星号标出本节点负责的桶
chongming-task --db tasks.db --num-buckets 64 --owned-buckets 0-15 buckets
# 重启恢复:只清理自己桶里的僵尸
chongming-task --db tasks.db --owned-buckets 0-15 reconcile --dry-run
```
`--num-buckets` 必须显式给出(或确认默认值 64 与部署一致):桶数是**部署范围的约定**,
各节点不一致会让同一个键落到不同的桶,而且不会有任何报错。
**桶归属的校验在做任何事之前完成。** 有些命令(`ps` / `stats` / `buckets`)直接查存储、
不构造 `Controller`;如果把校验只放在 `Controller` 里,越界的桶号在这些命令上会被**静默
接受**——筛选条件匹配不到任何东西,看起来就像"这个桶没有任务"。
---
## 11. 这个架构新增的风险(诚实)
"各司其职"用配置换掉了协调,代价是**没有东西替你检查配置**。
### 11.1 配置漂移
| 情况 | 后果 | 今天能挡住吗 |
|---|---|---|
| 两个节点配了同一个桶 | 两个都可能执行同一个任务 | ❌ 挡不住。提交校验只能挡"写进别人的桶" |
| 某个桶没人配 | 它的任务永远 `queued`,**静默堆积** | ❌ 挡不住 |
缓解手段:
1. **提交时校验**(已做)能挡住"写进别人桶"这一类;
2. **监控"长时间未变化的 `queued` 任务"**——这是最有效的兜底,因为它直接盯住症状;
3. 把桶归属做成**一处生成、多处使用**的配置(配置即代码),从源头避免手写漂移。
### 11.2 孤儿任务
拥有某桶的节点下线后,它的任务没人跑,也**没人报警**——这是"各司其职"的固有代价。
**统一存储恰好能发现它**,而且只有统一存储能:
```
非终态任务 + 超过 N 分钟没有状态变化 + 该桶没有活着的节点 → 告警
```
最后那个条件需要知道"谁负责哪个桶"(见 §11.3)。
### 11.3 桶归属需要一个**可审计的声明**(建议,未实现)
今天 `owned_buckets` 只存在于各节点自己的配置里,中心看不到,因此无法检测重复与空洞。
建议加一张**静态声明表**:
```sql
CREATE TABLE node_buckets (
bucket INTEGER PRIMARY KEY,
node TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
```
节点启动时把自己的桶写进这张表(`ON CONFLICT (bucket) DO UPDATE`),并在冲突时**告警**
("桶 7 同时被 node-a 与 node-b 声明")。
> **⚠️ 注意它与被否决方案里的"桶租约表"是两件完全不同的事**(见 §17):
> 那是**动态抢占**的运行时状态(在本架构里多余);这是**静态归属的声明**(用于审计与
> 告警,不参与抢占,也不决定谁能执行)。同名不同物,别混淆。
### 11.4 跨节点停止的送达
`stop` 一个属于别的节点、当前没在跑的任务会写一条指令,等那个节点取走。所以**节点必须
持续轮询**——这一条已经在 §9 落地。
指令的语义仍然是"请求已受理",不是"已停止":要确认结果得查状态。
### 11.5 不能跨节点重跑
存储里没有参数(见 §13),所以谁也不能重跑别人的任务——**本来就是这样**,现在更明确:
连"将来做跨节点重跑"都不必考虑了。重跑只能由**拥有该任务定义的节点**自己做。
---
## 12. 做的时候不能破的不变量
这些是已经用测试和文档钉死的性质。破了任何一条,故障都是**静默**的。
| 不变量 | 破了会怎样 |
|---|---|
| **每个任务只有一个写入者** | 乐观并发冲突变成常态;`reconcile` 会不断"放手" |
| **`save` 不写租约列** | 一次状态迁移把租约覆盖回旧值 → 活着的任务被误判失联并杀掉 |
| **心跳必须先同步进内存快照**(`refresh_activity`) | `save` 会写心跳列,而快照不知道 `touch` 写了什么 → 看板上的心跳被覆盖回空 |
| **`release_command` 不标记完成** | 静默丢指令,真正的持有者永远收不到 |
| **`touch` 不递增版本号** | 高频写入与状态写入持续冲突,版本号变噪声 |
| **`save` 的版本号单调但不连续** | 写穿缓冲恢复后要能一次跳进;要求"只接受 +1"等于重新引入永久冻结 |
| **`CANCELLED` 不是 fail_fast 的触发条件** | 用户主动取消被记成失败;连坐再触发连坐 |
| **判活靠租约,不靠任务心跳** | 长时间不 checkpoint 的健康任务被误杀 |
| **`blake2b`,不用内置 `hash()`** | 跨进程分桶静默错位 |
| **桶数全局一致** | 同一个键落到不同的桶,无任何报错 |
| **返回值为 JSON 可序列化**(见 §14) | 存储里停在 running,重启后被判 `LOST` |
### 关于时钟
租约判定用的是**本地时钟**(`snapshot.lease_until > self._clock()`),而 `lease_until`
由另一个进程用自己的时钟算出。同机或 NTP 良好时不是问题(TTL 默认 30 秒,NTP 偏差通常
毫秒级)。**迁移到 Postgres 后建议改用存储侧 `now()` 做租约裁定**(见 §15),那样这个
假设就彻底消失了。
---
## 13. 语义上的诚实:它是 at-least-once
必须说清楚的事实:
* 被 `stop` / `cancel` 的任务**可能已经产生了副作用**。系统把它记为 `stopped` /
`cancelled`,而不是假装什么都没发生。
* 进程崩溃时,任务可能**仍在后台运行**。此时状态是 `LOST`——"结果未知",**不是**
"没跑过"。把"未知"说成"已停止"会让人放心地重跑,而副作用已经发生两次。
* `reconcile()` **不会重新执行**任何任务(存储里只有状态,没有参数)。重跑要靠调用方
自己 `submit`,而那需要调用方知道参数。
* 因为节点不跑对方的任务,**跨节点的重复执行只可能来自"两个节点配了同一个桶"**
(§11.1),而不是来自租约竞争。
一句话:**它报告真相,不提供幻觉。**
---
## 14. 已修复的缺陷(记录)
这两处曾是真实的数据损坏,现在都有回归用例。记录在这里,因为它们解释了若干不变量的来历。
### 14.1 一次写穿失败会让任务在存储里**永久冻结**
`_persist` 曾用 `expect_version = transition.version - 1`,这个式子**假设此前每一次迁移
都成功落库**。一旦某次 `save` 失败(网络抖动、连接被断、返回值不可序列化……),存储停在
k-1 而内存已到 k;此后**每一次**写入都因版本对不上而冲突——一次瞬时故障让该任务的存储
状态永久冻结,最后被 `reconcile()` 判成 `LOST`。
修法:记录**上次确实写进存储的版本**(不是上次算出来的版本),未落库的迁移进**有界缓冲**,
下次迁移时一并补上;`close()` 时再尽力补一次。用"上次确实写进去的版本"**仍然**冲突时,
判定为**外部写入者**并**停止写穿**(`Controller.desynced_tasks`),而不是反复重试或覆盖
对方。
```
修复前 修复后
进程内 succeeded 进程内 failed(result_invalid)
存储里 running version=3 存储里 failed version=4
历史 [queued, starting, running] 历史 [queued, starting, running, failed]
(一次网络抖动)
存储里 starting version=2(永久) 存储里 succeeded version=4
历史 [queued, starting] 历史 [queued, starting, running, succeeded]
```
### 14.2 非法返回值会静默损坏存储状态
返回值会写入存储(`outcome` 列),因此必须 JSON 可序列化。不校验的话,失败发生在**写穿
路径**上——而那条路径的失败只被记录、不会让任务失败,结果是进程内显示 `succeeded`、
存储停在 `running`,随后 `reconcile()` 把一个**真正成功过**的任务判成 `LOST`。
修法:`TaskRun._settle()` 在 `mark_succeeded` **之前**校验,不可序列化就让任务**失败**
(失败类型 `result_invalid`),并且不重试(同一个值重试多少次都一样)。
> 顺带修掉的另一个独立缺陷:`CancellationToken.checkpoint()` 只在*进入*暂停分支时设置
> `_ack`,于是 `resume()` 之后紧接着的 `request_pause()` 会因为任务线程仍在等待循环里
> 而**永远得不到确认**——表现是"暂停 → 恢复 → 再暂停"里第二次暂停永久失效。
---
## 15. PostgreSQL 迁移建议
替换默认持久层(SQLite 文件)是跨机部署的**第 0 步**:SQLite 的网络文件系统锁不可靠,
不要跨机共享。
> **已实现**:`chongming_task.stores.postgres.PostgresTaskStore`
> (`pip install "chongming-task[postgres]"`;核心包仍是零依赖——没装驱动时它惰性报出安装
> 命令)。CLI 同样认连接串:`--db 'postgresql://…'`。§15.2 的硬要求全部满足(`StoreContract`
> 在真库上全绿),§15.4 的三条并发用例已固化在 `tests/integration/test_postgres_store.py`
> 里(不给 DSN 就跳过,不会默默变绿)。
>
> 本节其余部分保留为**设计依据**:它解释了为什么是这些要求,以及实现里**刻意偏离**了
> 哪些建议——偏离处都记在 §15.1 与 §15.3 的注里,不留"文档说 A、代码做 B"的暗角。
### 15.1 Postgres 解锁了什么
| 能力 | 对现有设计的直接影响 |
|---|---|
| **服务端 `now()`** | 租约裁定不再依赖"各节点时钟有界漂移"(§12 的隐患直接消失) |
| **`SELECT … FOR UPDATE SKIP LOCKED`** | 认领不再需要"先查后写",多个 worker 互不阻塞 |
| **`JSONB` + GIN** | 标签精确匹配不必再用 `LIKE '%"io"%'` 这种权宜之计 |
| **事务 + 行锁** | 乐观并发从"唯一防线"降级为"第二道保险" |
**时间处理建议**:把"业务时间"与"租约裁定时间"分开——`created_at` / `started_at` /
`ended_at` / `last_heartbeat_at` 仍用**应用时钟**(`Clock` 可注入 → 可测试);租约的
"过期了吗"一律用**存储侧 `now()`**。
> **实现时的偏离(重要,也是上表里唯一没兑现的一行)**:租约裁定用的仍是**调用方传入的
> `now`**,没有换成存储侧 `now()`。原因在契约:`claim_commands(*, now, lease_seconds, …)`
> 的时间由调用方给出,`StoreContract` 正是靠注入一个固定时钟才能可重复地测"租约到期 /
> 未到期";后端若自作主张改用服务端时钟,契约测试与"时钟可注入"这条设计会同时失效。
> 所以 **§12"关于时钟"的假设依旧成立**(各节点时钟需有界漂移,租约 TTL 30 秒足够宽容),
> 要真正兑现这一行得先改协议——让契约接受"时间由存储决定",那是独立的一次变更。
>
> 其余三项都已用上:`SKIP LOCKED` 认领、`JSONB` + GIN 精确标签、`TIMESTAMPTZ` 行锁事务。
### 15.2 契约上的硬要求
| 要求 | 为什么 |
|---|---|
| `save` 的"校验 + 写入"必须原子 | 否则跨进程下版本检查形同虚设 |
| **版本号单调但不连续**:只要求 `expect_version == 行里的版本` | 写穿缓冲恢复后会一次跳进;若后端要求"只接受 +1"就等于重新引入 §14.1 的冻结 |
| 不要自动重试冲突 | 冲突意味着存在第二个写入者,是设计缺陷 |
| `claim_commands` 的"挑选 + 打租约"必须原子 | 否则同一次停止被下发两次 |
| `append` 幂等(`task_id` + `version`) | 崩溃恢复与写穿补写都会重放 |
| **`save` 不写租约列** | 否则活着的任务被误判失联并杀掉 |
### 15.3 Schema 与索引建议
- 时间列一律 `TIMESTAMPTZ`,**不要** `timestamp`(后者无时区,跨机必然出错)。
- `tags` / `progress` / `failure` / `outcome` 改 `JSONB`:标签查询变成
`WHERE tags @> '["io"]'::jsonb` + GIN 索引。
- **未完成行用部分索引**(认领类查询只关心未完成的,而表在持续变大):
```sql
CREATE INDEX ON commands (created_at) WHERE finished_at IS NULL;
CREATE INDEX ON tasks (bucket, created_at) WHERE status = 'queued';
CREATE INDEX ON tasks (lease_until) WHERE status IN ('starting','running','pausing','stopping');
```
> **两处没照做的索引(有意为之)**:`(lease_until) WHERE status IN (…)` 与
> `(bucket, created_at) WHERE status = 'queued'` 都**没有**建。不是省事,而是当前代码路径
> 用不上它们,建了就是纯写入开销:
>
> * 租约过期判定发生在 **Python 里**——`Controller.reconcile()` 用 `store.query()` 取回
> 非终态任务后逐条对照 `lease_until`,SQL 里没有 `lease_until` 谓词,索引无从命中;
> * `query()` 是通用查询,拼出来的是 `status = ANY($1)`——即使只传一个值,规划器也无法
> 证明它等价于 `status = 'queued'`,部分索引不会被选中。
>
> 什么时候该加:把租约判定下推成 `WHERE lease_until < …`(那是一次查询接口变更),或给
> `query()` 加"已知单一状态"的快路径。在那之前,建成的 `idx_tasks_status` /
> `idx_tasks_bucket` / `idx_tasks_created_at` 覆盖的是现有查询形状。
- `transitions` 只增不减:按月分区或定期归档。`prune()` 在规模上不够用。
- **连接池**:`TaskStore` 会被多线程共享(SQLite 实现用 `check_same_thread=False` + 自己的
锁)。Postgres 实现应当用真的连接池,并把 `close()` 的语义写清楚。
### 15.4 契约测试的盲区:并发测试必须自建
`StoreContract` 只测**单连接**语义——设计使然(它不依赖任何外部环境,所以能随包发布)。
Postgres 后端必须**额外**自证:
| 要验证的 | 期望 | 已固化的用例 |
|---|---|---|
| N 个连接并发 `save` 同一任务 | 恰好一个成功,其余 `ConcurrencyConflict` | ✅ 8 线程 + 屏障,断言"1 个 ok + 7 个 conflict" |
| N 个连接并发 `claim_commands` | 同一条指令不会被两个连接取到 | ✅ 8 个消费者抢 20 条指令,断言无重复且全部认领 |
| `pg_terminate_backend` 杀掉持有者 | 租约仍能正常过期并被接管 | ✅ 杀连接后:期限内取不到、过期后可接管、池自愈 |
用例在 `tests/integration/test_postgres_store.py`(`CHONGMING_TEST_POSTGRES` 给 DSN 才跑)。
**不给 DSN 是 skip 而不是 pass**——"跨进程没测"这件事必须显式可见。
> 另一个已经踩过的坑:**`InMemoryTaskStore` 直接持有对象、从不序列化**,所以"内存后端
> 能跑"不等于"真后端能跑"。类型/序列化类的契约必须在会序列化的后端上测。
---
## 16. 落地清单
| 阶段 | 内容 | 状态 |
|---|---|---|
| 0 | 修掉写穿永久冻结与非法返回值(§14) | ✅ 已完成 |
| 1 | 节点桶归属:`owned_buckets` + 提交校验 + `reconcile` 范围 | ✅ 已完成 |
| 2 | CLI:`--owned-buckets` / `--num-buckets` / `ps --mine` / `buckets` 标记 | ✅ 已完成 |
| 3 | 声明归属的节点从启动就轮询指令(§9) | ✅ 已完成 |
| 4 | 统一存储换成 Postgres(§15) | ✅ 已完成:`PostgresTaskStore`(可选 extra,契约 + 并发用例全绿) |
| 5 | 桶归属的中心声明 `node_buckets` + 重复/空洞告警(§11.3) | 待做:运维需求 |
| 6 | 孤儿任务告警(§11.2) | 待做:运维需求 |
阶段 4 已完成,多节点可以跨机了(共用一套 Postgres):各节点仍是自治的,靠 `owned_buckets`
划界,跨节点控制走指令通道。**阶段 1–3 在单机上也有价值**:提交校验能挡住配错桶,
`reconcile` 的范围更准,CLI 能看"我这一份"。
跨机之后仍未解决的是**运维可见性**(阶段 5 / 6):两个节点配了同一个桶、或某个桶没人管,
现在依然不会自动报错——那两条是纯运维需求,与存储后端无关。
---
## 17. 附:被否决的方案——"任意节点可跑任意任务"
早先设计过一个真正的集群方案:提交者把任务写进共享存储,**任意**节点都能认领并执行它,
节点通过成员表 + rendezvous 哈希分配桶,桶带租约,支持弹性伸缩与抢占接管。
**它被否决,因为它解决的问题不是这里的问题。**
| 能力 | 集群方案 | 自治方案 |
|---|---|---|
| 需要持久化"配方"(函数名 + 参数) | ✅ 必须——别的节点要能重建这次执行 | ❌ 不需要——任务只在定义它的节点上跑 |
| 需要跨节点原子认领(`FOR UPDATE SKIP LOCKED` + 在事务里递增 attempt) | ✅ 必须(防重复执行) | ❌ 不需要(没人认领别人的任务) |
| 需要成员表、心跳、租约、epoch fencing | ✅ 必须 | ❌ 不需要 |
| 需要 rendezvous/一致性哈希分配 + 再均衡 | ✅ 必须 | ❌ 不需要(归属是配置) |
| 需要处理"接管时的在途任务归属" | ✅ 必须 | ❌ 不需要 |
| 弹性伸缩 / 算力池化 | ✅ 有 | ❌ 没有 |
| 跨机部署的复杂度 | 高 | 低(只需换存储后端) |
**根因**:定时/业务任务**是有归属的**——谁的数据谁处理,谁的定时谁触发。它不是一个
无差别的算力池,不需要"随便找台机器跑"。为它建集群,等于用一整套分布式协调去换一个
用不到的弹性。
**从那个方案里仍然成立、值得记住的结论**(它们与架构无关,是分布式本身的性质):
1. **正确性应来自"认领任务的原子 CAS",而不是"桶的归属"**——归属只是优化。自治方案里
这条依然成立:跨节点重复执行来自配置冲突,而不来自租约竞争。
2. **`attempt` 的递增必须在认领事务里**,否则"认领后就崩溃"的任务会被无限重试。
3. **执行任务期间绝不能持有事务或行锁**——任务可能跑几十分钟。
4. **不要用 `LISTEN/NOTIFY` 当投递机制**(不持久、payload 有 8KB 上限、同事务内相同通知
会去重);它只能当延迟优化,轮询必须保留为真相来源。
5. **不要用 advisory lock 表达持久所有权**——它是会话级的,连接池会把它泄漏给后来的请求。
6. **at-least-once 是这类系统的常态**(§13),任务作者要保证幂等。
---
## 18. 明确不做的事
* **不做跨节点的任务执行**(节点只跑自己的任务)。
* **不做配方落库 / 跨节点认领 / 成员表 / rendezvous / 再均衡 / fencing**——见 §17。
* **不做推送式调度器**(没有中心调度进程)。
* **不做分布式信号量**:桶亲和性已经给了近似全局的每桶并发上限。
* **不做资源感知调度**:不感知 CPU/内存/GPU,不做装箱。
* **不做 exactly-once**:见 §13。
* **不做跨节点 DAG 协调**:管道结构不落库,分布式 DAG 是另一个数量级的问题。
* **不做多租户安全隔离**:分桶给的是**公平性**与**亲和性**,不是安全边界。
+438
View File
@@ -0,0 +1,438 @@
# 持久层实现指南
**换掉默认持久层需要实现哪些接口**,以及每个接口的语义要求。
本文是**规格**,不是介绍。照着做即可替换内置的 SQLite 实现;有可执行的范例和可执行的
契约测试,两者都在仓库里。
---
## 一句话版
实现 `TaskStore` 协议里的 **14 个方法**,然后用
`chongming_task.testing.store_contract.StoreContract` 自证。不需要继承、不需要注册。
```python
from chongming_task import Controller
from chongming_task.stores.protocol import TaskStore # 结构化协议,无需继承
class MyStore: # 只要方法齐备、语义正确
...
controller = Controller(store=MyStore(...)) # 直接塞进去
```
`Controller` 只做**结构化检查**(`TaskStore` 是 `runtime_checkable` 的 `Protocol`),
所以任何满足契约的对象都能直接用。继承它、注册它都不必要。
---
## 1. 方法清单
| 分组 | 方法 | 一句话职责 |
|---|---|---|
| **任务快照**(参与乐观并发) | `create(snapshot)` | 写入新任务 |
| | `save(snapshot, *, expect_version)` | 按版本更新,冲突即抛错 |
| | `get(task_id)` | 读单个 |
| | `query(query=None)` | 按条件查询 |
| | `counts()` | 各状态计数 |
| **审计轨迹**(只追加) | `append(transition)` | 追加一条迁移,幂等 |
| | `history(task_id, *, since_version=0)` | 读迁移历史 |
| **高频旁路**(不碰版本号) | `touch(task_id, *, progress=None, heartbeat_at=None)` | 只更新进度与心跳 |
| **控制指令**(跨进程) | `submit_command(command)` | 写入一条待执行指令 |
| | `claim_commands(*, now, lease_seconds, limit=50)` | 取走一批并打租约 |
| | `complete_command(command_id, *, now, outcome=None)` | 标记完成 |
| | `release_command(command_id, *, now)` | **交还**(取错了或没认领) |
| **运维** | `prune(*, keep_terminal)` | 丢弃最旧的终态任务及其历史 |
| | `close()` | 释放资源 |
代码即真相:`src/chongming_task/stores/protocol.py`。本文与它的方法集合由测试
(`tests/integration/test_custom_store.py`)强制保持一致——协议加了方法却没写进本文,
或者本文写了一个不存在的名字,测试都会失败。
---
## 2. 逐方法语义
### 2.1 任务快照
#### `create(snapshot)`
写入一个新任务。**`task_id` 已存在必须抛错**(`StoreError`),不要静默覆盖。
> 为什么静默覆盖很危险:任务已经在跑,第二次 `create` 会把它的状态重置回
> `PENDING`,而正在运行的执行载体不会因此停下——内存与存储从此永久分叉。
#### `save(snapshot, *, expect_version)`
按乐观并发更新。`expect_version` 是调用方写入前读到的 `status_version`;不一致必须抛
`ConcurrencyConflict`。
**"校验 + 写入"必须是原子的一步。** 关系库里它应当是一条
`UPDATE ... WHERE task_id = ? AND status_version = ?`;拆成"先查后写"必然在跨进程下
产生竞态。KV 后端用 `WATCH/MULTI`、Lua 脚本或 CAS。
**不要自动重试冲突。** 每个任务的写入者唯一(它的持有进程),冲突意味着存在第二个
写入者——那是设计缺陷,掩盖它比报出来危险得多。
#### ⚠️ 版本号**单调但不连续**
`expect_version` 是调用方认为存储里当前的版本。它**不保证**等于 `实际版本 - 1`:
写穿失败会把迁移攒进缓冲,恢复后**一次跳进**(比如存储从 version 2 直接变成 4,中间的
3 只在迁移历史里出现)。所以后端**不得**假设:
* 版本号连续(每次 +1);
* `expect_version == 当前行版本`(这是对的)但 `写入的快照版本 == expect_version + 1`(这
是**不**成立的)。
后端唯一要保证的是:`expect_version` 与行里的 `status_version` 相等时才允许写入。
把它改写成"只接受 +1 的推进"会让写穿缓冲永远补不上,等于重新引入"一次抖动永久冻结"。
#### `get(task_id)`
读单个任务,不存在返回 `None`(**不要抛异常**:查询不存在的任务是正常操作)。
#### `query(query=None)`
按条件查询,按 `created_at` 升序(`newest_first=True` 则降序)。`None` 表示不限制。
**过滤要下推到后端**,不要取回全部再筛——`buckets` 那条尤其重要,它是分布式下
"取我拥有的那组桶"的高频查询。SQLite 侧为它建了索引。
需要支持的过滤条件:`statuses` / `name` / `name_prefix` / `tag` / `buckets` /
`pipeline_run_id` / `limit`。其中 `pipeline_run_id` 是"把这条管道的节点整条列出来"
的入口——管道恰恰是最需要按整体查看的东西,没有它就只能全表拉回来自己筛。
`tag` 必须**精确**匹配(`io` 不能命中 `ios`)。
#### `counts()`
返回 `{TaskStatus: 数量}`。后端能用 `GROUP BY` 就别把全部记录读出来数。
### 2.2 审计轨迹
#### `append(transition)`
追加一条迁移记录。**必须按 `task_id` + `version` 幂等**:崩溃恢复会重放,重复写入
不应产生重复行。关系库用 `INSERT OR IGNORE` / `ON CONFLICT DO NOTHING`。
#### `history(task_id, *, since_version=0)`
返回 `version > since_version` 的记录,**按版本升序**。`since_version` 是增量拉取的
入口(看板轮询靠它)。
### 2.3 高频旁路
#### `touch(task_id, *, progress=None, heartbeat_at=None, lease_owner=None, lease_until=None)`
只更新这几个字段,**不校验也不递增 `status_version`**。
> 这是整个契约里最容易做错的一条。进度可能每秒几十次;它若参与乐观并发,会与状态
> 写入持续冲突,把版本号变成噪声,最终表现是"状态老是写不进去",但没有任何报错指向
> 真正的原因。
任务不存在时**静默忽略**(心跳晚到一步不该报错)。参数全为 `None` 时可以什么都不做。
每个参数独立可选,不要要求调用方凑齐。
**两个信号必须分开,不能互相推导:**
| 参数 | 谁在写 | 回答的问题 |
|---|---|---|
| `heartbeat_at` | 任务自己的 `checkpoint()` | **任务在推进吗**(停滞 = 卡死) |
| `lease_owner` / `lease_until` | 监督进程定期续租 | **还有人在管它吗**(过期 = 监督者没了) |
拿任务心跳去判断"进程是否还活着"是错的:一个长时间不调 `checkpoint()` 的健康任务
(纯 CPU 计算)心跳同样会停滞,于是重启恢复会把它**误判为丢失并杀掉**。反过来拿租约
判断卡死也不对。实现必须原样存这两个信号,不要在中间做任何换算。
#### ⚠️ `save` **不得**写这两个租约列
这条单列出来,因为它是本契约里第二个"同一行两条写入路径"的坑(第一个是心跳)。
`touch` 写入的租约值**不在**调用方的内存快照里。若 `save` 把整份快照写回去,就会把
租约覆盖成快照里的旧值(通常是 `None`),于是:
* 一个活得好好的任务看起来"没有监督者";
* 下一次 `reconcile()` 把它判成 `LOST`——**把一个正常任务杀掉**;
* 而且只在重启恢复时才暴露,平时完全看不出来。
所以 `save` 必须**显式跳过**租约列:
```python
# SQLite 实现:save 的列集合 = 全部列 - 租约列
_SAVE_COLUMNS = tuple(c for c in _TASK_COLUMNS if not c.startswith("lease_"))
# 内存实现:整体替换时把租约从旧值带过来
replace(snapshot, lease_owner=current.lease_owner, lease_until=current.lease_until)
```
`StoreContract` 里有一条用例(`test_save_does_not_clobber_the_lease`)专门盯这件事,
三个内置实现都跑它。**它抓出过真实缺陷**:内存实现最初就是整体替换,契约测试当场
失败。
### 2.4 控制指令
#### `submit_command(command)`
写入一条指令。**存储层只传递,不解释、不执行**——执行者是持有该任务的进程。
#### `claim_commands(*, now, lease_seconds, limit=50)`
取走一批可执行指令并打上租约。可被取走的条件:**未完成**,且**无租约或租约已过期**。
**"挑选 + 打租约"必须是原子的。** 否则两个进程会同时取到同一条指令,于是同一次停止
被下发两次(对停止还算无害,对将来的"重试"就是灾难)。SQLite 用
`BEGIN IMMEDIATE` + `COMMIT`;Postgres 下标准做法是
`SELECT ... FOR UPDATE SKIP LOCKED` 再 `UPDATE`(内置 `PostgresTaskStore` 就是这么做的,
见 §6)。
按 `created_at` 升序取,并遵守 `limit`。
#### `complete_command(command_id, *, now, outcome=None)`
标记完成,此后不再被取走。`outcome` 是执行结果的摘要(如 `"stopped"` /
`"already-terminal"`),用于事后追溯。
#### `release_command(command_id, *, now)`
**交还**一条已取走但无人认领的指令——清除租约,但不标记完成。
这个方法看似多余,实则不可省。多进程下任何能取指令的进程都可能取到**不属于自己**的
任务(它手上没有那个任务)。此时只有三种选择,而前两种都是错的:
| 做法 | 后果 |
|---|---|
| 标记完成 | 真正的持有者**永远收不到**这次请求——一个静默的丢指令 |
| 什么都不做,等租约过期 | 只要该进程持续轮询就会不断续租,真正的持有者被**无限期饿死** |
| **`release_command`** | 立即交还,真正的持有者马上能取到 ✓ |
### 2.5 运维
#### `prune(*, keep_terminal)`
丢弃最旧的终态任务及其历史,返回丢弃数量。**绝不能碰非终态任务**——删掉一个正在跑的
任务等于把它的状态机扔了。按 `created_at` 升序决定丢谁。
#### `close()`
释放资源。内存实现是空操作;**文件/连接型实现必须真的关**——没关的 SQLite 连接会
泄漏文件句柄,在 Windows 上还会让数据库文件无法删除。
必须**幂等**:调用两次不报错。关闭之后再调用其它方法应当抛 `StoreError`(而不是
`AttributeError` 或段错误)——把它当成一个明确的状态,不是未定义行为。
---
## 3. 并发要求汇总
| 要求 | 为什么 |
|---|---|
| `save` 的"校验 + 写入"原子 | 否则跨进程下版本检查形同虚设 |
| **版本号单调但不连续**:只要求 `expect_version == 行里的版本` | 写穿缓冲恢复后会一次跳进;若后端要求"只接受 +1"就等于重新引入"一次抖动永久冻结" |
| 不要自动重试冲突 | 冲突意味着存在第二个写入者,是设计缺陷 |
| `claim_commands` 的"挑选 + 打租约"原子 | 否则同一指令被下发两次 |
| 租约到期可重取 | 取走指令的进程崩溃后,指令不能永久卡住 |
| `append` 幂等 | 崩溃恢复会重放 |
| `touch` 不参与乐观并发 | 高频写入会与状态写入持续冲突 |
| **`save` 写了租约列** | 租约被覆盖回旧值,重启恢复把活着的任务判成 `LOST` |
**契约测试不测跨进程。** 它只保证单连接语义正确;跨进程原子性必须由后端自己保证,
并在你的环境里单独压测。本文的范例实现(JSON 文件)就**只在单进程内正确**——它的
模块文档里写明了这一点,不要照抄成多进程方案。
---
## 4. 序列化:别自己重写
`TaskSnapshot` 里有 `datetime`、`frozenset`、枚举和嵌套的 `Progress` / `Failure`。
这些细节收敛在 `chongming_task.stores.codec`,**可以直接复用**:
```python
from chongming_task.stores.codec import (
snapshot_to_row,
row_to_snapshot, # 快照 <-> 扁平原语映射
transition_to_row,
row_to_transition, # 迁移记录
command_to_row,
row_to_command, # 控制指令
encode_dt,
decode_dt,
encode_tags,
decode_tags,
)
```
映射的形状是**扁平的一层字典**(列名 → 原语)——关系库直接就是一行,文档库存成子
对象,KV 直接写键值对。
三条硬要求:
1. **时间一律先转 UTC 再存。** 把本地时间原样写进去,跨时区部署时排序与比较都会错,
而且不报错。
2. **往返必须保真**:`row_to_snapshot(snapshot_to_row(s)) == s`。契约测试用一条
"每个字段都填满"的快照验证——只测几个字段会漏掉时区、`frozenset`、嵌套结构这类
静默丢失。
3. **枚举写 `.value`**,不要指望 `json.dumps` 对枚举的行为。
---
## 5. 接线
```python
from chongming_task import Controller
controller = Controller(
store=MyStore(...),
owner="worker-1", # 写进它下发的指令里,便于追溯
command_poll_interval=0.5, # 指令轮询间隔
command_lease=30.0, # 取走指令后的独占时间
)
```
接上之后:
| 方向 | 行为 |
|---|---|
| 写 | 每次状态迁移**写穿**到存储(挂在状态机的 `_apply` 上,因此不会漏) |
| 读 | `get` / `list_tasks` / `stats` / `bucket_counts` 走存储 |
| 控制 | 不属于本进程的任务 → 写一条指令,由持有者轮询取走 |
| 高频 | 进度与心跳由轮询线程定期经 `touch` 回写 |
**两个必须知道的边界**:
* **纯查询客户端不轮询指令。** 轮询线程只在提交过任务之后才启动——否则一个只读看板
会把别人的停止请求抢走。这是靠"提交过才启动"实现的,后端不需要配合。
* **`BucketPolicy.num_buckets` 是部署范围内的约定。** 多节点各配不同的桶数会让同一个
键落到不同的桶。后端不感知这件事,但你的部署脚本应当断言各处一致。
---
## 6. 自证:契约测试
```python
from chongming_task.testing.store_contract import StoreContract
class TestMyStore(StoreContract):
def make_store(self):
"""每次调用返回一个全新的、隔离的存储。"""
return MyStore(...)
```
套件**随包发布**,且**不依赖 pytest**(只用普通断言),因为它是给后端作者的规格而
不是本仓库的内部测试。pytest 仍会自动收集子类里的用例。
它覆盖什么:
* 协议的结构性要件(`isinstance(store, TaskStore)`);
* **每个字段**的往返保真;
* 乐观并发的接受与拒绝,以及拒绝后状态未被改动;
* 查询的每一种过滤组合、排序、`limit`、空存储;
* 标签的**精确**匹配(`io` 不能命中 `ios`);
* `since_version` 增量、幂等重放;
* `touch` 不改版本号、任务不存在时静默;
* 指令的取走 / 完成 / 租约过期重取 / **交还**;
* `prune` 只丢终态、历史一并清理。
**内置的实现跑的是同一套用例**——`InMemoryTaskStore`、`SqliteTaskStore`(文件库)、
`SqliteTaskStore(":memory:")`(内存库),以及 `PostgresTaskStore`
(`import chongming_task` 后惰性取用,需要 `pip install "chongming-task[postgres]"`)
——所以"通过"这件事是有基准的。
**契约测试不测跨进程。** 它只保证单连接语义正确;跨进程原子性必须由后端自己保证,
并在你的环境里单独压测。内置 Postgres 实现把这份证据留在了仓库里
(`tests/integration/test_postgres_store.py`,需要一台可连的库):
| 用例 | 钉住的承诺 |
|---|---|
| 8 个线程并发 `save` 同一版本 | 恰好一个成功,其余全部 `ConcurrencyConflict` |
| 8 个消费者并发 `claim_commands` | 每条指令恰好被取走一次(`SKIP LOCKED` 真的在起作用) |
| 持有者被 `pg_terminate_backend` 强杀 | 租约不提前释放也不永锁,到期可接管,连接池自愈 |
| 权限受限的角色 | 只用已有 schema,不做 DDL——只读运维控制台可用 |
要跑它们,给一个 DSN 即可(每个用例自建独立 schema,跑完即删):
```bash
CHONGMING_TEST_POSTGRES='postgresql://user@host:5432/db' uv run pytest tests/integration
```
不给 DSN 时整个模块干净跳过——**跨进程证据缺席会显式表现为 skip,而不是默默变绿**。
自己实现后端时,照这个模式补你自己的并发用例:契约测试全绿只说明单连接语义对,
不说明多进程下对。
---
## 7. 可执行范例
`tests/integration/test_custom_store.py` 里有一个完整的第三方后端
(`JsonFileTaskStore`,把全部状态放在一个 JSON 文件里)。它:
* 只实现契约,不继承任何东西;
* 复用 `stores.codec` 做序列化;
* 跑通完整的 `StoreContract`;
* 另有三条用例证明它真的能**接进 `Controller`**、并支持**跨进程控制**。
它是可以照抄的骨架。**但它演示的是接口形状,不是跨进程方案。** 无锁的文件型后端在
Windows 上**无法**支撑"两个实例共用一个文件":`Path.replace` 的原子替换与并发打开会
互相冲突,实测抛 `PermissionError`;直接 `write_text` 更糟,读方会拿到空文件或半截
JSON(`JSONDecodeError: Expecting value: line 1 column 1`)。
范例真实踩到的两个坑,都已固化进代码注释与用例:
1. **不能缓存。** 两个实例各持一份内存副本时彼此看不到对方的写入,跨进程控制会
**静默失效**(控制端把指令写进自己的副本,worker 压根不知道)。本实现因此每次
操作都重新读盘。
2. **写回必须原子替换。** 见上。但即便如此,Windows 上仍不足以支撑共享。
要跨进程就用内置的 `SqliteTaskStore`——文件锁、WAL、`BEGIN IMMEDIATE` 都由 SQLite
负责,这正是它作为默认实现的理由。范例里有一条用例(
`test_the_naive_file_store_is_not_cross_process_safe`)把这个局限**钉在测试里**,
而不是写在注释里自欺欺人。
---
## 8. 检查清单
替换默认持久层之前,逐条确认:
- [ ] 14 个方法齐备,签名与 `protocol.py` 一致
- [ ] `create` 对已存在的 `task_id` 抛错,不覆盖
- [ ] `save` 的"校验 + 写入"是原子的,冲突抛 `ConcurrencyConflict`,且不自动重试
- [ ] `save` 允许版本**跳号**(不要求连续),只按 `expect_version` 相等判定
- [ ] `append` 幂等(`task_id` + `version`)
- [ ] `history` 按版本升序,支持 `since_version`
- [ ] `touch` **不**改 `status_version`,任务不存在时静默
- [ ] `touch` 支持 `lease_owner` / `lease_until`,且每个参数独立可选
- [ ] **`save` 不写租约列**(否则重启恢复会误杀活着的任务)
- [ ] `claim_commands` 的"挑选 + 打租约"原子(关系库:`SELECT ... FOR UPDATE SKIP LOCKED`),租约到期可重取
- [ ] 实现了 `release_command`,且它不标记完成
- [ ] `prune` 只丢终态,且一并清理历史
- [ ] 时间以 UTC 存储,往返保真
- [ ] 过滤(尤其 `buckets`)下推到后端,不是取回全部再筛
- [ ] `close()` 真的释放连接/文件句柄,**幂等**,关闭后再用抛 `StoreError`
- [ ] `StoreContract` 全绿
- [ ] 若要在多实例间共享:有真正的跨进程锁(无锁文件方案在 Windows 上不可行)
- [ ] 跨进程原子性在你的环境里单独验证过(契约测试不覆盖它)
---
## 9. 常见陷阱
| 陷阱 | 症状 | 正确做法 |
|---|---|---|
| `save` 拆成"先查后写" | 跨进程下版本检查形同虚设,偶发丢更新 | 一条带 `WHERE version = ?` 的原子写 |
| `claim` 不加租约 | 取走指令的进程崩溃后,指令永久卡住 | 租约 + 到期可重取 |
| `claim` 不原子 | 同一次停止被下发两次 | `BEGIN IMMEDIATE` / Lua / CAS |
| 把 `release` 实现成 `complete` | 静默丢指令,持有者永远收不到 | 只清租约,不设 `finished_at` |
| `touch` 递增版本号 | 状态写入频繁冲突,但报错指向别处 | 高频旁路绝不碰版本号 |
| `append` 用普通 INSERT | 崩溃恢复产生重复历史行 | 幂等写 |
| 时间存本地时区 | 跨时区排序错,且不报错 | 先转 UTC |
| 标签用子串匹配 | `io` 命中 `ios`,筛选结果悄悄错 | 精确匹配(JSON 数组 + 带引号匹配) |
| `prune` 删掉非终态任务 | 正在跑的任务状态机被扔掉 | 只丢终态 |
| 忘记 `close()` | 文件句柄泄漏;Windows 下数据库文件删不掉 | 真的关 |
| `create` 静默覆盖 | 任务重跑但旧执行载体还在跑,内存与存储分叉 | 已存在就抛错 |
| **在内存里缓存整份数据** | 多实例各持副本,**静默**看不到对方写入 | 每次操作重新读,或配文件锁 + 失效判断 |
| **`write_text` 直接覆盖** | 读方拿到空文件/半截内容 | `Path.replace` 原子替换(含 pid 临时名) |
| **无锁文件当共享存储** | Windows 上并发读写直接 `PermissionError` | 需要共享就用带锁的存储(如 SQLite) |
File diff suppressed because it is too large Load Diff
+1313
View File
File diff suppressed because it is too large Load Diff