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)
This commit is contained in:
viettranx committed 2026-03-07 19:27:24 +07:00
1 parent 7ea27c4c16
commit 0d3230b2bf
17 files changed
+464 -61

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
//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
}
}
}
+35
View File
@@ -0,0 +1,35 @@
//go:build redis
package cache
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// NewRedisClient creates a Redis client from a DSN (e.g. "redis://localhost:6379/0")
// and verifies connectivity with a PING.
func NewRedisClient(dsn string) (*redis.Client, error) {
opts, err := redis.ParseURL(dsn)
if err != nil {
return nil, fmt.Errorf("redis: invalid DSN: %w", err)
}
opts.PoolSize = 10
opts.MinIdleConns = 2
opts.DialTimeout = 5 * time.Second
opts.ReadTimeout = 3 * time.Second
opts.WriteTimeout = 3 * time.Second
client := redis.NewClient(opts)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
client.Close()
return nil, fmt.Errorf("redis: ping failed: %w", err)
}
return client, nil
}
+142
View File
@@ -0,0 +1,142 @@
//go:build redis
package cache
import (
"context"
"os"
"testing"
"time"
"github.com/redis/go-redis/v9"
)
func testRedisClient(t *testing.T) *redis.Client {
t.Helper()
dsn := os.Getenv("REDIS_TEST_DSN")
if dsn == "" {
dsn = "redis://localhost:6379/15" // use DB 15 for tests
}
client, err := NewRedisClient(dsn)
if err != nil {
t.Skipf("Redis not available: %v", err)
}
t.Cleanup(func() {
client.FlushDB(context.Background())
client.Close()
})
return client
}
func TestRedisCache_GetSet(t *testing.T) {
client := testRedisClient(t)
c := NewRedisCache[string](client, "test:getset")
ctx := context.Background()
// miss
_, ok := c.Get(ctx, "k1")
if ok {
t.Fatal("expected miss")
}
// hit
c.Set(ctx, "k1", "hello", time.Minute)
v, ok := c.Get(ctx, "k1")
if !ok || v != "hello" {
t.Fatalf("expected hello, got %q ok=%v", v, ok)
}
}
func TestRedisCache_TTLExpiry(t *testing.T) {
client := testRedisClient(t)
c := NewRedisCache[int](client, "test:ttl")
ctx := context.Background()
c.Set(ctx, "k1", 42, 100*time.Millisecond)
v, ok := c.Get(ctx, "k1")
if !ok || v != 42 {
t.Fatalf("expected 42, got %d ok=%v", v, ok)
}
time.Sleep(150 * time.Millisecond)
_, ok = c.Get(ctx, "k1")
if ok {
t.Fatal("expected miss after TTL")
}
}
func TestRedisCache_Delete(t *testing.T) {
client := testRedisClient(t)
c := NewRedisCache[string](client, "test:del")
ctx := context.Background()
c.Set(ctx, "k1", "v1", time.Minute)
c.Delete(ctx, "k1")
_, ok := c.Get(ctx, "k1")
if ok {
t.Fatal("expected miss after delete")
}
}
func TestRedisCache_DeleteByPrefix(t *testing.T) {
client := testRedisClient(t)
c := NewRedisCache[string](client, "test:prefix")
ctx := context.Background()
c.Set(ctx, "agent:1:a", "v1", time.Minute)
c.Set(ctx, "agent:1:b", "v2", time.Minute)
c.Set(ctx, "agent:2:a", "v3", time.Minute)
c.DeleteByPrefix(ctx, "agent:1:")
if _, ok := c.Get(ctx, "agent:1:a"); ok {
t.Fatal("agent:1:a should be deleted")
}
if _, ok := c.Get(ctx, "agent:1:b"); ok {
t.Fatal("agent:1:b should be deleted")
}
if _, ok := c.Get(ctx, "agent:2:a"); !ok {
t.Fatal("agent:2:a should still exist")
}
}
func TestRedisCache_Clear(t *testing.T) {
client := testRedisClient(t)
c := NewRedisCache[int](client, "test:clear")
ctx := context.Background()
c.Set(ctx, "a", 1, time.Minute)
c.Set(ctx, "b", 2, time.Minute)
c.Clear(ctx)
if _, ok := c.Get(ctx, "a"); ok {
t.Fatal("expected miss after clear")
}
if _, ok := c.Get(ctx, "b"); ok {
t.Fatal("expected miss after clear")
}
}
// TestRedisCache_StructRoundtrip verifies JSON serialization of complex types.
type testStruct struct {
Name string `json:"name"`
Age int `json:"age"`
}
func TestRedisCache_StructRoundtrip(t *testing.T) {
client := testRedisClient(t)
c := NewRedisCache[[]testStruct](client, "test:struct")
ctx := context.Background()
data := []testStruct{{Name: "Alice", Age: 30}, {Name: "Bob", Age: 25}}
c.Set(ctx, "users", data, time.Minute)
got, ok := c.Get(ctx, "users")
if !ok {
t.Fatal("expected hit")
}
if len(got) != 2 || got[0].Name != "Alice" || got[1].Age != 25 {
t.Fatalf("roundtrip mismatch: %+v", got)
}
}