mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 14:15:46 +00:00
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:
1 parent
7ea27c4c16
commit
0d3230b2bf
17 files changed
+464
-61
No files matched your search
Vendored
+94
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+35
@@ -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
|
||||
}
|
||||
Vendored
+142
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user