Documentation
¶
Overview ¶
Package commitgate 环节终态的独占提交闸:同一环节的终态只允许被提交一次。
生命周期: Stable(v0.33.0 定级)——只允许 additive 变更(新增导出符号 / 新增方法且给零值默认实现 / 新增错误值);改签名、改语义、改默认值、 删导出符号都算 breaking,判定规则与变更流程见 docs/api-compat.md, 逐包理由见 api-lifecycle.yaml。
它防的是什么 ¶
本仓的 statemachine 是**纯谓词**:CanTransition / MustTransition 只回答 「这次迁移合不合法」,不回答「有没有人已经提交过了」。当编排把一个环节拆成 两条并发分支推进时,两条分支会各自通过同一个纯谓词检查,各自把活干完, 各自提交终态——**没有任何一层会发现这件事发生过**。
这不是理论形态。eventbus 里有 EventChainParallel(并行子图起跑标记), 并行分支的编排语义是宿主在跑的;一旦宿主并行化,「两个分支都以为自己赢了 同一个环节」就从「写不出来」变成「写得出来且无人拦截」。
checkpoint.SeqGate 挡不住这个:它按 RunID 排**快照序号**,只管「不倒退」。 两个分支先后提交 seq=5 与 seq=6 是完全合法的递增,两个都会被接受,后写的 覆盖先写的——而先写的那条分支的副作用早已发生。它能排序记录,排不出 「有两条分支都以为自己赢了」。
为什么不是放进 statemachine / checkpoint 里 ¶
- statemachine 自我声明是「纯函数件」,零依赖。给它加带锁的有状态闸, 是把两个性质相反的东西塞进一个包,调用方就无法再按性质选用。
- checkpoint 管的是**崩溃恢复**(快照存取 + 续跑判定);本包管的是 **并发执行**。两者都在说「编排推进的正确性」,但故障形态与恢复手段 完全不同,混在一处会让两边都变得难推理。
与 checkpoint 的关系 ¶
两者互补,不是替代:SeqGate 管快照序号单调,本闸管终态独占。一个环节的 推进要先过本闸拿到终态独占权,再由 SeqGate 保证快照不倒退。
必须成对使用:Commit 与 Release ¶
与 SeqGate 同一处陷阱:**落库失败时必须 Release**,否则该环节的闸被永久占住, 这个环节从此再也无法提交终态——不是「多花一个序号」,是彻底卡死。
正确顺序(用 Acquire + ReleaseLease——凭唯一令牌释放,承诺无条件成立):
lease, err := g.Acquire(c) // 抢终态独占权
if err != nil {
return err // 抢不到就别干后面的活
}
if err := store.Save(ctx, snap); err != nil {
g.ReleaseLease(lease) // 落库失败必须回滚
return err
}
旧接口 Commit + Release(c Commit) 仍在,但它靠 (status, branch, seq) 认人, 而那三元组不唯一(并行分支同名、重试同一份快照时逐字相同)——迟到/重复的 Release 会把别人的闸放掉。分支可能同名或会重试的场景请用上面的凭证路径。
终态判定由宿主注入 ¶
环节状态的词表属宿主(statemachine 那一组常量只是本仓的一份示例实现), 故 IsTerminal 由宿主注入。本包只管「独占」,不认任何状态取值。
状态在 Store 接缝之后(issue #145 多副本部署) ¶
闸的判定(词表、空引用、错误构造)在 Guard;已提交记录与原子占用在 Store 接缝之后——缺省进程内参考实现 NewMemoryStore,多副本宿主注入自己的 跨进程实现(NewGuardWithStore)。跨进程硬要求(Claim 原子、**令牌由存储 铸造**)见 store.go 文件头;判定逻辑不因换存储而有第二份。
store.go —— 终态记录存储接缝(issue #145 多副本部署)。
为什么要把状态抽出来 ¶
原实现把判定与状态焊在一起(mu + done map):闸的判定语义正确,但状态只 活在本进程里——副本 A 抢到的终态独占,副本 B 看不见,两副本可以同时赢得 同一个环节的闸(「同一 (run, 环节) 终态只提交一次」只在单进程成立)。 抽成 Store 之后:**判定逻辑(词表判定、空引用拒绝、错误构造)留在 Guard, 跨进程状态与原子占用归存储**——与 sideeffect / coord 同一条分工。
跨进程实现的硬要求(原子性契约) ¶
- **Claim 必须原子**:多副本并发 Claim 同一 Ref,恰好一个 claimed=true。 DB 形态 = 唯一索引上的 `INSERT ... ON CONFLICT DO NOTHING`(或事务内 先查后插 + 唯一约束兜底);「先查后插」的两步形态会把赢家电窗口 重新打开,正是本闸要防的那件事。
- **Token 由存储铸造**(Claim 返回的 Record 带出):占用令牌必须跨进程 唯一,否则副本 B 铸出与副本 A 迟到的 ReleaseLease 相同的令牌时, A 的迟到释放会放掉 B 的闸——这与 commitgate 自己踩过的「三元组不唯一」 是同一个坑,只是从进程内挪到了进程间。DB 形态 = 序列/雪花 id。
- **ReleaseToken 条件删除**:只删令牌匹配的记录(`DELETE ... WHERE ref=? AND token=?`),不匹配返回 released=false。
进程内参考实现 ¶
NewMemoryStore:单进程成立,**不得上生产**(跨进程原子性与令牌全局唯一 它都给不了)。职责是把判定逻辑垫在契约之下让契约可测——与 coord 的内存 后端同口径。
Index ¶
- Variables
- type AlreadyCommittedError
- type Commit
- type Guard
- type Lease
- type MemoryStore
- func (s *MemoryStore) Claim(ref Ref, rec Record) (Record, bool, error)
- func (s *MemoryStore) Count() (int, error)
- func (s *MemoryStore) Get(ref Ref) (Record, bool, error)
- func (s *MemoryStore) ReleaseToken(ref Ref, token uint64) (bool, error)
- func (s *MemoryStore) SeedIfAbsent(ref Ref, rec Record) error
- type Record
- type Ref
- type Store
Constants ¶
This section is empty.
Variables ¶
var ( // ErrAlreadyCommitted 该环节的终态已被提交过。 ErrAlreadyCommitted = errors.New("环节终态已被提交") // ErrEmptyRef 引用缺字段。空 RunID 或空 Step 无法定位,不得静默通过。 ErrEmptyRef = errors.New("环节引用不完整") )
Functions ¶
This section is empty.
Types ¶
type AlreadyCommittedError ¶
type AlreadyCommittedError struct {
Ref Ref
Prev record
PrevSeq uint64
Status string
Branch string
NewStatus string
NewBranch string
}
AlreadyCommittedError 终态重复提交。带先前提交者与先前状态。
func (*AlreadyCommittedError) Error ¶
func (e *AlreadyCommittedError) Error() string
Error 实现 error。
func (*AlreadyCommittedError) Unwrap ¶
func (e *AlreadyCommittedError) Unwrap() error
Unwrap 出哨兵,调用方用 errors.Is 判定。
type Commit ¶
type Commit struct {
Ref Ref
// Status 目标状态。非终态提交不受本闸独占约束(中间态本来就可以多次推进)。
Status string
// Branch 提交者标识(分支名 / agent 名),只用于留痕与排障定位。
Branch string
// Seq 提交序号。与 checkpoint.Snapshot.Seq 同一口径,交由 SeqGate 判单调。
Seq uint64
}
Commit 一次状态提交。
type Guard ¶
type Guard struct {
// contains filtered or unexported fields
}
Guard 终态独占提交闸。并发安全(状态操作全在 Store,实现各自保证)。
零值不可用,请用 NewGuard——闸的零值若表示「放行」,等于缺配置即失效。
func NewGuardWithStore ¶ added in v0.35.0
NewGuardWithStore 构造闸并注入存储接缝(issue #145)。
多副本宿主用它接自己的跨进程存储;判定逻辑仍只有本包这一份。 s 为 nil ⇒ 退化为进程内参考存储。
func (*Guard) Acquire ¶
Acquire 抢终态独占权并返回**占用凭证**。成功时持凭证者独占该环节; 落库失败时用 ReleaseLease 精确交还——只有持有该凭证的那一次占用会被放掉, 迟到的/重复的 ReleaseLease 对别人的闸无效。
非终态直接放行且不记录(返回零值凭证);判为重复时返回 *AlreadyCommittedError,它带**先前是谁提交的**——「两个分支都以为自己赢了」 之所以难查,正是因为日志里只有两次提交、没有「第二次本该被谁挡下」。
历史教训(旧三元组认人接口已删):旧接口用 (status, branch, seq) 认人, 而那三元组不唯一——同名并行分支、或重试同一份快照时,三元组逐字相同, 于是输家的迟到释放会删掉赢家的记录,闸对下一次提交敞开, 同一环节的终态被提交两次(实测 8 路并发提交出现 4 个赢家)。
func (*Guard) Committed ¶
Committed 返回已终态提交的环节数(自检与留痕用)。 零值 Guard / Store 故障 ⇒ 0(观测面,不因一次故障把自检打成假红)。
func (*Guard) ReleaseLease ¶
ReleaseLease 交还终态独占权,**凭占用凭证精确释放**(落库失败时必须调用, 否则该环节的闸被永久占住)。
只有令牌对得上才会放掉闸:迟到的、重复的、别人的 ReleaseLease 一律无效。 这是「一次迟到的释放不得把后来者的独占权一起放掉」唯一能兑现的形式 ——旧三元组认人接口(已删)挡不住「三元组完全相同」的两次占用。
凭证对不上时静默返回(交还闸不是错误路径,闸被回灌/已释放时都属正常)。
type MemoryStore ¶ added in v0.35.0
type MemoryStore struct {
// contains filtered or unexported fields
}
MemoryStore 进程内参考实现(并发安全;单进程成立,不得上生产)。
func (*MemoryStore) Count ¶ added in v0.35.0
func (s *MemoryStore) Count() (int, error)
Count 已终态提交的环节数。
func (*MemoryStore) Get ¶ added in v0.35.0
func (s *MemoryStore) Get(ref Ref) (Record, bool, error)
Get 查记录。
func (*MemoryStore) ReleaseToken ¶ added in v0.35.0
func (s *MemoryStore) ReleaseToken(ref Ref, token uint64) (bool, error)
ReleaseToken 凭令牌精确释放(令牌不匹配 = 不是这次占用,不动后来者的闸)。
func (*MemoryStore) SeedIfAbsent ¶ added in v0.35.0
func (s *MemoryStore) SeedIfAbsent(ref Ref, rec Record) error
SeedIfAbsent 回灌(已存在不覆盖)。
type Record ¶ added in v0.35.0
type Record struct {
// Status 目标状态。
Status string
// Branch 提交者标识(只用于留痕与排障定位)。
Branch string
// Seq 提交序号(交由 SeqGate 判单调的口径)。
Seq uint64
// Token 本次占用的唯一令牌——**由存储铸造**(见文件头原子性契约)。
// 回灌(SeedIfAbsent)的记录没有占用,Token 恒 0。
Token uint64
}
Record 存储面的已提交记录(Guard 内部 record 的接缝形态;字段一一同构)。
type Ref ¶
Ref 环节引用:一次编排运行里的一个环节。
必须是「运行 + 环节」两级,**不能只用环节名**:不同 run 里的同名环节是 不同对象,只按环节名去重会把第二次运行整个判成重复提交。
type Store ¶ added in v0.35.0
type Store interface {
// Claim 原子占用:不存在 ⇒ 写入 rec(Token 由实现铸造后随 out 返回)
// 且 claimed=true;已存在 ⇒ claimed=false,out 为先前记录(供
// AlreadyCommittedError 构造「先前是谁提交的」)。基础设施故障 ⇒ error。
Claim(ref Ref, rec Record) (out Record, claimed bool, err error)
// ReleaseToken 凭令牌精确释放:令牌匹配 ⇒ 删除并 released=true;
// 记录不存在或令牌不匹配 ⇒ released=false(迟到/重复释放的正确形态)。
ReleaseToken(ref Ref, token uint64) (released bool, err error)
// SeedIfAbsent 回灌已持久化的终态:已存在不覆盖(内存里的现状更新)。
SeedIfAbsent(ref Ref, rec Record) error
// Get 查记录。第二个返回值为是否已提交。
Get(ref Ref) (Record, bool, error)
// Count 已终态提交的环节数。
Count() (int, error)
}
Store 终态记录存储接缝(并发安全;原子性契约见文件头)。