Documentation
¶
Overview ¶
Package sideeffect 副作用「至多一次」的跨进程契约。
它补的是哪一段 ¶
本仓已有两个相邻机制,但都不解决**跨进程的副作用去重**:
- commitgate:同一 (run, 环节) 的终态只允许被提交一次。判据是**状态写**, 判据面在进程内的闸,且提交的是「结论」不是「对外部世界的动作」。
- checkpoint:进度快照的跨进程 CAS。判据是**快照序号单调**,管的是 「恢复点不回退」。
两者之间那块空地是:**对外部世界施加的一次动作**。它有三个独立失效形态, 本包逐条封口:
- **同一逻辑动作被重试**(超时后重发、恢复后重跑、两个 worker 都以为该做) → 幂等键(Key):同一 Key 的动作只施加一次,后到者拿回先到者的结果。
- **两个 worker 同时想做同一个资源上的动作** → 租约(Lease):同一资源 同一时刻至多一个持有者,带 TTL,过期可被接管。
- **持有者过期后又回来写入**(GC 停顿、网络分区后恢复)→ 栅栏令牌 (Fence):每次接管发一个单调递增的令牌,下游按「令牌比已见过的旧」拒收。
第 3 条是前两条都盖不住的:**租约过期不等于持有者已死**。持有者可能只是 暂停了,恢复后继续用它那份已经过期的租约去写。若下游只认「谁拿着租约」 而不认「拿着的是哪一代租约」,那么一个 2026 年签发的租约在暂停两小时后 仍然能覆盖掉别人这两小时里写下的新状态——**更新的数据被旧数据覆盖**。 栅栏令牌就是让下游能区分「这份租约是不是已经过期的那一代」。
三个判据必须共享一个真相 ¶
幂等键、租约、栅栏令牌**不是三份独立状态,而是一份状态的三个投影**: 同一把「动作闸」在存储侧的**单次原子操作**里同时决定 「这个 Key 做过没有」「这个资源谁占着」「发第几代令牌」。
若拆成三份存储、三次独立写入,就会出现一个窗口:租约判定说「你占着」 而幂等判定说「没做过」——两者都自洽,合起来却允许同一动作被施加两次。 所以本包的 Store 接口**只有一个 Acquire 入口**,一次返回三者的联合判定; 宿主实现它时,这三者**必须在同一个存储侧原子操作里成立**。
这与本仓既有的两条纪律同源:
- 「派生状态只能有一个真相」——令牌是闸的投影,不是第二个计数器;
- 「判据归存储,不归进程」(checkpoint/cas.go)——跨进程的判据必须在 存储侧判,进程内无论怎么加锁都判不出来。
边界:内核给契约,宿主给存储 ¶
本包**不实现存储**。宿主实现 Store 接口(Redis / PostgreSQL / etcd 皆可), 内核只保证:
- 判据是确定的(同一输入必得同一结论,不依赖进程内状态);
- 三者的联合判定只有一条路径(不存在「租约过了但幂等没过」的中间态);
- 判据失败时的**失败形态是变红**(拒绝并报明确原因),不是静默放行。
宿主实现时的硬要求:**Acquire 必须是一个原子操作**(一次 CAS / 一条带 条件的 UPDATE / 一个 Lua 脚本),不能拆成「读闸 → 判 → 写闸」三步—— 那三步之间正是本包要封的那个窗口。
不做什么 ¶
本包不提供:重试策略(宿主定)、补偿/回滚(副作用不可逆时无回滚可言)、 分布式事务(跨资源 2PC 不在本仓职责内)。
本包也**不承诺 exactly-once**。它承诺的是「至多一次施加」——这是可达的上界; exactly-once 需要下游也参与(幂等键要传到下游),那是宿主的接线责任。 把上界说成 exactly-once 是本包明确拒绝的一种失真。
Index ¶
- Variables
- type Action
- type Effect
- type Fence
- type FencedSink
- type Key
- type Lease
- type Outcome
- type Result
- type Runner
- func (r *Runner) Execute(ctx context.Context, act Action, fn func(ctx context.Context) (Result, error)) (Outcome, Result, error)
- func (r *Runner) ExecuteWith(ctx context.Context, act Action, eff Effect) (Outcome, Result, error)
- func (r *Runner) Run(ctx context.Context, act Action, fn func(ctx context.Context) (Result, error)) (Result, error)
- type Store
Constants ¶
This section is empty.
Variables ¶
var ErrFenceStale = errors.New("栅栏令牌已过期")
ErrFenceStale 令牌已不是当前最新代(过期持有者的迟到写入)。
它同时用于两处,语义相同:
- 上游侧:过期持有者调 Complete / Renew 时被拒;
- 下游侧:FencedSink.WriteFenced 拒收陈旧写入时返回。
两处共用一个哨兵是有意的:**「你的写入被拒因为令牌陈旧」是同一件事**, 不该让调用方分别判两个错误。
var ErrInvalidAction = errors.New("副作用动作定义不完整")
ErrInvalidAction 动作定义不完整(缺 Key 或 Resource)。
var ErrLeaseLost = errors.New("租约持有资格已失效")
ErrLeaseLost 续期时发现闸已易主(本次持有资格已失效)。
与 ErrFenceStale 分开是为了让调用方能区分两件事:
- ErrFenceStale:**你刚才的写入**被拒(迟到写);
- ErrLeaseLost:**你不再持有**这道闸(该停止动作了)。
两者都指向「本次动作不该继续」,但一个发生在事后(写被拒), 一个发生在事前(还能悬崖勒马)。事前那个更值钱——它让宿主 有机会**在施加副作用之前**收手。
var ErrNoStore = errors.New("未装配存储,无法执行副作用")
ErrNoStore 未装配存储。这是显式失败,不是静默成功—— 「没接存储也返回 nil」会让调用方以为动作已施加。
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
}
Action 一条待施加的动作。
type Effect ¶
type Effect struct {
// Prepare 准备阶段:算结果、构造请求,但**不施加副作用**。
// 可为 nil(不需要准备阶段)。
Prepare func(ctx context.Context) (any, error)
// Apply 施加阶段:真正对外部世界动手。prepared 是 Prepare 的产出。
Apply func(ctx context.Context, prepared any) (Result, error)
}
Effect 动作的执行体。
拆成「准备」与「施加」两段,是为了在**两者之间**插入持有资格检查: 准备阶段可能很慢(调外部接口、算结果),而闸的有效期可能在这段时间里 到期被别人接管。此时若不加检查就进入施加阶段,就是**两份闸同时开着** ——本次动作与新持有者做同一件事,副作用施加两次。
两段各自可失败:
- prepare 失败 → 动作没开始,**未施加任何副作用**(可安全重试);
- apply 失败 → 副作用**可能已部分生效**(不可假定「没发生」)。
apply 返回的 Result 只在 apply 成功时才有意义。
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 时递增),但「下游如何拒收陈旧写入」 此前完全推给宿主。而这件事有两样必须全局一致:
- **比较口径**:收到的令牌与已见过的最大令牌相比,什么算「陈旧」;
- **原子性**:比较与写入必须在**同一次原子操作**里完成。
第 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 )
type Runner ¶
type Runner struct {
// contains filtered or unexported fields
}
Runner 副作用闸的编排器:把「抢闸 → 检查 → 执行 → 记账 → 释放」固化成一次调用。
存在的理由与 checkpoint.Committer 同源:步骤顺序本身是机制, 每个宿主手抄一遍就会有一个抄漏的。
最容易抄漏的四处:
- **Complete 时忘了传 Fence** → 过期持有者的迟到结果能覆盖新结果;
- **执行失败后仍 Complete** → 一个没做成的动作被记成「做过」, 后续重试拿到的是空结果,而真相是「没做过」;
- **执行失败后不释放闸** → 失败的那次把资源占住直到 TTL, 后续重试全被挡成「别人占着」,动作永远做不成;
- **施加副作用前不检查持有资格** → 闸已易主时仍施加,与新持有者重复。
零值不可用,请用 NewRunner。
func NewRunner ¶
NewRunner 组装编排器。store 为 nil 时 Execute 恒失败,不静默放行。
ttl 是每次抢闸时申请的租约有效期。它是**业务判断**:宿主知道自己的动作 预计要多久,内核不知道。零值不表示「无限期」——那会让一道闸永远关着; 零值被判为非法(见 ErrInvalidAction)。
func (*Runner) Execute ¶
func (r *Runner) Execute(ctx context.Context, act Action, fn func(ctx context.Context) (Result, error)) (Outcome, Result, error)
Execute 执行一次副作用,保证「至多一次」。
流程:
- 抢闸(Acquire):一次原子判定,决定「该做 / 已做过 / 别人占着」。
- 若已做过 → 返回 OutcomeReplayed 与既有结果,**一个字节都不执行**。
- 若别人占着 → 返回 OutcomeContended(可重试的拒绝,不是失败)。
- 否则执行动作,随后 Complete 记账,最后释放闸。
返回的 Outcome 就是三态本身——调用方不需要从「结果为空」去反推 「是别人占着还是我做过了」。压成一个 bool 会让这两种情况共用同一个 false,调用方要么误把「没轮上我」当成「早已完成」,要么反过来。
error 的含义:**只表示本次调用没能完成动作**(存储故障、动作失败、 记账失败)。OutcomeContended 不带 error——它是正常的三态之一, 宿主该做的是退避后重试,而不是把它当成失败放弃。
**执行失败时不记账**(不 Complete):动作没做成就是没做成, 下一个持有者必须能重新做它。这是「至多一次」与「至少一次」之间 本包选择的落点——宁可重做一次,也不把没做成的记成做成了。
**执行失败时仍释放闸**:失败的那次已经把资源占住了,不释放的话 后续重试会被自己的残留闸挡成「别人占着」——动作从此再也做不成。 释放的是**自己这次**的闸(凭令牌),不会放掉别人的。
Execute 是 ExecuteWith 的单段特例(无准备阶段、无资格检查)。 **动作耗时可能超过 TTL 时,请改用 ExecuteWith**——见那里关于 「危险窗口」的说明。
func (*Runner) ExecuteWith ¶
ExecuteWith 执行一次带「准备 / 施加」两段与**持有资格检查**的副作用。
为什么要在施加前检查 ¶
单段执行有一个**危险窗口**:从抢闸到施加副作用之间,如果准备耗时超过 TTL,闸就过期并被别人接管。此时本次调用若继续施加,就是 **两份闸同时开着**——本次动作与新持有者做同一件事,副作用施加两次。
而这个窗口**无法靠 Complete 时的令牌校验堵住**:Complete 被拒只说明 「你没记上账」,**副作用早已施加**。事后补救不了已发生的事。
所以检查必须落在**施加之前**:那时若发现闸已易主,本次动作还能 干净地放弃(未施加任何副作用),下一个持有者可以重做。
检查做了什么 ¶
调用 `Store.Renew`:它同时做「延长有效期」与「校验令牌仍是最新代」。 只做前者的续期是**假续期**——闸已易主时延长的其实是别人的闸。
检查失败(ErrLeaseLost)时:**不施加任何副作用**,释放闸(如有), 返回 OutcomeContended + ErrLeaseLost。这是可重试的拒绝: 动作没做,闸已易主,宿主应当退避后**重试整个动作** (新一轮会重新抢闸,且幂等键保证若上次已施加则取回既有结果)。
两段的失败语义不同 ¶
- Prepare 失败 → **未施加任何副作用**,可安全重试;
- Apply 失败 → 副作用**可能已部分生效**,不可假定「没发生」。 此时本包**不记账**(动作没记成做过),但也不会假装它没发生—— 错误原样透出,由宿主按自己的语义决定怎么办。
type Store ¶
type Store interface {
// Acquire 一次原子判定,返回「能否执行」与「若能执行则用哪一代令牌」。
//
// ttl 是本次持有的有效期,**必须由调用方显式给出**:
// 租约多长是「本次动作预计要多久」的业务判断,内核不知道,宿主也不知道——
// 它只知道自己在编排什么动作。所以 TTL 走参数,不走实现里的硬编码常量
// (硬编码的 TTL 要么太长(别人迟迟拿不到闸)要么太短
// (自己的动作还没做完就被接管,栅栏令牌发得再准也拦不住重入)。
//
// 语义(存储侧必须保证):
// 1. 若该 Key 的动作**已完成** → 返回 OutcomeReplayed 与既有结果,
// 不发放新令牌(已完成的动作不再需要令牌)。
// 2. 否则若该 Resource 正被他人持有且**租约未过期** → 返回
// OutcomeContended,不发放令牌。
// 3. 否则 → 授予本次持有:令牌 = 该 Resource 上**已见过的最大值 + 1**
// (单调递增,不回退),并返回 OutcomeApplied。
//
// 已完成(1)的判定**优先于**占用(2):动作已做完时,后来者应当拿到
// 结果而不是被「资源正忙」挡住——否则调用方会把「早已完成」误判成
// 「没轮上我」,而真相是「早就做完了」。
//
// 第 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)
}
Store 副作用闸的存储边界。宿主实现(Redis / PostgreSQL / etcd 皆可)。
Acquire 必须是一个原子操作 ¶
这是本接口**唯一的硬要求**,也是整个包成立的前提: 「这个 Key 做过没有」「这个资源谁占着」「发第几代令牌」三者的判定 必须在存储侧的**一次**原子操作里完成。
若宿主实现成「读闸 → 进程内判 → 写闸」三步,那么两个 worker 会同时 读到「没做过、资源空闲」,各自认定自己该做——同一动作被施加两次, 而本包的所有判据都显示为绿。这是本包要封的头号窗口。
典型实现形态:
- 一条带条件的 UPDATE(WHERE fence < :next AND done = false);
- 一个 Redis Lua 脚本(多键读写在脚本内原子执行);
- 一个 etcd transaction(compare-and-swap)。