taskboard

package
v0.34.0 Latest Latest
Warning

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

Go to latest
Published: Nov 12, 2026 License: MIT Imports: 9 Imported by: 0

Documentation

Overview

contention.go —— 认领的并发正确性**判据**(判据,不是测试风格)。

生命周期: Stable(v0.33.0 定级)——只允许 additive 变更(新增导出符号 / 新增方法且给零值默认实现 / 新增错误值);改签名、改语义、改默认值、 删导出符号都算 breaking,判定规则与变更流程见 docs/api-compat.md, 逐包理由见 api-lifecycle.yaml。

为什么判据要长在包里而不是只在测试里

「并发认领同一任务只能有一个赢家」是 @@Board@@ 的**契约条款**,不是某个 实现的性质。写在测试里 → 每个宿主各写一份、各判一套;写成导出的判据 → 宿主的存储实现只要被这个函数跑一遍就算验过契约(@sidetest@ 的同一范式)。

判据怎么判

让 N 个 agent 在同一个起跑线上同时 @@Claim@@ 同一个任务:

  • 成功者必须**恰好 1 个**(0 个说明谁都认领不了;>1 个说明存储没做到原子);
  • 失败者的错误必须是 @@ErrTaken@@(不能是 ErrBlocked / ErrTerminal 之类—— 那说明这些 agent 压根没走到争用那一步,这次判定没测到想测的东西);
  • 任何**其它**错误都是基础设施故障(要显式报出来,不能混进「输家」里)。

第三条容易被忘掉:把「数据库连不上」也算成「输家」,判据就会在存储坏掉时 **变绿**——它测到的是「没有人赢」而不是「只有一个赢家」,而两者都符合 「winners <= 1」。

第二条判据:重复令牌(issue #97)

@@DuplicateTokenSubmit@ 让多个 worker 同时用**同一个**令牌提交,成功者 必须**恰好 1 个**——它撞的是「校验与写入分离」这一形态:各拿各的令牌的 并发认领(@@ConcurrentClaim@@)撞不出它,必须让同一令牌的使用者正面相撞。

renew.go —— 租约续期与接管代次的契约(issue #87)。

它补的是哪一段

长任务必然撞上租约到期:执行时长超过 Until 而没有续期手段,宿主只能在 「估一个很大的租约」与「永久占用」之间二选一——估大了,持有者崩了之后 任务要白等很久才被接管;估小了,活还没干完闸就没了。

更要害的是接管后的世界:旧持有者(GC 停顿、网络分区后恢复)迟到的提交, 此前只能靠「新旧令牌恰好不同」保安全——那是碰巧,不是判据。

Renew 不是心跳,是资格检查

它同时做两件事,缺一不可:

  1. 把到期时刻推后(长动作不至于中途失去闸);
  2. 校验令牌仍是该任务的最新一代——不是则拒绝,**不做任何修改**。

只做第 1 条的续期是**假续期**:租约已被接管时,延长的其实是**别人的闸**, 持有者却以为延长的是自己的,于是两份闸同时开着,同一份工作被做两遍。

接管代次(Fence)与令牌同源

代次与令牌在同一次 Claim 的同一次原子操作里铸造,同一任务上**严格递增** (Renew 不递增——续期不是接管)。它给两样东西:

  • 判据:接管后的新代必须大于旧代(boardtest 的「接管代次严格递增」钉住, 代次不递增的实现必红);
  • 栅栏:宿主的下游资源可凭「代次比已见过的最大值小」拒收过期持有者的 迟到写入(与 sideeffect.FencedSink 同一形态、同一纪律)。

旧持有者迟到的提交不需要专门的判别机制:令牌不匹配即 @@ErrLeaseLost@@。 接管必然铸造新令牌,故迟到提交**必然**被拒,且不波及新持有者。

Package taskboard 多 agent 协调面的**存储中立契约 + 一致性判据**。

它补的是哪一段

@dispatch@ 只回答「能不能派」。派出去之后的三件事——谁负责哪个子任务、 它依赖谁、怎么把认领还回去——此前**没有任何机制面**:各宿主自己拿一张 表或一个 map 兜着,于是「同一个子任务被两个 agent 同时认领」这种错在 报告里读起来完全正常(两条分支都干完了活,各自都以为自己是负责人)。

本仓给什么、不给什么

**给**:① 存储中立的接口(@@Board@@);② 快照的**不变量判据**(@@Check@@, 纯函数,宿主可以随时拿当前快照跑一遍);③ 并发正确性的**可跑断言** (@@ConcurrentClaim@@,判据本身而不是测试风格)。

**不给**:存储实现。表结构、事务边界、持久化形态是宿主资产——本仓给一份 参考实现,宿主就会照着改它,最后两份真相并存。这与 @checkpoint.StoreCAS@ 的分工完全同构:契约与判据归内核,落库归宿主。

为什么「认领」必须是原子的

认领是**读-判-写**:读当前状态、判依赖是否完成、写 owner。三段分开做, 两个 agent 会读到同一个「open」,各自判定自己可以认领,然后先后写入—— 两个赢家。这与 @@commitgate@@ 的「两条并行分支都赢了」是同一种失效, 而这里的代价更直接:同一份工作被做两遍,或者两份互相覆盖的结果被当成一份。

故契约要求 @@Board.Claim@@ **由存储实现保证原子性**,本包只判结果 (并发跑 N 次,成功者必须恰好 1 个)。

释放认领凭令牌,不凭内容

与 @@commitgate@@ 的教训同源:用「任务 id + agent 名」认人判的是 「内容相同」,不是「同一次占用」。同名 agent(并行分支常同名)或 「失败 → 释放 → 重试」这条推荐路径都会撞上,于是输家放掉赢家的闸、 上一轮的迟到释放放掉新一轮的闸。故 @Lease@ 里的 token 由存储**铸造**, @@Release@@ / @@Complete@@ 只认 token。

令牌必须由存储铸造(v0.33.0 起)

此前 @Claim@ 收的是调用方自带的 @Lease@(含 token)——契约写着 「token 由存储铸造」,实现却让调用方造 token。两者对不上时,**实现赢**: 调用方能造出任意 token,于是「输家的迟到释放放掉赢家的闸」这条失效形态 又回来了,而报告里一切正常。

故 @Claim@ 改收 @ClaimRequest@(只说「要多长的租约」),token 由存储铸造 并随返回的 @@Task@ 给出。契约与实现从此一致,且不可能被调用方绕过。

续期与接管代次(v0.33.0 起,详见 renew.go)

长任务没有续期手段就必然被接管;续期走 @@RenewingBoard.Renew@@—— 它是**资格检查**不是心跳:校验令牌仍是最新代,同时把到期时刻推后。 接管代次 @@Fence@@ 与令牌**同源**(同一次认领的同一原子操作铸造), 同一任务上严格递增;旧持有者迟到的提交凭「令牌不匹配 ⇒ @@ErrLeaseLost@@」 必然被拒,且不波及新持有者。

Index

Constants

View Source
const (
	CodeEmptyID         = "empty_id"
	CodeDuplicateID     = "duplicate_id"
	CodeUnknownState    = "unknown_state"
	CodeUnknownDep      = "unknown_dep"
	CodeSelfDep         = "self_dep"
	CodeCycle           = "dep_cycle"
	CodeClaimedNoLease  = "claimed_without_lease"
	CodeLeaseNoClaim    = "lease_without_claim"
	CodeBlockedClaim    = "blocked_claim"
	CodeTerminalClaimed = "terminal_claimed"
)

不变量类别。

Variables

View Source
var (
	// ErrNotFound 任务不存在。
	ErrNotFound = errors.New("taskboard: 任务不存在")
	// ErrTaken 任务被他人活着持有。
	ErrTaken = errors.New("taskboard: 任务已被他人认领")
	// ErrBlocked 依赖未完成。
	ErrBlocked = errors.New("taskboard: 依赖未完成")
	// ErrTerminal 任务已是终态。
	ErrTerminal = errors.New("taskboard: 任务已是终态")
	// ErrLeaseLost 令牌不匹配(租约已易主或被释放过)。
	//
	// 旧持有者迟到的一切动作——Release / Complete / Renew——都用它拒收:
	// 接管必然铸造新令牌,令牌不匹配即说明你不是现任持有者。
	ErrLeaseLost = errors.New("taskboard: 租约已易主")
)

错误哨兵(宿主实现必须复用,不要各造一套:判定方按 As/Is 认人)。

Functions

func ConcurrentClaim

func ConcurrentClaim(ctx context.Context, b Board, id TaskID, agents []AgentID, ttl time.Duration) (int, error)

ConcurrentClaim 让多个 agent 同时认领同一个任务,返回成功者数量。

ttl 是本次认领申请的租约有效期(每个 agent 申请同一把长度)—— **令牌由存储铸造**(见 Board.Claim),本判据不碰令牌。

返回的 error 只表示**基础设施故障或契约违反**,不表示「有人输」—— 输家是正常结果。

func DuplicateTokenSubmit added in v0.34.0

func DuplicateTokenSubmit(ctx context.Context, b Board, id TaskID, workers int, ttl time.Duration) (int, error)

DuplicateTokenSubmit 让多个 worker 同时用**同一个**令牌提交同一任务, 返回成功者数量(必须恰好 1)。

它补的是哪一面(issue #97)

「释放/完成只认 token」这条契约有一个并发缺口:若存储把 Complete 的 **校验**与**写入**拆成两步(先查「claimed 且 token 匹配」、再写 done, 中间没有事务),两个同时用同一令牌的提交者会**都**通过校验、先后写入 ——两个都成功,等于「只认 token」名存实亡。4 个 agent 各拿各的令牌 撞不出这个形态;必须让**同一令牌**的并发使用者正面相撞。

判据怎么判

本判据自己先认领一次(令牌由存储铸造),再让 workers 个提交者**同时** 用该令牌调 Complete:

  • 成功者必须**恰好 1 个**(>1 说明校验与写入没有原子性;0 说明连真持有者 都被拒了,契约同样破);
  • 其余必须是 @@ErrLeaseLost@@(正常输家);任何**其它**错误按基础设施故障 显式报出,不混进「输家」里——把存储故障算成输家,判据会在存储坏掉时 变绿。

返回的 error 只表示**基础设施故障或判据无法给出结论**,不表示「有人输」。

Types

type AgentID

type AgentID string

AgentID 执行者标识。

**允许重复**(并行分支常同名)——这正是「凭 token 不凭内容」的理由。

type Board

type Board interface {
	// List 返回当前全部任务的快照(顺序不要求,判据会排序)。
	List(ctx context.Context) ([]Task, error)
	// Claim 认领一个任务。
	//
	// **令牌由存储铸造**并随返回的 Task 一起给出(Task.Lease.Token 与
	// Task.Lease.Fence 同一次原子操作铸造,Fence 在同一任务上严格递增);
	// 调用方只给「要多长的租约」(ClaimRequest),不给令牌。
	//
	// 以下情形必须报错而不是「尽力而为」:不存在(ErrNotFound)/
	// 被别人活着持有(ErrTaken)/ 依赖未完成(ErrBlocked)/ 已是终态(ErrTerminal)。
	Claim(ctx context.Context, id TaskID, agent AgentID, req ClaimRequest) (Task, error)
	// Release 归还认领(凭 token)。
	Release(ctx context.Context, id TaskID, token string) error
	// Complete 标记完成(凭 token)。
	Complete(ctx context.Context, id TaskID, token string) error
}

Board 协调面的存储中立契约。

实现方必须保证:

  • @@Claim@@ **原子**:读-判-写不可分割(并发认领同一任务只能有一个赢家);
  • @@Release@@ / @@Complete@@ **只认 token**,且 token 不匹配时报 @@ErrLeaseLost@@(不许静默成功——静默成功会让输家以为自己的释放生效了);
  • @@List@@ 返回**一致快照**(不是边改边读的流)。

type ClaimRequest added in v0.33.0

type ClaimRequest struct {
	// TTL 本次认领的租约有效期。
	//
	// 正数 ⇒ 存储按「自己的当前时刻 + TTL」算出到期时刻。
	// 零值 ⇒ 不限期(到期时刻为零值,由宿主自担)——持有着崩了会留下一把
	// 永久占用,故宿主通常应给一个正的 TTL。
	TTL time.Duration
}

ClaimRequest 认领请求:调用方给出**想要多长的租约**,不给出令牌。

为什么请求里没有令牌

令牌必须由**存储**铸造。调用方自带令牌时,「释放/完成只认 token」 这条契约就名存实亡:调用方能造出任意 token,于是 「同名 agent 的输家迟到释放放掉赢家的闸」这条失效形态又回来了 (commitgate 同源教训:凭令牌释放、不认内容)。

为什么给的是「多长」而不是「到什么时候」

到期时刻归存储算:调用方的时钟与存储的时钟可能对不上,跨进程时更不能 假设一致。给 TTL,存储按**自己的**当前时刻推算,两边不会各算一套。

type Fence added in v0.34.0

type Fence uint64

Fence 接管代次(栅栏令牌):同一任务上**每次认领成功**都严格递增。

与 @@Lease.Token@@ **同源**:同一次认领的同一次原子操作里由存储铸造。 Renew **不**递增它——续期不是接管,代次只回答「这是第几任持有者」。

首次认领的取值不限(从 0 或 1 起都可以),契约只要求同一任务上 **严格递增**:新代必须大于旧代。

用途:

  • 判据:接管后的代次必须递增(boardtest 的「接管代次严格递增」钉住, 代次不递增的实现必红);
  • 栅栏:宿主的下游资源可凭「代次比已见过的最大值小」拒收过期持有者的 迟到写入——与 sideeffect.FencedSink 同一形态、同一纪律 (比较与写入须在下游的同一次原子操作里完成)。

type Lease

type Lease struct {
	// Token 由存储**铸造**的唯一令牌(释放与完成的唯一凭据)。
	//
	// 它**不由调用方提供**——见 ClaimRequest 的注释:调用方能造令牌,
	// 「凭令牌认人」就名存实亡。
	Token string
	// Fence 接管代次(栅栏令牌):与 Token **同一次原子操作**铸造,同一任务上
	// 每次认领成功严格递增;续期**不**递增(续期不是接管)。
	//
	// 判据与下游栅栏的用法见 Fence 与 RenewingBoard 的契约说明(renew.go)。
	Fence Fence
	// Until 租约到期时刻(零值 = 不限期,由宿主自担)。
	//
	// 到期后其他 agent 可以接管:进程崩掉时没有它会留下一把永久占用。
	Until time.Time
}

Lease 一次认领的凭证。

type Problem

type Problem struct {
	// Task 相关任务 id。
	Task TaskID
	// Code 机器可判的类别(宿主按它分流与留痕)。
	Code string
	// Text 人读的说明。
	Text string
}

Problem 一条快照不变量违反。

func Check

func Check(tasks []Task) []Problem

Check 判一份快照的**不变量**,按 (Task, Code) 排序返回(确定性)。

这是宿主随时能跑的「体检」:不必等出问题,配置加载、每个调度周期都可以跑; 它判的是**状态自身是否自相矛盾**,不判「策略对不对」。

type RenewingBoard added in v0.34.0

type RenewingBoard interface {
	Board
	// Renew 续期并确认持有资格(契约见类型级说明)。
	Renew(ctx context.Context, id TaskID, token string, req ClaimRequest) (Task, error)
}

RenewingBoard 支持租约续期的 Board。

为什么是单独的接口而不是并进 Board

续期要求存储提供「校验持有资格 + 延长到期时刻」的**原子**操作——不是每份 既有实现都具备。并进 Board 等于让全部实现同时破;单独成接口则由判定方按 类型断言认人:**接入 boardtest 套件的实现必须实现它**(套件对缺失硬失败, 不跳过——跳过等于把「没测过」伪装成「通过」)。

Renew 的契约(存储侧必须全部保证)

Renew 续期并**同时确认持有资格**,语义是「我还持有吗,顺便把有效期推后」:

  • token 不是该任务当前租约的令牌 ⇒ @@ErrLeaseLost@@,**不做任何修改**。 不许静默成功:过期持有者的迟到续期延长的是**别人的闸**;
  • 租约**已过期** ⇒ @@ErrLeaseLost@@。资格在到期时刻终止——过期后任务随时 可能被接管,存储不能既承诺「过期随时可接管」又给死者续命。想继续做, 重新 Claim:要么拿到新租约(新令牌新代次),要么明确得到 @@ErrTaken@@, 不会拿着旧身份悄悄续命;
  • 资格校验与延长在**同一次原子操作**里完成(否则「校验通过」与「续期 生效」之间仍有窗口,接管者与续期者可以同时得手);
  • TTL 语义与 Claim **完全一致**:正数 ⇒ 按存储**自己的**当前时刻推算新的 到期时刻;零值 ⇒ 不限期(到期时刻清零,宿主自担);
  • 续期**不**递增 @@Fence@@、**不**更换 token(不是接管),返回的 Task.Seq 应前进(Until 变了,这是一次任务变更)。

宿主应在**施加副作用之前**调 Renew:返回 @@ErrLeaseLost@@ 说明任务已易主, 本次动作必须放弃——继续做就是与新持有者重复施加。

type State

type State string

State 任务状态。

const (
	// StateOpen 待认领。
	StateOpen State = "open"
	// StateClaimed 已被认领(owner + lease 必须有值)。
	StateClaimed State = "claimed"
	// StateDone 已完成(终态)。
	StateDone State = "done"
	// StateFailed 已失败(终态;但允许重新认领——失败的工作通常要重做)。
	StateFailed State = "failed"
)

func (State) Terminal

func (s State) Terminal() bool

Terminal 报告该状态是否**不可再被认领**。

只有 done 是终态:failed 的任务必须能重做,否则一次失败就把子任务永久钉死, 而宿主的补救路径(重新派一个人)会静默失效。

type Task

type Task struct {
	// ID 任务标识(全局唯一)。
	ID TaskID
	// Title 展示用标题。
	Title string
	// Deps 依赖的任务 id(全部完成才可认领)。
	Deps []TaskID
	// State 当前状态。
	State State
	// Owner 当前认领者(仅 StateClaimed 时有值)。
	Owner AgentID
	// Lease 当前租约(仅 StateClaimed 时有值)。
	Lease Lease
	// Seq 单调递增序号(宿主用它做 CAS 与留痕;本包只要求它随变更增大)。
	Seq uint64
}

Task 一个子任务(值类型,可安全复制与比较)。

type TaskID

type TaskID string

TaskID 子任务标识。

Directories

Path Synopsis
Package boardtest @@Board@@ 的**共享契约测试套件**。
Package boardtest @@Board@@ 的**共享契约测试套件**。

Jump to

Keyboard shortcuts

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