Merge pull request #1895 from pikasTech/fix/1870-p6-cache-consistency

fix: 补齐 Workbench Redis cache freshness 诊断
This commit is contained in:
Lyon
2026-06-22 14:19:00 +08:00
committed by GitHub
2 changed files with 140 additions and 7 deletions
+32
View File
@@ -158,6 +158,38 @@ func TestSessionsSummaryCacheKeyActorIsolation(t *testing.T) {
} }
} }
func TestSessionsSummaryRevisionMetadataExplainsFreshness(t *testing.T) {
payload := map[string]any{"sessions": []any{
map[string]any{"sessionId": "ses_1", "projectedSeq": int64(12), "updatedAt": "2026-06-22T06:00:00Z", "projection": map[string]any{"lastProjectedSeq": int64(30)}},
map[string]any{"sessionId": "ses_2", "projectedSeq": int64(44), "updatedAt": "2026-06-22T06:01:00Z", "turnSummary": map[string]any{"eventCount": int64(41)}},
}}
revision := sessionsSummaryRevisionForPayload(payload)
if revision.SessionCount != 2 || revision.ProjectionSeqMax != 44 || revision.SessionUpdatedAtMax != "2026-06-22T06:01:00Z" {
t.Fatalf("unexpected revision: %#v", revision)
}
if !strings.HasPrefix(revision.ProjectionRevision, "digest:") {
t.Fatalf("revision should use a bounded digest, got %q", revision.ProjectionRevision)
}
meta := sessionsCacheMetadata("miss")
applySessionsCacheRevisionMetadata(meta, revision)
if meta["invalidationMode"] != "ttl_slo" || meta["revisionSource"] != "response_payload" {
t.Fatalf("unexpected freshness metadata: %#v", meta)
}
if meta["projectionRevision"] == nil || meta["projectionSeq"] != int64(44) || meta["sessionCount"] != 2 {
t.Fatalf("missing revision metadata: %#v", meta)
}
}
func TestProjectionRevisionTokenUsesProjectedSeq(t *testing.T) {
if got := projectionRevisionToken(42); got != "seq:42" {
t.Fatalf("projection revision token=%q", got)
}
if got := projectionRevisionToken(0); got != "" {
t.Fatalf("zero projection revision token=%q", got)
}
}
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 {
+108 -7
View File
@@ -30,11 +30,15 @@ const (
) )
type sessionsCacheEntry struct { type sessionsCacheEntry struct {
CachedAt string `json:"cachedAt"` CachedAt string `json:"cachedAt"`
Payload map[string]any `json:"payload"` Payload map[string]any `json:"payload"`
PayloadBytes int `json:"payloadBytes"` PayloadBytes int `json:"payloadBytes"`
TTLMillis int64 `json:"ttlMs"` TTLMillis int64 `json:"ttlMs"`
ValuesRedacted bool `json:"valuesRedacted"` ProjectionRevision string `json:"projectionRevision,omitempty"`
ProjectionSeqMax int64 `json:"projectionSeqMax,omitempty"`
SessionUpdatedAtMax string `json:"sessionUpdatedAtMax,omitempty"`
SessionCount int `json:"sessionCount,omitempty"`
ValuesRedacted bool `json:"valuesRedacted"`
} }
type factsCacheEntry struct { type factsCacheEntry struct {
@@ -56,6 +60,14 @@ type factsCachePlan struct {
Status string Status string
} }
type sessionsSummaryRevision struct {
ProjectionRevision string
ProjectionSeqMax int64
SessionUpdatedAtMax string
SessionCount int
RevisionSource string
}
type Config struct { type Config struct {
Addr string Addr string
DatabaseURL string DatabaseURL string
@@ -283,11 +295,13 @@ func (s *Server) queryFactsResponse(ctx context.Context, query factQuery) (map[s
plan.Meta["dbQueryDurationMs"] = int64(0) plan.Meta["dbQueryDurationMs"] = int64(0)
plan.Meta["payloadBytes"] = entry.PayloadBytes plan.Meta["payloadBytes"] = entry.PayloadBytes
plan.Meta["ttlMs"] = entry.TTLMillis plan.Meta["ttlMs"] = entry.TTLMillis
plan.Meta["freshnessSloMs"] = entry.TTLMillis
attachFactsCacheMetadata(payload, plan.Meta) attachFactsCacheMetadata(payload, plan.Meta)
return payload, nil return payload, nil
} }
setFactsCacheStatus(plan.Meta, "stale") setFactsCacheStatus(plan.Meta, "stale")
plan.Meta["cacheAgeMs"] = ageMs plan.Meta["cacheAgeMs"] = ageMs
plan.Meta["freshnessSloMs"] = entry.TTLMillis
} }
} }
@@ -308,6 +322,7 @@ func (s *Server) queryFactsResponse(ctx context.Context, query factQuery) (map[s
ttl, unstable, ttlReason := factsCacheTTL(plan, s.config.Cache) ttl, unstable, ttlReason := factsCacheTTL(plan, s.config.Cache)
plan.Meta["unstable"] = unstable plan.Meta["unstable"] = unstable
plan.Meta["ttlMs"] = ttl.Milliseconds() plan.Meta["ttlMs"] = ttl.Milliseconds()
plan.Meta["freshnessSloMs"] = ttl.Milliseconds()
plan.Meta["ttlReason"] = ttlReason plan.Meta["ttlReason"] = ttlReason
storedPayload := factsCacheStorablePayload(payload) storedPayload := factsCacheStorablePayload(payload)
payloadBytes, payloadErr := cachePayloadBytes(storedPayload, s.config.Cache.MaxPayloadBytes) payloadBytes, payloadErr := cachePayloadBytes(storedPayload, s.config.Cache.MaxPayloadBytes)
@@ -377,9 +392,12 @@ func (s *Server) factsCachePlan(ctx context.Context, query factQuery) factsCache
plan.ProjectionHealth = state.ProjectionHealth plan.ProjectionHealth = state.ProjectionHealth
plan.Status = state.Status plan.Status = state.Status
meta["projectionSeq"] = state.ProjectedSeq meta["projectionSeq"] = state.ProjectedSeq
meta["projectionRevision"] = projectionRevisionToken(state.ProjectedSeq)
meta["projectionStatus"] = state.ProjectionStatus meta["projectionStatus"] = state.ProjectionStatus
meta["projectionHealth"] = state.ProjectionHealth meta["projectionHealth"] = state.ProjectionHealth
meta["traceStatus"] = state.Status meta["traceStatus"] = state.Status
meta["revisionSource"] = "workbench_projection_checkpoints"
meta["invalidationMode"] = "projection_revision_key"
if state.ProjectedSeq <= 0 { if state.ProjectedSeq <= 0 {
setFactsCacheStatus(meta, "skipped") setFactsCacheStatus(meta, "skipped")
meta["skipReason"] = "missing_projection_revision" meta["skipReason"] = "missing_projection_revision"
@@ -513,6 +531,10 @@ func factsCacheMetadata(class string, status string) map[string]any {
"dbQueryDurationMs": nil, "dbQueryDurationMs": nil,
"payloadBytes": nil, "payloadBytes": nil,
"ttlMs": nil, "ttlMs": nil,
"freshnessSloMs": nil,
"projectionRevision": nil,
"invalidationMode": nil,
"revisionSource": nil,
"valuesRedacted": true, "valuesRedacted": true,
"secretMaterialStored": false, "secretMaterialStored": false,
} }
@@ -707,11 +729,15 @@ func (s *Server) listSessions(ctx context.Context, query sessionListQuery) (map[
cacheMeta["dbQueryDurationMs"] = int64(0) cacheMeta["dbQueryDurationMs"] = int64(0)
cacheMeta["payloadBytes"] = entry.PayloadBytes cacheMeta["payloadBytes"] = entry.PayloadBytes
cacheMeta["ttlMs"] = entry.TTLMillis cacheMeta["ttlMs"] = entry.TTLMillis
cacheMeta["freshnessSloMs"] = entry.TTLMillis
applySessionsCacheRevisionMetadata(cacheMeta, sessionsSummaryRevision{ProjectionRevision: entry.ProjectionRevision, ProjectionSeqMax: entry.ProjectionSeqMax, SessionUpdatedAtMax: entry.SessionUpdatedAtMax, SessionCount: entry.SessionCount, RevisionSource: "cached_payload"})
attachSessionsCacheMetadata(payload, cacheMeta) attachSessionsCacheMetadata(payload, cacheMeta)
return payload, nil return payload, nil
} }
setSessionsCacheStatus(cacheMeta, "stale") setSessionsCacheStatus(cacheMeta, "stale")
cacheMeta["cacheAgeMs"] = ageMs cacheMeta["cacheAgeMs"] = ageMs
cacheMeta["freshnessSloMs"] = entry.TTLMillis
applySessionsCacheRevisionMetadata(cacheMeta, sessionsSummaryRevision{ProjectionRevision: entry.ProjectionRevision, ProjectionSeqMax: entry.ProjectionSeqMax, SessionUpdatedAtMax: entry.SessionUpdatedAtMax, SessionCount: entry.SessionCount, RevisionSource: "cached_payload"})
} }
} }
@@ -731,7 +757,10 @@ func (s *Server) listSessions(ctx context.Context, query sessionListQuery) (map[
ttl, unstable, ttlReason := sessionsSummaryCacheTTL(payload, s.config.Cache) ttl, unstable, ttlReason := sessionsSummaryCacheTTL(payload, s.config.Cache)
cacheMeta["unstable"] = unstable cacheMeta["unstable"] = unstable
cacheMeta["ttlMs"] = ttl.Milliseconds() cacheMeta["ttlMs"] = ttl.Milliseconds()
cacheMeta["freshnessSloMs"] = ttl.Milliseconds()
cacheMeta["ttlReason"] = ttlReason cacheMeta["ttlReason"] = ttlReason
revision := sessionsSummaryRevisionForPayload(payload)
applySessionsCacheRevisionMetadata(cacheMeta, revision)
storedPayload := sessionsCacheStorablePayload(payload) storedPayload := sessionsCacheStorablePayload(payload)
payloadBytes, payloadErr := cachePayloadBytes(storedPayload, s.config.Cache.MaxPayloadBytes) payloadBytes, payloadErr := cachePayloadBytes(storedPayload, s.config.Cache.MaxPayloadBytes)
if payloadErr != nil { if payloadErr != nil {
@@ -747,7 +776,7 @@ func (s *Server) listSessions(ctx context.Context, query sessionListQuery) (map[
attachSessionsCacheMetadata(payload, cacheMeta) attachSessionsCacheMetadata(payload, cacheMeta)
return payload, nil return payload, nil
} }
entry := sessionsCacheEntry{CachedAt: time.Now().UTC().Format(time.RFC3339Nano), Payload: storedPayload, PayloadBytes: len(payloadBytes), TTLMillis: ttl.Milliseconds(), ValuesRedacted: true} entry := sessionsCacheEntry{CachedAt: time.Now().UTC().Format(time.RFC3339Nano), Payload: storedPayload, PayloadBytes: len(payloadBytes), TTLMillis: ttl.Milliseconds(), ProjectionRevision: revision.ProjectionRevision, ProjectionSeqMax: revision.ProjectionSeqMax, SessionUpdatedAtMax: revision.SessionUpdatedAtMax, SessionCount: revision.SessionCount, ValuesRedacted: true}
if err := s.cache.Set(ctx, cacheKey, entry, ttl); err != nil { if err := s.cache.Set(ctx, cacheKey, entry, ttl); err != nil {
setSessionsCacheStatus(cacheMeta, "set_failed") setSessionsCacheStatus(cacheMeta, "set_failed")
cacheMeta["errorKind"] = classifyCacheError(err) cacheMeta["errorKind"] = classifyCacheError(err)
@@ -863,6 +892,13 @@ func sessionsCacheMetadata(status string) map[string]any {
"dbQueryDurationMs": nil, "dbQueryDurationMs": nil,
"payloadBytes": nil, "payloadBytes": nil,
"ttlMs": nil, "ttlMs": nil,
"freshnessSloMs": nil,
"projectionRevision": nil,
"projectionSeq": nil,
"sessionUpdatedAtMax": nil,
"sessionCount": nil,
"revisionSource": nil,
"invalidationMode": "ttl_slo",
"valuesRedacted": true, "valuesRedacted": true,
"secretMaterialStored": false, "secretMaterialStored": false,
} }
@@ -901,6 +937,68 @@ func sessionsSummaryCacheTTL(payload map[string]any, config cacheConfig) (time.D
return config.SessionsSummaryTTL, false, "stable_page" return config.SessionsSummaryTTL, false, "stable_page"
} }
func sessionsSummaryRevisionForPayload(payload map[string]any) sessionsSummaryRevision {
rows := sessionsPayloadRows(payload["sessions"])
revision := sessionsSummaryRevision{RevisionSource: "response_payload"}
maxUpdatedAt := ""
var maxSeq int64
for _, row := range rows {
item := objectMap(row)
if len(item) == 0 {
continue
}
revision.SessionCount++
if updatedAt := text(item["updatedAt"]); updatedAt > maxUpdatedAt {
maxUpdatedAt = updatedAt
}
maxSeq = maxInt64(maxSeq, int64FromAny(item["projectedSeq"]))
projection := objectMap(item["projection"])
maxSeq = maxInt64(maxSeq, int64FromAny(projection["lastProjectedSeq"]))
turnSummary := objectMap(item["turnSummary"])
maxSeq = maxInt64(maxSeq, int64FromAny(turnSummary["eventCount"]))
}
revision.ProjectionSeqMax = maxSeq
revision.SessionUpdatedAtMax = maxUpdatedAt
raw := fmt.Sprintf("contract=workbench-sessions-v1|count=%d|maxProjectionSeq=%d|maxUpdatedAt=%s", revision.SessionCount, revision.ProjectionSeqMax, revision.SessionUpdatedAtMax)
revision.ProjectionRevision = "digest:" + cacheHash(raw)
return revision
}
func applySessionsCacheRevisionMetadata(meta map[string]any, revision sessionsSummaryRevision) {
if meta == nil {
return
}
if revision.ProjectionRevision != "" {
meta["projectionRevision"] = revision.ProjectionRevision
}
meta["projectionSeq"] = revision.ProjectionSeqMax
meta["sessionUpdatedAtMax"] = nilIfEmpty(revision.SessionUpdatedAtMax)
meta["sessionCount"] = revision.SessionCount
meta["revisionSource"] = first(revision.RevisionSource, "response_payload")
meta["invalidationMode"] = "ttl_slo"
}
func projectionRevisionToken(projectedSeq int64) string {
if projectedSeq <= 0 {
return ""
}
return "seq:" + strconv.FormatInt(projectedSeq, 10)
}
func maxInt64(a int64, b int64) int64 {
if b > a {
return b
}
return a
}
func nilIfEmpty(value string) any {
if strings.TrimSpace(value) == "" {
return nil
}
return value
}
func sessionsSummaryPayloadUnstable(payload map[string]any) bool { func sessionsSummaryPayloadUnstable(payload map[string]any) bool {
for _, row := range sessionsPayloadRows(payload["sessions"]) { for _, row := range sessionsPayloadRows(payload["sessions"]) {
item := objectMap(row) item := objectMap(row)
@@ -1221,8 +1319,9 @@ func (s *Server) querySessionSummariesAttempt(ctx context.Context, stage string,
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var sessionID, ownerUserID, projectID, conversationID, threadID, status, lastTraceID sql.NullString var sessionID, ownerUserID, projectID, conversationID, threadID, status, lastTraceID sql.NullString
var projectedSeq sql.NullInt64
var createdAt, updatedAt sql.NullString var createdAt, updatedAt sql.NullString
if err := rows.Scan(&sessionID, &ownerUserID, &projectID, &conversationID, &threadID, &status, &lastTraceID, &createdAt, &updatedAt); err != nil { if err := rows.Scan(&sessionID, &ownerUserID, &projectID, &conversationID, &threadID, &status, &lastTraceID, &projectedSeq, &createdAt, &updatedAt); err != nil {
return err return err
} }
if !sessionID.Valid || strings.TrimSpace(sessionID.String) == "" { if !sessionID.Valid || strings.TrimSpace(sessionID.String) == "" {
@@ -1237,6 +1336,7 @@ func (s *Server) querySessionSummariesAttempt(ctx context.Context, stage string,
"threadId": nullableString(threadID), "threadId": nullableString(threadID),
"status": first(nullableStringValue(status), "unknown"), "status": first(nullableStringValue(status), "unknown"),
"lastTraceId": nullableString(lastTraceID), "lastTraceId": nullableString(lastTraceID),
"projectedSeq": nullableInt(projectedSeq),
"createdAt": nullableTimeText(createdAt), "createdAt": nullableTimeText(createdAt),
"updatedAt": first(nullableTimeText(updatedAt), nullableTimeText(createdAt)), "updatedAt": first(nullableTimeText(updatedAt), nullableTimeText(createdAt)),
"valuesPrinted": false, "valuesPrinted": false,
@@ -1601,6 +1701,7 @@ func sessionSummarySelectClause() string {
"thread_id", "thread_id",
"status", "status",
"last_trace_id", "last_trace_id",
"projected_seq",
"created_at", "created_at",
"updated_at", "updated_at",
}, ", ") }, ", ")