checkpoint

package
v0.31.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 11, 2026 License: MIT Imports: 5 Imported by: 0

Documentation

Overview

Package checkpoint 编排续跑契约:编排进度快照的**存取边界**与**恢复判定机制**。

为什么需要这个包

「编排是代码,LLM 只做单环节」的必然推论是「崩溃后能接着跑」。此前本仓把编排 状态全押在宿主 DB 上(eventbus 的模块说明写明「持久化在发布点由宿主负责」), 内核自己回答不了两个问题:

  1. 这一步执行到哪了?
  2. 进程重启后该从哪继续?

这不是宿主能替内核决定的——它决定的是编排语义,不是存储布局。

关键取舍:续跑而非重放

恢复有两种形态,处置完全不同:

  • **重放**:从第一步重跑。编排是代码所以结果是确定的,但**已产生的副作用会再来一次**。
  • **续跑**:只跑未完成的部分。副作用不重复,但代价是依赖快照足够可信。

本包选续跑,并把该选择做成**可机械判定的纯函数**(BuildPlan),而不是留给宿主 在崩溃那一刻临场决定——临场决定必然选最坏的那个。

边界

与 contract 同口径:**编排语义字段定死,领域字段走开放键**。 内核解释 Done/Seq/Status,不解释 Fields 里的任何键。

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNotFound 该 runID 无进度快照(首次执行的正常情形)。
	ErrNotFound = errors.New("编排进度快照不存在")

	// ErrStaleSeq 快照序号未严格递增,被单调闸拒绝。
	ErrStaleSeq = errors.New("编排进度快照序号回退")
)

哨兵错误。宿主实现 Load 时无记录**必须**返回 ErrNotFound, 不得返回 (零值, nil)——那会被上游误判成「这个 run 从没跑过」从而全量重跑, 重复副作用正是续跑要避免的那件事。

View Source
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

func NewCommitter(gate *SeqGate, store StoreCAS) *Committer

NewCommitter 组装提交器。gate 传 nil 可退化为仅依赖存储侧 CAS 的形态 (单进程场景够用,多进程场景仍安全,只是多几次注定失败的存储往返)。

func (*Committer) Commit

func (c *Committer) Commit(ctx context.Context, s Snapshot) error

Commit 提交一次进度快照,返回 nil 表示已成功落库。

顺序是机制的一部分,调用方不需要(也无法)自己重排:

  1. 进程内闸 Accept —— 挡掉本进程已知的陈旧写,不惊动存储;
  2. 存储侧 SaveIfNewer —— 跨进程的最终判据;
  3. **仅在失败时**回滚进程内闸 —— 这一步不能省,省了该 run 卡死: 重试 s.Seq 被判陈旧而永远写不进去,跳过它又留下一条补不回来的空洞。

反过来,成功时**绝不能**回滚:闸的当前值就是「本进程已见过的最大序号」, 落库成功后把它退回去,一次陈旧的 seq=3 慢写就会畅通无阻——正是本机制 要防的恢复点静默回退。

返回 ErrStaleSeq 表示「有更新的快照已经落地」,调用方应丢弃本次并重新 Load。

func (*Committer) Last

func (c *Committer) Last(runID string) uint64

Last 返回进程内闸已接受的最大序号(无 gate 时恒为 0)。

func (*Committer) SeedFromStore

func (c *Committer) SeedFromStore(ctx context.Context, runID string) error

SeedFromStore 用持久化事实回灌进程内闸。宿主启动或重启后应调用一次。

为什么必须调:SeqGate 是进程内存态,重启后是空的。不回灌的话,上一进程 写下的「陈旧快照」(慢写终于落地)畅通无阻——这不是「闸没生效」,是 **闸被重置后没人告知**。

该 run 从未跑过时(ErrNotFound)不算错误,直接返回:首次执行就是没有记录。

type Plan

type Plan struct {
	// Skip 本次**不执行**的步骤(已完成且可信),按编排图顺序。
	Skip []string
	// Run 本次**要执行**的步骤。
	//
	// 图里没人声明依赖时按**编排图顺序**;声明过依赖时按**依赖拓扑序**
	// (显式依赖先满足,同层保持编排图顺序)——图顺序本就与依赖一致时两者相同。
	Run []string
	// Anomalies 编排图与快照的不一致,按检出顺序。
	Anomalies []Anomaly
}

Plan 恢复计划:续跑而非重放。

func BuildPlan

func BuildPlan(graph []StepDef, snap *Snapshot) Plan

BuildPlan 由编排图与(可为 nil 的)进度快照算出恢复计划。纯函数,无副作用。

snap 为 nil

首次执行:全量 Run,不报异常。

已完成的可信边界:按**依赖**判定

这是本包最关键的一条判定。一步的完成可信 ⟺ 它自己已完成 **且** 它的全部前驱 也都可信完成(传递闭包:前驱本身不可信时,靠它完成的这一步同样不可信—— 采信它等于承认一个已失序的快照仍然可信,而那正是续跑唯一危险的前提)。

**零值语义(图里没人声明依赖)= 图内连续已完成的前缀。** 一步的完成只有在它图内的前驱也都已完成时才可信;若快照的完成集有空洞 (后面几步完成了、前面没完成),说明推进本身已经失序——故空洞之后的完成 **一律不采信**,整体重跑,并报 AnomalyDoneOutOfOrder 让宿主知道发生过失序。

**声明过依赖时,按各自声明的前驱判定。** 并行组(fan-out)里 「第 1 个还没完成、第 3 个完成」是**合法完成集**,不是失序:前置都满足的 那一步自己完成即可信,不必等图内更早的步骤。此时失序判定收窄到 「有依赖却失序」——某步自己完成了、但它的前驱没满足,那种完成不采信, 并点名报告(Anomaly.Step 是那一步的 ID)。

两种口径下「代价」的方向一致:**多跑可恢复,重复副作用往往不可。** 有依赖却失序时,宁可重跑那一步,也不采信一条说不清来历的完成记录。

图收缩

快照里的某步在当前图中不存在时,报 AnomalyDoneNotInGraph。 内核不重跑它(压根不在图里),但必须报告——宿主可能刚换过编排包, 无声跳过会让「换包后少做了事」彻底不可见。

终态不自动重开

快照标记运行已终结、图里却有未完成步骤时,计划置为 Blocked 且 Run/Skip 均留空: 内核拒绝替宿主决定是否重开,也不给一个「看起来能跑」的半截计划。

func (Plan) Blocked

func (p Plan) Blocked() bool

Blocked 该计划是否含内核**拒绝自动决定**的异常。

唯一一种:快照说这次运行已终结,图里却还有未完成步骤。 内核不会擅自重开一个已终结的运行——是否重开是宿主的决定。 其余异常都不阻断:内核已按最保守解释给出可执行计划。

func (Plan) HasAnomaly

func (p Plan) HasAnomaly(kind AnomalyKind) bool

HasAnomaly 是否报告了指定类别的不一致。

type SeqGate

type SeqGate struct {
	// contains filtered or unexported fields
}

SeqGate 同一 RunID 的快照序号单调闸:拒绝回写不更新的快照。

为什么需要它

真实故障模式不是「有人故意写旧快照」,而是**并发落库乱序**:编排按步骤并发推进, 第 N+1 步的快照先落库、第 N 步的慢写随后到达,把新状态覆盖成旧状态。 结果是恢复点静默回退——不报错,只在下一次崩溃时让已完成的步骤重跑。 这个 bug 极难定位,且**纯靠 Store 的幂等实现挡不住**(两次写都是合法的)。

局限:它是进程内的,而本包的立论是崩溃恢复

零值不可用,请用 NewSeqGate。

SeqGate 的记录只在进程内存里。**进程重启后它是空的**——此时上一进程写下的 「陈旧快照」(例如慢写终于落地的旧值)会被畅通无阻地写入, 正是本包要防的「恢复点静默回退」。

补救是宿主在重启后用 Store.Load 把最新快照灌进来:用 Seed。 不灌就等于放弃了这道闸——这不是「闸没生效」,是**闸被重置后没人告知**。

func NewSeqGate

func NewSeqGate() *SeqGate

NewSeqGate 构造单调闸。

func (*SeqGate) Accept

func (g *SeqGate) Accept(s Snapshot) error

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) Last

func (g *SeqGate) Last(runID string) uint64

Last 返回该 RunID 已接受的最大序号;无记录返回 0。

func (*SeqGate) Release

func (g *SeqGate) Release(runID string, seq uint64) bool

Release 回滚一次已接受但**未成功落库**的序号,使同序号可以重试。

仅当该 RunID 当前记录的最大序号**恰好等于** seq 时才回滚,返回 true。 若期间已有更新的写入落地(last > seq),返回 false 且不动任何状态—— 那时把闸退回去反而会让陈旧写重新获得通行权。

回滚到前一个值,不是清零

这里退回到 Accept(seq) **之前**的最大值(prev 一格),而不是删掉记录: 删记录会把上一个**已成功落库**的序号一起抹掉,闸的地板从 5 跌到「无」, 于是 seq=3 的陈旧慢写畅通无阻——恢复点静默回退,正是本闸要防的那件事。 只有在本就没有历史(prev 为 0)时才退化成删除。

func (*SeqGate) Seed

func (g *SeqGate) Seed(runID string, seq uint64)

Seed 用**已持久化的**快照初始化序号基线。宿主重启后必须调用一次, 做法是 Store.Load 拿到最新快照后灌进来。

seq <= 0 视为无基线(直接忽略),避免用「零快照」把闸误设成已有记录。 幂等:以同一个快照重复 Seed 结果相同。

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 一次编排进度快照。

粒度约定:**每完成一个步骤落一次**。粒度更细则恢复点更精确但写放大, 更粗则在崩溃时重做更多——宿主按自己副作用的代价选,本包不定死。

func (Snapshot) Clone

func (s Snapshot) Clone() Snapshot

Clone 深拷贝快照。宿主实现 Store 时**必须**拷贝而非持引用: 快照若被调用方在落库后继续改动,恢复判定的依据就不再可信。

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 退化为进程内的快速过滤(省掉绝大多数注定 失败的数据库往返),不再是唯一的防线。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL