test: cover workbench redis cache regressions
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user