test: cover workbench redis cache regressions

This commit is contained in:
lyon
2026-06-22 15:30:11 +08:00
parent b86e660133
commit 0467019f24
3 changed files with 117 additions and 0 deletions
+2
View File
@@ -3,6 +3,7 @@ module github.com/pikasTech/HWLAB
go 1.18 go 1.18
require ( require (
github.com/alicebob/miniredis/v2 v2.38.0
github.com/jackc/pgx/v4 v4.18.3 github.com/jackc/pgx/v4 v4.18.3
github.com/redis/go-redis/v9 v9.7.0 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/pgproto3/v2 v2.3.3 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgtype v1.14.0 // 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/crypto v0.27.0 // indirect
golang.org/x/text v0.18.0 // indirect golang.org/x/text v0.18.0 // indirect
) )
+4
View File
@@ -1,6 +1,8 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 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 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= 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/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 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.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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 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= 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.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= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
+111
View File
@@ -7,6 +7,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9" "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) { func TestCachePrometheusMetricsUseLowCardinalityLabels(t *testing.T) {
var metrics cacheMetrics var metrics cacheMetrics
metrics.recordCacheObservation("turn.terminal.snapshot", "get", "hit", 12*time.Millisecond, 4096) 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) { func TestFactsCacheClassAndTTL(t *testing.T) {
tracePage := factQuery{TraceID: "trc_1", Families: []string{"traceEvents"}, AfterProjectedSeq: 10, Limit: 51} tracePage := factQuery{TraceID: "trc_1", Families: []string{"traceEvents"}, AfterProjectedSeq: 10, Limit: 51}
if got := factsCacheClass(tracePage); got != terminalTracePageCacheClass { if got := factsCacheClass(tracePage); got != terminalTracePageCacheClass {