Merge pull request #1895 from pikasTech/fix/1870-p6-cache-consistency
fix: 补齐 Workbench Redis cache freshness 诊断
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
}, ", ")
|
}, ", ")
|
||||||
|
|||||||
Reference in New Issue
Block a user