feat: per-group cache TTL

GroupConfig gains CacheTTL (json cacheTTL, empty inherits the global
cache.ttl). Parsed and validated at construction (newAuthStore,
groupCacheTTL in cache.go): a malformed duration, a duration <= 0, or
CacheTTL set while the global cache block is not configured is a
constructor error naming the group, mirroring Cache:true's existing
"nothing to inherit from" check.

group carries the resolved time.Duration (0 means inherit).
responseCache.store gains an explicit ttl parameter; routes_unified.go
computes effectiveTTL(g.cache, grp) immediately before each store call,
so setEx's Redis-native EX seconds reflect the group's own override.

Tests: constructor-error table, empty-inherits and valid-override
resolution, effectiveTTL inheritance/override, a scripted-RESP-server
proof that store's SET...EX carries a different value per group, and an
end-to-end Gateway.ServeHTTP test with two groups asserting the actual
wire EX value (30 vs 60) differs per group.

README: GroupConfig.cacheTTL config row and a Caching-section paragraph.
This commit is contained in:
2026-08-20 23:25:06 +01:00
parent 8eb06a4b8b
commit 1ae8555255
8 changed files with 329 additions and 17 deletions
+68
View File
@@ -190,6 +190,74 @@ func TestNewAuthStore_NoUsers_OK(t *testing.T) {
}
}
// --- GroupConfig.CacheTTL: constructor validation (item B) ---
// TestNewAuthStore_GroupCacheTTL_ConstructorErrors is the constructor-error
// table for GroupConfig.CacheTTL: a malformed duration, a zero duration, a
// negative duration, and CacheTTL set while the global cache block is not
// configured (nothing to inherit ttl from, the same reasoning
// newAuthStore already applies to Cache:true) must all fail construction.
func TestNewAuthStore_GroupCacheTTL_ConstructorErrors(t *testing.T) {
tests := []struct {
name string
cacheTTL string
globalCache bool
}{
{name: "malformed duration", cacheTTL: "not-a-duration", globalCache: true},
{name: "zero duration", cacheTTL: "0s", globalCache: true},
{name: "negative duration", cacheTTL: "-5s", globalCache: true},
{name: "set without global cache enabled", cacheTTL: "5m", globalCache: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"default": {CacheTTL: tt.cacheTTL}},
Cache: CacheConfig{Enabled: tt.globalCache},
}
if _, err := newAuthStore(cfg); err == nil {
t.Fatalf("newAuthStore: want a constructor error for cacheTTL=%q (globalCache=%v)", tt.cacheTTL, tt.globalCache)
}
})
}
}
// TestNewAuthStore_GroupCacheTTL_EmptyInheritsZero proves an omitted
// CacheTTL resolves to group.cacheTTL == 0 — effectiveTTL's (cache.go)
// signal to inherit the global cache TTL rather than override it.
func TestNewAuthStore_GroupCacheTTL_EmptyInheritsZero(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"default": {}},
}
a, err := newAuthStore(cfg)
if err != nil {
t.Fatalf("newAuthStore: %v", err)
}
if got := a.groups["default"].cacheTTL; got != 0 {
t.Errorf("groups[default].cacheTTL = %v, want 0 (inherit)", got)
}
}
// TestNewAuthStore_GroupCacheTTL_ValidOverride_ResolvedOnGroup proves a
// well-formed, positive CacheTTL with the global cache block configured
// both constructs cleanly and resolves onto the group struct exactly,
// ready for effectiveTTL to read.
func TestNewAuthStore_GroupCacheTTL_ValidOverride_ResolvedOnGroup(t *testing.T) {
cfg := &Config{
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
Groups: map[string]*GroupConfig{"default": {CacheTTL: "30s"}},
Cache: CacheConfig{Enabled: true},
}
a, err := newAuthStore(cfg)
if err != nil {
t.Fatalf("newAuthStore: %v", err)
}
if got := a.groups["default"].cacheTTL; got != 30*time.Second {
t.Errorf("groups[default].cacheTTL = %v, want 30s", got)
}
}
func TestGroup_AllowsProvider_EmptyListAllowsAll(t *testing.T) {
grp := &group{name: "eng"}
if !grp.allowsProvider("openai") {