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