Spaces:
Runtime error
Runtime error
| package cachex | |
| import ( | |
| "context" | |
| "errors" | |
| "strings" | |
| "sync" | |
| "time" | |
| "github.com/go-redis/redis/v8" | |
| "github.com/samber/hot" | |
| ) | |
| const ( | |
| defaultRedisOpTimeout = 2 * time.Second | |
| defaultRedisScanTimeout = 30 * time.Second | |
| defaultRedisDelTimeout = 10 * time.Second | |
| ) | |
| type HybridCacheConfig[V any] struct { | |
| Namespace Namespace | |
| // Redis is used when RedisEnabled returns true (or RedisEnabled is nil) and Redis is not nil. | |
| Redis *redis.Client | |
| RedisCodec ValueCodec[V] | |
| RedisEnabled func() bool | |
| // Memory builds a hot cache used when Redis is disabled. Keys stored in memory are fully namespaced. | |
| Memory func() *hot.HotCache[string, V] | |
| } | |
| // HybridCache is a small helper that uses Redis when enabled, otherwise falls back to in-memory hot cache. | |
| type HybridCache[V any] struct { | |
| ns Namespace | |
| redis *redis.Client | |
| redisCodec ValueCodec[V] | |
| redisEnabled func() bool | |
| memOnce sync.Once | |
| memInit func() *hot.HotCache[string, V] | |
| mem *hot.HotCache[string, V] | |
| } | |
| func NewHybridCache[V any](cfg HybridCacheConfig[V]) *HybridCache[V] { | |
| return &HybridCache[V]{ | |
| ns: cfg.Namespace, | |
| redis: cfg.Redis, | |
| redisCodec: cfg.RedisCodec, | |
| redisEnabled: cfg.RedisEnabled, | |
| memInit: cfg.Memory, | |
| } | |
| } | |
| func (c *HybridCache[V]) FullKey(key string) string { | |
| return c.ns.FullKey(key) | |
| } | |
| func (c *HybridCache[V]) redisOn() bool { | |
| if c.redis == nil || c.redisCodec == nil { | |
| return false | |
| } | |
| if c.redisEnabled == nil { | |
| return true | |
| } | |
| return c.redisEnabled() | |
| } | |
| func (c *HybridCache[V]) memCache() *hot.HotCache[string, V] { | |
| c.memOnce.Do(func() { | |
| if c.memInit == nil { | |
| c.mem = hot.NewHotCache[string, V](hot.LRU, 1).Build() | |
| return | |
| } | |
| c.mem = c.memInit() | |
| }) | |
| return c.mem | |
| } | |
| func (c *HybridCache[V]) Get(key string) (value V, found bool, err error) { | |
| full := c.ns.FullKey(key) | |
| if full == "" { | |
| var zero V | |
| return zero, false, nil | |
| } | |
| if c.redisOn() { | |
| ctx, cancel := context.WithTimeout(context.Background(), defaultRedisOpTimeout) | |
| defer cancel() | |
| raw, e := c.redis.Get(ctx, full).Result() | |
| if e == nil { | |
| v, decErr := c.redisCodec.Decode(raw) | |
| if decErr != nil { | |
| var zero V | |
| return zero, false, decErr | |
| } | |
| return v, true, nil | |
| } | |
| if errors.Is(e, redis.Nil) { | |
| var zero V | |
| return zero, false, nil | |
| } | |
| var zero V | |
| return zero, false, e | |
| } | |
| return c.memCache().Get(full) | |
| } | |
| func (c *HybridCache[V]) SetWithTTL(key string, v V, ttl time.Duration) error { | |
| full := c.ns.FullKey(key) | |
| if full == "" { | |
| return nil | |
| } | |
| if c.redisOn() { | |
| raw, err := c.redisCodec.Encode(v) | |
| if err != nil { | |
| return err | |
| } | |
| ctx, cancel := context.WithTimeout(context.Background(), defaultRedisOpTimeout) | |
| defer cancel() | |
| return c.redis.Set(ctx, full, raw, ttl).Err() | |
| } | |
| c.memCache().SetWithTTL(full, v, ttl) | |
| return nil | |
| } | |
| // Keys returns keys with valid values. In Redis, it returns all matching keys. | |
| func (c *HybridCache[V]) Keys() ([]string, error) { | |
| if c.redisOn() { | |
| return c.scanKeys(c.ns.MatchPattern()) | |
| } | |
| return c.memCache().Keys(), nil | |
| } | |
| func (c *HybridCache[V]) scanKeys(match string) ([]string, error) { | |
| ctx, cancel := context.WithTimeout(context.Background(), defaultRedisScanTimeout) | |
| defer cancel() | |
| var cursor uint64 | |
| keys := make([]string, 0, 1024) | |
| for { | |
| k, next, err := c.redis.Scan(ctx, cursor, match, 1000).Result() | |
| if err != nil { | |
| return keys, err | |
| } | |
| keys = append(keys, k...) | |
| cursor = next | |
| if cursor == 0 { | |
| break | |
| } | |
| } | |
| return keys, nil | |
| } | |
| func (c *HybridCache[V]) Purge() error { | |
| if c.redisOn() { | |
| keys, err := c.scanKeys(c.ns.MatchPattern()) | |
| if err != nil { | |
| return err | |
| } | |
| if len(keys) == 0 { | |
| return nil | |
| } | |
| _, err = c.DeleteMany(keys) | |
| return err | |
| } | |
| c.memCache().Purge() | |
| return nil | |
| } | |
| func (c *HybridCache[V]) DeleteByPrefix(prefix string) (int, error) { | |
| fullPrefix := c.ns.FullKey(prefix) | |
| if fullPrefix == "" { | |
| return 0, nil | |
| } | |
| if !strings.HasSuffix(fullPrefix, ":") { | |
| fullPrefix += ":" | |
| } | |
| if c.redisOn() { | |
| match := fullPrefix + "*" | |
| keys, err := c.scanKeys(match) | |
| if err != nil { | |
| return 0, err | |
| } | |
| if len(keys) == 0 { | |
| return 0, nil | |
| } | |
| res, err := c.DeleteMany(keys) | |
| if err != nil { | |
| return 0, err | |
| } | |
| deleted := 0 | |
| for _, ok := range res { | |
| if ok { | |
| deleted++ | |
| } | |
| } | |
| return deleted, nil | |
| } | |
| // In memory, we filter keys and bulk delete. | |
| allKeys := c.memCache().Keys() | |
| keys := make([]string, 0, 128) | |
| for _, k := range allKeys { | |
| if strings.HasPrefix(k, fullPrefix) { | |
| keys = append(keys, k) | |
| } | |
| } | |
| if len(keys) == 0 { | |
| return 0, nil | |
| } | |
| res, _ := c.DeleteMany(keys) | |
| deleted := 0 | |
| for _, ok := range res { | |
| if ok { | |
| deleted++ | |
| } | |
| } | |
| return deleted, nil | |
| } | |
| // DeleteMany accepts either fully namespaced keys or raw keys and deletes them. | |
| // It returns a map keyed by fully namespaced keys. | |
| func (c *HybridCache[V]) DeleteMany(keys []string) (map[string]bool, error) { | |
| res := make(map[string]bool, len(keys)) | |
| if len(keys) == 0 { | |
| return res, nil | |
| } | |
| fullKeys := make([]string, 0, len(keys)) | |
| for _, k := range keys { | |
| k = c.ns.FullKey(k) | |
| if k == "" { | |
| continue | |
| } | |
| fullKeys = append(fullKeys, k) | |
| } | |
| if len(fullKeys) == 0 { | |
| return res, nil | |
| } | |
| if c.redisOn() { | |
| ctx, cancel := context.WithTimeout(context.Background(), defaultRedisDelTimeout) | |
| defer cancel() | |
| pipe := c.redis.Pipeline() | |
| cmds := make([]*redis.IntCmd, 0, len(fullKeys)) | |
| for _, k := range fullKeys { | |
| // UNLINK is non-blocking vs DEL for large key batches. | |
| cmds = append(cmds, pipe.Unlink(ctx, k)) | |
| } | |
| _, err := pipe.Exec(ctx) | |
| if err != nil && !errors.Is(err, redis.Nil) { | |
| return res, err | |
| } | |
| for i, cmd := range cmds { | |
| deleted := cmd != nil && cmd.Err() == nil && cmd.Val() > 0 | |
| res[fullKeys[i]] = deleted | |
| } | |
| return res, nil | |
| } | |
| return c.memCache().DeleteMany(fullKeys), nil | |
| } | |
| func (c *HybridCache[V]) Capacity() (mainCacheCapacity int, missingCacheCapacity int) { | |
| if c.redisOn() { | |
| return 0, 0 | |
| } | |
| return c.memCache().Capacity() | |
| } | |
| func (c *HybridCache[V]) Algorithm() (mainCacheAlgorithm string, missingCacheAlgorithm string) { | |
| if c.redisOn() { | |
| return "redis", "" | |
| } | |
| return c.memCache().Algorithm() | |
| } | |