Documentation
¶
Overview ¶
backend.go —— 协调后端的最小原语契约(适配形态约定的代码面)。
为什么是这三个原语,而不是更多 ¶
把 taskboard.Board 与 sideeffect.Store 的判定逻辑推到底之后, 后端真正要提供的只有三件事:
- **快照读**:一次取多键的一致视图(判定所需的全部状态,同一瞬间);
- **条件事务**:全部断言成立才提交全部写入,否则一个都不执行 (「一次原子操作」的通用形态——Redis 的 Lua、etcd 的 txn、 PG 的事务条件更新都是它的实例);
- **前缀列键**:按前缀枚举(List 类操作需要,快照语义由实现保证)。
少一个:没有快照读就得逐键读,一致性窗口从「一次」变成「N 次」; 没有条件事务就没有原子提交,三判合一必然退化成三步三写。 多一个:锁、watch、租约原语都不需要——那些语义已经由适配器在原语之上 组合实现,后端多给只会引入第二份真相。
版本(Version)是断言的锚点 ¶
条件事务的断言形态刻意取「版本相等」而不是「值相等」:版本由后端在 **每次成功写入时 +1**(单调不回退),等值断言等价于「自我上次快照后 这个键没被动过」。值相等断言在 ABA 场景(改走又改回)会误判「没动过」, 版本断言不会——这与 commitgate「凭令牌认占用」是同一条纪律: 用变更的次数而不是变更后的内容认变更。
board.go —— taskboard.Board 的**通用适配器**:判定逻辑落在 Backend 原语之上。
判定逻辑只有这一份 ¶
Board 的语义(认领原子、凭令牌释放/完成、续期是资格检查、接管代次严格 递增)全部在本适配器里实现;宿主的后端只负责让三个原语真的原子。 判定与提交分离的窗口由断言护住:判定读过的每个键都进断言, 窗口内被人动过就取新快照重判——这就是「CAS 循环」形态在 Board 上的落法。
令牌由适配器在提交路径上铸造 ¶
token 每次认领尝试用 crypto/rand 现铸:断言保证只有铸这枚令牌的那次 事务能提交,输家的令牌从不落库——「由存储铸造」的本意(凭据出自判定面, 不由调用方自带)由此成立。到期时刻由适配器的时钟算(时钟可注入, 契约套件用它做过期场景),不是调用方的时钟。
依赖键的动态展开 ¶
认领判定要读依赖任务的状态,而依赖清单在任务记录里——首轮快照只有 任务键。这里不假装键集是静态的:首轮解码出依赖后重取一次快照 (把依赖键一并纳入断言面);认领期间拓扑被改(断言失败)时按新清单 再展开。拓扑变更在运行期是病态事件,多花一次快照读比漏护一扇窗便宜。
board_internal.go —— Board 适配器的内部件(键集构造 / 断言面 / 解码 / 凭据铸造)。 从 board.go 拆出:结构阈值(400 行)按判据的一侧切——适配器的判定入口 与它们消费的内部件分开,两边各自可读。
cas.go —— CAS 循环与并发原子性判据(适配形态约定的执行面)。
casLoop:判(快照)与写(事务)之间的窗口通用收尾 ¶
快照读与条件事务是两次后端交互——之间有窗口,别的进程可能改了状态。 适配器不假装窗口不存在:断言失败就取新快照重判,收敛为止。 上限只是防挂死的兜底(持续冲突 = 有高频写者或后端没实现原子性, 显式报 ErrTxnConflict,不无限重试)。
ConcurrentTxn:原语原子性的可跑判据 ¶
「Txn 的断言与写入不可分割」是 Backend 的**契约条款**,不是实现细节。 与 taskboard.ConcurrentClaim 同一形态:写成导出判据,宿主的后端实现 跑一遍即算验过——两个进程内竞争者同时断言同一版本、各自写不同的值, 恰好一个提交成功、版本只前进一格。断言与写入分离的坏实现 (先查后写没有原子性)在这里必红。
Package coord 分布式协调后端的**适配形态约定**:一个后端要接上本仓的 协调契约(taskboard.Board / sideeffect.Store),要按什么形状表达 「一次原子操作」「条件更新」「CAS 循环」。
生命周期: Experimental(v0.35.0 定级)——本包仍会改、删、拆,不承诺任何 兼容,升级风险自负;定级理由见 api-lifecycle.yaml,分级规则见 docs/api-compat.md。
它补的是哪一段(issue #107) ¶
本仓的协调契约全部是**存储中立**的:sideeffect.Store / taskboard.Board / checkpoint.StoreCAS 只定语义,实现由宿主提供。真实后端的参考适配此前只有 PostgreSQL 一种(examples/pgstore)——「后端不止一种,且形态有约定」 那一半是空的:宿主接 Redis / etcd 时没有任何形态可抄,各自发明 「读-判-写」三步实现,把跨进程原子性写丢(sideeffect 包头警告的头号窗口)。
本包把形态收成**一条代码级的约定**而不是一篇散文:
- @@Backend@@:协调后端的最小原语集(快照读 / 条件事务 / 前缀列键)。 任何后端(Redis / etcd / 单机文件)只要实现这三个方法,
- @@BoardAdapter@@ / @@StoreAdapter@@:把契约的三判合一、资格联查、 执行意图等**全部判定逻辑**落在原语之上——判定逻辑只有这一份实现, 宿主的后端只负责让原语真的原子。
于是「接一个新后端」的工作量从「重写一遍 Store 的 29 条契约语义」 缩成「实现三个原语 + 让它们在存储侧真的原子」。
形态约定:三种原语在各后端怎么表达 ¶
- **快照读(Snapshot)**:一次取多键的一致视图。 Redis → 一条 Lua 脚本内 MGET(脚本内无并发写);etcd → 一个只读 txn (shared 读,同 revision);PostgreSQL → 一条 SELECT(单语句一致性)。
- **条件事务(Txn)**:全部断言成立才执行全部写入,否则一个都不执行。 Redis → Lua 脚本(单线程执行天然原子;断言写在脚本开头,不满足即返回 0); etcd → txn(If 全部成立才 Then);PostgreSQL → 事务内的 SELECT … FOR UPDATE + 条件 UPDATE。
- **CAS 循环**:断言失败(applied=false)→ 取新快照 → 重判 → 再事务。 这是适配器的通用收尾:判(快照)与写(事务)之间有窗口,冲突就重来。 Redis 有 Lua 时整个判定可推进脚本里、免循环;etcd/PG 保留循环即可。
各后端的原子性边界(能保证什么、不能保证什么) ¶
- **Redis**:单 Lua 脚本内的多键读写在脚本执行期间原子(单线程模型); 脚本之间无隔离保证——跨脚本的「读后写」必须整段进同一个脚本。 附加约束:集群模式下多键必须同 slot(hash tag);主从复制异步, 主挂时会丢已确认的写——用它当协调状态需要等价于共识的部署形态 (如 RedLock 的争议面),宿主自担。
- **etcd**:txn 的 If/Then/Else 是一次真正的原子比较交换,revision 全局 单调——天然适合当版本与栅栏。边界:性能量级远低于 Redis(raft 落盘), 不适合高频租约续期;watch 的语义边界(历史窗口)与本包无关。
- **PostgreSQL**:事务隔离下的条件更新(examples/pgstore 已示范)。 本包不重复它——那条路径的参考在 pgrefstore 模块。
- **内存(@@MemoryBackend@@)**:进程内互斥锁,**跨进程原子性不存在**。 只用于测试与演示——拿它上生产等于以为自己在去重,实际两个进程各做一次 (sideeffect 包头警告的原话)。它在本包的用途是给适配器当测试保底: 让「判定逻辑这份实现」在没有真实后端时也能被契约套件验证。
与「判定逻辑单份实现」的边界声明 ¶
本包是**机制**:适配器的判定逻辑(判据归内核)+ 原语契约(原子性归存储)。 它**不是**「一份生产可用的存储」——单靠本包什么都没有, 必须有一个真原子的 Backend 才成立。这与 sideeffect「不给参考存储实现」 的纪律不冲突:那份纪律拦的是「内存实现被当成生产存储」, 本包的内存后端只垫在**判定逻辑**与**契约套件**之间,且在类型名上写明边界。
memory.go —— Backend 的内存参考实现(**单进程演示保底,不得上生产**)。
边界刻在名字与文档里 ¶
MemoryBackend 的原子性来自**进程内互斥锁**:三个原语在锁内逐字原子, 但那只对同一进程成立。拿它当生产协调存储 = 两个进程各持一把各自的锁, 「Acquire 是跨进程原子操作」这条前提不存在——sideeffect 的全部判据 会显示为绿,而去重已经失效(AGENTS.md「参考实现是可被误用的资产」 那条纪律的原话场景)。
它在本包的用途只有两个:①给适配器(Board/Store)当**测试保底**—— 判定逻辑那份实现由契约套件验证时,不需要真实后端;②给宿主当 **形态示范**——照它的三个方法写 Redis/etcd 版本。
record.go —— Store 适配器的记录形态与三项联查。 从 store.go 拆出(结构阈值按判据的一侧切):两份状态(Key 级 act / 资源级 res)的落库形态、编解码,以及「令牌是最新代 + 持有者是授予铸造 的那份 + 租约与动作资源一致」的联查口径——判定入口在 store.go, 它们消费的凭据语义在这里。
store.go —— sideeffect.Store 的**通用适配器**:判定逻辑落在 Backend 原语之上。
判定逻辑只有这一份 ¶
Store 的 29 条契约语义(三判合一、判定顺序「已完成 > 结果未知 > 执行中 > 资源被占 > 授予」、三项联查、执行意图、对账占用、Unsafe 显式放行)全部 在本适配器里实现。宿主的后端只负责让三个原语真的原子;判定与提交之间 的窗口由断言护住(判定读过的键全部列进断言,被人动过就重判)。
两份状态、两个键族 ¶
- act/<key> Key 级:这个动作做过没有 / 结果未知没有 / 有没有一次 尚未裁决的尝试(执行意图)/ 有没有人在对账;
- res/<resource> 资源级:这个资源谁占着 / 发第几代令牌。
两条出口分开(Abandon 动 Key 级 + 资源级,Release 只动资源级)在键族上 直接可见。栅栏令牌住在资源记录里:它只在授予(接管)时递增,释放不清零 ——「释放后的租约」凭旧令牌回来时,令牌仍是那一代但持有者已空, 三项联查的 ErrFenceStale 照样拦下。
标记失败必须保住执行意图 ¶
MarkUncertain 的契约硬要求(标记写失败时意图不得被撤)在本形态下由 **断言事务天然成立**:标记与「撤销意图」是同一事务里的写入, 事务失败 = 什么都没发生,意图原样挡着。
store_reconcile.go —— Store 适配器的裁决侧(ResolveUncertain / BeginReconcile)。 从 store.go 拆出:裁决与对账占用是「结果未知」一族的方法面,与施加侧 (Acquire/Complete/...)的判定性质不同——裁决**不校验栅栏**(依据是下游 事实,不是持有权),单独成文件把这个差别写在眼前。
Index ¶
- Variables
- func ConcurrentTxn(ctx context.Context, b Backend, key string, workers int) error
- type Assert
- type Backend
- type BoardAdapter
- func (a *BoardAdapter) Claim(ctx context.Context, id taskboard.TaskID, agent taskboard.AgentID, ...) (taskboard.Task, error)
- func (a *BoardAdapter) Complete(ctx context.Context, id taskboard.TaskID, token string) error
- func (a *BoardAdapter) List(ctx context.Context) ([]taskboard.Task, error)
- func (a *BoardAdapter) Release(ctx context.Context, id taskboard.TaskID, token string) error
- func (a *BoardAdapter) Renew(ctx context.Context, id taskboard.TaskID, token string, ...) (taskboard.Task, error)
- func (a *BoardAdapter) Seed(tasks ...taskboard.Task)
- func (a *BoardAdapter) UseClock(now func() time.Time)
- type Entry
- type MemoryBackend
- type Mutation
- type StoreAdapter
- func (s *StoreAdapter) Abandon(ctx context.Context, act sideeffect.Action, lease sideeffect.Lease, _ string) error
- func (s *StoreAdapter) Acquire(ctx context.Context, act sideeffect.Action, ttl time.Duration) (sideeffect.Outcome, sideeffect.Lease, sideeffect.Result, error)
- func (s *StoreAdapter) BeginReconcile(ctx context.Context, act sideeffect.Action, ttl time.Duration) (sideeffect.Reconcile, error)
- func (s *StoreAdapter) Complete(ctx context.Context, act sideeffect.Action, lease sideeffect.Lease, ...) (sideeffect.Result, error)
- func (s *StoreAdapter) MarkUncertain(ctx context.Context, act sideeffect.Action, lease sideeffect.Lease, ...) error
- func (s *StoreAdapter) Release(ctx context.Context, act sideeffect.Action, lease sideeffect.Lease) error
- func (s *StoreAdapter) Renew(ctx context.Context, act sideeffect.Action, lease sideeffect.Lease, ...) (sideeffect.Lease, error)
- func (s *StoreAdapter) ResolveUncertain(ctx context.Context, act sideeffect.Action, res sideeffect.Resolution) (sideeffect.Outcome, sideeffect.Result, error)
- func (s *StoreAdapter) ResolveUncertainOwned(ctx context.Context, act sideeffect.Action, res sideeffect.Resolution, ...) (sideeffect.Outcome, sideeffect.Result, error)
- func (s *StoreAdapter) UseClock(now func() time.Time)
- type Version
Constants ¶
This section is empty.
Variables ¶
var ErrMalformedRecord = errors.New("coord: 记录解码失败(键下内容不是本适配器的编码)")
ErrMalformedRecord 键下的记录无法解码(后端里的字节与本包的编码不一致)。
它说明这批键不是本适配器写的(键前缀撞了,或后端被别的写者占用)—— 放行会把别人的字节当自己的状态判,静默给出错误结论。
var ErrTxnConflict = errors.New("coord: 条件事务反复冲突,重试上限内未提交")
ErrTxnConflict CAS 循环重试到上限仍未提交(高争用或后端行为异常)。
循环上限是防挂死的兜底:正常争用几次内必收敛;持续冲突说明有调用方 在高频改同一批键(或后端根本没实现原子性),此时把冲突显式交上去, 不无限重试。
Functions ¶
func ConcurrentTxn ¶
ConcurrentTxn 并发原子性判据:两个竞争者同时断言同一版本,恰好一个提交。
判法(与 taskboard.ConcurrentClaim 同一形态):
- 先取 key 的当前版本 v(不存在则 VerMissing);
- workers 个竞争者**同时**提交 Txn([key@v], 各自写自己的值);
- 成功者必须**恰好 1 个**(0 = 全被断言拒了,说明判据没测到争用; >1 = 断言与写入没有原子性,契约破);
- 其余必须 applied=false(正常输家);任何 error 都是基础设施故障, 显式报出,不混进「输家」里——把后端故障算成输家,判据会在后端 坏掉时变绿;
- 版本必须恰好 v+1(版本断言之外多写一格或少写一格都是版本语义破)。
返回的 error 只表示**基础设施故障或契约违反**,不表示「有人输」。
Types ¶
type Assert ¶
Assert 条件事务的断言:键的当前版本必须等于 Ver。
Ver = VerMissing 即断言「键不存在」(防「快照时没有、提交时被别人建了」 的窗口)。断言的键与被写的键**必须同批提交**:断言只护自己列出的键, 没列出的键在快照与提交之间怎么变都不管——适配器要把判定读过的键全部 列进断言,少列一个就是少护一扇窗。
type Backend ¶
type Backend interface {
// Snapshot 一致快照读:返回这些键的当前状态。
// 同一个键重复出现按一次计;不存在的键返回 Exists=false 的条目。
Snapshot(ctx context.Context, keys ...string) (map[string]Entry, error)
// Txn 一次原子操作:全部 Assert 成立才执行全部 Mutation。
//
// 返回 applied=false 表示有断言不成立(什么都没发生)——这是**正常结论**
// 不是错误:CAS 循环的调用方取新快照重判即可。error 只表示基础设施故障。
Txn(ctx context.Context, asserts []Assert, mutations []Mutation) (applied bool, err error)
// Keys 列出前缀下的全部键名(快照语义:与 Snapshot 同一一致性面)。
Keys(ctx context.Context, prefix string) ([]string, error)
}
Backend 协调后端的最小原语契约。
实现方必须保证(这三条是「接上协调契约」的全部硬要求):
- Txn **原子**:全部 Assert 成立时执行全部 Mutation 并把被写键的版本 各 +1;任一断言不成立时**一个 Mutation 都不执行**(返回 false);
- Snapshot **一致**:返回的是同一瞬间的视图(后端自己的锁/事务/脚本内);
- Version **单调**:同一键的版本随每次成功写入严格 +1,不回退、不复用。
典型实现形态见包文档的「形态约定」节。跨进程原子性由实现方的存储保证 ——本包的内存后端只在单进程内成立,不得上生产。
type BoardAdapter ¶
type BoardAdapter struct {
// contains filtered or unexported fields
}
BoardAdapter 把任意 Backend 适配成 taskboard.Board(含 RenewingBoard)。
func (*BoardAdapter) Claim ¶
func (a *BoardAdapter) Claim(ctx context.Context, id taskboard.TaskID, agent taskboard.AgentID, req taskboard.ClaimRequest) (taskboard.Task, error)
Claim 认领一个任务(读-判-写窗口由断言护住,冲突即重判)。
代次与认领在同一事务里推进(fenceKey +1 + 任务落库,同一次原子操作铸造)。
func (*BoardAdapter) Renew ¶
func (a *BoardAdapter) Renew(ctx context.Context, id taskboard.TaskID, token string, req taskboard.ClaimRequest) (taskboard.Task, error)
Renew 续期并确认持有资格(taskboard.RenewingBoard):令牌不匹配或已过期 都报 ErrLeaseLost 且不做任何修改——续期不是心跳,是资格检查。
func (*BoardAdapter) Seed ¶
func (a *BoardAdapter) Seed(tasks ...taskboard.Task)
Seed 摆初始任务(boardtest 契约套件的接缝:契约本身没有「创建」入口, 任务拓扑是宿主资产)。
套件的接缝签名没有 error(与 reference-runtime 的内存板同口径):任务体是 纯数据、编码不会失败;摆入的后端故障只能静默——这个方法不是生产 API, 生产路径的任务落库在宿主自己的装配代码里。
func (*BoardAdapter) UseClock ¶
func (a *BoardAdapter) UseClock(now func() time.Time)
UseClock 注入时钟(boardtest 契约套件要求:过期判定必须读可注入的时钟)。
type Entry ¶
type Entry struct {
// Key 键名。
Key string
// Ver 当前版本(键不存在时为 VerMissing)。
Ver Version
// Val 当前值(键不存在时为 nil)。后端不得复用底层字节——
// 返回的切片必须是快照那一刻的独立拷贝。
Val []byte
// Exists 键是否存在。
Exists bool
}
Entry 一次快照读到的键状态。
type MemoryBackend ¶
type MemoryBackend struct {
// contains filtered or unexported fields
}
MemoryBackend Backend 的进程内实现(见文件头的边界声明)。
type StoreAdapter ¶
type StoreAdapter struct {
// contains filtered or unexported fields
}
StoreAdapter 把任意 Backend 适配成 sideeffect.Store。
func (*StoreAdapter) Abandon ¶
func (s *StoreAdapter) Abandon(ctx context.Context, act sideeffect.Action, lease sideeffect.Lease, _ string) error
Abandon 显式放弃本次尝试:撤销执行意图 + 释放资源持有(资源闸只在 仍是自己的时才放——易主了就不碰,那是新持有者的闸)。
func (*StoreAdapter) Acquire ¶
func (s *StoreAdapter) Acquire(ctx context.Context, act sideeffect.Action, ttl time.Duration) (sideeffect.Outcome, sideeffect.Lease, sideeffect.Result, error)
Acquire 一次原子判定。判定顺序是契约里最要紧的一行: **已完成 > 结果未知 > 执行中 > 资源被占 > 授予**。 顺序写反的代价是重复施加:把「结果未知」排到「资源被占」之后, 调用方会拿到 contended(以为「晚点重试就行」),重试于是发生。
func (*StoreAdapter) BeginReconcile ¶
func (s *StoreAdapter) BeginReconcile(ctx context.Context, act sideeffect.Action, ttl time.Duration) (sideeffect.Reconcile, error)
BeginReconcile 认领对账权(同一 Key 同一时刻至多一个对账者; 占用过期可重入——对账者死亡不永久占住这个 Key)。
func (*StoreAdapter) Complete ¶
func (s *StoreAdapter) Complete(ctx context.Context, act sideeffect.Action, lease sideeffect.Lease, res sideeffect.Result) (sideeffect.Result, error)
Complete 标记完成并留存结果(幂等:先到者的结果不被后到者覆盖; 三项联查通过才接受)。
func (*StoreAdapter) MarkUncertain ¶
func (s *StoreAdapter) MarkUncertain(ctx context.Context, act sideeffect.Action, lease sideeffect.Lease, reason string) error
MarkUncertain 把该 Key 标记为「结果未知」(按键,与资源租约无关)。 标记与「撤销意图」在同一事务里:事务失败 = 什么都没发生, 意图原样留在存储里挡着后续 Acquire(契约硬要求第 5 条)。 标记成功后闸**不**自动释放——调用方显式 Release 只放资源。
func (*StoreAdapter) Release ¶
func (s *StoreAdapter) Release(ctx context.Context, act sideeffect.Action, lease sideeffect.Lease) error
Release 释放资源持有,**不动** Key 级的执行意图——意图没被裁决之前, 这个 Key 不得再被执行(「施加成功但进程死在记账之前」的封口)。
func (*StoreAdapter) Renew ¶
func (s *StoreAdapter) Renew(ctx context.Context, act sideeffect.Action, lease sideeffect.Lease, ttl time.Duration) (sideeffect.Lease, error)
Renew 续期并确认持有资格:闸已易主、尝试已失效**或租约已过期**则拒绝 续期(假续期延长的是别人的闸——重复施加的入口)。
func (*StoreAdapter) ResolveUncertain ¶
func (s *StoreAdapter) ResolveUncertain(ctx context.Context, act sideeffect.Action, res sideeffect.Resolution) (sideeffect.Outcome, sideeffect.Result, error)
ResolveUncertain 裁决「结果未知」的 Key(先到者为准;**不校验栅栏**—— 它的依据是下游事实,不是持有权,对账恰恰要在持有者死亡后进行)。 简化入口不校验对账凭据——短对账无须认领,跨进程长对账走下面的 ResolveUncertainOwned。
func (*StoreAdapter) ResolveUncertainOwned ¶
func (s *StoreAdapter) ResolveUncertainOwned(ctx context.Context, act sideeffect.Action, res sideeffect.Resolution, holder string) (sideeffect.Outcome, sideeffect.Result, error)
ResolveUncertainOwned 带对账占用凭据的裁决(OwnedResolveStore): holder 不是**当前有效**对账认领的持有者 → ErrReconcileLost,不改任何状态。
校验与裁决同一事务:CAS 断言保证判定读到的占用就是裁决要落的那份—— 「校验时还有效、裁决前已被接管」的窗口由断言失败重判兜住。
func (*StoreAdapter) UseClock ¶
func (s *StoreAdapter) UseClock(now func() time.Time)
UseClock 注入时钟(sidetest 契约套件要求:过期判定必须读可注入的时钟)。