mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-18 05:47:38 +00:00
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:
+64
-5
@@ -425,7 +425,7 @@ func TestResponseCache_Store_SendsSETWithTTL(t *testing.T) {
|
||||
c := newResponseCache(client, 90*time.Second, defaultCacheMaxBodyBytes, noop, noop)
|
||||
c.nowFn = time.Now
|
||||
|
||||
c.store("llmgw:cache:k", 200, "application/json", []byte(`{"ok":true}`))
|
||||
c.store("llmgw:cache:k", 200, "application/json", []byte(`{"ok":true}`), 90*time.Second)
|
||||
}
|
||||
|
||||
func TestResponseCache_Store_OversizeBody_SkipsSilentlyLogged(t *testing.T) {
|
||||
@@ -443,7 +443,7 @@ func TestResponseCache_Store_OversizeBody_SkipsSilentlyLogged(t *testing.T) {
|
||||
c := newResponseCache(client, time.Minute, 4, logf, errorf) // maxBodyBytes=4
|
||||
c.nowFn = time.Now
|
||||
|
||||
c.store("k", 200, "text/plain", []byte("way too big"))
|
||||
c.store("k", 200, "text/plain", []byte("way too big"), time.Minute)
|
||||
|
||||
if len(logged) != 1 {
|
||||
t.Fatalf("logged = %d lines, want 1 (oversize skip is logged via logf, not errorf)", len(logged))
|
||||
@@ -464,12 +464,12 @@ func TestResponseCache_Store_RedisDown_LoggedRateLimited(t *testing.T) {
|
||||
now := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC)
|
||||
c.nowFn = func() time.Time { return now }
|
||||
|
||||
c.store("k", 200, "application/json", []byte(`{}`))
|
||||
c.store("k", 200, "application/json", []byte(`{}`), time.Minute)
|
||||
if len(logged) != 1 {
|
||||
t.Fatalf("logged = %d lines after the first failed store, want 1", len(logged))
|
||||
}
|
||||
|
||||
c.store("k", 200, "application/json", []byte(`{}`))
|
||||
c.store("k", 200, "application/json", []byte(`{}`), time.Minute)
|
||||
if len(logged) != 1 {
|
||||
t.Fatalf("logged = %d lines still inside the rate-limit window, want 1", len(logged))
|
||||
}
|
||||
@@ -526,7 +526,7 @@ func TestResponseCache_DownLatch_SkipsNetworkCallsWithinWindow(t *testing.T) {
|
||||
}
|
||||
|
||||
// store while latched: also zero network calls.
|
||||
c.store("k", 200, "application/json", []byte(`{}`))
|
||||
c.store("k", 200, "application/json", []byte(`{}`), time.Minute)
|
||||
if stub.setCalls != 0 {
|
||||
t.Fatalf("setCalls = %d while latched, want 0 (no network call attempted)", stub.setCalls)
|
||||
}
|
||||
@@ -630,3 +630,62 @@ func TestNewAuthStore_GroupCacheTrueWithGlobalCacheEnabled_NoError(t *testing.T)
|
||||
t.Fatalf("newAuthStore: %v, want no error when the global cache block is configured", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- effectiveTTL: per-group cache TTL (item B) ---
|
||||
|
||||
// TestEffectiveTTL_NilInheritsGlobal proves a group with no CacheTTL
|
||||
// override (cacheTTL == 0, GroupConfig.CacheTTL left empty) resolves to
|
||||
// the cache's own global TTL.
|
||||
func TestEffectiveTTL_NilInheritsGlobal(t *testing.T) {
|
||||
c := &responseCache{ttl: 5 * time.Minute}
|
||||
grp := &group{name: "g"}
|
||||
if got := effectiveTTL(c, grp); got != 5*time.Minute {
|
||||
t.Errorf("effectiveTTL = %v, want the global TTL 5m (group has no override)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveTTL_GroupOverrideHonored proves a group's own resolved
|
||||
// cacheTTL wins over the cache's global TTL.
|
||||
func TestEffectiveTTL_GroupOverrideHonored(t *testing.T) {
|
||||
c := &responseCache{ttl: 5 * time.Minute}
|
||||
grp := &group{name: "g", cacheTTL: 30 * time.Second}
|
||||
if got := effectiveTTL(c, grp); got != 30*time.Second {
|
||||
t.Errorf("effectiveTTL = %v, want the group override 30s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResponseCache_Store_EffectiveTTLDiffersPerGroup_SETCarriesGroupTTL is
|
||||
// item B's "override honored" proof against a real (scripted) RESP wire:
|
||||
// store's ttl argument — effectiveTTL's per-group resolution, computed by
|
||||
// the caller (routes_unified.go) before store is ever invoked — reaches
|
||||
// the SET...EX command as a genuinely different EX value for a group that
|
||||
// overrides the global TTL (30) versus one that inherits it (60, the
|
||||
// cache's own 1m global TTL). runFakeRESPServer fails the test outright if
|
||||
// either SET carries the wrong EX value.
|
||||
func TestResponseCache_Store_EffectiveTTLDiffersPerGroup_SETCarriesGroupTTL(t *testing.T) {
|
||||
val1, err := json.Marshal(cachedResponse{Status: 200, ContentType: "application/json", Body: []byte(`{"a":1}`)})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
val2, err := json.Marshal(cachedResponse{Status: 200, ContentType: "application/json", Body: []byte(`{"b":2}`)})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
ln := newFakeListener(t)
|
||||
runFakeRESPServer(t, ln, []respStep{
|
||||
{wantArgs: []string{"SELECT", "0"}, reply: []byte("+OK\r\n")},
|
||||
{wantArgs: []string{"SET", "k-override", string(val1), "EX", "30"}, reply: []byte("+OK\r\n")},
|
||||
{wantArgs: []string{"SET", "k-inherit", string(val2), "EX", "60"}, reply: []byte("+OK\r\n")},
|
||||
})
|
||||
client := newRESPClient(ln.Addr().String(), "", 0)
|
||||
noop := func(string, ...any) {}
|
||||
c := newResponseCache(client, time.Minute, defaultCacheMaxBodyBytes, noop, noop) // global ttl = 60s
|
||||
c.nowFn = time.Now
|
||||
|
||||
overrideGrp := &group{name: "override", cacheTTL: 30 * time.Second}
|
||||
inheritGrp := &group{name: "inherit"} // cacheTTL 0 → inherits c.ttl (60s)
|
||||
|
||||
c.store("k-override", 200, "application/json", []byte(`{"a":1}`), effectiveTTL(c, overrideGrp))
|
||||
c.store("k-inherit", 200, "application/json", []byte(`{"b":2}`), effectiveTTL(c, inheritGrp))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user