mooncat-checkpoint
v0.5.1
Published
轻量级 step 幂等/断点续跑 checkpoint 服务。默认文件存储,SQLite 可选,全异步 API,状态机判别联合。不依赖 daemon,不是 workflow engine。
Maintainers
Readme
mooncat-checkpoint
轻量级 step 幂等 / 断点续跑 checkpoint。 默认文件存储,SQLite 可选,全异步 API,状态机判别联合,lease fencing token。 不是 workflow engine,不是 daemon——只是一个库 + 可选 CLI。
它解决什么
flow / 批处理 / 采集里最常见的痛点:"这一步跑过了没?跑过了就跳过,没跑过就干活并标记"。
没有它,每个项目都手写一份 if (exists(cache)) skip else run + save,而且并发时会撞车。
import { CheckpointClient } from "mooncat-checkpoint";
const cp = new CheckpointClient();
const c = await cp.claim("vision", imageHash, { ownerRunId: "run-1" });
switch (c.kind) {
case "done": return c.checkpoint.result; // 跑过了,复用结果
case "busy": return; // 别的 run 正在跑,跳过
case "failed": return; // 之前失败了,需 reset 才能重试
case "claimed": break; // 你抢到了,拿 c.leaseToken 去干活
}
try {
const result = await expensiveCall();
await cp.done("vision", imageHash, { leaseToken: c.leaseToken, result });
} catch (e) {
await cp.fail("vision", imageHash, e.message, { leaseToken: c.leaseToken });
throw e;
}状态机(严格 + fencing token)
none(不存在)→ claimed(leaseUntil, leaseToken)→ done | failed
↑ 终态
└── lease 过期可重新 claim(生成新 token)- done:已完成。
claim直接返回done+ result,不重跑。 - claimed:正在处理。带 lease + leaseToken。到期后别人可抢(新 token)。
- failed:失败终态。
claim返回failed,不会自动重试——重试必须显式reset。
严格状态机:done/fail 要求记录处于 claimed 且 leaseToken 匹配,否则抛 LeaseLostError。
不支持"无 claim 直接建终态"(防语义不一致)。
API
claim(ns, key, { ownerRunId?, leaseMs? }) → ClaimResult(claimed kind 含 leaseToken)
touch(ns, key, { leaseToken, extendMs? }) → Checkpoint | null // 续 lease
done(ns, key, { leaseToken, result?, resultRef? }) → Checkpoint // token 不匹配抛 LeaseLostError
fail(ns, key, error, { leaseToken }) → Checkpoint // token 不匹配抛 LeaseLostError
get(ns, key) → Checkpoint | null
reset(ns, key) → boolean // 删记录,允许重跑
list(ns, { status? }) → Checkpoint[]
gc({ olderThan? }) → number // 清过期 leaselease fencing(防 lease 过期后旧 owner 覆盖新 owner)
claim 成功返回 leaseToken(32 字符 hex)。done/fail/touch 必须回传这个 token,
backend 只在 token 匹配当前 claimed 记录时才允许变更。
为什么需要:A claim → lease 过期 → B 重抢成功。此时 A 完成工作调 done,
若无 token 校验,A 会把 B 的 claimed 记录改成 done,破坏"过期可重抢"语义。
fencing token 保证 A 的旧 token 被拒绝(抛 LeaseLostError),B 的工作不被覆盖。
import { LeaseLostError } from "mooncat-checkpoint";
try {
await cp.done("ns", key, { leaseToken, result });
} catch (e) {
if (e instanceof LeaseLostError) {
// lease 已不属于你(被重抢或过期),你的工作成果可能已被接管,放弃
return;
}
throw e;
}claim 的四种结果(判别联合)
claim 返回 { kind, checkpoint },kind 是 "done" | "claimed" | "busy" | "failed"。
claimed 额外带 leaseToken。用 switch(c.kind) 处理,编译器保证分支完备。
result vs resultRef
result:内联小结果(≤ 64KB UTF-8 字节)。claim命中done时直接拿回,零摩擦。 不可 JSON 序列化(BigInt/循环引用)抛CheckpointResultSerializationError。resultRef:大产物引用(如 artifact 文件路径)。超过 64KB 必须用 resultRef。
await cp.done("vision", hash, {
leaseToken,
result: { count: 42 }, // 小结果,内联
resultRef: "artifacts/vision/abc.png", // 大产物,引用
});lease + touch(长 step 必须续期)
claim 默认 lease 60 秒。如果你的 step 跑 10 分钟,必须定期 touch 续期(带 leaseToken),否则 lease 到期会被别的 run 抢走:
const interval = setInterval(() => cp.touch(ns, key, { leaseToken, extendMs: 60000 }), 30000);
try {
await longRunningWork();
await cp.done(ns, key, { leaseToken, result });
} finally {
clearInterval(interval);
}后端
| 后端 | URL 格式 | 并发安全 | 说明 |
|------|----------|---------|------|
| file(默认) | file://./.mooncat/checkpoints | 单进程 | JSON 文件,透明可读,可 git 跟踪 |
| sqlite | sqlite://./checkpoints.db | 跨进程 | WAL + 事务原子,推荐用于多 run 并发 |
| memory | memory:// | 单进程 | 进程内 Map,测试用,退出丢失 |
优先级:显式 url 参数 > MOONCAT_CHECKPOINT_URL 环境变量 > 默认 file://./.mooncat/checkpoints。
文件后端 vs SQLite
- file:单进程/低并发。透明可读(JSON 直接看/手改/git)。跨进程只靠 lease 语义(过期可抢),不保证强一致。
- sqlite:多 run 并发。单文件、无服务、WAL 模式。
claim靠BEGIN IMMEDIATE事务跨进程原子。 需要可选依赖better-sqlite3(原生模块,需编译)。
// 默认 file
import { CheckpointClient } from "mooncat-checkpoint";
const cp = new CheckpointClient();
// 指定 sqlite
const cp = new CheckpointClient({ url: "sqlite://./checkpoints.db" });安装 SQLite 支持
npm i better-sqlite3 # 原生模块,需要编译工具链未安装时 SQLite 后端不可用(构造函数抛清晰错误),file/memory 后端不受影响。
CLI
mooncat-checkpoint url # 打印当前后端 URL
mooncat-checkpoint list <ns> [--status done|failed|claimed]
mooncat-checkpoint get <ns> <key>
mooncat-checkpoint reset <ns> <key>
mooncat-checkpoint gc [--older-than <ms>]
# 所有命令支持 --url <backend-url> 覆盖后端文件后端的目录结构
.mooncat/checkpoints/
vision/ ← namespace
img-001.json ← key(安全字符直接用文件名)
sha256-a1b2c3....json ← 含特殊字符的 key → sha256 hash 文件名
sycm-export/
overview.json
daily-data.jsonnamespace 只允许 [a-zA-Z0-9_-]+(防 path traversal)。key 任意,安全字符直接用文件名,否则 hash。
文件名用 hash,但 JSON 内 record.key 存原始 key——get/list 始终返回原始 key,三后端一致。
和 workflow engine 的关系
mooncat-checkpoint 只管状态(claim/done/fail),不管调度、不管编排、不管执行。
它是一个可被任何 flow runner(Inngest / Dagu / 自定义循环)调用的状态原语。
workflow engine(调度/编排)→ 调用 → checkpoint(状态原语)
↕
后端(file/sqlite/memory)边界清晰:engine 换了,checkpoint 不用动;checkpoint 换后端,engine 不用动。
设计决策
- 全异步:file/sqlite/redis 的共同接口只能是 async。这不是 mooncat
state.checkpoint(同步)的 drop-in 替换——是 API 迁移。 - fencing token:done/fail/touch 必须 leaseToken 匹配,防 lease 过期后旧 owner 覆盖新 owner 的 claim。
- 严格状态机:done/fail 要求 claimed 态 + token 匹配,不支持"无 claim 直接建终态"。
- failed 是终态:失败不自动重试(高危场景轰炸风险)。重试必须显式 reset。
- 不需要 daemon:checkpoint 无常驻资源(不像 browserd 持有 Chrome 会话),是库 + CLI。
- file 不保证跨进程强一致:单进程靠 MutexMap,跨进程靠 lease 语义。需要跨进程原子 → SQLite。
- result 软上限 64KB(UTF-8 字节):超过拒绝内联,强制用 resultRef。防止 JSON 文件膨胀。
- key 一致性:三后端
get/list/reset始终用原始 key(sqlite 用 storage_key 作 PK 但 record.key 返回原值)。
License
MIT
