Files
chongming-utils-task/docs/distributed-design.md
T

567 lines
30 KiB
Markdown
Raw Normal View History

# 分布式设计说明
本文说明**多节点部署**的架构、边界与风险。
> **阅读顺序**:§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 是另一个数量级的问题。
* **不做多租户安全隔离**:分桶给的是**公平性**与**亲和性**,不是安全边界。