coord

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: 15 Imported by: 0

Documentation

Overview

backend.go —— 协调后端的最小原语契约(适配形态约定的代码面)。

为什么是这三个原语,而不是更多

把 taskboard.Board 与 sideeffect.Store 的判定逻辑推到底之后, 后端真正要提供的只有三件事:

  1. **快照读**:一次取多键的一致视图(判定所需的全部状态,同一瞬间);
  2. **条件事务**:全部断言成立才提交全部写入,否则一个都不执行 (「一次原子操作」的通用形态——Redis 的 Lua、etcd 的 txn、 PG 的事务条件更新都是它的实例);
  3. **前缀列键**:按前缀枚举(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 包头警告的头号窗口)。

本包把形态收成**一条代码级的约定**而不是一篇散文:

  1. @@Backend@@:协调后端的最小原语集(快照读 / 条件事务 / 前缀列键)。 任何后端(Redis / etcd / 单机文件)只要实现这三个方法,
  2. @@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

Constants

This section is empty.

Variables

View Source
var ErrMalformedRecord = errors.New("coord: 记录解码失败(键下内容不是本适配器的编码)")

ErrMalformedRecord 键下的记录无法解码(后端里的字节与本包的编码不一致)。

它说明这批键不是本适配器写的(键前缀撞了,或后端被别的写者占用)—— 放行会把别人的字节当自己的状态判,静默给出错误结论。

View Source
var ErrTxnConflict = errors.New("coord: 条件事务反复冲突,重试上限内未提交")

ErrTxnConflict CAS 循环重试到上限仍未提交(高争用或后端行为异常)。

循环上限是防挂死的兜底:正常争用几次内必收敛;持续冲突说明有调用方 在高频改同一批键(或后端根本没实现原子性),此时把冲突显式交上去, 不无限重试。

Functions

func ConcurrentTxn

func ConcurrentTxn(ctx context.Context, b Backend, key string, workers int) error

ConcurrentTxn 并发原子性判据:两个竞争者同时断言同一版本,恰好一个提交。

判法(与 taskboard.ConcurrentClaim 同一形态):

  1. 先取 key 的当前版本 v(不存在则 VerMissing);
  2. workers 个竞争者**同时**提交 Txn([key@v], 各自写自己的值);
  3. 成功者必须**恰好 1 个**(0 = 全被断言拒了,说明判据没测到争用; >1 = 断言与写入没有原子性,契约破);
  4. 其余必须 applied=false(正常输家);任何 error 都是基础设施故障, 显式报出,不混进「输家」里——把后端故障算成输家,判据会在后端 坏掉时变绿;
  5. 版本必须恰好 v+1(版本断言之外多写一格或少写一格都是版本语义破)。

返回的 error 只表示**基础设施故障或契约违反**,不表示「有人输」。

Types

type Assert

type Assert struct {
	Key string
	Ver Version
}

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 NewBoardAdapter

func NewBoardAdapter(b Backend) *BoardAdapter

NewBoardAdapter 构造 Board 适配器。

func (*BoardAdapter) Claim

Claim 认领一个任务(读-判-写窗口由断言护住,冲突即重判)。

代次与认领在同一事务里推进(fenceKey +1 + 任务落库,同一次原子操作铸造)。

func (*BoardAdapter) Complete

func (a *BoardAdapter) Complete(ctx context.Context, id taskboard.TaskID, token string) error

Complete 标记完成(凭令牌;终态不带 owner,避免「谁做完的」与 「谁还占着」混在一起)。

func (*BoardAdapter) List

func (a *BoardAdapter) List(ctx context.Context) ([]taskboard.Task, error)

List 返回一致快照(按键名取全量后逐键解码,按键 ID 序返回)。

func (*BoardAdapter) Release

func (a *BoardAdapter) Release(ctx context.Context, id taskboard.TaskID, token string) error

Release 归还认领(凭令牌;非持有者一律 ErrLeaseLost,不静默成功)。

func (*BoardAdapter) Renew

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 的进程内实现(见文件头的边界声明)。

func NewMemoryBackend

func NewMemoryBackend() *MemoryBackend

NewMemoryBackend 构造空后端。

func (*MemoryBackend) Keys

func (m *MemoryBackend) Keys(_ context.Context, prefix string) ([]string, error)

Keys 前缀列键(锁内快照语义,结果按键名排序保证确定性)。

func (*MemoryBackend) Snapshot

func (m *MemoryBackend) Snapshot(_ context.Context, keys ...string) (map[string]Entry, error)

Snapshot 一致快照读(锁内逐字一致)。

func (*MemoryBackend) Txn

func (m *MemoryBackend) Txn(_ context.Context, asserts []Assert, mutations []Mutation) (bool, error)

Txn 条件事务:断言全过才提交(锁内原子),任一失败则一个都不执行。

type Mutation

type Mutation struct {
	Key string
	Val []byte
	Del bool
}

Mutation 条件事务的写入:Set(Del=false)或删除(Del=true)。

type StoreAdapter

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

StoreAdapter 把任意 Backend 适配成 sideeffect.Store。

func NewStoreAdapter

func NewStoreAdapter(b Backend) *StoreAdapter

NewStoreAdapter 构造 Store 适配器。

func (*StoreAdapter) Abandon

func (s *StoreAdapter) Abandon(ctx context.Context, act sideeffect.Action, lease sideeffect.Lease, _ string) error

Abandon 显式放弃本次尝试:撤销执行意图 + 释放资源持有(资源闸只在 仍是自己的时才放——易主了就不碰,那是新持有者的闸)。

func (*StoreAdapter) Acquire

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

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

Renew 续期并确认持有资格:闸已易主、尝试已失效**或租约已过期**则拒绝 续期(假续期延长的是别人的闸——重复施加的入口)。

func (*StoreAdapter) ResolveUncertain

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 契约套件要求:过期判定必须读可注入的时钟)。

type Version

type Version uint64

Version 键版本:每次成功写入 +1,单调不回退,由后端保证。

const VerMissing Version = 0

VerMissing 键不存在时的版本(0;新键首写后从 1 起)。

Jump to

Keyboard shortcuts

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