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

+5 -1
View File
@@ -346,6 +346,10 @@ func runGateway() {
initOTelExporter(context.Background(), cfg, traceCollector)
}
// Redis cache: compiled via build tags. Build with 'go build -tags redis' to enable.
redisClient := initRedisClient(cfg)
defer shutdownRedis(redisClient)
// Wire cron retry config from config.json
cronRetryCfg := cfg.Cron.ToRetryConfig()
// Apply retry config via type assertion on the concrete cron store.
@@ -575,7 +579,7 @@ func runGateway() {
}
var mcpPool *mcpbridge.Pool
contextFileInterceptor, delegateMgr, mcpPool = wireExtras(pgStores, agentRouter, providerRegistry, msgBus, pgStores.Sessions, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, dynamicLoader)
contextFileInterceptor, delegateMgr, mcpPool = wireExtras(pgStores, agentRouter, providerRegistry, msgBus, pgStores.Sessions, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, dynamicLoader, redisClient)
if mcpPool != nil {
defer mcpPool.Stop()
}
+7 -3
View File
@@ -44,18 +44,22 @@ func wireExtras(
appCfg *config.Config,
sandboxMgr sandbox.Manager,
dynamicLoader *tools.DynamicToolLoader,
redisClient any, // nil when built without -tags redis or when Redis is unconfigured
) (*tools.ContextFileInterceptor, *tools.DelegateManager, *mcpbridge.Pool) {
// 1. Context file interceptor (created before resolver so callbacks can reference it)
// 1. Build cache instances (in-memory or Redis depending on build tags)
agentCtxCache, userCtxCache, gwCache := makeCaches(redisClient)
// 1a. Context file interceptor (created before resolver so callbacks can reference it)
var contextFileInterceptor *tools.ContextFileInterceptor
var delegateMgr *tools.DelegateManager
if stores.Agents != nil {
contextFileInterceptor = tools.NewContextFileInterceptor(stores.Agents, workspace)
contextFileInterceptor = tools.NewContextFileInterceptor(stores.Agents, workspace, agentCtxCache, userCtxCache)
}
// 1b. Group writer cache (wraps ListGroupFileWriters with TTL cache)
var groupWriterCache *store.GroupWriterCache
if stores.Agents != nil {
groupWriterCache = store.NewGroupWriterCache(stores.Agents)
groupWriterCache = store.NewGroupWriterCache(stores.Agents, gwCache)
}
// 2. User seeding callback: seeds per-user context files on first chat
+56
View File
@@ -0,0 +1,56 @@
//go:build redis
package cmd
import (
"log/slog"
"github.com/redis/go-redis/v9"
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// initRedisClient creates a Redis client when built with -tags redis.
// Returns nil (typed as any) if GOCLAW_REDIS_DSN is empty or connection fails.
func initRedisClient(cfg *config.Config) any {
dsn := cfg.Database.RedisDSN
if dsn == "" {
slog.Debug("Redis available but not configured (set GOCLAW_REDIS_DSN)")
return nil
}
client, err := cache.NewRedisClient(dsn)
if err != nil {
slog.Warn("Redis connection failed, falling back to in-memory cache", "error", err)
return nil
}
slog.Info("Redis cache connected")
return client
}
// makeCaches creates typed cache instances backed by Redis (or in-memory if client is nil).
func makeCaches(raw any) (
agentCtxCache cache.Cache[[]store.AgentContextFileData],
userCtxCache cache.Cache[[]store.AgentContextFileData],
groupWriterCache cache.Cache[[]store.GroupFileWriterData],
) {
client, _ := raw.(*redis.Client)
if client == nil {
slog.Info("cache backend: in-memory (Redis not connected)")
return cache.NewInMemoryCache[[]store.AgentContextFileData](),
cache.NewInMemoryCache[[]store.AgentContextFileData](),
cache.NewInMemoryCache[[]store.GroupFileWriterData]()
}
slog.Info("cache backend: redis")
return cache.NewRedisCache[[]store.AgentContextFileData](client, "ctx:agent"),
cache.NewRedisCache[[]store.AgentContextFileData](client, "ctx:user"),
cache.NewRedisCache[[]store.GroupFileWriterData](client, "grp:writers")
}
// shutdownRedis closes the Redis client connection.
func shutdownRedis(raw any) {
if client, ok := raw.(*redis.Client); ok && client != nil {
client.Close()
}
}
+30
View File
@@ -0,0 +1,30 @@
//go:build !redis
package cmd
import (
"log/slog"
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// initRedisClient is a no-op when built without the "redis" tag.
// Build with `go build -tags redis` to enable Redis cache.
func initRedisClient(_ *config.Config) any { return nil }
// makeCaches returns in-memory cache instances when Redis is not compiled in.
func makeCaches(_ any) (
agentCtxCache cache.Cache[[]store.AgentContextFileData],
userCtxCache cache.Cache[[]store.AgentContextFileData],
groupWriterCache cache.Cache[[]store.GroupFileWriterData],
) {
slog.Info("cache backend: in-memory")
return cache.NewInMemoryCache[[]store.AgentContextFileData](),
cache.NewInMemoryCache[[]store.AgentContextFileData](),
cache.NewInMemoryCache[[]store.GroupFileWriterData]()
}
// shutdownRedis is a no-op when built without the "redis" tag.
func shutdownRedis(_ any) {}