sideeffect

package
v0.31.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 不在本仓职责内)。

本包也**不承诺 exactly-once**。它承诺的是「至多一次施加」——这是可达的上界; exactly-once 需要下游也参与(幂等键要传到下游),那是宿主的接线责任。 把上界说成 exactly-once 是本包明确拒绝的一种失真。

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 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 时递增),但「下游如何拒收陈旧写入」 此前完全推给宿主。而这件事有两样必须全局一致:

  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
)

func (Outcome) String

func (o Outcome) String() string

type Result

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

Result 一次动作的结果。

type Runner

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

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

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

最容易抄漏的四处:

  • **Complete 时忘了传 Fence** → 过期持有者的迟到结果能覆盖新结果;
  • **执行失败后仍 Complete** → 一个没做成的动作被记成「做过」, 后续重试拿到的是空结果,而真相是「没做过」;
  • **执行失败后不释放闸** → 失败的那次把资源占住直到 TTL, 后续重试全被挡成「别人占着」,动作永远做不成;
  • **施加副作用前不检查持有资格** → 闸已易主时仍施加,与新持有者重复。

零值不可用,请用 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) (Result, error)) (Outcome, Result, error)

Execute 执行一次副作用,保证「至多一次」。

流程:

  1. 抢闸(Acquire):一次原子判定,决定「该做 / 已做过 / 别人占着」。
  2. 若已做过 → 返回 OutcomeReplayed 与既有结果,**一个字节都不执行**。
  3. 若别人占着 → 返回 OutcomeContended(可重试的拒绝,不是失败)。
  4. 否则执行动作,随后 Complete 记账,最后释放闸。

返回的 Outcome 就是三态本身——调用方不需要从「结果为空」去反推 「是别人占着还是我做过了」。压成一个 bool 会让这两种情况共用同一个 false,调用方要么误把「没轮上我」当成「早已完成」,要么反过来。

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

**执行失败时不记账**(不 Complete):动作没做成就是没做成, 下一个持有者必须能重新做它。这是「至多一次」与「至少一次」之间 本包选择的落点——宁可重做一次,也不把没做成的记成做成了。

**执行失败时仍释放闸**:失败的那次已经把资源占住了,不释放的话 后续重试会被自己的残留闸挡成「别人占着」——动作从此再也做不成。 释放的是**自己这次**的闸(凭令牌),不会放掉别人的。

Execute 是 ExecuteWith 的单段特例(无准备阶段、无资格检查)。 **动作耗时可能超过 TTL 时,请改用 ExecuteWith**——见那里关于 「危险窗口」的说明。

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。这是可重试的拒绝: 动作没做,闸已易主,宿主应当退避后**重试整个动作** (新一轮会重新抢闸,且幂等键保证若上次已施加则取回既有结果)。

两段的失败语义不同

  • Prepare 失败 → **未施加任何副作用**,可安全重试;
  • Apply 失败 → 副作用**可能已部分生效**,不可假定「没发生」。 此时本包**不记账**(动作没记成做过),但也不会假装它没发生—— 错误原样透出,由宿主按自己的语义决定怎么办。

func (*Runner) Run

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

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

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

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)。

Jump to

Keyboard shortcuts

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