sidetest

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

Package sidetest 副作用闸(sideeffect.Store)的**共享契约测试套件**。

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

为什么需要它

sideeffect.Store 的正确性无法靠「看起来对」保证:它要跨进程、靠一次原子 操作维持三个判据的联合,而**每一家宿主都会自己写一遍存储实现**。 各写一遍必然各判一套,语义漂移开到没人发现为止——这正是本仓 「跨包契约的规则只能有一份,且要有端到端用例」那条纪律要防的事。

套件把「宿主实现必须满足什么」固化成**一组可复跑的子测试**: 宿主写完存储实现后,把本套件接进自己的测试里跑一遍, 违反哪条契约就红在哪一条,不必等人肉 review 发现。

范式沿用上游 `oekit/pkg/cache/cachetest` 与 `pkg/singleflighttest` (同一家族的做法):共享套件 + **硬失败,不静默跳过**。

为什么是「硬失败」

套件入口一律 `t.Fatalf` 而不是 `t.Skip`。理由:调用方**显式接入**套件 就是在声明「我这个实现要满足副作用闸的契约」——此时跳过一个场景, 等于把「我没测过」伪装成「我通过了」。这与本仓反复出现的教训同型: **静默跳过是最贵的失败形态**,因为它让未验证的东西看起来像已验证。

覆盖了什么

套件覆盖的是**跨实现必须一致**的语义(每条子测试对应一个真实失效形态):

  1. apply_once 同一 Key 第二次 Acquire → replayed,且取回同一结果
  2. completed_wins 已完成优先于「资源正忙」(不因占用而看不到已完成)
  3. contended_while_held 租约未过期时,第二个持有者拿到 contended
  4. takeover_after_expiry 租约过期后可被接管,且新令牌更大
  5. fence_monotonic 同一 Resource 上令牌单调递增,不回退
  6. stale_complete_rejected 过期持有者的 Complete 被拒(ErrFenceStale)
  7. stale_release_rejected 过期持有者的 Release 被拒(不能放掉新持有者的闸)
  8. release_does_not_done Release 不把 Key 标记为已完成

另有两场景在 **renew.go**(续期一族,与「结果判据」是不同语义):

  1. renew_extends_lease Renew 延长有效期且不换令牌
  2. renew_after_takeover_rejected 闸已易主后 Renew 被拒(假续期的封口) 10b. renew_after_release_rejected 闸已释放后 Renew 被拒(只比令牌会漏掉的那条)

「结果未知」一族五场景在 **uncertain.go**(v0.32.0 新增,判失败路径上的 「至多一次」是否真的成立):

  1. uncertain_blocks_execution 结果未知的 Key 不得再被授予执行权
  2. resolve_not_applied_allows_retry 裁决「未生效」后该 Key 重新可执行
  3. resolve_applied_records_result 裁决「已生效」后该 Key 重放既有结果
  4. resolve_first_wins 同一 Key 的多次裁决先到者为准
  5. stale_fence_cannot_mark 过期持有者不得标记未知态

「执行意图」一族六场景在 **intent.go**(v0.33.0 新增,判「进程死在施加 成功之后、记账之前」那条窗口是否真被封住):

  1. intent_blocks_reacquire 未裁决的尝试不得被重新授予执行权
  2. expired_intent_requires_reconcile 租约过期且未裁决 ⇒ 转入需对账态
  3. expired_intent_safe_may_retry 声明了可安全重试 ⇒ 过期后可重新授予
  4. abandon_allows_retry 显式放弃后该 Key 重新可执行
  5. release_keeps_intent 只放闸不撤销意图 ⇒ 仍不得重新执行
  6. abandon_requires_owner 放弃只认本次尝试的令牌

「持有者身份」一族三场景在 **holder.go**(v0.33.0 新增,判三项联查里 令牌盖不住的那两项):

  1. holder_minted_by_store 持有者身份由存储铸造(非空、不重合)
  2. non_holder_rejected 偷到令牌的组件冒充持有者被拒(ErrNotHolder)
  3. resource_mismatch_rejected A 资源的租约用于 B 资源被拒(ErrResourceMismatch)

「标记失败」一族一场景在 **markfail.go**(v0.33.0 新增,套件内置故障 注入装饰器模拟存储侧不可用):

  1. mark_failure_blocks_acquire 标记写失败后同 Key 不得被授予执行权 (意图必须还在挡着)

「重试安全三档」一族两场景在 **unsafe.go**(v0.33.0 新增,判 Unsafe 与 Unknown 在裁决后的分道):

  1. unsafe_resolve_stays_blocked Unsafe 裁决「未生效」仍阻塞(ErrUnsafeNotRetryable)
  2. unsafe_force_retryable_explicit 显式决策(ForceRetryable)才放行

「对账占用」一族两场景在 **reconcile.go**(v0.33.0 新增,判对账权认领):

  1. reconcile_taken_while_active 占用未过期时第二个对账者被拒;裁决清除占用
  2. reconcile_reentrant_after_expiry 占用过期可重新认领;过期 ≠ 放行

「下游栅栏」一族六场景在 **sink.go**(v0.33.0 新增,RunSinkContract,判 FencedSink 的比较口径与原子性):

  1. fresh_accepted 令牌更大 → 接受写入
  2. equal_accepted 等于已见最大 → 接受(只有更小才是陈旧)
  3. stale_rejected_no_write 陈旧拒收为 ErrFenceStale,且零写入
  4. stale_after_fresh_keeps_max 新先旧后:已见最大不被陈旧写污染
  5. concurrent_stale_rejected 并发下更旧的那次不能后落覆盖
  6. write_error_no_partial_write 写入体失败 → 预占与部分写一起回滚

判据收编自 sideeffect 包内对内存替身的用例(判据只有一份):内存替身与 PostgreSQL 参考实现(examples/pgstore)跑同一段断言,宿主的 FencedSink 实现也接它,不另造一套。

时钟

租约过期需要可控时钟,否则只能靠 sleep(慢且 flaky)。 套件提供 FakeClock;实现须通过 ClockedStore 接口暴露「用哪个时钟」, 且**过期判定必须读这个时钟**(不是 time.Now)—— 否则套件的过期场景测的是真实时间,恒不触发,套件就成了一组空断言。

sink.go —— 下游栅栏(sideeffect.FencedSink)的**共享契约测试套件**。

判据从哪来

本套件的六条场景就是把 sideeffect 包内 `sink_contract_test.go` 对内存替身 的判据收编成可复跑的共享形态(与 Store 一族的 Run 同一范式):判据**只有 一份**,内存替身与 PostgreSQL 参考实现(examples/pgstore)跑的是同一段 断言——宿主的 FencedSink 实现也接它,不另造一套。

覆盖了什么

  1. fresh_accepted 令牌更大(更新一代)→ 接受写入
  2. equal_accepted **等于**已见最大 → 接受(只有更小才是陈旧; 「小于等于当陈旧」会把分批提交卡死在中途)
  3. stale_rejected_no_write 令牌更小 → 拒收 ErrFenceStale,且**没有 发生任何写入**(写入体不执行、无部分写)
  4. stale_after_fresh_keeps_max 新令牌先写、旧令牌后写 → 旧的那次被拒, 且已见最大令牌**仍是新值**(陈旧写不得污染它)
  5. concurrent_stale_rejected 并发下更旧的那次**不能后落覆盖**更新的那次
  6. write_error_no_partial_write 写入体报错 → 整体不落地(预占与标记一起回滚)

第 3 与第 6 条合起来才是完整的「不得部分写」:陈旧拒收不得写, 写入体失败也不得写——栅栏预占与业务数据要么都落地、要么都不落地。

原子性是判据,不是注释

场景 5 用并发撞「比较与写入不在同一次原子操作」的窗;上游 sideeffect 包内的 TestNonAtomicSinkIsCaught 用钩子替身**确定性**证明 三步实现会坏(替身专属,收不进通用套件)——两者互补,缺一不可。

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Run

func Run(t *testing.T, factory Factory)

Run 跑完整契约场景(29 个子测试,任何一个失败即实现违反契约)。

func RunSinkContract added in v0.34.0

func RunSinkContract(t *testing.T, factory SinkFactory)

RunSinkContract 跑完整栅栏契约场景(6 个子测试,任一失败即实现违反契约)。

Types

type ClockedStore

type ClockedStore interface {
	sideeffect.Store
	// UseClock 指定存储实现读时钟的方式(通常是注入一个 func() time.Time)。
	UseClock(now func() time.Time)
}

ClockedStore 实现须提供的能力:把时钟注入到存储实现里。

**为什么单独一个接口**:过期判定是存储实现内部的事(它自己知道 「租约还在不在」)。套件不能替它判,只能要求它**用可注入的时钟**。 没实现这个接口 → 套件硬失败(而不是跳过过期场景——跳过等于假绿)。

type Factory

type Factory func(t *testing.T, clock *FakeClock) ClockedStore

Factory 构造被测存储实现。

factory 每个子测试调用一次,构造一份全新实现,并把套件的假时钟注入它。 实现的后台资源由 factory 经 t.Cleanup 清理。

type FakeClock

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

FakeClock 套件提供的假时钟(并发安全;测试内手动推进)。

取非零真实量级时刻,避开零值 time.Time 的「无过期」歧义。

func NewFakeClock

func NewFakeClock() *FakeClock

NewFakeClock 创建起点为 2026-01-01T00:00:00Z 的假时钟。

func (*FakeClock) Advance

func (c *FakeClock) Advance(d time.Duration)

Advance 把假时钟前进 d。

func (*FakeClock) Now

func (c *FakeClock) Now() time.Time

Now 返回当前假时刻。

type SinkFactory added in v0.34.0

type SinkFactory func(t *testing.T) SinkProbe

SinkFactory 构造被测下游实现;每个子测试调一次,构造全新实现。

type SinkProbe added in v0.34.0

type SinkProbe interface {
	sideeffect.FencedSink
	// SeenFence 返回该资源已见过的最大令牌(无记录返回 0)。
	SeenFence(ctx context.Context, resource string) (sideeffect.Fence, error)
	// WriteMarker 在**写入体里**执行一次参考业务写入(与栅栏预占同一原子面)。
	// 供「不得部分写」场景使用:先写标记再报错,断言标记与预占一起消失。
	WriteMarker(ctx context.Context, resource string) error
	// Marker 读取已持久化的参考业务写入(无记录返回空串)。
	Marker(ctx context.Context, resource string) (string, error)
}

SinkProbe 套件要求的能力:FencedSink 契约 + 两个观测面。

观测面只服务判据(读已见最大 / 制造并读取一次参考业务写入), 不要求实现方暴露任何内部结构。

Jump to

Keyboard shortcuts

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