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

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

567 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 分布式设计说明
本文说明**多节点部署**的架构、边界与风险。
> **阅读顺序**:§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 是另一个数量级的问题。
* **不做多租户安全隔离**:分桶给的是**公平性**与**亲和性**,不是安全边界。