sideeffect

package
v0.32.0 Latest Latest
Warning

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

Go to latest
Published: Nov 11, 2026 License: MIT Imports: 3 Imported by: 0

Documentation

Overview

Package sideeffect 副作用「至多一次」的跨进程契约。

它补的是哪一段

本仓已有两个相邻机制,但都不解决**跨进程的副作用去重**:

  • commitgate:同一 (run, 环节) 的终态只允许被提交一次。判据是**状态写**, 判据面在进程内的闸,且提交的是「结论」不是「对外部世界的动作」。
  • checkpoint:进度快照的跨进程 CAS。判据是**快照序号单调**,管的是 「恢复点不回退」。

两者之间那块空地是:**对外部世界施加的一次动作**。它有三个独立失效形态, 本包逐条封口:

  1. **同一逻辑动作被重试**(超时后重发、恢复后重跑、两个 worker 都以为该做) → 幂等键(Key):同一 Key 的动作只施加一次,后到者拿回先到者的结果。
  2. **两个 worker 同时想做同一个资源上的动作** → 租约(Lease):同一资源 同一时刻至多一个持有者,带 TTL,过期可被接管。
  3. **持有者过期后又回来写入**(GC 停顿、网络分区后恢复)→ 栅栏令牌 (Fence):每次接管发一个单调递增的令牌,下游按「令牌比已见过的旧」拒收。

第 3 条是前两条都盖不住的:**租约过期不等于持有者已死**。持有者可能只是 暂停了,恢复后继续用它那份已经过期的租约去写。若下游只认「谁拿着租约」 而不认「拿着的是哪一代租约」,那么一个 2026 年签发的租约在暂停两小时后 仍然能覆盖掉别人这两小时里写下的新状态——**更新的数据被旧数据覆盖**。 栅栏令牌就是让下游能区分「这份租约是不是已经过期的那一代」。

三个判据必须共享一个真相

幂等键、租约、栅栏令牌**不是三份独立状态,而是一份状态的三个投影**: 同一把「动作闸」在存储侧的**单次原子操作**里同时决定 「这个 Key 做过没有」「这个资源谁占着」「发第几代令牌」。

若拆成三份存储、三次独立写入,就会出现一个窗口:租约判定说「你占着」 而幂等判定说「没做过」——两者都自洽,合起来却允许同一动作被施加两次。 所以本包的 Store 接口**只有一个 Acquire 入口**,一次返回三者的联合判定; 宿主实现它时,这三者**必须在同一个存储侧原子操作里成立**。

这与本仓既有的两条纪律同源:

  • 「派生状态只能有一个真相」——令牌是闸的投影,不是第二个计数器;
  • 「判据归存储,不归进程」(checkpoint/cas.go)——跨进程的判据必须在 存储侧判,进程内无论怎么加锁都判不出来。

边界:内核给契约,宿主给存储

本包**不实现存储**。宿主实现 Store 接口(Redis / PostgreSQL / etcd 皆可), 内核只保证:

  • 判据是确定的(同一输入必得同一结论,不依赖进程内状态);
  • 三者的联合判定只有一条路径(不存在「租约过了但幂等没过」的中间态);
  • 判据失败时的**失败形态是变红**(拒绝并报明确原因),不是静默放行。

宿主实现时的硬要求:**Acquire 必须是一个原子操作**(一次 CAS / 一条带 条件的 UPDATE / 一个 Lua 脚本),不能拆成「读闸 → 判 → 写闸」三步—— 那三步之间正是本包要封的那个窗口。

不做什么

本包不提供:重试策略(宿主定)、补偿/回滚(副作用不可逆时无回滚可言)、 分布式事务(跨资源 2PC 不在本仓职责内)。

本包承诺什么、不承诺什么(v0.32.0 起收紧口径)

**承诺**(机制面能独立保证的):

  • 同一 Key 的动作在同一时刻**至多一个持有者**(租约 + 栅栏);
  • **已完成**的动作被重放而不是重做(幂等键 + 结果留存)。

**不承诺**(需要下游参与才成立,本仓无权替它打包票):

  • 「至多一次施加」——远端动作是否生效由**远端**说了算。若远端已生效而 响应在回程丢了,本仓能做的只是把它标成**结果未知**(OutcomeUncertain) 并要求对账,**不能**替调用方判定「没生效」;
  • exactly-once——那需要幂等键一路传到远端,是宿主的接线责任。

把上界说成 exactly-once 是本包明确拒绝的一种失真;把「结果未知」说成 「没生效」是同一种失真的另一个方向——后者会直接变成重复施加。

「结果未知」是一等状态

遇到 OutcomeUncertain 的 Key,**禁止盲目重试**:必须先对账 (Runner.Reconcile)。这条不是建议,是判据——闸会拒绝把一个 处于未知态的 Key 再授予执行权。

Index

Constants

This section is empty.

Variables

View Source
var ErrFenceStale = errors.New("栅栏令牌已过期")

ErrFenceStale 令牌已不是当前最新代(过期持有者的迟到写入)。

它同时用于两处,语义相同:

  • 上游侧:过期持有者调 Complete / Renew 时被拒;
  • 下游侧:FencedSink.WriteFenced 拒收陈旧写入时返回。

两处共用一个哨兵是有意的:**「你的写入被拒因为令牌陈旧」是同一件事**, 不该让调用方分别判两个错误。

View Source
var ErrInvalidAction = errors.New("副作用动作定义不完整")

ErrInvalidAction 动作定义不完整(缺 Key 或 Resource)。

View Source
var ErrLeaseLost = errors.New("租约持有资格已失效")

ErrLeaseLost 续期时发现闸已易主(本次持有资格已失效)。

与 ErrFenceStale 分开是为了让调用方能区分两件事:

  • ErrFenceStale:**你刚才的写入**被拒(迟到写);
  • ErrLeaseLost:**你不再持有**这道闸(该停止动作了)。

两者都指向「本次动作不该继续」,但一个发生在事后(写被拒), 一个发生在事前(还能悬崖勒马)。事前那个更值钱——它让宿主 有机会**在施加副作用之前**收手。

View Source
var ErrNoStore = errors.New("未装配存储,无法执行副作用")

ErrNoStore 未装配存储。这是显式失败,不是静默成功—— 「没接存储也返回 nil」会让调用方以为动作已施加。

View Source
var ErrNotUncertain = errors.New("该 Key 不处于结果未知态")

ErrNotUncertain 该 Key 不处于「结果未知」态,无需裁决。

单独一个哨兵而不是复用通用错误:调用方需要区分 「我要裁决的东西不存在」(可能已正常完成,也可能压根没失败过) 与「裁决过程本身出了问题」——两者的处置完全不同。

View Source
var ErrOutcomeUnknown = errors.New("副作用结果未知:需对账裁决后才能再动这个 Key")

ErrOutcomeUnknown 动作的**结果未知**:上一次尝试可能已经生效,也可能没有。

为什么它是一等状态而不是错误路径的边角料

远端动作是否生效,由远端说了算。本仓能看见的只有「响应没回来」—— 而「响应没回来」同时兼容两种事实:

  • 远端**没**执行(请求根本没送到);
  • 远端**执行了**,只是响应在回程丢了(超时、网络中断、进程被杀)。

这两种事实在调用方看来**完全一样**。此时若放行重试,就是把 「可能已经做过的动作」再做一遍——**重复施加的入口就在这里**。

故本包把「不知道」显式化成一个状态(OutcomeUncertain)与一个哨兵: 遇到它,调用方**必须先对账**(查下游事实),不得盲目重试。

这与本仓反复强调的同一条纪律一致:**没结论必须是可观测的状态, 不是让下游自己挑一个默认值**。默认挑「没生效」=默认重复施加。

View Source
var ErrUnknownOutcome = errors.New("副作用闸返回未知结论")

ErrUnknownOutcome 存储侧返回了未定义的结论。

存在的原因:判据枚举里若多出一个「存储侧没告诉我到底怎么判」的取值, 调用方就只能在 default 分支里猜。把它显式化,调用方必须显式处理 「不知道」——这是本仓反复强调的:**没结论必须是可观测的状态, 不是让下游自己挑一个默认值**。

Functions

This section is empty.

Types

type Action

type Action struct {
	// Key 幂等键(必填,空串视为非法动作)。
	Key Key
	// Resource 动作作用的目标资源标识(必填)。
	// 租约按它判定独占:同一 Resource 上同一时刻至多一个持有者。
	Resource string
	// Payload 动作参数。内核不解释其内容(机制面不认识领域概念),
	// 但**结果记录需要能把它与结果对应起来**,故由宿主定义序列化方式。
	Payload []byte
	// RetrySafety 该动作在**施加失败**后的重试安全性(缺省 = 未声明 = 按最弱处理)。
	//
	// 声明放在 Action 而不是执行体上:重试安全性是**这个逻辑动作**的属性
	// (下游按不按幂等键去重),同一动作的多次重试必须是同一份声明——
	// 放在执行体上就变成「每次调用各说各话」,重试与首次施加口径不一致,
	// 于是「这次失败了能不能再来一次」的答案随调用点漂移。
	//
	// 零值 RetrySafetyUnknown 的含义:施加失败即**结果未知**,闸转入未知态,
	// 这个 Key 必须先对账才能再动。声明 RetrySafetySafe 则是断言
	// 「下游按幂等键去重,重试不会重复施加」——**断言错了就是重复施加**,
	// 故它只能是宿主的显式选择。
	RetrySafety RetrySafety
}

Action 一条待施加的动作。

type Effect

type Effect struct {
	// Prepare 准备阶段:算结果、构造请求,但**不施加副作用**。
	// 可为 nil(不需要准备阶段)。
	Prepare func(ctx context.Context) (any, error)
	// Apply 施加阶段:真正对外部世界动手。prepared 是 Prepare 的产出,
	// lease 是本次持有(含栅栏令牌)——把它传给下游。
	Apply func(ctx context.Context, prepared any, lease Lease) (Result, error)
}

Effect 动作的执行体。

拆成「准备」与「施加」两段,是为了在**两者之间**插入持有资格检查: 准备阶段可能很慢(调外部接口、算结果),而闸的有效期可能在这段时间里 到期被别人接管。此时若不加检查就进入施加阶段,就是**两份闸同时开着** ——本次动作与新持有者做同一件事,副作用施加两次。

Apply 为什么必须拿到 Lease

Lease 里带着**栅栏令牌**(Fence)。它不是给你看的,是给你**传下去**的:

  • 下游按令牌拒收陈旧写入 → 过期持有者的迟到写被下游挡下(见 FencedSink);
  • 下游按令牌/幂等键去重 → 重试不会重复施加(此时才敢声明 Action.RetrySafety = RetrySafetySafe)。

不给令牌,下游就无从区分「这次写」与「那次过期持有者的写」—— 栅栏就只剩上游那半截,形同虚设。

两段的失败语义不同

  • Prepare 失败 → 动作没开始,**未施加任何副作用**(可安全重试);
  • Apply 失败 → 副作用**可能已生效**,是否生效取决于远端 (见 Action.RetrySafety)。

type Fence

type Fence uint64

Fence 栅栏令牌:一次接管动作的代号,单调递增。

令牌**只在 Acquire 成功时**递增,且必须由存储侧在同一原子操作里分配。 进程内自增是错的:两个进程各从 1 开始,栅栏就形同虚设。

下游用法:写入时带上令牌,并记住见过的最大令牌; 收到的令牌**小于**已见过的最大令牌 → 拒收(这是一次过期持有者的迟到写入)。

下游那侧的完整契约见 FencedSink。

type FencedSink

type FencedSink interface {
	// WriteFenced 在资源上执行一次带栅栏令牌的写入。
	//
	// 语义(实现侧必须保证):
	//  1. 若 presented **小于**该资源已见过的最大令牌 → **不执行**写入,
	//     返回 ErrFenceStale(这是一次过期持有者的迟到写入);
	//  2. 否则执行写入,并把该资源已见过的最大令牌更新为 presented;
	//  3. 第 1 条的判定与第 2 条的写入(含令牌更新)必须在**同一次原子操作**里。
	//
	// 第 1 条用「小于」而非「不等于或小于」:**等于**是合法的
	// (同一代持有者的多次写入,如分批提交),只有**更小**才是陈旧。
	//
	// 返回 ErrFenceStale 时**必须没有发生任何写入**(不得部分写)。
	WriteFenced(ctx context.Context, resource string, presented Fence, write func(ctx context.Context) error) error
}

FencedSink 下游资源的「拒收陈旧写入」边界。

为什么要有它

本包负责**发**令牌(Acquire/Renew 时递增),但「下游如何拒收陈旧写入」 此前完全推给宿主。而这件事有两样必须全局一致:

  1. **比较口径**:收到的令牌与已见过的最大令牌相比,什么算「陈旧」;
  2. **原子性**:比较与写入必须在**同一次原子操作**里完成。

第 2 条是致命的:若宿主写成「先读已见最大 → 判断 → 再写入」三步, 两个持有者可以同时读到同一个旧值,双双通过检查,然后先后写入—— **更旧的那次写入反而后落,覆盖掉更新的**。栅栏就形同虚设。

这与 `checkpoint.StoreCAS` 是同一条纪律:**判据归存储,不归进程**。

实现形态

典型是一条带条件的写入(SQL 的 `WHERE fence < :incoming`、Redis 的 带条件 Lua、CAS 循环),且「更新已见最大令牌」与「写业务数据」 在同一次操作里。

与 Store 的关系

两者是**下游与上游**的关系,不是替代:

  • `Store` 管「谁有权做这个动作」(闸);
  • `FencedSink` 管「这个动作的结果能不能落到资源上」(栅栏)。

一个动作可以没有闸(进程内单持有者),也可以有闸而无栅栏 (下游自己判陈旧)。两者独立。

type Key

type Key string

Key 幂等键:标识「一个逻辑动作」。

同一逻辑动作的**所有**重试必须用同一个 Key——这是去重成立的前提。 用不同 Key 重试等于声明「这是两个不同的动作」,此时本包不拦(那是调用方 的语义错误,不是本包能判定的)。

Key 的构造归宿主(它知道什么构成「同一个逻辑动作」)。内核只要求它稳定。

type Lease

type Lease struct {
	// Resource 被独占的资源标识。
	Resource string
	// Fence 本次持有对应的栅栏令牌(同一 Resource 上单调递增)。
	Fence Fence
	// TTL 本次持有的有效期。
	TTL time.Duration
}

Lease 一次独占持有。

type Outcome

type Outcome int

判据结果:Acquire 的三种结论。

用枚举而非 (bool, error):这三种情况对调用方的**后续动作完全不同** (该做 / 不该做但可稍后重试 / 永久不可做),一个 bool 会把它们压平。

const (
	// OutcomeApplied 本次调用获得了执行权,应当执行动作。
	OutcomeApplied Outcome = iota
	// OutcomeReplayed 该 Key 的动作**已完成**,本次应直接取回既有结果,不执行。
	OutcomeReplayed
	// OutcomeContended 该资源正被他人持有且租约未过期,本次不应执行。
	//
	// 这是**可重试**的拒绝:等租约过期或对方释放后,同一 Key 再次调用
	// 仍会得到 OutcomeApplied(动作尚未做过)。若宿主把它当成永久失败,
	// 动作就永远丢掉了——这是本包明确要防的误用。
	OutcomeContended
	// OutcomeUncertain 该 Key 上一次尝试的**结果未知**:可能已生效,
	// 也可能没有。本次**不得执行**,必须先对账裁决(Runner.Reconcile)。
	//
	// 这是 v0.32.0 新增的一态。此前「施加失败 → 释放闸 → 允许重试」是
	// 唯一的失败出口,于是「远端已生效但响应丢了」这一形态会被下一次重试
	// 变成**重复施加**——本包存在的理由(同一逻辑动作只施加一次)被绕过,
	// 且失效是**静默**的:调用方拿到的是一个普通的错误。
	//
	// 与 OutcomeContended 的区别:contended 是「还没轮上我」(等一等再来),
	// uncertain 是「上一次可能已经做过了」(来之前先查清楚)。
	OutcomeUncertain
)

func (Outcome) String

func (o Outcome) String() string

type Resolution added in v0.32.0

type Resolution struct {
	// Applied 确认动作**已生效**吗?
	//
	// true  → 闸把该 Key 记为已完成并留存 Result:后续同一 Key 的 Acquire
	//         返回 OutcomeReplayed + 该结果(重放而不是重做)。
	// false → 确认动作**未生效**:清除未知标记,该 Key 重新可被执行。
	Applied bool
	// Result 已生效时的动作结果(Applied=false 时忽略)。
	Result Result
	// Reason 裁决依据(留痕)。事后复盘时,「凭什么判定已生效」比
	// 「判定结果是什么」更有价值——前者能定位判据的薄弱处。
	Reason string
}

Resolution 对账裁决:宿主查过下游事实之后给出的结论。

为什么裁决是宿主的责任

「远端到底做没做」是**下游的事实**,内核看不见也不该猜。 内核能做的只有两件:把「不知道」如实标出来(OutcomeUncertain), 以及提供一个**唯一的裁决入口**,让裁决这件事有迹可循、不各判一套。

type Result

type Result struct {
	// Payload 动作产出的数据(宿主定义序列化方式)。
	Payload []byte
}

Result 一次动作的结果。

type RetrySafety added in v0.32.0

type RetrySafety int

RetrySafety 动作在**施加失败**后的重试安全性声明。

为什么它必须是宿主声明而不是内核推断

内核看不见远端,只能看见「响应没回来」。远端按不按幂等键去重,是 **下游的事实**:本仓无从判断,只能要求宿主把这条事实说出来。

而「不说」的代价是不对称的:说「可安全重试」错了 → 重复施加; 不说(沉默)按最弱处理 → 一次对账。换句话说,**沉默的代价是一次对账, 误报的代价是一次重复施加**——所以沉默必须按最弱处理。

const (
	// RetrySafetyUnknown 未声明(零值)。按最弱处理:施加失败即视为
	// **结果未知**,闸转入未知态,必须对账后才能再动这个 Key。
	//
	// 零值即最弱档,不是「默认安全」——与安全判据里「沉默按最弱处理」
	// 同一条方向:漏声明得到的是「兜不住」,不是「兜得住」。
	RetrySafetyUnknown RetrySafety = iota

	// RetrySafetySafe 该动作在施加失败后**可安全重试**:下游按幂等键
	// 去重,重试不会重复施加(如把幂等键传到了远端 API)。
	//
	// 声明这个值等于断言「我的下游会自己去重」——**断言错了就是重复施加**,
	// 故它必须是宿主的显式选择,而不是内核的乐观假设。
	RetrySafetySafe

	// RetrySafetyUnsafe 该动作在施加失败后**不可重试**:重试会重复施加。
	//
	// 与 RetrySafetyUnknown 行为相同(都走未知态 + 对账),区别只在**意图**
	// 与审计口径:前者是「我明确知道不幂等」,后者是「我没说」。
	// 两者都不放行重试——这条纪律不因声明与否而松动。
	RetrySafetyUnsafe
)

func (RetrySafety) String added in v0.32.0

func (r RetrySafety) String() string

String 人类可读的档位名。

type Runner

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

Runner 副作用闸的编排器:把「抢闸 → 检查 → 执行 → 记账 → 释放」固化成一次调用。

存在的理由与 checkpoint.Committer 同源:步骤顺序本身是机制, 每个宿主手抄一遍就会有一个抄漏的。

最容易抄漏的六处:

  • **Complete 时忘了传 Fence** → 过期持有者的迟到结果能覆盖新结果;
  • **执行失败后仍 Complete** → 一个没做成的动作被记成「做过」, 后续重试拿到的是空结果,而真相是「没做过」;
  • **执行失败后不释放闸** → 失败的那次把资源占住直到 TTL, 后续重试全被挡成「别人占着」,动作永远做不成;
  • **施加副作用前不检查持有资格** → 闸已易主时仍施加,与新持有者重复;
  • **执行失败后一律放行重试** → 「远端已生效但响应丢了」被重试成 重复施加(本包 v0.32.0 起封掉的那条,见 Action.RetrySafety);
  • **不把栅栏令牌交给执行器** → 下游无从拒收迟到写,栅栏只在上游侧生效。

零值不可用,请用 NewRunner。

func NewRunner

func NewRunner(store Store, ttl time.Duration) *Runner

NewRunner 组装编排器。store 为 nil 时 Execute 恒失败,不静默放行。

ttl 是每次抢闸时申请的租约有效期。它是**业务判断**:宿主知道自己的动作 预计要多久,内核不知道。零值不表示「无限期」——那会让一道闸永远关着; 零值被判为非法(见 ErrInvalidAction)。

func (*Runner) Execute

func (r *Runner) Execute(ctx context.Context, act Action, fn func(ctx context.Context, lease Lease) (Result, error)) (Outcome, Result, error)

Execute 执行一次副作用(单段执行体)。

它是 ExecuteWith 的特例:无准备阶段,但**持有资格检查照做** (抢闸与施加之间仍可能隔着一次远端往返)。

执行体拿到本次持有(含栅栏令牌),好让下游能拒收陈旧写入、 或按幂等键去重——见 Effect 的注释。

返回的 Outcome 是判据本身

调用方不需要从「结果为空」去反推「是别人占着还是我做过了还是上次没干成」。 压成一个 bool 会让这几种情况共用同一个 false,调用方要么误把「没轮上我」 当成「早已完成」,要么反过来把「上次可能已经做过了」当成「没做过」—— 后者就是重复施加。

error 的含义:**只表示本次调用没能完成动作**(存储故障、动作失败、 记账失败)。OutcomeContended 不带 error——它是正常判据之一, 宿主该做的是退避后重试,而不是把它当成失败放弃。

三条失败出口的处置各不相同

  • **准备失败** → 未施加任何副作用 → 可安全重试;
  • **施加失败 + RetrySafetySafe** → 下游会去重 → 可安全重试;
  • **施加失败 + 未声明/不安全** → 结果未知 → **禁止重试,先对账**;
  • **记账失败** → 动作已生效但没记上账 → 结果未知 → **禁止重试,先对账**。

最后两条是本包 v0.32.0 收紧的核心:此前「执行失败 → 释放闸 → 允许重试」 是唯一出口,于是「远端已生效但响应丢了」会被下一次重试变成重复施加—— 本包存在的理由被绕过,且失效是**静默**的(调用方只拿到一个普通错误)。

func (*Runner) ExecuteWith

func (r *Runner) ExecuteWith(ctx context.Context, act Action, eff Effect) (Outcome, Result, error)

ExecuteWith 执行一次带「准备 / 施加」两段与**持有资格检查**的副作用。

为什么要在施加前检查

单段执行有一个**危险窗口**:从抢闸到施加副作用之间,如果准备耗时超过 TTL,闸就过期并被别人接管。此时本次调用若继续施加,就是 **两份闸同时开着**——本次动作与新持有者做同一件事,副作用施加两次。

而这个窗口**无法靠 Complete 时的令牌校验堵住**:Complete 被拒只说明 「你没记上账」,**副作用早已施加**。事后补救不了已发生的事。

所以检查必须落在**施加之前**:那时若发现闸已易主,本次动作还能 干净地放弃(未施加任何副作用),下一个持有者可以重做。

检查做了什么

调用 `Store.Renew`:它同时做「延长有效期」与「校验令牌仍是最新代」。 只做前者的续期是**假续期**——闸已易主时延长的其实是别人的闸。

检查失败(ErrLeaseLost)时:**不施加任何副作用**,释放闸(如有), 返回 OutcomeContended + ErrLeaseLost。这是可重试的拒绝: 动作没做,闸已易主,宿主应当退避后**重试整个动作** (新一轮会重新抢闸,且幂等键保证若上次已施加则取回既有结果)。

长动作的续期责任在宿主

施加阶段可能耗时超过 TTL——本包只在**施加前**检查一次,之后不再管。 闸在施加期间到期,就会被别人接管,两边同时做同一个动作。 宿主若知道动作会跑很久,应当在执行器内部**续期** (执行器拿得到 lease,可自行调 Store.Renew),并把 lease.Fence 传给下游做栅栏——两道防线都比事后补救便宜。

func (*Runner) Reconcile added in v0.32.0

func (r *Runner) Reconcile(ctx context.Context, act Action, res Resolution) (Outcome, Result, error)

Reconcile 对账裁决「结果未知」的 Key。

流程:宿主先查下游事实(这次动作到底做没做、结果是什么), 再把结论交回来:

  • res.Applied == true → 该 Key 记为已完成并留存结果; 之后同一 Key 的 Execute 返回 OutcomeReplayed + 该结果。
  • res.Applied == false → 清除未知标记,该 Key 重新可被执行。

裁决**不校验栅栏**:它的依据是下游事实,不是持有权——原持有者可能已经 死亡、租约早已过期,而对账恰恰要在这种情况下进行。

同一 Key 的多次裁决**先到者为准**(幂等):后到的裁决不会覆盖先到的结论。

func (*Runner) Run

func (r *Runner) Run(ctx context.Context, act Action, fn func(ctx context.Context, lease Lease) (Result, error)) (Result, error)

Run 是 Execute 的便捷封装:只取结果,忽略判据。

用它就等于自愿放弃区分「我做完了」「别人做过了我拿回结果」 「正被别人占着」「上次结果未知」这几种情况——若调用方需要那个区分 (审计、计费、给用户看「已完成」而不是「正在做」),请用 Execute。

type Store

type Store interface {
	// Acquire 一次原子判定,返回「能否执行」与「若能执行则用哪一代令牌」。
	//
	// ttl 是本次持有的有效期,**必须由调用方显式给出**:
	// 租约多长是「本次动作预计要多久」的业务判断,内核不知道,宿主也不知道——
	// 它只知道自己在编排什么动作。所以 TTL 走参数,不走实现里的硬编码常量
	// (硬编码的 TTL 要么太长(别人迟迟拿不到闸)要么太短
	// (自己的动作还没做完就被接管,栅栏令牌发得再准也拦不住重入)。
	//
	// 语义(存储侧必须保证):
	//  1. 若该 Key 的动作**已完成** → 返回 OutcomeReplayed 与既有结果,
	//     不发放新令牌(已完成的动作不再需要令牌)。
	//  2. 否则若该 Key 处于**结果未知**态(MarkUncertain 标记的)→ 返回
	//     OutcomeUncertain,不发放令牌。
	//  3. 否则若该 Resource 正被他人持有且**租约未过期** → 返回
	//     OutcomeContended,不发放令牌。
	//  4. 否则 → 授予本次持有:令牌 = 该 Resource 上**已见过的最大值 + 1**
	//     (单调递增,不回退),并返回 OutcomeApplied。
	//
	// 已完成(1)的判定**优先于**占用(3):动作已做完时,后来者应当拿到
	// 结果而不是被「资源正忙」挡住——否则调用方会把「早已完成」误判成
	// 「没轮上我」,而真相是「早就做完了」。
	//
	// 结果未知(2)**优先于**占用(3),但**次于**已完成(1):未知态是
	// 这个 Key 头上的**硬阻塞**(再动它就是重复施加),而占用只是「等一等」。
	// 顺序写反的后果是:调用方拿到 contended 就以为「晚点重试就行」,
	// 而真相是「这个 Key 得先对账」——于是重试发生了,重复施加也就发生了。
	//
	// 第 3 条的令牌分配必须与「标记该 Key 已完成」同处一个原子操作面
	// (可分两步但需同一事务/脚本内),否则会出现「发了令牌但没记住谁占着」。
	//
	// 返回的 Result 是**既有结果原样**(不做任何标记):本次是否在重放
	// 由 Acquire 的 Outcome 表达,不靠结果里的一个标志位——
	// 同一件事只有一个真相。
	Acquire(ctx context.Context, act Action, ttl time.Duration) (Outcome, Lease, Result, error)

	// Complete 标记该 Key 的动作已完成并记录结果。
	//
	// 幂等:同一 Key 重复 Complete 不报错,返回首次记录的结果
	// (先到者的结果为准,不被后到者覆盖)。
	//
	// 必须带 Fence:存储侧**只在令牌仍是当前 Resource 的最新令牌时**才接受
	// 这次完成。过期持有者的迟到 Complete 会被拒(否则它会用旧结果覆盖
	// 新持有者刚写入的结果——更新的状态被旧状态覆盖,与栅栏要防的正是
	// 同一件事)。
	Complete(ctx context.Context, act Action, lease Lease, res Result) (Result, error)

	// Release 释放持有(提前结束,不必等到 TTL 自然过期)。
	//
	// 只释放**自己**的持有:存储侧须校验令牌是当前令牌才释放,
	// 否则一个过期持有者迟到的 Release 会把新持有者的闸放掉
	// (与 commitgate「凭令牌释放、不认内容」同一条纪律)。
	//
	// 释放**不**把该 Key 标记为已完成——动作没做完就是没做完,
	// 下次同一 Key 仍可获得执行权。
	Release(ctx context.Context, act Action, lease Lease) error

	// Renew 续期并**同时确认持有资格**。
	//
	// 它做两件事,缺一不可:
	//  1. 把本次持有的到期时刻推后 ttl(长动作不至于中途失去闸);
	//  2. 校验本次令牌**仍是该 Resource 的最新代**——若已被他人接管,
	//     返回 ErrFenceStale,**不续期**。
	//
	// 第 2 条才是 Renew 的重点。只做第 1 条的「续期」是**假续期**:
	// 闸已经易主时,延长时间延的是别人的闸,持有者却以为延长的是自己的。
	// 于是持有者带着一份早已过期的租约继续施加副作用——
	// 而此时新持有者可能正在做同一件事。这就是「两份闸同时开着」。
	//
	// 因此 Renew 的语义是**「我还持有吗,顺便把有效期推后」**,
	// 不是纯心跳。宿主应在**施加副作用之前**调用它:
	// 那时若返回 ErrFenceStale,说明闸已易主,本次动作**必须放弃**——
	// 继续做就是与新持有者重复施加。
	//
	// 存储侧须保证:续期与资格校验在**同一次原子操作**里完成
	// (否则「校验通过」与「续期生效」之间仍有窗口)。
	Renew(ctx context.Context, act Action, lease Lease, ttl time.Duration) (Lease, error)

	// MarkUncertain 把该 Key 标记为「结果未知」。
	//
	// 存储侧必须保证:
	//  1. 该 Key 的下一次 Acquire 返回 OutcomeUncertain,**不得**返回
	//     OutcomeApplied——否则会盲目重试一个可能已经生效的动作。
	//  2. 标记是**按 Key** 的,与资源租约无关:闸可以释放(让别的动作用这个
	//     资源),但这个 Key 在被裁决之前**不得再被执行**。
	//  3. 幂等:重复标记同一 Key 不报错。
	//  4. 若本次持有的令牌已不是最新代(ErrFenceStale)→ **拒绝标记**。
	//     否则一个过期持有者会把新持有者刚做完的动作标成未知,
	//     而新持有者那边的结果是确定的——事实被写反了。
	MarkUncertain(ctx context.Context, act Action, lease Lease, reason string) error

	// ResolveUncertain 裁决「结果未知」的 Key。
	//
	// 存储侧必须保证:
	//  1. res.Applied == true → 把该 Key 记为已完成并留存 res.Result;
	//     之后同一 Key 的 Acquire 返回 OutcomeReplayed + 该结果
	//     (重放而不是重做)。
	//  2. res.Applied == false → 清除未知标记,该 Key 重新可被执行
	//     (之后同一 Key 的 Acquire 可返回 OutcomeApplied)。
	//  3. **先到者为准**(幂等):同一 Key 的二次裁决不覆盖首次裁决。
	//  4. 该 Key 不在未知态时:若已完成 → 返回 OutcomeReplayed + 既有结果;
	//     否则 → 返回 ErrNotUncertain。
	//  5. 裁决**不校验栅栏**:它的依据是**下游事实**,不是持有权——
	//     原持有者可能已经死亡、租约早已过期,而对账恰恰要在这种情况下进行。
	//     (栅栏管的是「谁能写」,对账管的是「到底做没做」,两件事。)
	ResolveUncertain(ctx context.Context, act Action, res Resolution) (Outcome, Result, error)
}

Store 副作用闸的存储边界。宿主实现(Redis / PostgreSQL / etcd 皆可)。

Acquire 必须是一个原子操作

这是本接口**唯一的硬要求**,也是整个包成立的前提: 「这个 Key 做过没有」「这个资源谁占着」「发第几代令牌」三者的判定 必须在存储侧的**一次**原子操作里完成。

若宿主实现成「读闸 → 进程内判 → 写闸」三步,那么两个 worker 会同时 读到「没做过、资源空闲」,各自认定自己该做——同一动作被施加两次, 而本包的所有判据都显示为绿。这是本包要封的头号窗口。

典型实现形态:

  • 一条带条件的 UPDATE(WHERE fence < :next AND done = false);
  • 一个 Redis Lua 脚本(多键读写在脚本内原子执行);
  • 一个 etcd transaction(compare-and-swap)。

Jump to

Keyboard shortcuts

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