Files
goclaw/internal/cache/redis.go
T
viettranx 0d3230b2bf feat(cache): add build-tag-gated Redis cache backend
Add optional Redis cache support via `go build -tags redis`, following
the same paired-stub pattern as OTel and Tailscale. The Cache[V] interface
is unchanged; Redis and in-memory implementations are injected at startup
without altering usage logic.

- Add RedisCache[V] implementation with JSON serialization, fail-open on errors
- Add gateway_redis.go / gateway_redis_noop.go paired wiring files
- Refactor GroupWriterCache and ContextFileInterceptor to accept injected caches
- Add GOCLAW_REDIS_DSN env var, docker-compose.redis.yml overlay
- Update Dockerfile and GitHub Actions with ENABLE_REDIS build arg
- Add Redis variant to CI matrix (5 variants: latest, otel, tsnet, redis, full)
2026-03-07 19:27:24 +07:00

95 lines
2.6 KiB
Go

//go:build redis
package cache
import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/redis/go-redis/v9"
)
// RedisCache is a Cache implementation backed by Redis.
// All Redis errors are treated as cache misses (fail-open) to avoid breaking callers.
type RedisCache[V any] struct {
client *redis.Client
prefix string // key namespace, e.g. "ctx:agent"
}
// NewRedisCache creates a Redis-backed cache with the given key prefix.
// Keys are stored as "goclaw:{prefix}:{key}".
func NewRedisCache[V any](client *redis.Client, prefix string) *RedisCache[V] {
return &RedisCache[V]{client: client, prefix: prefix}
}
func (c *RedisCache[V]) fullKey(key string) string {
return "goclaw:" + c.prefix + ":" + key
}
func (c *RedisCache[V]) keyPattern() string {
return "goclaw:" + c.prefix + ":*"
}
func (c *RedisCache[V]) Get(ctx context.Context, key string) (V, bool) {
var zero V
data, err := c.client.Get(ctx, c.fullKey(key)).Bytes()
if err != nil {
return zero, false
}
var val V
if err := json.Unmarshal(data, &val); err != nil {
slog.Warn("redis cache: unmarshal error", "key", c.fullKey(key), "error", err)
return zero, false
}
return val, true
}
func (c *RedisCache[V]) Set(ctx context.Context, key string, value V, ttl time.Duration) {
data, err := json.Marshal(value)
if err != nil {
slog.Warn("redis cache: marshal error", "key", c.fullKey(key), "error", err)
return
}
if err := c.client.Set(ctx, c.fullKey(key), data, ttl).Err(); err != nil {
slog.Warn("redis cache: set error", "key", c.fullKey(key), "error", err)
}
}
func (c *RedisCache[V]) Delete(ctx context.Context, key string) {
if err := c.client.Del(ctx, c.fullKey(key)).Err(); err != nil {
slog.Warn("redis cache: delete error", "key", c.fullKey(key), "error", err)
}
}
func (c *RedisCache[V]) DeleteByPrefix(ctx context.Context, prefix string) {
pattern := "goclaw:" + c.prefix + ":" + prefix + "*"
c.deleteByPattern(ctx, pattern)
}
func (c *RedisCache[V]) Clear(ctx context.Context) {
c.deleteByPattern(ctx, c.keyPattern())
}
// deleteByPattern scans for keys matching pattern and deletes them in batches.
func (c *RedisCache[V]) deleteByPattern(ctx context.Context, pattern string) {
var cursor uint64
for {
keys, next, err := c.client.Scan(ctx, cursor, pattern, 100).Result()
if err != nil {
slog.Warn("redis cache: scan error", "pattern", pattern, "error", err)
return
}
if len(keys) > 0 {
if err := c.client.Del(ctx, keys...).Err(); err != nil {
slog.Warn("redis cache: batch delete error", "count", len(keys), "error", err)
}
}
cursor = next
if cursor == 0 {
break
}
}
}