初始提交: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:
@@ -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) |
|
||||
Reference in New Issue
Block a user