Skip to content

Commit d8cd90a

Browse files
committed
fix(storage): stop session retention from evicting live sessions
Session keys are {StartTime.UnixNano()}_{ID}, and enforceSessionRetention deleted the lowest keys. That evicts the LONGEST-LIVED session first, which is exactly the session most likely to still be connected and working. A client that stayed connected while 100 newer sessions were created vanished from storage, went missing from the sessions API, and its later close/stat updates had no record to find. Choose victims by usefulness instead of key order: 1. closed sessions, stalest first - finished work, nothing will write to these records again; 2. only if that frees too little, active sessions by last activity, stalest first - an abandoned client goes before a working one. Status is authoritative for liveness and activity only ranks within a tier, so a connected-but-idle client outranks a closed session that churned more recently; CloseInactiveSessions is what flips a truly dead session to closed, and only then does it become evictable. Tier 2 keeps the cap absolute. Refusing to evict active sessions at all would let an all-active bucket (abandoned clients, a reconnect loop that never closes cleanly) grow without bound, which is worse than the bug being fixed. Two further defects surfaced in the same function: - bucket.Stats().KeyN does not count records Put earlier in the same write transaction, so the bucket settled permanently at 101 records rather than 100. The scan now counts keys directly. This one is observable: the new cap assertions failed with "expected 100, actual 101" before the change. - Records were deleted while iterating the bucket's own cursor. bbolt documents that as unsafe ("Changing data while traversing with a cursor may cause it to be invalidated and return unexpected keys and/or values"). A 2000-key probe did not manage to make it skip, so this is a documented hazard rather than an observed failure - but sorting requires collecting keys first regardless, which is also what PruneExcessActivities in this package already does. Retention also falls back to StartTime when LastActivity is unset, so records predating that field do not all sort as year 1 and get evicted ahead of genuinely stale sessions. The suite is mutation-verified. An early version of the live-session test passed with the status tier removed - its live session also had the freshest activity, so the staleness ordering alone kept it - so the connected-but-idle case was added to pin the tier itself.
1 parent 255d11d commit d8cd90a

2 files changed

Lines changed: 301 additions & 13 deletions

File tree

internal/storage/manager.go

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,6 +1218,11 @@ type SessionRecord struct {
12181218
WorkSessionID string `json:"work_session_id,omitempty"`
12191219
}
12201220

1221+
// sessionRetentionLimit is the hard cap on stored session records. The cap is
1222+
// absolute: it holds even when every retained session is "active" (see
1223+
// enforceSessionRetention).
1224+
const sessionRetentionLimit = 100
1225+
12211226
// CreateSession creates a new session record
12221227
func (m *Manager) CreateSession(session *SessionRecord) error {
12231228
m.mu.Lock()
@@ -1279,9 +1284,9 @@ func (m *Manager) CreateSession(session *SessionRecord) error {
12791284
return fmt.Errorf("failed to store session: %w", err)
12801285
}
12811286

1282-
// Enforce retention limit (keep 100 most recent) only when creating new sessions
1287+
// Enforce retention limit only when creating new sessions
12831288
if existingKey == nil {
1284-
return m.enforceSessionRetention(bucket, 100)
1289+
return m.enforceSessionRetention(bucket, sessionRetentionLimit)
12851290
}
12861291
return nil
12871292
})
@@ -1699,26 +1704,104 @@ func (m *Manager) GetToolCallsBySession(sessionID string, limit, offset int) ([]
16991704
return toolCalls, total, err
17001705
}
17011706

1702-
// enforceSessionRetention deletes oldest sessions if count exceeds limit
1707+
// sessionEvictionCandidate is one stored session, reduced to the two properties
1708+
// retention actually cares about.
1709+
type sessionEvictionCandidate struct {
1710+
key []byte
1711+
active bool
1712+
activity time.Time // LastActivity, falling back to StartTime when unset
1713+
}
1714+
1715+
// enforceSessionRetention trims the sessions bucket down to maxSessions records.
1716+
//
1717+
// Victims are chosen by usefulness, NOT by key order. Session keys are
1718+
// {StartTime.UnixNano()}_{ID}, so deleting the lowest keys deletes the
1719+
// longest-lived session first — which is precisely the session most likely to
1720+
// still be connected and working. A client that stayed connected all day used to
1721+
// disappear from storage as soon as 100 newer sessions had been created; after
1722+
// that it was absent from the sessions API and its later close/stat updates had
1723+
// no record to find.
1724+
//
1725+
// Eviction order is therefore two-tiered:
1726+
//
1727+
// 1. closed sessions, stalest first — finished work; nothing will ever write to
1728+
// these records again;
1729+
// 2. only if tier 1 does not free enough room, active sessions ordered by last
1730+
// activity, stalest first — a client that died without closing goes before a
1731+
// client that is genuinely working.
1732+
//
1733+
// Tier 2 is what keeps the cap absolute. Refusing to evict active sessions at
1734+
// all would let an all-active bucket (abandoned clients, a reconnect loop that
1735+
// never closes cleanly) grow without bound, which is a worse bug than the one
1736+
// this fixes.
17031737
func (m *Manager) enforceSessionRetention(bucket *bbolt.Bucket, maxSessions int) error {
1704-
stats := bucket.Stats()
1705-
if stats.KeyN <= maxSessions {
1738+
if maxSessions <= 0 {
17061739
return nil
17071740
}
17081741

1709-
// Delete oldest sessions (first keys since they have oldest timestamps)
1710-
toDelete := stats.KeyN - maxSessions
1711-
deleted := 0
1712-
1742+
// Classify every record in one pass. bucket.Stats() is deliberately not used
1743+
// for the count: inside a write transaction it does not account for records
1744+
// Put earlier in the same transaction, which let the bucket settle one record
1745+
// above the cap forever.
1746+
var candidates []sessionEvictionCandidate
17131747
c := bucket.Cursor()
1714-
for k, _ := c.First(); k != nil && deleted < toDelete; k, _ = c.Next() {
1715-
if err := bucket.Delete(k); err != nil {
1748+
for k, v := c.First(); k != nil; k, v = c.Next() {
1749+
cand := sessionEvictionCandidate{key: append([]byte(nil), k...)}
1750+
var session SessionRecord
1751+
if err := json.Unmarshal(v, &session); err != nil {
1752+
// Unreadable record: nothing can use it, so it evicts first.
1753+
m.logger.Warnw("Unreadable session record during retention", "key", string(k), "error", err)
1754+
} else {
1755+
cand.active = session.Status == "active"
1756+
cand.activity = session.LastActivity
1757+
if cand.activity.IsZero() {
1758+
// Records written before LastActivity existed; StartTime is the
1759+
// best evidence available. Without this they would all sort as
1760+
// year 1 and be evicted ahead of genuinely stale sessions.
1761+
cand.activity = session.StartTime
1762+
}
1763+
}
1764+
candidates = append(candidates, cand)
1765+
}
1766+
1767+
toDelete := len(candidates) - maxSessions
1768+
if toDelete <= 0 {
1769+
return nil
1770+
}
1771+
1772+
sort.Slice(candidates, func(i, j int) bool {
1773+
a, b := candidates[i], candidates[j]
1774+
if a.active != b.active {
1775+
return !a.active // closed sessions are evicted before active ones
1776+
}
1777+
if !a.activity.Equal(b.activity) {
1778+
return a.activity.Before(b.activity) // stalest first
1779+
}
1780+
return bytes.Compare(a.key, b.key) < 0 // deterministic tiebreak
1781+
})
1782+
1783+
// Delete only after the scan has finished. Sorting requires it anyway, and
1784+
// bbolt documents mutation during traversal as unsafe ("Changing data while
1785+
// traversing with a cursor may cause it to be invalidated and return
1786+
// unexpected keys and/or values"), which is what the previous
1787+
// delete-inside-the-cursor-loop did. PruneExcessActivities already collects
1788+
// keys first for the same reason.
1789+
activeEvicted := 0
1790+
for i := 0; i < toDelete; i++ {
1791+
if err := bucket.Delete(candidates[i].key); err != nil {
17161792
return fmt.Errorf("failed to delete old session: %w", err)
17171793
}
1718-
deleted++
1794+
if candidates[i].active {
1795+
activeEvicted++
1796+
}
17191797
}
17201798

1721-
m.logger.Debugw("Enforced session retention", "deleted", deleted, "remaining", maxSessions)
1799+
if activeEvicted > 0 {
1800+
// Only reachable when there was no closed session left to sacrifice.
1801+
m.logger.Warnw("Session retention had to evict active sessions",
1802+
"active_evicted", activeEvicted, "limit", maxSessions)
1803+
}
1804+
m.logger.Debugw("Enforced session retention", "deleted", toDelete, "remaining", maxSessions)
17221805
return nil
17231806
}
17241807

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package storage
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
"go.uber.org/zap"
11+
)
12+
13+
func newRetentionTestManager(t *testing.T) *Manager {
14+
t.Helper()
15+
m, err := NewManager(t.TempDir(), zap.NewNop().Sugar())
16+
require.NoError(t, err)
17+
t.Cleanup(func() { _ = m.Close() })
18+
return m
19+
}
20+
21+
// makeSession builds a session record. startOffset is relative to base, so tests
22+
// can control the retention key ({StartTime.UnixNano()}_{ID}) precisely.
23+
func makeSession(id string, base time.Time, startOffset, activityOffset time.Duration, status string) *SessionRecord {
24+
start := base.Add(startOffset)
25+
rec := &SessionRecord{
26+
ID: id,
27+
ClientName: "test-client",
28+
StartTime: start,
29+
LastActivity: base.Add(activityOffset),
30+
Status: status,
31+
}
32+
if status == "closed" {
33+
end := start.Add(time.Second)
34+
rec.EndTime = &end
35+
}
36+
return rec
37+
}
38+
39+
func sessionIDs(sessions []*SessionRecord) []string {
40+
ids := make([]string, 0, len(sessions))
41+
for _, s := range sessions {
42+
ids = append(ids, s.ID)
43+
}
44+
return ids
45+
}
46+
47+
func containsSessionID(sessions []*SessionRecord, id string) bool {
48+
for _, s := range sessions {
49+
if s.ID == id {
50+
return true
51+
}
52+
}
53+
return false
54+
}
55+
56+
// The bug: retention deletes the oldest KEYS, and keys are ordered by StartTime.
57+
// A long-lived ACTIVE session has the oldest start time by definition, so it is
58+
// the first thing evicted once enough newer sessions are created — even though
59+
// the client is still connected and every one of those newer sessions is closed.
60+
//
61+
// Retention must evict finished work before it evicts live work.
62+
func TestEnforceSessionRetention_KeepsLiveSessionWhenClosedOnesCouldGo(t *testing.T) {
63+
m := newRetentionTestManager(t)
64+
base := time.Now().Add(-24 * time.Hour)
65+
66+
// A long-running client connects first and is still working.
67+
live := makeSession("live-session", base, 0, 24*time.Hour, "active")
68+
require.NoError(t, m.CreateSession(live))
69+
70+
// Then a chatty client reconnects sessionRetentionLimit+20 times. Every one of
71+
// those sessions is finished, so every one of them is a better eviction
72+
// candidate than the live session.
73+
for i := 0; i < sessionRetentionLimit+20; i++ {
74+
s := makeSession(fmt.Sprintf("closed-%04d", i), base,
75+
time.Duration(i+1)*time.Minute, time.Duration(i+1)*time.Minute, "closed")
76+
require.NoError(t, m.CreateSession(s))
77+
}
78+
79+
got, err := m.GetSessionByID("live-session")
80+
require.NoError(t, err, "the live session must still exist in storage — "+
81+
"a connected client's record must not be evicted while closed records remain")
82+
assert.Equal(t, "active", got.Status)
83+
84+
sessions, total, err := m.GetRecentSessions(sessionRetentionLimit)
85+
require.NoError(t, err)
86+
assert.LessOrEqual(t, total, sessionRetentionLimit, "the hard cap must still hold")
87+
assert.True(t, containsSessionID(sessions, "live-session"),
88+
"the live session must be listed; got %v", sessionIDs(sessions))
89+
}
90+
91+
// The same bug, with the staleness ordering deliberately pointing the wrong way:
92+
// a connected-but-idle client (an editor left open in the background) has OLDER
93+
// last-activity than every one of the closed sessions that churned past it. Only
94+
// the status tier can save it here — ranking purely by last activity would evict
95+
// the live session first.
96+
//
97+
// Status is authoritative for liveness; activity only ranks within a tier. A
98+
// session that really is dead gets its status flipped by CloseInactiveSessions,
99+
// and only then becomes evictable.
100+
func TestEnforceSessionRetention_KeepsIdleLiveSessionOverFresherClosedOnes(t *testing.T) {
101+
m := newRetentionTestManager(t)
102+
base := time.Now().Add(-24 * time.Hour)
103+
104+
// Connected, but last did anything a minute after base.
105+
idleLive := makeSession("idle-live-session", base, 0, time.Minute, "active")
106+
require.NoError(t, m.CreateSession(idleLive))
107+
108+
// Closed sessions, every one of them more recently active than the live one.
109+
for i := 0; i < sessionRetentionLimit+20; i++ {
110+
s := makeSession(fmt.Sprintf("closed-%04d", i), base,
111+
time.Duration(i+2)*time.Minute, time.Duration(i+2)*time.Minute, "closed")
112+
require.NoError(t, m.CreateSession(s))
113+
}
114+
115+
got, err := m.GetSessionByID("idle-live-session")
116+
require.NoError(t, err, "a connected client must outrank every closed session, "+
117+
"however recently those closed sessions were active")
118+
assert.Equal(t, "active", got.Status)
119+
120+
sessions, total, err := m.GetRecentSessions(sessionRetentionLimit)
121+
require.NoError(t, err)
122+
assert.Equal(t, sessionRetentionLimit, total)
123+
assert.True(t, containsSessionID(sessions, "idle-live-session"),
124+
"the idle live session must be listed; got %v", sessionIDs(sessions))
125+
}
126+
127+
// The abandoned-client case: if every retained session is "active" (clients that
128+
// died without closing), preferring active sessions must NOT turn the bucket
129+
// into an unbounded bucket. The cap still holds, and among active sessions the
130+
// one with the freshest activity is the one that survives.
131+
func TestEnforceSessionRetention_CapHoldsWhenEverySessionIsActive(t *testing.T) {
132+
m := newRetentionTestManager(t)
133+
base := time.Now().Add(-48 * time.Hour)
134+
135+
// The genuinely live one: oldest start time, but active seconds ago.
136+
live := makeSession("live-session", base, 0, 48*time.Hour, "active")
137+
require.NoError(t, m.CreateSession(live))
138+
139+
// Clients that died without closing: newer start times, stale activity.
140+
for i := 0; i < sessionRetentionLimit+30; i++ {
141+
s := makeSession(fmt.Sprintf("abandoned-%04d", i), base,
142+
time.Duration(i+1)*time.Minute, time.Duration(i+1)*time.Minute, "active")
143+
require.NoError(t, m.CreateSession(s))
144+
}
145+
146+
sessions, total, err := m.GetRecentSessions(sessionRetentionLimit + 100)
147+
require.NoError(t, err)
148+
assert.Equal(t, sessionRetentionLimit, total,
149+
"an all-active bucket must still be capped — otherwise abandoned sessions grow without bound")
150+
assert.Len(t, sessions, sessionRetentionLimit)
151+
152+
got, err := m.GetSessionByID("live-session")
153+
require.NoError(t, err, "among active sessions, the freshest activity must be the last to go")
154+
assert.Equal(t, "active", got.Status)
155+
}
156+
157+
// Closed sessions are still evicted oldest-first among themselves.
158+
func TestEnforceSessionRetention_EvictsOldestClosedFirst(t *testing.T) {
159+
m := newRetentionTestManager(t)
160+
base := time.Now().Add(-24 * time.Hour)
161+
162+
for i := 0; i < sessionRetentionLimit+5; i++ {
163+
s := makeSession(fmt.Sprintf("closed-%04d", i), base,
164+
time.Duration(i)*time.Minute, time.Duration(i)*time.Minute, "closed")
165+
require.NoError(t, m.CreateSession(s))
166+
}
167+
168+
sessions, total, err := m.GetRecentSessions(sessionRetentionLimit + 50)
169+
require.NoError(t, err)
170+
assert.Equal(t, sessionRetentionLimit, total)
171+
172+
// The 5 oldest are gone, the newest are kept.
173+
for i := 0; i < 5; i++ {
174+
id := fmt.Sprintf("closed-%04d", i)
175+
assert.False(t, containsSessionID(sessions, id), "%s should have been evicted", id)
176+
}
177+
assert.True(t, containsSessionID(sessions, fmt.Sprintf("closed-%04d", sessionRetentionLimit+4)),
178+
"the newest closed session must be kept")
179+
}
180+
181+
// A session with no LastActivity (older records predate the field) must fall
182+
// back to StartTime rather than sorting as "epoch zero" and being evicted first.
183+
func TestEnforceSessionRetention_ZeroLastActivityFallsBackToStartTime(t *testing.T) {
184+
m := newRetentionTestManager(t)
185+
base := time.Now().Add(-24 * time.Hour)
186+
187+
// Legacy active record: recent start, no LastActivity recorded.
188+
legacy := &SessionRecord{
189+
ID: "legacy-active",
190+
StartTime: base.Add(10 * time.Hour),
191+
Status: "active",
192+
}
193+
require.NoError(t, m.CreateSession(legacy))
194+
195+
// Older active records with equally-old activity.
196+
for i := 0; i < sessionRetentionLimit+10; i++ {
197+
s := makeSession(fmt.Sprintf("old-active-%04d", i), base,
198+
time.Duration(i)*time.Minute, time.Duration(i)*time.Minute, "active")
199+
require.NoError(t, m.CreateSession(s))
200+
}
201+
202+
got, err := m.GetSessionByID("legacy-active")
203+
require.NoError(t, err, "a zero LastActivity must fall back to StartTime, not sort as the year 1")
204+
assert.Equal(t, "active", got.Status)
205+
}

0 commit comments

Comments
 (0)