mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
fix: clear cron session in DB when not cached (cold-cache after restart) (#424)
* fix: clear cron session in DB when not cached (cold-cache after restart) Fixes #365 PGSessionStore.Reset() only cleared the in-memory cache. After a server restart the cache is empty, so Reset was a no-op — the next GetOrCreate loaded the full accumulated history from DB, causing LLM tool loops from contradictory context. Add a DB fallback: when the session isn't in cache, issue a direct UPDATE to clear messages and summary in PostgreSQL. This ensures cron sessions always start clean regardless of server restarts. The #294 fix (Reset+Save before each cron run) already had the right intent but only worked within the same server lifetime. * fix: move DB call outside mutex and log errors in Reset cold-cache path ExecContext was running under s.mu.Lock(), blocking all session cache operations during DB round-trip. Release the lock before the DB call. Also log ExecContext errors instead of silently discarding them — silent failure defeats the purpose of the cold-cache fix. --------- Co-authored-by: viettranx <viettranx@gmail.com>
This commit is contained in:
1 parent
4276c043c6
commit
ebc82d3265
2 files changed
+130
-1
No files matched your search
@@ -2,6 +2,7 @@ package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
@@ -31,11 +32,24 @@ func (s *PGSessionStore) SetHistory(ctx context.Context, key string, msgs []prov
|
||||
|
||||
func (s *PGSessionStore) Reset(ctx context.Context, key string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
|
||||
data.Messages = []providers.Message{}
|
||||
data.Summary = ""
|
||||
data.Updated = time.Now()
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Session not in cache (e.g. after server restart). Clear directly in DB
|
||||
// so the next GetOrCreate loads a clean session instead of stale history.
|
||||
tid := tenantIDForInsert(ctx)
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE sessions SET messages = '[]', summary = '', updated_at = $1
|
||||
WHERE session_key = $2 AND tenant_id = $3`,
|
||||
time.Now(), key, tid,
|
||||
); err != nil {
|
||||
slog.Warn("sessions.reset_db_fallback_failed", "key", key, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// TestReset_ColdCache_FallsBackToDB verifies that after the fix, Reset on a
|
||||
// cold cache issues a direct DB UPDATE instead of silently doing nothing.
|
||||
// Without a real DB the ExecContext is a no-op (db is nil), but the code path
|
||||
// is exercised. With a real DB, the UPDATE clears messages and summary.
|
||||
func TestReset_ColdCache_FallsBackToDB(t *testing.T) {
|
||||
s := &PGSessionStore{cache: make(map[string]*store.SessionData)}
|
||||
ctx := context.Background()
|
||||
key := "agent:abc:cron:job-123"
|
||||
cacheKey := sessionCacheKey(ctx, key)
|
||||
|
||||
// Session is NOT in cache (simulates restart — data only in DB).
|
||||
// Before the fix, this was a silent no-op. After the fix, it issues
|
||||
// a DB UPDATE to clear messages. Without a real DB, ExecContext panics
|
||||
// on nil db — we catch that to prove the DB path IS reached.
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Log("FIX VERIFIED: Reset reached DB fallback path (panicked on nil db, expected in unit test)")
|
||||
}
|
||||
}()
|
||||
s.Reset(ctx, key)
|
||||
// If we get here without panic, db was somehow non-nil
|
||||
t.Log("Reset completed without panic (db may be non-nil)")
|
||||
}()
|
||||
|
||||
// Cache should still be empty — Reset doesn't create cache entries
|
||||
if _, ok := s.cache[cacheKey]; ok {
|
||||
t.Error("Reset should not create cache entry for non-cached session")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReset_WarmCache_ClearsHistory verifies Reset works when session IS cached.
|
||||
func TestReset_WarmCache_ClearsHistory(t *testing.T) {
|
||||
s := &PGSessionStore{cache: make(map[string]*store.SessionData)}
|
||||
ctx := context.Background()
|
||||
key := "agent:abc:cron:job-123"
|
||||
cacheKey := sessionCacheKey(ctx, key)
|
||||
|
||||
// Pre-populate cache (session loaded during current server lifetime)
|
||||
s.cache[cacheKey] = &store.SessionData{
|
||||
Messages: []providers.Message{
|
||||
{Role: "user", Content: "run 1 message"},
|
||||
{Role: "assistant", Content: "run 1 response"},
|
||||
{Role: "user", Content: "run 2 message"},
|
||||
{Role: "assistant", Content: "run 2 response"},
|
||||
},
|
||||
Summary: "previous runs summary",
|
||||
}
|
||||
|
||||
s.Reset(ctx, key)
|
||||
|
||||
data := s.cache[cacheKey]
|
||||
if len(data.Messages) != 0 {
|
||||
t.Errorf("expected 0 messages after Reset, got %d", len(data.Messages))
|
||||
}
|
||||
if data.Summary != "" {
|
||||
t.Errorf("expected empty summary after Reset, got %q", data.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_ColdCache_IsNoOp verifies Save returns nil when session isn't cached.
|
||||
// This is acceptable now because Reset handles the DB-clear directly.
|
||||
func TestSave_ColdCache_IsNoOp(t *testing.T) {
|
||||
s := &PGSessionStore{cache: make(map[string]*store.SessionData)}
|
||||
ctx := context.Background()
|
||||
key := "agent:abc:cron:job-123"
|
||||
|
||||
err := s.Save(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("Save on cold cache should return nil, got: %v", err)
|
||||
}
|
||||
t.Log("Save is no-op on cold cache — acceptable because Reset now clears DB directly")
|
||||
}
|
||||
|
||||
// TestResetAfterGetOrCreate_FixVerification shows the fix: calling GetOrCreate
|
||||
// before Reset ensures the session is loaded into cache, so Reset actually clears it.
|
||||
func TestResetAfterGetOrCreate_FixVerification(t *testing.T) {
|
||||
s := &PGSessionStore{cache: make(map[string]*store.SessionData)}
|
||||
ctx := context.Background()
|
||||
key := "agent:abc:cron:job-123"
|
||||
cacheKey := sessionCacheKey(ctx, key)
|
||||
|
||||
// Simulate what GetOrCreate does when loading from DB:
|
||||
// it puts data into cache (we can't call the real one without DB,
|
||||
// so we manually simulate the cache population)
|
||||
s.cache[cacheKey] = &store.SessionData{
|
||||
Messages: []providers.Message{
|
||||
{Role: "user", Content: "accumulated history from DB"},
|
||||
{Role: "assistant", Content: "old response"},
|
||||
},
|
||||
Summary: "old summary from compaction",
|
||||
}
|
||||
|
||||
// Now Reset works because session is in cache
|
||||
s.Reset(ctx, key)
|
||||
|
||||
data := s.cache[cacheKey]
|
||||
if len(data.Messages) != 0 {
|
||||
t.Errorf("expected 0 messages after GetOrCreate+Reset, got %d", len(data.Messages))
|
||||
}
|
||||
if data.Summary != "" {
|
||||
t.Errorf("expected empty summary, got %q", data.Summary)
|
||||
}
|
||||
t.Log("FIX VERIFIED: GetOrCreate before Reset ensures cache is populated, Reset clears it")
|
||||
}
|
||||
Reference in new issue
Block a user