项目文件夹

文件
2026-07-14 10:24:10 +08:00

414 行
12 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
package cache_test
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/cache"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
// setupMiniRedis 启动一个 miniredis 实例并把它注入 database 内部,返回清理函数。
func setupMiniRedis(t *testing.T) *miniredis.Miniredis {
t.Helper()
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = client.Close() })
// 通过 SetTestRedisClient 注入 miniredis 客户端到 database 包的内部 redisClient
database.SetTestRedisClient(client)
t.Cleanup(func() { database.SetTestRedisClient(nil) })
return mr
}
// ===== C1aWithLockAutoExtend 不 panic / 不泄漏锁 =====
// 回归 C1actx 取消时 WithLockAutoExtend 不 send-on-closed panic,且锁被释放。
// 旧实现:ctx 取消时子 goroutine defer close(done),父 fn() 后 done<-struct{}{} 即 panic,
// Unlock 不执行,锁泄漏到 TTL。
func TestWithLockAutoExtendCtxCancelNoPanic(t *testing.T) {
mr := setupMiniRedis(t)
_ = mr
ctx, cancel := context.WithCancel(context.Background())
key := "c1a:cancel"
ran := make(chan struct{})
var panicked any
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer func() { panicked = recover() }()
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(runCtx context.Context) error {
close(ran)
// 阻塞直到 ctx 被取消,模拟长任务。
<-runCtx.Done()
return runCtx.Err()
})
// WithLockAutoExtend 返回 fn 的错误(ctx.Err),不应 panic。
if err != nil && !errors.Is(err, context.Canceled) {
t.Errorf("unexpected err: %v", err)
}
}()
// 等 fn 启动 + 至少一次续期 ticker,再取消 ctx,制造最大竞态窗口。
<-ran
time.Sleep(250 * time.Millisecond)
cancel()
wg.Wait()
if panicked != nil {
t.Fatalf("WithLockAutoExtend panicked on ctx cancel (C1a send-on-closed): %v", panicked)
}
// 锁必须被释放(Unlock 用 Background ctx 执行)。
locked, err := cache.IsLocked(context.Background(), key)
if err != nil {
t.Fatalf("IsLocked: %v", err)
}
if locked {
t.Error("lock leaked after ctx cancel (Unlock not executed)")
}
}
// 回归 C1a:fn 正常返回时锁被释放,无 panic。
func TestWithLockAutoExtendNormalRelease(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "c1a:normal"
called := false
err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error {
called = true
return nil
})
if err != nil {
t.Fatalf("WithLockAutoExtend: %v", err)
}
if !called {
t.Error("fn not called")
}
locked, _ := cache.IsLocked(ctx, key)
if locked {
t.Error("lock not released after normal fn return")
}
}
// 回归 C1a:fn 执行超过续期间隔,锁被续期不丢失(续期 goroutine 工作)。
func TestWithLockAutoExtendExtendsLock(t *testing.T) {
mr := setupMiniRedis(t)
ctx := context.Background()
key := "c1a:extend"
// initialTTL=500ms,extendInterval=100ms。fn 执行 800ms,期间应多次续期。
// 若续期失效,锁会在 500ms 过期,另一 worker 可获取。
err := cache.WithLockAutoExtend(ctx, key, 500*time.Millisecond, 100*time.Millisecond, func(context.Context) error {
time.Sleep(800 * time.Millisecond)
return nil
})
if err != nil {
t.Fatalf("WithLockAutoExtend: %v", err)
}
// 期间另一 worker 应无法获取锁(续期生效)。
// 此断言在 fn 执行中验证更准;这里用 fn 返回后锁已释放验证基础闭环。
locked, _ := cache.IsLocked(ctx, key)
if locked {
t.Error("lock not released after fn")
}
_ = mr
}
// 回归 C1afn 执行中另一 worker 拿不到锁(续期保活)。
func TestWithLockAutoExtendBlocksContender(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "c1a:block"
fnStarted := make(chan struct{})
fnDone := make(chan struct{})
lockDone := make(chan struct{})
var contenderGotLock atomic.Bool
go func() {
defer close(lockDone)
cache.WithLockAutoExtend(ctx, key, 2*time.Second, 100*time.Millisecond, func(context.Context) error {
close(fnStarted)
time.Sleep(600 * time.Millisecond)
close(fnDone)
return nil
})
}()
<-fnStarted
// 期间尝试获取锁,应失败(nil)。
token, err := cache.NewLock(ctx, key, 2*time.Second)
if err != nil {
t.Fatalf("NewLock contender: %v", err)
}
if token != nil {
contenderGotLock.Store(true)
_ = cache.Unlock(ctx, token)
}
<-fnDone
<-lockDone
if contenderGotLock.Load() {
t.Error("contender acquired lock while auto-extend active (extend failed)")
}
}
// 回归 C1a:fn panic 时锁仍被释放、续期 goroutine 不泄漏(defer 兜底)。
// 旧实现(无 deferfn panic → close(stop) 不执行 → 续期 goroutine 永久泄漏 + Unlock 不执行 → 锁泄漏。
func TestWithLockAutoExtendFnPanicReleasesLock(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "c1a:panic"
var panicked any
func() {
defer func() { panicked = recover() }()
_ = cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error {
time.Sleep(150 * time.Millisecond) // 让续期 ticker 至少触发一次
panic("boom")
})
}()
if panicked == nil {
t.Fatal("expected fn panic to propagate")
}
// 锁必须被释放(defer Unlock 执行)。
locked, _ := cache.IsLocked(ctx, key)
if locked {
t.Error("lock leaked after fn panic (defer Unlock not executed)")
}
}
// 回归 C1a/HIGHWithLock 在 ctx 取消后仍能解锁(Unlock 用 Background ctx)。
// 旧实现 defer Unlock(ctx, token) 用原 ctx,ctx 取消致 Unlock 失败、锁泄漏到 TTL。
func TestWithLockCtxCancelReleasesLock(t *testing.T) {
setupMiniRedis(t)
ctx, cancel := context.WithCancel(context.Background())
key := "c1a:withlock"
fnStarted := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
_ = cache.WithLock(ctx, key, 10*time.Second, func(runCtx context.Context) error {
close(fnStarted)
<-runCtx.Done()
return runCtx.Err()
})
}()
<-fnStarted
cancel()
<-done
locked, _ := cache.IsLocked(context.Background(), key)
if locked {
t.Error("lock leaked after ctx cancel (WithLock Unlock should use Background ctx)")
}
}
func TestWithLockPassesContextToFunction(t *testing.T) {
setupMiniRedis(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
key := "m10:ctx-aware"
var seen context.Context
err := cache.WithLock(ctx, key, time.Second, func(runCtx context.Context) error {
seen = runCtx
cancel()
<-runCtx.Done()
return runCtx.Err()
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("WithLock ctx-aware err = %v, want context.Canceled", err)
}
if seen != ctx {
t.Fatal("WithLock 应把调用方 ctx 传入业务函数")
}
}
func TestWithLockRejectsNilFunction(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
if err := cache.WithLock(ctx, "m10:nil-fn", time.Second, nil); !errors.Is(err, cache.ErrLockFuncNil) {
t.Fatalf("WithLock nil fn err = %v, want ErrLockFuncNil", err)
}
if err := cache.WithLockAutoExtend(ctx, "m10:nil-fn-auto", time.Second, 100*time.Millisecond, nil); !errors.Is(err, cache.ErrLockFuncNil) {
t.Fatalf("WithLockAutoExtend nil fn err = %v, want ErrLockFuncNil", err)
}
}
// ===== C1b:类型断言不 panic =====
// 回归 C1bNewLock/Unlock/ExtendLock 在正常路径返回正确结果(Lua 脚本返 int64)。
// 此用例锁定正常路径不被 toInt64 改坏;裸断言 panic 路径需构造非 int64 返回,
// miniredis Lua 恒返整数,故 panic 路径由 toInt64 的 comma-ok 防护(代码审查保证)。
func TestLockUnlockExtendCycle(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "c1b:cycle"
// 加锁
token, err := cache.NewLock(ctx, key, 5*time.Second)
if err != nil || token == nil {
t.Fatalf("NewLock: err=%v token=%v", err, token)
}
// 重复加锁应失败(返回 nil token)
t2, err := cache.NewLock(ctx, key, 5*time.Second)
if err != nil {
t.Fatalf("second NewLock err: %v", err)
}
if t2 != nil {
t.Error("second NewLock should return nil token (lock held)")
_ = cache.Unlock(ctx, t2)
}
// 续期
if err := cache.ExtendLock(ctx, token, 5*time.Second); err != nil {
t.Errorf("ExtendLock: %v", err)
}
// 用错误 token 解锁应失败
wrong := &cache.LockToken{Key: key, Token: "wrong-token"}
if err := cache.Unlock(ctx, wrong); !errors.Is(err, cache.ErrLockNotHeld) {
t.Errorf("Unlock wrong token err = %v, want ErrLockNotHeld", err)
}
// 正确解锁
if err := cache.Unlock(ctx, token); err != nil {
t.Errorf("Unlock: %v", err)
}
// 解锁后另一方可获取
t3, err := cache.NewLock(ctx, key, 5*time.Second)
if err != nil || t3 == nil {
t.Fatalf("NewLock after unlock: err=%v token=%v", err, t3)
}
_ = cache.Unlock(ctx, t3)
}
// ===== C1cTryLock 响应 ctx 取消 =====
// 回归 C1cTryLock 在 ctx 取消时立即返回,不阻塞 maxRetry*retryInterval。
// 旧实现 time.Sleep 不响应 ctx,取消后仍要等满所有重试。
func TestTryLockRespectsCtxCancel(t *testing.T) {
setupMiniRedis(t)
// 先占用锁,使 TryLock 必然重试。
holder, err := cache.NewLock(context.Background(), "c1c:try", 10*time.Second)
if err != nil || holder == nil {
t.Fatalf("setup holder: err=%v token=%v", err, holder)
}
defer cache.Unlock(context.Background(), holder)
ctx, cancel := context.WithCancel(context.Background())
// retryInterval=200ms,maxRetry=10 → 旧实现取消后最长阻塞 2s。
start := time.Now()
go func() {
time.Sleep(150 * time.Millisecond)
cancel()
}()
token, err := cache.TryLock(ctx, "c1c:try", 5*time.Second, 200*time.Millisecond, 10)
elapsed := time.Since(start)
if !errors.Is(err, context.Canceled) {
t.Errorf("TryLock err = %v, want context.Canceled", err)
}
if token != nil {
t.Error("TryLock should return nil token (held by other)")
_ = cache.Unlock(context.Background(), token)
}
// 取消应在 ~150ms 后返回,远小于 2s。
if elapsed > 1*time.Second {
t.Errorf("TryLock took %v, should return shortly after ctx cancel (C1c: time.Sleep ignored ctx)", elapsed)
}
}
func TestLockRejectsSubMillisecondTTL(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
if token, err := cache.NewLock(ctx, "m10:ttl", time.Nanosecond); !errors.Is(err, cache.ErrInvalidLockTTL) || token != nil {
t.Fatalf("NewLock sub-ms ttl token=%v err=%v, want ErrInvalidLockTTL", token, err)
}
token, err := cache.NewLock(ctx, "m10:ttl-valid", time.Second)
if err != nil || token == nil {
t.Fatalf("NewLock valid setup token=%v err=%v", token, err)
}
defer cache.Unlock(ctx, token)
if err := cache.ExtendLock(ctx, token, 0); !errors.Is(err, cache.ErrInvalidLockTTL) {
t.Errorf("ExtendLock zero ttl err = %v, want ErrInvalidLockTTL", err)
}
}
func TestWithLockAutoExtendRejectsNonPositiveInterval(t *testing.T) {
setupMiniRedis(t)
called := false
err := cache.WithLockAutoExtend(context.Background(), "m10:interval", time.Second, 0, func(context.Context) error {
called = true
return nil
})
if !errors.Is(err, cache.ErrInvalidLockRetryInterval) {
t.Fatalf("WithLockAutoExtend zero interval err = %v, want ErrInvalidLockRetryInterval", err)
}
if called {
t.Fatal("WithLockAutoExtend should not run fn with invalid interval")
}
}
func TestWithLockHeldReturnsErrLockNotAcquired(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "m10:not-acquired"
holder, err := cache.NewLock(ctx, key, time.Second)
if err != nil || holder == nil {
t.Fatalf("setup holder token=%v err=%v", holder, err)
}
defer cache.Unlock(ctx, holder)
called := false
err = cache.WithLock(ctx, key, time.Second, func(context.Context) error {
called = true
return nil
})
if !errors.Is(err, cache.ErrLockNotAcquired) {
t.Fatalf("WithLock held err = %v, want ErrLockNotAcquired", err)
}
if called {
t.Fatal("WithLock should not run fn when lock is held")
}
}
func TestTryLockExhaustedReturnsErrLockNotAcquired(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "m10:try-exhausted"
holder, err := cache.NewLock(ctx, key, time.Second)
if err != nil || holder == nil {
t.Fatalf("setup holder token=%v err=%v", holder, err)
}
defer cache.Unlock(ctx, holder)
token, err := cache.TryLock(ctx, key, time.Second, time.Millisecond, 2)
if !errors.Is(err, cache.ErrLockNotAcquired) {
t.Fatalf("TryLock exhausted err = %v, want ErrLockNotAcquired", err)
}
if token != nil {
t.Fatalf("TryLock exhausted token = %v, want nil", token)
}
}