diff --git a/CHANGELOG.md b/CHANGELOG.md index 9755271..387f45f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange - **cache 写操作与计数器/原始 Redis helper 在 Redis 未初始化时返回 `ErrRedisNotReady`**:`Set` / `Delete` / `DeleteByPattern` / `Incr` / `IncrBy` / `Decr` / `GetTTL` / `SetExpire` / `GetRaw` / `SetRaw` 旧行为会静默返回成功或零值,调用方容易误判缓存写入、计数器更新或过期时间设置已经生效;现在统一显式返回错误。公共接口签名不变,但依赖“未启用 Redis 时当作成功”的下游需要改为忽略 `errors.Is(err, cache.ErrRedisNotReady)` 或显式启用 Redis。 - **`cache.WithLock` / `cache.WithLockAutoExtend` 未获取到锁时返回 `ErrLockNotAcquired`**:旧行为返回 `nil` 并跳过业务函数,调用方无法区分“业务执行成功”和“根本没有执行”。同时锁 TTL 小于 1ms、续期/重试间隔非正会返回显式错误,避免 Redis PX=0 或 `time.NewTicker(0)` 崩溃。 +- **`cache.WithLock` / `cache.WithLockAutoExtend` 的业务函数签名改为 `func(context.Context) error`**:旧签名 `func() error` 无法强制业务函数接收取消信号,容易在请求取消/超时后继续访问 DB/HTTP 等下游资源;现在框架会把调用方 ctx 传入业务函数。nil 业务函数返回新增 `ErrLockFuncNil`。 - **`test.Request.Execute()` 改为返回 `*test.Response`**:旧返回值是 `*httptest.ResponseRecorder`,与文档示例中的 `resp.AssertOK(t)` / `resp.ParseJSON(...)` 不一致;现在 `Execute()` 返回带断言和 JSON 解析方法的包装类型。需要原始 recorder 的调用方改用新增 `ExecuteRecorder()`。 - **`config.Get()` / `(*config.Manager).Get()` 改为返回配置副本**:旧行为暴露内部 `*Config`,调用方修改返回值会污染全局配置并可能与热重载并发读写竞态;现在返回深拷贝。需要动态替换配置的测试或工具代码请改用 `config.Set(cfg)`。 - **`config.Set()` / `(*config.Manager).Set()` 现在返回 `error`**:非 nil 配置会先执行 `Validate()`,非法配置不会覆盖旧配置,并返回 `ErrInvalidConfig` 包装错误。旧代码可以继续忽略返回值,但建议测试和启动路径显式检查。 @@ -40,7 +41,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange ### Fixed 🐛 - **M9 JWT issuer / refresh expiry 契约修复**:`ParseToken`、`InvalidateToken`、`GetClaimsFromToken` 统一按当前配置校验 issuer;`RefreshToken` 不再忽略 `refresh_expire`;空 JTI 不再写入永不命中的 `jwt_bl:` 黑名单键。 -- **M10 分布式锁参数与未获锁语义修复**:锁 TTL 统一校验到 Redis 毫秒粒度;`TryLock` 的非正 retry interval 不再 busy-loop;`WithLockAutoExtend` 的非正 extend interval 不再触发 goroutine panic;`UnlockByKey` 在 Redis 未初始化时与 `ForceUnlock` 一样返回 `ErrRedisNotReady`。 +- **M10 分布式锁参数与取消传播修复**:锁 TTL 统一校验到 Redis 毫秒粒度;`TryLock` 的非正 retry interval 不再 busy-loop;`WithLockAutoExtend` 的非正 extend interval 不再触发 goroutine panic;`UnlockByKey` 在 Redis 未初始化时与 `ForceUnlock` 一样返回 `ErrRedisNotReady`;`WithLock` / `WithLockAutoExtend` 现在把调用方 ctx 传入业务函数,避免取消后业务函数继续运行。 - **M10 cache 剩余错误语义收口**:新增 `cache.ExistsE` 与可选 `CacheExistChecker`,让调用方能区分 key 不存在和 Redis/backend 故障;保留旧 `Exists` bool-only 兼容方法但记录后端错误;`KeyBuilder` 现在忽略 nil option,`WithPrefix` / `WithSeparator` / `WithCacheType` 直接作用于 nil builder 时 no-op,避免扩展配置路径 panic。 - **M2 config 热重载生命周期修复**:`StopWatcher` 会等待已触发的 reload/回调结束;包级 `Load` / `LoadWithWatch` 只有在新配置成功加载并启动 watcher 后才替换默认 manager,失败时保留旧 watcher;`SetDefaultManager` 会停止旧 manager 的 watcher,避免全局置换后遗留 goroutine;数据库配置出现字段时会校验 driver/host/name/port,未知 driver 不再静默回退 MySQL;MySQL DSN 转义用户名/库名,Postgres DSN 统一转义字符串字段。 - **M4 database nil 边界修复**:`InitDB(nil)` / `InitDBWithReplicas(nil, ...)` / `InitRedis(nil)` 现在返回中文错误,不再空指针 panic;`UseMaster(nil)` / `UseReplica(nil)` / `GetDBFromContext(nil)` / `WithTx(nil, ...)` / `TxFromContext(nil)` / `TransactionWithContext(nil, ...)` / `ReadQuery(nil, ...)` / `WriteQuery(nil, ...)` / Redis health check 会把 nil context 归一化为 `context.Background()`,避免异常调用路径触发 panic;`Dialector(nil)` 安全回退到 MySQL 空 DSN。 diff --git a/GUIDE.md b/GUIDE.md index 94c68a6..411217f 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -582,12 +582,12 @@ if token != nil { } // 自动管理锁(简单场景) -err := cache.WithLock(ctx, key, ttl, func() error { +err := cache.WithLock(ctx, key, ttl, func(ctx context.Context) error { return nil }) // 自动续期锁(长任务场景) -err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func() error { +err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func(ctx context.Context) error { // 执行时间超过 TTL 时自动续期 return nil }) diff --git a/README.md b/README.md index dc6765a..46556dd 100644 --- a/README.md +++ b/README.md @@ -394,7 +394,7 @@ if token != nil { cache.ExtendLock(ctx, token, 30*time.Second) // 自动续期执行 -err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func() error { +err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func(ctx context.Context) error { // 长任务自动续期 return nil }) diff --git a/cache/lock.go b/cache/lock.go index 2c89228..d358854 100644 --- a/cache/lock.go +++ b/cache/lock.go @@ -23,6 +23,8 @@ var ( ErrInvalidLockRetryInterval = errors.New("锁重试/续期间隔必须大于 0") // ErrLockUnexpectedResult Lua 脚本返回了非预期的结果类型(C1b:裸类型断言防护)。 ErrLockUnexpectedResult = errors.New("锁脚本返回非预期结果") + // ErrLockFuncNil 表示分布式锁业务函数为空。 + ErrLockFuncNil = errors.New("锁业务函数不能为空") ) // toInt64 将 Lua 脚本返回值安全断言为 int64(C1b:禁止裸断言 panic)。 @@ -231,7 +233,10 @@ func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval t // // 解锁用独立 Background ctx(C1a 一致性修复):fn 返回或 panic 后,原 ctx 可能已被 // 调用方取消,用其解锁会失败导致锁泄漏到 TTL。fn panic 时 defer 也保证解锁执行。 -func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() error) error { +func WithLock(ctx context.Context, key string, ttl time.Duration, fn func(context.Context) error) error { + if fn == nil { + return ErrLockFuncNil + } token, err := NewLock(ctx, key, ttl) if err != nil { return err @@ -245,7 +250,7 @@ func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() erro _ = Unlock(unlockCtx, token) }() - return fn() + return fn(ctx) } // WithLockAutoExtend 使用分布式锁执行函数(自动续期)。 @@ -256,7 +261,10 @@ func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() erro // 避免旧实现 done 无缓冲 + 子 defer close(done) + 父 done<-struct{}{} 的 send-on-closed panic // (ctx 取消或 ExtendLock 失败时 done 已 closed,父再 send 即 panic,Unlock 不执行、锁泄漏到 TTL)。 // Unlock 用 context.Background() 派生超时,避免原 ctx 已取消致 Unlock 失败再泄漏。 -func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func() error) error { +func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func(context.Context) error) error { + if fn == nil { + return ErrLockFuncNil + } if extendInterval <= 0 { return ErrInvalidLockRetryInterval } @@ -302,8 +310,8 @@ func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duratio _ = Unlock(unlockCtx, token) }() - // 执行业务函数。 - err = fn() + // 执行业务函数。fn 必须接收 ctx,避免请求取消后业务 loader/DB/HTTP 调用继续运行。 + err = fn(ctx) return err } diff --git a/cache/lock_concurrency_test.go b/cache/lock_concurrency_test.go index cc55590..6ab3b87 100644 --- a/cache/lock_concurrency_test.go +++ b/cache/lock_concurrency_test.go @@ -45,11 +45,11 @@ func TestWithLockAutoExtendCtxCancelNoPanic(t *testing.T) { go func() { defer wg.Done() defer func() { panicked = recover() }() - err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func() error { + err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(runCtx context.Context) error { close(ran) // 阻塞直到 ctx 被取消,模拟长任务。 - <-ctx.Done() - return ctx.Err() + <-runCtx.Done() + return runCtx.Err() }) // WithLockAutoExtend 返回 fn 的错误(ctx.Err),不应 panic。 if err != nil && !errors.Is(err, context.Canceled) { @@ -83,7 +83,7 @@ func TestWithLockAutoExtendNormalRelease(t *testing.T) { key := "c1a:normal" called := false - err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func() error { + err := cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error { called = true return nil }) @@ -107,7 +107,7 @@ func TestWithLockAutoExtendExtendsLock(t *testing.T) { // initialTTL=500ms,extendInterval=100ms。fn 执行 800ms,期间应多次续期。 // 若续期失效,锁会在 500ms 过期,另一 worker 可获取。 - err := cache.WithLockAutoExtend(ctx, key, 500*time.Millisecond, 100*time.Millisecond, func() error { + err := cache.WithLockAutoExtend(ctx, key, 500*time.Millisecond, 100*time.Millisecond, func(context.Context) error { time.Sleep(800 * time.Millisecond) return nil }) @@ -137,7 +137,7 @@ func TestWithLockAutoExtendBlocksContender(t *testing.T) { go func() { defer close(lockDone) - cache.WithLockAutoExtend(ctx, key, 2*time.Second, 100*time.Millisecond, func() error { + cache.WithLockAutoExtend(ctx, key, 2*time.Second, 100*time.Millisecond, func(context.Context) error { close(fnStarted) time.Sleep(600 * time.Millisecond) close(fnDone) @@ -173,7 +173,7 @@ func TestWithLockAutoExtendFnPanicReleasesLock(t *testing.T) { var panicked any func() { defer func() { panicked = recover() }() - _ = cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func() error { + _ = cache.WithLockAutoExtend(ctx, key, 10*time.Second, 100*time.Millisecond, func(context.Context) error { time.Sleep(150 * time.Millisecond) // 让续期 ticker 至少触发一次 panic("boom") }) @@ -201,10 +201,10 @@ func TestWithLockCtxCancelReleasesLock(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - _ = cache.WithLock(ctx, key, 10*time.Second, func() error { + _ = cache.WithLock(ctx, key, 10*time.Second, func(runCtx context.Context) error { close(fnStarted) - <-ctx.Done() - return ctx.Err() + <-runCtx.Done() + return runCtx.Err() }) }() @@ -218,6 +218,39 @@ func TestWithLockCtxCancelReleasesLock(t *testing.T) { } } +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 ===== // 回归 C1b:NewLock/Unlock/ExtendLock 在正常路径返回正确结果(Lua 脚本返 int64)。 @@ -323,7 +356,7 @@ func TestWithLockAutoExtendRejectsNonPositiveInterval(t *testing.T) { setupMiniRedis(t) called := false - err := cache.WithLockAutoExtend(context.Background(), "m10:interval", time.Second, 0, func() error { + err := cache.WithLockAutoExtend(context.Background(), "m10:interval", time.Second, 0, func(context.Context) error { called = true return nil }) @@ -347,7 +380,7 @@ func TestWithLockHeldReturnsErrLockNotAcquired(t *testing.T) { defer cache.Unlock(ctx, holder) called := false - err = cache.WithLock(ctx, key, time.Second, func() error { + err = cache.WithLock(ctx, key, time.Second, func(context.Context) error { called = true return nil }) diff --git a/docs/plans/v2.0-review.md b/docs/plans/v2.0-review.md index 3671ab8..244d673 100644 --- a/docs/plans/v2.0-review.md +++ b/docs/plans/v2.0-review.md @@ -236,7 +236,7 @@ cache.KSession("sid") // -> "session:user_api:sid" cache.Lock(ctx, key, ttl) cache.Unlock(ctx, key) cache.TryLock(ctx, key, ttl, retry, maxRetry) -cache.WithLock(ctx, key, ttl, fn) // 自动管理锁 +cache.WithLock(ctx, key, ttl, func(context.Context) error) // 自动管理锁 // 计数器 cache.Incr(ctx, key) @@ -482,4 +482,4 @@ go test ./cache/... ./handler/... ./middleware/... *评估日期:2026-04-29* *评估版本:v2.0.0* -*优化内容:新增5个包 + 增强5个包 + 配置扩展 + CLI重构 = 约120个函数* \ No newline at end of file +*优化内容:新增5个包 + 增强5个包 + 配置扩展 + CLI重构 = 约120个函数*