Documentation
¶
Overview ¶
Package checkpoint 编排续跑契约:编排进度快照的**存取边界**与**恢复判定机制**。
为什么需要这个包 ¶
「编排是代码,LLM 只做单环节」的必然推论是「崩溃后能接着跑」。此前本仓把编排 状态全押在宿主 DB 上(eventbus 的模块说明写明「持久化在发布点由宿主负责」), 内核自己回答不了两个问题:
- 这一步执行到哪了?
- 进程重启后该从哪继续?
这不是宿主能替内核决定的——它决定的是编排语义,不是存储布局。
关键取舍:续跑而非重放 ¶
恢复有两种形态,处置完全不同:
- **重放**:从第一步重跑。编排是代码所以结果是确定的,但**已产生的副作用会再来一次**。
- **续跑**:只跑未完成的部分。副作用不重复,但代价是依赖快照足够可信。
本包选续跑,并把该选择做成**可机械判定的纯函数**(BuildPlan),而不是留给宿主 在崩溃那一刻临场决定——临场决定必然选最坏的那个。
边界 ¶
与 contract 同口径:**编排语义字段定死,领域字段走开放键**。 内核解释 Done/Seq/Status,不解释 Fields 里的任何键。
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrNotFound 该 runID 无进度快照(首次执行的正常情形)。 ErrNotFound = errors.New("编排进度快照不存在") // ErrStaleSeq 快照序号未严格递增,被单调闸拒绝。 ErrStaleSeq = errors.New("编排进度快照序号回退") )
哨兵错误。宿主实现 Load 时无记录**必须**返回 ErrNotFound, 不得返回 (零值, nil)——那会被上游误判成「这个 run 从没跑过」从而全量重跑, 重复副作用正是续跑要避免的那件事。
var ErrStoreMissing = errors.New("未装配存储,无法提交进度快照")
ErrStoreMissing 未装配存储。这是显式失败,不是静默成功—— 「没接存储也返回 nil」会让调用方以为进度已落库。
Functions ¶
This section is empty.
Types ¶
type Anomaly ¶
type Anomaly struct {
Kind AnomalyKind
Step string
Detail string
}
Anomaly 一条不一致报告。
**只报告不裁决**(与 watchdog 同款哲学):除终态重开外, 内核都按最保守的一种解释继续跑,而不是停下来问宿主。
type AnomalyKind ¶
type AnomalyKind string
AnomalyKind 编排图与快照的不一致类别。
const ( // AnomalyEmptyStepID 步骤 ID 为空,已从编排图剔除。 AnomalyEmptyStepID AnomalyKind = "empty_step_id" // AnomalyDuplicateStep 编排图内步骤 ID 重复,只保留首次出现处。 AnomalyDuplicateStep AnomalyKind = "duplicate_step" // AnomalyDoneNotInGraph 快照记为已完成,但当前编排图里没有这一步 // (典型成因:宿主换过编排包,图收缩了)。 AnomalyDoneNotInGraph AnomalyKind = "done_not_in_graph" // AnomalyDoneOutOfOrder 完成记录与依赖不符:零值语义下是「完成集合不是编排图的 // 前缀(含空洞)」;声明依赖后收窄为「该步有依赖却失序」(它的前驱没满足)。 AnomalyDoneOutOfOrder AnomalyKind = "done_out_of_order" // AnomalyCompleteWithPending 快照标记运行已终结,编排图里却仍有未完成步骤。 AnomalyCompleteWithPending AnomalyKind = "complete_with_pending" )
type Committer ¶
type Committer struct {
// contains filtered or unexported fields
}
Committer 把「进程内抢序号 → 落库 → 失败回滚」固化成一个调用。
零值不可用,请用 NewCommitter。gate 为 nil 时退化为纯跨进程判定(仍安全, 只是少了进程内的快速过滤);store 为 nil 时 Commit 恒失败,不静默放行。
func NewCommitter ¶
NewCommitter 组装提交器。gate 传 nil 可退化为仅依赖存储侧 CAS 的形态 (单进程场景够用,多进程场景仍安全,只是多几次注定失败的存储往返)。
func (*Committer) Commit ¶
Commit 提交一次进度快照,返回 nil 表示已成功落库。
顺序是机制的一部分,调用方不需要(也无法)自己重排:
- 进程内闸 Accept —— 挡掉本进程已知的陈旧写,不惊动存储;
- 存储侧 SaveIfNewer —— 跨进程的最终判据;
- **仅在失败时**回滚进程内闸 —— 这一步不能省,省了该 run 卡死: 重试 s.Seq 被判陈旧而永远写不进去,跳过它又留下一条补不回来的空洞。
反过来,成功时**绝不能**回滚:闸的当前值就是「本进程已见过的最大序号」, 落库成功后把它退回去,一次陈旧的 seq=3 慢写就会畅通无阻——正是本机制 要防的恢复点静默回退。
返回 ErrStaleSeq 表示「有更新的快照已经落地」,调用方应丢弃本次并重新 Load。
type Plan ¶
type Plan struct {
// Skip 本次**不执行**的步骤(已完成且可信),按编排图顺序。
Skip []string
// Run 本次**要执行**的步骤。
//
// 图里没人声明依赖时按**编排图顺序**;声明过依赖时按**依赖拓扑序**
// (显式依赖先满足,同层保持编排图顺序)——图顺序本就与依赖一致时两者相同。
Run []string
// Anomalies 编排图与快照的不一致,按检出顺序。
Anomalies []Anomaly
}
Plan 恢复计划:续跑而非重放。
func BuildPlan ¶
BuildPlan 由编排图与(可为 nil 的)进度快照算出恢复计划。纯函数,无副作用。
snap 为 nil ¶
首次执行:全量 Run,不报异常。
已完成的可信边界:按**依赖**判定 ¶
这是本包最关键的一条判定。一步的完成可信 ⟺ 它自己已完成 **且** 它的全部前驱 也都可信完成(传递闭包:前驱本身不可信时,靠它完成的这一步同样不可信—— 采信它等于承认一个已失序的快照仍然可信,而那正是续跑唯一危险的前提)。
**零值语义(图里没人声明依赖)= 图内连续已完成的前缀。** 一步的完成只有在它图内的前驱也都已完成时才可信;若快照的完成集有空洞 (后面几步完成了、前面没完成),说明推进本身已经失序——故空洞之后的完成 **一律不采信**,整体重跑,并报 AnomalyDoneOutOfOrder 让宿主知道发生过失序。
**声明过依赖时,按各自声明的前驱判定。** 并行组(fan-out)里 「第 1 个还没完成、第 3 个完成」是**合法完成集**,不是失序:前置都满足的 那一步自己完成即可信,不必等图内更早的步骤。此时失序判定收窄到 「有依赖却失序」——某步自己完成了、但它的前驱没满足,那种完成不采信, 并点名报告(Anomaly.Step 是那一步的 ID)。
两种口径下「代价」的方向一致:**多跑可恢复,重复副作用往往不可。** 有依赖却失序时,宁可重跑那一步,也不采信一条说不清来历的完成记录。
图收缩 ¶
快照里的某步在当前图中不存在时,报 AnomalyDoneNotInGraph。 内核不重跑它(压根不在图里),但必须报告——宿主可能刚换过编排包, 无声跳过会让「换包后少做了事」彻底不可见。
终态不自动重开 ¶
快照标记运行已终结、图里却有未完成步骤时,计划置为 Blocked 且 Run/Skip 均留空: 内核拒绝替宿主决定是否重开,也不给一个「看起来能跑」的半截计划。
type SeqGate ¶
type SeqGate struct {
// contains filtered or unexported fields
}
SeqGate 同一 RunID 的快照序号单调闸:拒绝回写不更新的快照。
为什么需要它 ¶
真实故障模式不是「有人故意写旧快照」,而是**并发落库乱序**:编排按步骤并发推进, 第 N+1 步的快照先落库、第 N 步的慢写随后到达,把新状态覆盖成旧状态。 结果是恢复点静默回退——不报错,只在下一次崩溃时让已完成的步骤重跑。 这个 bug 极难定位,且**纯靠 Store 的幂等实现挡不住**(两次写都是合法的)。
局限:它是进程内的,而本包的立论是崩溃恢复 ¶
零值不可用,请用 NewSeqGate。
SeqGate 的记录只在进程内存里。**进程重启后它是空的**——此时上一进程写下的 「陈旧快照」(例如慢写终于落地的旧值)会被畅通无阻地写入, 正是本包要防的「恢复点静默回退」。
补救是宿主在重启后用 Store.Load 把最新快照灌进来:用 Seed。 不灌就等于放弃了这道闸——这不是「闸没生效」,是**闸被重置后没人告知**。
func (*SeqGate) Accept ¶
Accept 校验并记录快照序号。
首个快照(seq > 0 且无历史)恒通过;此后要求 seq **严格大于**该 RunID 的历史最大值。 返回 ErrStaleSeq 时**不记录**,调用方应丢弃该次落库。
调用顺序(重要) ¶
Accept 只保证「不倒退」,它**不保证「已落库」**——落库成败由 Store 决定。 因此正确顺序是 Accept → Store.Save,**且 Save 失败时必须调 Release 回滚**:
gate.Accept(s) // 挡住并发的陈旧写
if err := store.Save(ctx, s); err != nil {
gate.Release(s.RunID, s.Seq) // 不回滚的话闸会跑到实际存储前面,
return err // 同序号重试将被判为陈旧而永远写不进去
}
不回滚的真实后果不是「多花一个序号」,而是**这个 run 从此卡死**:闸认为 seq=N 已落库,实际没有;重试 N 被拒、跳过 N 又留下一条永远补不回来的空洞。
func (*SeqGate) Release ¶
Release 回滚一次已接受但**未成功落库**的序号,使同序号可以重试。
仅当该 RunID 当前记录的最大序号**恰好等于** seq 时才回滚,返回 true。 若期间已有更新的写入落地(last > seq),返回 false 且不动任何状态—— 那时把闸退回去反而会让陈旧写重新获得通行权。
回滚到前一个值,不是清零 ¶
这里退回到 Accept(seq) **之前**的最大值(prev 一格),而不是删掉记录: 删记录会把上一个**已成功落库**的序号一起抹掉,闸的地板从 5 跌到「无」, 于是 seq=3 的陈旧慢写畅通无阻——恢复点静默回退,正是本闸要防的那件事。 只有在本就没有历史(prev 为 0)时才退化成删除。
type Snapshot ¶
type Snapshot struct {
// RunID 编排运行标识(宿主分配,须稳定且可跨进程重建)。
RunID string
// Seq 单调序号。同一 RunID 下**严格递增**(见 SeqGate)。
// 取 0 表示「尚无快照」。
Seq uint64
// Status 编排语义状态,取值见 contract 包的状态常量集。
// 内核据此判「这次运行是否已终结」,不解释具体语义。
Status string
// Done 已完成步骤的 ID,**无序要求**(按完成顺序追加即可)。
// 内核按**依赖**判定可信边界,不依赖这里的顺序。
Done []string
// Fields 宿主领域状态走开放键(目标、策略档、租户……)。
// 内核不解释键的含义、不校验、不假设它存在。
Fields map[string]string
}
Snapshot 一次编排进度快照。
粒度约定:**每完成一个步骤落一次**。粒度更细则恢复点更精确但写放大, 更粗则在崩溃时重做更多——宿主按自己副作用的代价选,本包不定死。
type StepDef ¶
type StepDef struct {
ID string
Label string
// DependsOn 本步的显式前驱集(步骤 ID)。非空时**取代**图内位置隐含的前驱。
//
// 列出的是**全部直接前驱**(间接前驱由可信性的传递闭包自动带上)。
// 指向不在图内的 ID = 悬空依赖:该步恒不可信(恒重跑),不静默放行。
//
// ⚠️ **空切片不等于「无前驱」**:`DependsOn: []string{}` 走的是位置隐含前驱
// (有序链语义),不是「本步无前驱」——要声明无前驱请用 `Isolated: true`。
// 这条刻意不做「nil 与空切片分义」:宿主若动态拼装前驱集,拼出空切片时
// 悄悄变成链语义(更严)尚可,拼成 nil 时变成「无前驱」(更松)才是事故。
// 两种空值都按「未声明」处理,方向恒为保守的那一侧。
DependsOn []string
// Isolated 本步不依赖任何其他步骤(与执行环境的隔离无关):
// 它自己完成即可信,可与任何步骤乱序完成。
//
// 注意它只改变**本步**的前驱集合:排在它之后的未声明步骤,仍按位置把它当前驱
// ——「本步不依赖别人」≠「别人不依赖本步」。
Isolated bool
}
StepDef 编排图中的一个步骤。
内核只认 **ID、图内顺序与依赖声明**,不认语义:Label 是给宿主/用户看的, 内核既不解释也不校验。
依赖声明(零值 = 有序步骤链) ¶
图默认是**有序步骤链**:一步的完成,只有在它图内的前驱也都已完成时才可信。 并行组(fan-out)需要显式声明,否则合法的乱序完成集会被当成失序而整体重跑:
StepDef{ID: "a"} // 未声明:位置隐含前驱(有序链)
StepDef{ID: "a", Isolated: true} // 显式无前驱:可乱序完成
StepDef{ID: "a", DependsOn: []string{"b"}} // 显式前驱集:取代位置隐含前驱
未声明即有序链,是**零值语义**:既有调用方逐字不变。依赖语义的完整推导、 声明写法与混图的坑见 deps.go。
type Store ¶
type Store interface {
Save(ctx context.Context, s Snapshot) error
Load(ctx context.Context, runID string) (Snapshot, error)
}
Store 进度快照的存取边界(唯一接缝)。
实现须保证:
- Save 对同一 (RunID, Seq) 重复调用**幂等**,不得产生第二份记录;
- Load 无记录时返回 ErrNotFound,不得返回 (零值, nil);
- 并发安全(编排可能从多个环节同时落库)。
type StoreCAS ¶
type StoreCAS interface {
// SaveIfNewer 仅当该 runID **已持久化的序号严格小于** s.Seq 时才写入。
//
// 否则必须返回 ErrStaleSeq 且**不得写入**(不得部分写、不得覆盖)。
// 首次写入(无记录)视作已持久化序号为 0,故 seq>0 的首写总是通过。
//
// 并发安全:两个进程同时提交时,只有序号较大的那个成功。典型的实现形态是
// 条件更新(SQL 的 WHERE seq < ?、带条件的 put、CAS 重试循环)。
//
// 注意 Seq == 0 恒视为「不参与单调判定」——它不是一次真实提交。
SaveIfNewer(ctx context.Context, s Snapshot) error
// Load 无记录时返回 ErrNotFound,不得返回 (零值, nil)。
Load(ctx context.Context, runID string) (Snapshot, error)
}
StoreCAS 进度快照的**跨进程**存取边界。
相对 Store 的唯一增量是 SaveIfNewer;Load 语义相同。宿主实现本接口即自动 获得跨进程的单调保证,SeqGate 退化为进程内的快速过滤(省掉绝大多数注定 失败的数据库往返),不再是唯一的防线。