Files
chongming-utils-task/docs/store-guide.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

439 lines
20 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.
# 持久层实现指南
**换掉默认持久层需要实现哪些接口**,以及每个接口的语义要求。
本文是**规格**,不是介绍。照着做即可替换内置的 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) |