commitgate

package
v0.35.0 Latest Latest
Warning

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

Go to latest
Published: Nov 13, 2026 License: MIT Imports: 2 Imported by: 0

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

Constants

This section is empty.

Variables

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

func NewGuard(isTerminal func(status string) bool) *Guard

NewGuard 构造闸(进程内参考存储)。isTerminal 判定某状态是否为终态; 传 nil 表示所有提交都按终态处理。

func NewGuardWithStore added in v0.35.0

func NewGuardWithStore(s Store, isTerminal func(status string) bool) *Guard

NewGuardWithStore 构造闸并注入存储接缝(issue #145)。

多副本宿主用它接自己的跨进程存储;判定逻辑仍只有本包这一份。 s 为 nil ⇒ 退化为进程内参考存储。

func (*Guard) Acquire

func (g *Guard) Acquire(c Commit) (Lease, error)

Acquire 抢终态独占权并返回**占用凭证**。成功时持凭证者独占该环节; 落库失败时用 ReleaseLease 精确交还——只有持有该凭证的那一次占用会被放掉, 迟到的/重复的 ReleaseLease 对别人的闸无效。

非终态直接放行且不记录(返回零值凭证);判为重复时返回 *AlreadyCommittedError,它带**先前是谁提交的**——「两个分支都以为自己赢了」 之所以难查,正是因为日志里只有两次提交、没有「第二次本该被谁挡下」。

历史教训(旧三元组认人接口已删):旧接口用 (status, branch, seq) 认人, 而那三元组不唯一——同名并行分支、或重试同一份快照时,三元组逐字相同, 于是输家的迟到释放会删掉赢家的记录,闸对下一次提交敞开, 同一环节的终态被提交两次(实测 8 路并发提交出现 4 个赢家)。

func (*Guard) Committed

func (g *Guard) Committed() int

Committed 返回已终态提交的环节数(自检与留痕用)。 零值 Guard / Store 故障 ⇒ 0(观测面,不因一次故障把自检打成假红)。

func (*Guard) ReleaseLease

func (g *Guard) ReleaseLease(l Lease)

ReleaseLease 交还终态独占权,**凭占用凭证精确释放**(落库失败时必须调用, 否则该环节的闸被永久占住)。

只有令牌对得上才会放掉闸:迟到的、重复的、别人的 ReleaseLease 一律无效。 这是「一次迟到的释放不得把后来者的独占权一起放掉」唯一能兑现的形式 ——旧三元组认人接口(已删)挡不住「三元组完全相同」的两次占用。

凭证对不上时静默返回(交还闸不是错误路径,闸被回灌/已释放时都属正常)。

func (*Guard) Seed

func (g *Guard) Seed(c Commit)

Seed 用**已持久化的**终态回灌闸(宿主进程重启后调用一次)。

不灌的后果与 SeqGate 同源:进程重启后闸是空的,上一进程已提交的终态被畅通无阻 地重复提交——正是本包要防的那件事。这是「闸被重置后没人告知」,不是「闸没生效」。

幂等:以同一条记录重复 Seed 结果相同。已存在的记录不会被更旧的覆盖。

func (*Guard) Status

func (g *Guard) Status(ref Ref) (status string, branch string, committed bool)

Status 查该环节当前已提交的终态。第二个返回值为是否已提交。 零值 Guard / Store 故障 ⇒ 未提交(与旧实现 nil map 读的良性口径一致)。

type Lease

type Lease struct {
	Ref   Ref
	Token uint64
}

Lease 一次终态独占的凭证。

只有持有它的那次占用能被精确释放——这是「迟到的 Release 不得放掉后来者的闸」 这条承诺唯一能兑现的方式。

type MemoryStore added in v0.35.0

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

MemoryStore 进程内参考实现(并发安全;单进程成立,不得上生产)。

func NewMemoryStore added in v0.35.0

func NewMemoryStore() *MemoryStore

NewMemoryStore 构造进程内参考实现。

func (*MemoryStore) Claim added in v0.35.0

func (s *MemoryStore) Claim(ref Ref, rec Record) (Record, bool, error)

Claim 原子占用(单锁内判-占-铸令牌,进程内恰好一个赢家)。

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

type Ref struct {
	RunID string
	Step  string
}

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 终态记录存储接缝(并发安全;原子性契约见文件头)。

Jump to

Keyboard shortcuts

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