fix cache error semantics
This commit is contained in:
@@ -20,6 +20,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
|
||||
### Breaking ⚠️
|
||||
|
||||
- **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)` 崩溃。
|
||||
- **`jwt.ParseToken` 开始校验 issuer,`RefreshToken` 使用 `jwt.refresh_expire`**:签发者与当前配置不一致的 token 会被拒绝;刷新后的 token 过期时间优先使用 `refresh_expire`,未配置时回退 `expire`。`GenerateTokenWithCustomExpiry` 现在拒绝非正过期时间,`InvalidateTokenByID("")` 返回 `ErrEmptyJTI`。
|
||||
- **`App.Init()` 由 `sync.Once` 改为生命周期状态机**(app.go,M1):5 态 `stateCreated/Initializing/Initialized/Stopping/Stopped` + `lifecycleMu`(RWMutex) + `initMu`(Mutex)。`Shutdown` 后或 `Init` 失败后再调 `Init()` 返回新增导出错误 **`xlgo.ErrAppClosed`**(原 `sync.Once` "多次调用返回首次结果"语义不再适用——已关闭的 App 不可再 Init,需新建 App)。
|
||||
@@ -36,6 +37,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
|
||||
- **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 cache 剩余错误语义收口**:新增 `cache.ExistsE` 与可选 `CacheExistChecker`,让调用方能区分 key 不存在和 Redis/backend 故障;保留旧 `Exists` bool-only 兼容方法但记录后端错误;`KeyBuilder` 现在忽略 nil option,`WithPrefix` / `WithSeparator` / `WithCacheType` 直接作用于 nil builder 时 no-op,避免扩展配置路径 panic。
|
||||
- **M11 SSE 换行注入修复**:`WriteEvent` 拒绝带 CR/LF 的 event 名,`WriteMessage` / `WriteEvent` 的 data 按 SSE 多行格式逐行输出,避免用户数据伪造额外 `event:`/`id:` 字段。
|
||||
- **M12 storage/compress 安全边界修复**:本地上传写侧 `Close` 错误会通过返回值暴露并清理残片;OSS `GetSignedURL` 统一经过 object key 净化;`UnzipWithOptions` 解析目标绝对路径失败时 fail-closed。
|
||||
|
||||
|
||||
Vendored
+42
-9
@@ -7,10 +7,8 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -29,6 +27,12 @@ type CacheService interface {
|
||||
Exists(ctx context.Context, key string) bool
|
||||
}
|
||||
|
||||
// CacheExistChecker is implemented by cache backends that can distinguish a
|
||||
// missing key from a backend failure.
|
||||
type CacheExistChecker interface {
|
||||
ExistsE(ctx context.Context, key string) (bool, error)
|
||||
}
|
||||
|
||||
// redisCache Redis 缓存实现。
|
||||
//
|
||||
// 不在构造时快照 redis.Client(M12 修复:原 NewRedisCache 构造时取 database.GetRedis(),
|
||||
@@ -73,7 +77,7 @@ func (c *redisCache) Get(ctx context.Context, key string, dest any) bool {
|
||||
func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return nil // Redis 未启用,跳过缓存
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
data, err := json.Marshal(value)
|
||||
@@ -94,7 +98,7 @@ func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Du
|
||||
func (c *redisCache) Delete(ctx context.Context, key string) error {
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return nil
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
if err := cli.Del(ctx, key).Err(); err != nil {
|
||||
@@ -109,7 +113,7 @@ func (c *redisCache) Delete(ctx context.Context, key string) error {
|
||||
func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error {
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return nil
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
var cursor uint64
|
||||
@@ -150,12 +154,27 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error
|
||||
|
||||
// Exists 检查缓存是否存在
|
||||
func (c *redisCache) Exists(ctx context.Context, key string) bool {
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
ok, err := c.ExistsE(ctx, key)
|
||||
if err != nil {
|
||||
logger.Warn("缓存存在性检查失败", zap.String("key", key), zap.Error(err))
|
||||
return false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
return cli.Exists(ctx, key).Val() > 0
|
||||
// ExistsE checks whether key exists and returns Redis/backend errors to callers
|
||||
// that need to distinguish a missing key from a cache outage.
|
||||
func (c *redisCache) ExistsE(ctx context.Context, key string) (bool, error) {
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
|
||||
n, err := cli.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// CacheManager 缓存管理器(#10)。照 database.Manager 模式:
|
||||
@@ -222,3 +241,17 @@ func Init() {
|
||||
func GetCache() CacheService {
|
||||
return GetDefaultCache().Get()
|
||||
}
|
||||
|
||||
// ExistsE checks whether key exists and returns backend errors. It complements
|
||||
// the legacy bool-only CacheService.Exists method without changing that public
|
||||
// interface for downstream custom cache implementations.
|
||||
func ExistsE(ctx context.Context, key string) (bool, error) {
|
||||
svc := GetCache()
|
||||
if svc == nil {
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
if checker, ok := svc.(CacheExistChecker); ok {
|
||||
return checker.ExistsE(ctx, key)
|
||||
}
|
||||
return svc.Exists(ctx, key), nil
|
||||
}
|
||||
|
||||
Vendored
+113
@@ -0,0 +1,113 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func setupM10MiniRedis(t *testing.T) *miniredis.Miniredis {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
database.SetTestRedisClient(client)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(nil) })
|
||||
return mr
|
||||
}
|
||||
|
||||
func TestM10CacheWritesReturnRedisNotReady(t *testing.T) {
|
||||
database.SetTestRedisClient(nil)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(nil) })
|
||||
|
||||
c := &redisCache{}
|
||||
ctx := context.Background()
|
||||
|
||||
if err := c.Set(ctx, "k", "v", time.Minute); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("Set without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if err := c.Delete(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("Delete without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if err := c.DeleteByPattern(ctx, "k:*"); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("DeleteByPattern without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM10RawHelpersReturnRedisNotReady(t *testing.T) {
|
||||
database.SetTestRedisClient(nil)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(nil) })
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := Incr(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("Incr without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if _, err := IncrBy(ctx, "k", 2); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("IncrBy without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if _, err := Decr(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("Decr without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if _, err := GetTTL(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("GetTTL without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if _, err := SetExpire(ctx, "k", time.Minute); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("SetExpire without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if _, err := GetRaw(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("GetRaw without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if err := SetRaw(ctx, "k", "v", time.Minute); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("SetRaw without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM10ExistsEReturnsBackendErrors(t *testing.T) {
|
||||
setupM10MiniRedis(t)
|
||||
c := &redisCache{}
|
||||
ctx := context.Background()
|
||||
|
||||
exists, err := c.ExistsE(ctx, "missing")
|
||||
if err != nil {
|
||||
t.Fatalf("ExistsE missing err = %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Fatal("ExistsE missing = true, want false")
|
||||
}
|
||||
|
||||
if err := c.Set(ctx, "present", "value", time.Minute); err != nil {
|
||||
t.Fatalf("Set present: %v", err)
|
||||
}
|
||||
exists, err = c.ExistsE(ctx, "present")
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("ExistsE present = %v, err=%v; want true,nil", exists, err)
|
||||
}
|
||||
|
||||
canceled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
if exists, err = c.ExistsE(canceled, "present"); err == nil || exists {
|
||||
t.Fatalf("ExistsE canceled = %v, err=%v; want false,error", exists, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM10PackageExistsEUsesOptionalInterface(t *testing.T) {
|
||||
setupM10MiniRedis(t)
|
||||
orig := GetDefaultCache()
|
||||
t.Cleanup(func() { SetDefaultCacheManager(orig) })
|
||||
SetDefaultCacheManager(NewCacheManager())
|
||||
Init()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := GetCache().Set(ctx, "present", "value", time.Minute); err != nil {
|
||||
t.Fatalf("Set through facade: %v", err)
|
||||
}
|
||||
exists, err := ExistsE(ctx, "present")
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("facade ExistsE = %v, err=%v; want true,nil", exists, err)
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -25,6 +25,9 @@ type KeyBuilderOption func(*KeyBuilder)
|
||||
// 示例: WithPrefix("site_a") -> 所有 key 自动添加 "site_a" 前缀
|
||||
func WithPrefix(prefix string) KeyBuilderOption {
|
||||
return func(kb *KeyBuilder) {
|
||||
if kb == nil {
|
||||
return
|
||||
}
|
||||
kb.prefix = prefix
|
||||
}
|
||||
}
|
||||
@@ -33,6 +36,9 @@ func WithPrefix(prefix string) KeyBuilderOption {
|
||||
// 示例: WithSeparator(":") -> "site_a:user:1"
|
||||
func WithSeparator(separator string) KeyBuilderOption {
|
||||
return func(kb *KeyBuilder) {
|
||||
if kb == nil {
|
||||
return
|
||||
}
|
||||
kb._separator = separator
|
||||
}
|
||||
}
|
||||
@@ -41,6 +47,9 @@ func WithSeparator(separator string) KeyBuilderOption {
|
||||
// 示例: WithCacheType("session") -> "session_site_a_user:1"
|
||||
func WithCacheType(cacheType string) KeyBuilderOption {
|
||||
return func(kb *KeyBuilder) {
|
||||
if kb == nil {
|
||||
return
|
||||
}
|
||||
kb._cacheType = cacheType
|
||||
}
|
||||
}
|
||||
@@ -53,6 +62,9 @@ func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder {
|
||||
_cacheType: "cache",
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt == nil {
|
||||
continue
|
||||
}
|
||||
opt(kb)
|
||||
}
|
||||
return kb
|
||||
|
||||
Vendored
+12
-1
@@ -62,6 +62,17 @@ func TestKeyBuilderNoPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderNilOptionIgnored(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(nil, cache.WithPrefix("site"))
|
||||
if got := kb.Build("user:1"); got != "cache:site:user:1" {
|
||||
t.Fatalf("Build with nil option = %q, want cache:site:user:1", got)
|
||||
}
|
||||
|
||||
cache.WithPrefix("ignored")(nil)
|
||||
cache.WithSeparator("_")(nil)
|
||||
cache.WithCacheType("ignored")(nil)
|
||||
}
|
||||
|
||||
func TestKeyBuilderSetPrefix(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(cache.WithPrefix("site_a"))
|
||||
|
||||
@@ -211,4 +222,4 @@ func contains(s, sub string) bool {
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+7
-7
@@ -360,7 +360,7 @@ func ForceUnlock(ctx context.Context, key string) error {
|
||||
func Incr(ctx context.Context, key string) (int64, error) {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
return 0, ErrRedisNotReady
|
||||
}
|
||||
return rdb.Incr(ctx, key).Result()
|
||||
}
|
||||
@@ -369,7 +369,7 @@ func Incr(ctx context.Context, key string) (int64, error) {
|
||||
func IncrBy(ctx context.Context, key string, value int64) (int64, error) {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
return 0, ErrRedisNotReady
|
||||
}
|
||||
return rdb.IncrBy(ctx, key, value).Result()
|
||||
}
|
||||
@@ -378,7 +378,7 @@ func IncrBy(ctx context.Context, key string, value int64) (int64, error) {
|
||||
func Decr(ctx context.Context, key string) (int64, error) {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
return 0, ErrRedisNotReady
|
||||
}
|
||||
return rdb.Decr(ctx, key).Result()
|
||||
}
|
||||
@@ -387,7 +387,7 @@ func Decr(ctx context.Context, key string) (int64, error) {
|
||||
func GetTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return 0, nil
|
||||
return 0, ErrRedisNotReady
|
||||
}
|
||||
return rdb.TTL(ctx, key).Result()
|
||||
}
|
||||
@@ -396,7 +396,7 @@ func GetTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return false, nil
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
return rdb.Expire(ctx, key, ttl).Result()
|
||||
}
|
||||
@@ -405,7 +405,7 @@ func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error)
|
||||
func GetRaw(ctx context.Context, key string) (string, error) {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return "", nil
|
||||
return "", ErrRedisNotReady
|
||||
}
|
||||
return rdb.Get(ctx, key).Result()
|
||||
}
|
||||
@@ -414,7 +414,7 @@ func GetRaw(ctx context.Context, key string) (string, error) {
|
||||
func SetRaw(ctx context.Context, key string, value string, ttl time.Duration) error {
|
||||
rdb := database.GetRedis()
|
||||
if rdb == nil {
|
||||
return nil
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
return rdb.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
|
||||
Vendored
+13
-15
@@ -60,28 +60,26 @@ func TestLockErrors(t *testing.T) {
|
||||
func TestIncrDecr(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test Incr without Redis
|
||||
// Redis 未初始化时应显式返回 ErrRedisNotReady,避免调用方误判为计数器值为 0。
|
||||
n, err := cache.Incr(ctx, "counter")
|
||||
if err != nil {
|
||||
t.Errorf("Incr error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("Incr without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("Incr should return 0 without Redis")
|
||||
}
|
||||
|
||||
// Test IncrBy
|
||||
n, err = cache.IncrBy(ctx, "counter", 10)
|
||||
if err != nil {
|
||||
t.Errorf("IncrBy error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("IncrBy without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("IncrBy should return 0 without Redis")
|
||||
}
|
||||
|
||||
// Test Decr
|
||||
n, err = cache.Decr(ctx, "counter")
|
||||
if err != nil {
|
||||
t.Errorf("Decr error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("Decr without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("Decr should return 0 without Redis")
|
||||
@@ -92,8 +90,8 @@ func TestSetExpire(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
ok, err := cache.SetExpire(ctx, "test_key", time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("SetExpire error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("SetExpire without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("SetExpire should return false without Redis")
|
||||
@@ -105,14 +103,14 @@ func TestGetRawSetRaw(t *testing.T) {
|
||||
|
||||
// Test SetRaw
|
||||
err := cache.SetRaw(ctx, "test_key", "test_value", time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("SetRaw error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("SetRaw without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
|
||||
// Test GetRaw
|
||||
val, err := cache.GetRaw(ctx, "test_key")
|
||||
if err != nil {
|
||||
t.Errorf("GetRaw error: %v", err)
|
||||
if !errors.Is(err, cache.ErrRedisNotReady) {
|
||||
t.Errorf("GetRaw without Redis should return ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
if val != "" {
|
||||
t.Error("GetRaw should return empty string without Redis")
|
||||
|
||||
Reference in New Issue
Block a user