Files
viettranx 175e05287a fix(cache): heartbeat/cron used UUID in session keys causing stale cache after agent update
Session keys must use agentKey (human-readable, e.g. "default") not UUID.
Cache invalidation via InvalidateAgent(agentKey) only matches agentKey suffix,
so UUID-keyed entries were never cleared — causing stale provider/model after
agent updates from the web UI.

- heartbeat: use agentKey (already resolved) instead of UUID for session key
  and HasActiveSessionsForAgent check
- cron: resolve agentKey from UUID via agentStore before building session key
- agents_update: also invalidate by UUID as safety net for legacy entries
- startup migration: auto-fix existing UUID-based session keys in DB
- tests: 7 new tests covering session key format and cache invalidation
2026-03-28 19:02:53 +07:00

308 lines
8.6 KiB
Go

package pg
import (
"context"
"database/sql"
"encoding/json"
"log/slog"
"maps"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// PGSessionStore implements store.SessionStore backed by Postgres.
type PGSessionStore struct {
db *sql.DB
mu sync.RWMutex
// In-memory cache for hot sessions (reduces DB reads during tool loops)
cache map[string]*store.SessionData
// OnDelete is called with the session key when a session is deleted.
// Used for media file cleanup.
OnDelete func(sessionKey string)
}
func NewPGSessionStore(db *sql.DB) *PGSessionStore {
s := &PGSessionStore{
db: db,
cache: make(map[string]*store.SessionData),
}
s.migrateLegacyWSKeys()
s.migrateUUIDSessionKeys()
return s
}
// migrateLegacyWSKeys renames old WS session keys from non-canonical format
// (agent:X:ws-userId-ts) to canonical format (agent:X:ws:direct:ts).
// The last hyphen-delimited segment is the base36 timestamp used as convId.
// Idempotent — no-op if no legacy keys exist.
func (s *PGSessionStore) migrateLegacyWSKeys() {
res, err := s.db.ExecContext(context.Background(), `
UPDATE sessions
SET session_key = regexp_replace(
session_key,
'^(agent:[^:]+):ws-.+-([^-]+)$',
'\1:ws:direct:\2'
)
WHERE session_key ~ '^agent:[^:]+:ws-'
`)
if err != nil {
slog.Warn("sessions.migrate_legacy_ws_keys", "error", err)
return
}
if n, _ := res.RowsAffected(); n > 0 {
slog.Info("sessions.migrate_legacy_ws_keys", "migrated", n)
}
}
// migrateUUIDSessionKeys fixes legacy heartbeat/cron session keys that used the agent's
// UUID instead of agentKey. The old format "agent:{UUID}:heartbeat" or "agent:{UUID}:cron:..."
// is replaced with "agent:{agentKey}:..." by JOINing with the agents table.
// Idempotent — no-op if no UUID-based keys exist.
func (s *PGSessionStore) migrateUUIDSessionKeys() {
// UUID pattern: 8-4-4-4-12 hex chars. Matches session keys where the agent segment is a UUID.
// Rewrites to use agents.agent_key instead.
// Build the target key and skip rows where the target already exists (avoids unique constraint violation
// when both UUID-keyed and agentKey-keyed sessions coexist for the same agent).
res, err := s.db.ExecContext(context.Background(), `
UPDATE sessions s
SET session_key = 'agent:' || a.agent_key || ':' || split_part(s.session_key, ':', 3)
|| CASE WHEN array_length(string_to_array(s.session_key, ':'), 1) > 3
THEN ':' || (SELECT string_agg(part, ':') FROM unnest((string_to_array(s.session_key, ':'))[4:]) AS part)
ELSE '' END
FROM agents a
WHERE s.session_key ~ '^agent:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:'
AND a.id = (split_part(s.session_key, ':', 2))::uuid
AND a.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM sessions s2
WHERE s2.tenant_id = s.tenant_id
AND s2.session_key = 'agent:' || a.agent_key || ':' || split_part(s.session_key, ':', 3)
|| CASE WHEN array_length(string_to_array(s.session_key, ':'), 1) > 3
THEN ':' || (SELECT string_agg(p, ':') FROM unnest((string_to_array(s.session_key, ':'))[4:]) AS p)
ELSE '' END
)
`)
if err != nil {
slog.Warn("sessions.migrate_uuid_keys", "error", err)
return
}
if n, _ := res.RowsAffected(); n > 0 {
slog.Info("sessions.migrate_uuid_keys", "migrated", n)
}
}
// sessionCacheKey prefixes session key with tenant UUID to prevent cross-tenant cache collisions.
// Two tenants with the same agent_key produce different cache keys.
func sessionCacheKey(ctx context.Context, key string) string {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
return tid.String() + ":" + key
}
func (s *PGSessionStore) GetOrCreate(ctx context.Context, key string) *store.SessionData {
s.mu.Lock()
defer s.mu.Unlock()
if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return cached
}
data := s.loadFromDB(ctx, key)
if data != nil {
s.cache[sessionCacheKey(ctx, key)] = data
return data
}
// Create new
now := time.Now()
data = &store.SessionData{
Key: key,
Messages: []providers.Message{},
Created: now,
Updated: now,
}
// Extract team_id from team session keys (agent:{agentId}:team:{teamId}:{chatId}).
var teamID *uuid.UUID
if parts := strings.SplitN(key, ":", 5); len(parts) >= 4 && parts[2] == "team" {
if tid, err := uuid.Parse(parts[3]); err == nil {
teamID = &tid
data.TeamID = teamID
}
}
s.cache[sessionCacheKey(ctx, key)] = data
msgsJSON, _ := json.Marshal([]providers.Message{})
s.db.ExecContext(ctx,
`INSERT INTO sessions (id, session_key, messages, created_at, updated_at, team_id, tenant_id)
VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (tenant_id, session_key) DO NOTHING`,
uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, teamID, tenantIDForInsert(ctx),
)
return data
}
// Get returns the session if it exists (cache or DB), nil otherwise. Never creates.
func (s *PGSessionStore) Get(ctx context.Context, key string) *store.SessionData {
s.mu.RLock()
if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
s.mu.RUnlock()
return cached
}
s.mu.RUnlock()
s.mu.Lock()
defer s.mu.Unlock()
// Double-check after acquiring write lock
if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return cached
}
data := s.loadFromDB(ctx, key)
if data != nil {
s.cache[sessionCacheKey(ctx, key)] = data
}
return data
}
func (s *PGSessionStore) AddMessage(ctx context.Context, key string, msg providers.Message) {
s.mu.Lock()
defer s.mu.Unlock()
// Stamp message creation time if not already set.
if msg.CreatedAt == nil {
now := time.Now().UTC()
msg.CreatedAt = &now
}
data := s.getOrInit(ctx, key)
data.Messages = append(data.Messages, msg)
data.Updated = time.Now()
}
func (s *PGSessionStore) GetHistory(ctx context.Context, key string) []providers.Message {
s.mu.RLock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
msgs := make([]providers.Message, len(data.Messages))
copy(msgs, data.Messages)
s.mu.RUnlock()
return msgs
}
s.mu.RUnlock()
// Not in cache — load from DB and cache it
s.mu.Lock()
defer s.mu.Unlock()
// Double-check after acquiring write lock
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
msgs := make([]providers.Message, len(data.Messages))
copy(msgs, data.Messages)
return msgs
}
data := s.loadFromDB(ctx, key)
if data == nil {
return nil
}
s.cache[sessionCacheKey(ctx, key)] = data
msgs := make([]providers.Message, len(data.Messages))
copy(msgs, data.Messages)
return msgs
}
func (s *PGSessionStore) GetSummary(ctx context.Context, key string) string {
s.mu.RLock()
defer s.mu.RUnlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.Summary
}
return ""
}
func (s *PGSessionStore) SetSummary(ctx context.Context, key, summary string) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.Summary = summary
data.Updated = time.Now()
}
}
func (s *PGSessionStore) GetLabel(ctx context.Context, key string) string {
s.mu.RLock()
defer s.mu.RUnlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.Label
}
return ""
}
func (s *PGSessionStore) SetLabel(ctx context.Context, key, label string) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.Label = label
data.Updated = time.Now()
}
}
func (s *PGSessionStore) GetSessionMetadata(ctx context.Context, key string) map[string]string {
s.mu.RLock()
defer s.mu.RUnlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok && data.Metadata != nil {
out := make(map[string]string, len(data.Metadata))
maps.Copy(out, data.Metadata)
return out
}
return nil
}
func (s *PGSessionStore) SetSessionMetadata(ctx context.Context, key string, metadata map[string]string) {
s.mu.Lock()
defer s.mu.Unlock()
data := s.getOrInit(ctx, key)
if data.Metadata == nil {
data.Metadata = make(map[string]string)
}
maps.Copy(data.Metadata, metadata)
data.Updated = time.Now()
}
func (s *PGSessionStore) SetAgentInfo(ctx context.Context, key string, agentUUID uuid.UUID, userID string) {
s.mu.Lock()
defer s.mu.Unlock()
data := s.getOrInit(ctx, key)
if agentUUID != uuid.Nil {
data.AgentUUID = agentUUID
}
if userID != "" {
data.UserID = userID
}
}
func (s *PGSessionStore) UpdateMetadata(ctx context.Context, key, model, provider, channel string) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
if model != "" {
data.Model = model
}
if provider != "" {
data.Provider = provider
}
if channel != "" {
data.Channel = channel
}
}
}