From 0467019f244bfe4a3c9da8df2ea4fe9d24811941 Mon Sep 17 00:00:00 2001 From: lyon Date: Mon, 22 Jun 2026 15:30:11 +0800 Subject: [PATCH] test: cover workbench redis cache regressions --- go.mod | 2 + go.sum | 4 + internal/workbenchruntime/cache_test.go | 111 ++++++++++++++++++++++++ 3 files changed, 117 insertions(+) diff --git a/go.mod b/go.mod index 0ac27844..861789f4 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/pikasTech/HWLAB go 1.18 require ( + github.com/alicebob/miniredis/v2 v2.38.0 github.com/jackc/pgx/v4 v4.18.3 github.com/redis/go-redis/v9 v9.7.0 ) @@ -17,6 +18,7 @@ require ( github.com/jackc/pgproto3/v2 v2.3.3 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgtype v1.14.0 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect golang.org/x/crypto v0.27.0 // indirect golang.org/x/text v0.18.0 // indirect ) diff --git a/go.sum b/go.sum index 9a5d04d7..9f4e6784 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= @@ -110,6 +112,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= diff --git a/internal/workbenchruntime/cache_test.go b/internal/workbenchruntime/cache_test.go index 3fbd32a9..170f53f7 100644 --- a/internal/workbenchruntime/cache_test.go +++ b/internal/workbenchruntime/cache_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" ) @@ -94,6 +95,93 @@ func TestDisabledCacheDoesNotFailReadPath(t *testing.T) { } } +func TestDerivedCacheHitMissAndMetricsWithRedis(t *testing.T) { + redisServer := miniredis.RunT(t) + cache := newDerivedCache(cacheConfig{Enabled: true, RedisURL: "redis://" + redisServer.Addr() + "/0", ConnectTimeout: 100 * time.Millisecond, OperationTimeout: 100 * time.Millisecond, MaxKeyBytes: 512, MaxPayloadBytes: 4096}) + defer cache.Close() + key, err := cache.BuildKey(cacheKeyParts{Class: "sessions.summary", ActorID: "actor-a", Cursor: "idx:0|limit:20", ProjectionRevision: "workbench-sessions-v1", Authority: "contract=workbench-sessions-v1|limit=20"}) + if err != nil { + t.Fatalf("build key: %v", err) + } + + var missTarget map[string]any + hit, err := cache.Get(context.Background(), key, &missTarget) + if err != nil || hit { + t.Fatalf("expected first read miss without error, hit=%v err=%v", hit, err) + } + payload := map[string]any{"contractVersion": "workbench-sessions-v1", "sessions": []any{map[string]any{"sessionId": "ses_cache_test", "terminal": true, "valuesRedacted": true}}, "valuesRedacted": true} + if err := cache.Set(context.Background(), key, payload, time.Minute); err != nil { + t.Fatalf("set cache payload: %v", err) + } + var got map[string]any + hit, err = cache.Get(context.Background(), key, &got) + if err != nil || !hit { + t.Fatalf("expected second read hit, hit=%v err=%v", hit, err) + } + if got["contractVersion"] != "workbench-sessions-v1" || got["valuesRedacted"] != true { + t.Fatalf("unexpected cached payload: %#v", got) + } + + diagnostic := cache.Diagnostic(context.Background()) + metrics := objectMap(diagnostic["metrics"]) + if metrics["hits"] != int64(1) || metrics["misses"] != int64(1) || metrics["sets"] != int64(1) || diagnostic["available"] != true { + t.Fatalf("unexpected cache diagnostic: %#v", diagnostic) + } + text := cache.PrometheusText(serviceID) + for _, want := range []string{`cache_key_class="sessions.summary"`, `cache_status="miss"`, `cache_status="hit"`, `cache_status="stored"`, "workbench_cache_payload_bytes"} { + if !strings.Contains(text, want) { + t.Fatalf("metrics missing %s in:\n%s", want, text) + } + } + if strings.Contains(text, "actor-a") || strings.Contains(text, key) || strings.Contains(text, "ses_cache_test") { + t.Fatalf("metrics leaked actor, full key, or payload identity:\n%s", text) + } +} + +func TestDerivedCacheUnavailableIsObservableWithoutFallbackAuthority(t *testing.T) { + redisServer := miniredis.RunT(t) + addr := redisServer.Addr() + redisServer.Close() + cache := newDerivedCache(cacheConfig{Enabled: true, RedisURL: "redis://" + addr + "/0", ConnectTimeout: 50 * time.Millisecond, OperationTimeout: 50 * time.Millisecond, MaxKeyBytes: 512, MaxPayloadBytes: 4096}) + defer cache.Close() + key, err := cache.BuildKey(cacheKeyParts{Class: "sessions.summary", ActorID: "actor-a", Cursor: "idx:0|limit:20", ProjectionRevision: "workbench-sessions-v1", Authority: "contract=workbench-sessions-v1|limit=20"}) + if err != nil { + t.Fatalf("build key: %v", err) + } + + var target map[string]any + hit, err := cache.Get(context.Background(), key, &target) + errKind := cacheErrKind(err) + if hit || (errKind != "cache_unavailable" && errKind != "cache_timeout") { + t.Fatalf("expected unavailable/timeout miss, hit=%v err=%v kind=%s", hit, err, errKind) + } + diagnostic := cache.Diagnostic(context.Background()) + if diagnostic["available"] != false || diagnostic["degraded"] != true || diagnostic["role"] != "derived-read-cache" { + t.Fatalf("unexpected unavailable diagnostic: %#v", diagnostic) + } + text := cache.PrometheusText(serviceID) + if !strings.Contains(text, `cache_status="unavailable"`) || !strings.Contains(text, "workbench_cache_unavailable_total") { + t.Fatalf("unavailable metrics missing:\n%s", text) + } +} + +func TestStaleCacheMetadataDoesNotClaimDBAvoided(t *testing.T) { + now := time.Now().UTC() + entry := sessionsCacheEntry{CachedAt: now.Add(-2 * time.Minute).Format(time.RFC3339Nano), Payload: map[string]any{"sessions": []any{}}, PayloadBytes: 64, TTLMillis: int64((30 * time.Second).Milliseconds()), ProjectionRevision: "digest:old", ProjectionSeqMax: 7, ValuesRedacted: true} + ageMs, stale := sessionsCacheEntryAgeMs(entry, now) + if !stale || ageMs <= entry.TTLMillis { + t.Fatalf("expected stale cache entry, ageMs=%d ttlMs=%d stale=%v", ageMs, entry.TTLMillis, stale) + } + meta := sessionsCacheMetadata("miss") + setSessionsCacheStatus(meta, "stale") + meta["cacheAgeMs"] = ageMs + meta["freshnessSloMs"] = entry.TTLMillis + applySessionsCacheRevisionMetadata(meta, sessionsSummaryRevision{ProjectionRevision: entry.ProjectionRevision, ProjectionSeqMax: entry.ProjectionSeqMax, RevisionSource: "cached_payload"}) + if meta["hit"] == true || meta["dbQueryAvoided"] == true || meta["status"] != "stale" { + t.Fatalf("stale cache must remain diagnostic and not claim DB avoidance: %#v", meta) + } +} + func TestCachePrometheusMetricsUseLowCardinalityLabels(t *testing.T) { var metrics cacheMetrics metrics.recordCacheObservation("turn.terminal.snapshot", "get", "hit", 12*time.Millisecond, 4096) @@ -211,6 +299,29 @@ func TestProjectionRevisionTokenUsesProjectedSeq(t *testing.T) { } } +func TestFactsCacheKeyChangesWithProjectionRevision(t *testing.T) { + config := cacheConfig{Enabled: true, RedisURL: "redis://localhost:6379/0", MaxKeyBytes: 512} + cache := newDerivedCache(config) + defer cache.Close() + base := cacheKeyParts{Class: terminalTurnCacheClass, ActorID: "workbench-runtime", TraceID: "trc-cache-key", Cursor: "after:0|limit:100|families:sessions,messages,turns,checkpoints", Authority: "contract=workbench-runtime-facts-v1|class=turn.terminal.snapshot|projectionStatus=caught-up|projectionHealth=healthy"} + base.ProjectionSeq = 41 + keyA, err := cache.BuildKey(base) + if err != nil { + t.Fatalf("build key A: %v", err) + } + base.ProjectionSeq = 42 + keyB, err := cache.BuildKey(base) + if err != nil { + t.Fatalf("build key B: %v", err) + } + if keyA == keyB { + t.Fatalf("projection revision must invalidate facts cache key: %q", keyA) + } + if strings.Contains(keyA, "trc-cache-key") || strings.Contains(keyB, "trc-cache-key") { + t.Fatalf("facts cache keys must hash raw trace ids: %s / %s", keyA, keyB) + } +} + func TestFactsCacheClassAndTTL(t *testing.T) { tracePage := factQuery{TraceID: "trc_1", Families: []string{"traceEvents"}, AfterProjectedSeq: 10, Limit: 51} if got := factsCacheClass(tracePage); got != terminalTracePageCacheClass {