Skip to content

Commit 074bd31

Browse files
author
dori
committed
feat: remain 2000 session in memory using LRU (to remain old active sessions) and remain 20 records per session with FIFO
1 parent 6919eb1 commit 074bd31

File tree

5 files changed

+289
-11
lines changed

5 files changed

+289
-11
lines changed

src/mcp_as_a_judge/constants.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@
1414
# Database Configuration
1515
DATABASE_URL = "sqlite://:memory:"
1616
MAX_SESSION_RECORDS = 20 # Maximum records to keep per session (FIFO)
17-
RECORD_RETENTION_DAYS = 1
17+
MAX_TOTAL_SESSIONS = 2000 # Maximum total sessions to keep (LRU cleanup)
18+
RECORD_RETENTION_DAYS = 1 # Optional time-based cleanup (fallback)

src/mcp_as_a_judge/db/cleanup_service.py

Lines changed: 147 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77

88
from datetime import datetime, timedelta
99

10-
from sqlalchemy import Engine
10+
from sqlalchemy import Engine, func
1111
from sqlmodel import Session, select
1212

13-
from mcp_as_a_judge.constants import RECORD_RETENTION_DAYS
13+
from mcp_as_a_judge.constants import MAX_TOTAL_SESSIONS, RECORD_RETENTION_DAYS
1414
from mcp_as_a_judge.db.interface import ConversationRecord
1515
from mcp_as_a_judge.logging_config import get_logger
1616

@@ -22,8 +22,16 @@ class ConversationCleanupService:
2222
"""
2323
Service for cleaning up old conversation history records.
2424
25-
Handles time-based cleanup: Removes records older than retention period.
26-
Note: LRU cleanup is handled by the SQLite provider during save operations.
25+
Implements session-based LRU cleanup strategy:
26+
- Maintains max 2000 sessions by removing least recently used sessions
27+
- Runs once per day to avoid performance overhead
28+
29+
LRU vs FIFO for Better User Experience:
30+
- LRU (Least Recently Used): Keeps sessions that users are actively using, even if they're old
31+
- FIFO (First In, First Out): Would remove oldest sessions regardless of recent activity
32+
- LRU provides better UX because active conversations are preserved longer
33+
34+
Note: Per-session FIFO cleanup (max 20 records) is handled by the SQLite provider.
2735
"""
2836

2937
def __init__(self, engine: Engine) -> None:
@@ -34,8 +42,10 @@ def __init__(self, engine: Engine) -> None:
3442
engine: SQLAlchemy engine for database operations
3543
"""
3644
self.engine = engine
45+
self.max_total_sessions = MAX_TOTAL_SESSIONS
3746
self.retention_days = RECORD_RETENTION_DAYS
3847
self.last_cleanup_time = datetime.utcnow()
48+
self.last_session_cleanup_time = datetime.utcnow()
3949

4050
def cleanup_old_records(self) -> int:
4151
"""
@@ -79,3 +89,136 @@ def cleanup_old_records(self) -> int:
7989
f"🧹 Daily cleanup: Deleted {old_count} records older than {self.retention_days} days"
8090
)
8191
return old_count
92+
93+
def get_session_count(self) -> int:
94+
"""
95+
Get the total number of unique sessions in the database.
96+
97+
Returns:
98+
Number of unique sessions
99+
"""
100+
with Session(self.engine) as session:
101+
# Count distinct session_ids
102+
count_stmt = select(func.count(func.distinct(ConversationRecord.session_id)))
103+
result = session.exec(count_stmt).first()
104+
return result or 0
105+
106+
def get_least_recently_used_sessions(self, limit: int) -> list[str]:
107+
"""
108+
Get session IDs of the least recently used sessions.
109+
110+
Uses LRU strategy: finds sessions with the oldest "last activity" timestamp.
111+
Last activity = MAX(timestamp) for each session (most recent record in session).
112+
113+
Args:
114+
limit: Number of session IDs to return
115+
116+
Returns:
117+
List of session IDs ordered by last activity (oldest first)
118+
"""
119+
with Session(self.engine) as session:
120+
# Find sessions with oldest last activity (LRU)
121+
# GROUP BY session_id, ORDER BY MAX(timestamp) ASC to get least recently used
122+
lru_stmt = (
123+
select(
124+
ConversationRecord.session_id,
125+
func.max(ConversationRecord.timestamp).label("last_activity")
126+
)
127+
.group_by(ConversationRecord.session_id)
128+
.order_by(func.max(ConversationRecord.timestamp).asc())
129+
.limit(limit)
130+
)
131+
132+
results = session.exec(lru_stmt).all()
133+
return [result.session_id for result in results]
134+
135+
def delete_sessions(self, session_ids: list[str]) -> int:
136+
"""
137+
Bulk delete all records for the given session IDs.
138+
139+
Args:
140+
session_ids: List of session IDs to delete
141+
142+
Returns:
143+
Number of records deleted
144+
"""
145+
if not session_ids:
146+
return 0
147+
148+
with Session(self.engine) as session:
149+
# Count records before deletion for logging
150+
count_stmt = select(ConversationRecord).where(
151+
ConversationRecord.session_id.in_(session_ids)
152+
)
153+
records_to_delete = session.exec(count_stmt).all()
154+
delete_count = len(records_to_delete)
155+
156+
# Bulk delete all records for these sessions
157+
for record in records_to_delete:
158+
session.delete(record)
159+
160+
session.commit()
161+
162+
logger.info(
163+
f"🗑️ Deleted {delete_count} records from {len(session_ids)} sessions: "
164+
f"{', '.join(session_ids[:3])}{'...' if len(session_ids) > 3 else ''}"
165+
)
166+
167+
return delete_count
168+
169+
def cleanup_excess_sessions(self) -> int:
170+
"""
171+
Remove least recently used sessions when total sessions exceed MAX_TOTAL_SESSIONS.
172+
173+
This implements LRU (Least Recently Used) cleanup strategy:
174+
- Keeps sessions that users are actively using (better UX than FIFO)
175+
- Only runs once per day to avoid excessive cleanup operations
176+
- During the day, session count can exceed limit (e.g., 5000 sessions is not a memory issue)
177+
- Daily cleanup brings it back to the target limit (2000 sessions)
178+
- Removes entire sessions (all records for those session_ids)
179+
180+
Returns:
181+
Number of records deleted
182+
"""
183+
# Only run session cleanup once per day
184+
if (datetime.utcnow() - self.last_session_cleanup_time).days < 1:
185+
return 0
186+
187+
current_session_count = self.get_session_count()
188+
189+
if current_session_count <= self.max_total_sessions:
190+
logger.info(
191+
f"🧹 Daily session LRU cleanup: {current_session_count} sessions "
192+
f"(max: {self.max_total_sessions}) - no cleanup needed"
193+
)
194+
self.last_session_cleanup_time = datetime.utcnow()
195+
return 0
196+
197+
# Calculate how many sessions to remove
198+
sessions_to_remove = current_session_count - self.max_total_sessions
199+
200+
logger.info(
201+
f"🧹 Daily session LRU cleanup: {current_session_count} sessions exceeds limit "
202+
f"({self.max_total_sessions}), removing {sessions_to_remove} least recently used sessions"
203+
)
204+
205+
# Get least recently used sessions
206+
lru_session_ids = self.get_least_recently_used_sessions(sessions_to_remove)
207+
208+
if not lru_session_ids:
209+
logger.warning("🧹 No sessions found for LRU cleanup")
210+
self.last_session_cleanup_time = datetime.utcnow()
211+
return 0
212+
213+
# Delete all records for these sessions
214+
deleted_count = self.delete_sessions(lru_session_ids)
215+
216+
# Reset cleanup tracking
217+
self.last_session_cleanup_time = datetime.utcnow()
218+
219+
logger.info(
220+
f"✅ Daily session LRU cleanup completed: removed {sessions_to_remove} sessions, "
221+
f"deleted {deleted_count} records"
222+
)
223+
224+
return deleted_count

src/mcp_as_a_judge/db/db_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from mcp_as_a_judge.constants import (
99
DATABASE_URL,
1010
MAX_SESSION_RECORDS,
11+
MAX_TOTAL_SESSIONS,
1112
RECORD_RETENTION_DAYS,
1213
)
1314

@@ -61,6 +62,7 @@ class DatabaseConfig:
6162
def __init__(self) -> None:
6263
self.url = DATABASE_URL
6364
self.max_session_records = MAX_SESSION_RECORDS
65+
self.max_total_sessions = MAX_TOTAL_SESSIONS
6466
self.record_retention_days = RECORD_RETENTION_DAYS
6567

6668

src/mcp_as_a_judge/db/providers/sqlite_provider.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ class SQLiteProvider(ConversationHistoryDB):
2929
Features:
3030
- SQLModel with SQLAlchemy for type safety
3131
- In-memory or file-based SQLite storage
32-
- LRU cleanup per session
33-
- Time-based cleanup (configurable retention)
32+
- Two-level cleanup strategy:
33+
1. Daily session-based LRU cleanup (max 2000 sessions, removes least recently used)
34+
2. Per-session FIFO cleanup (max 20 records per session, runs on every save)
3435
- Session-based conversation retrieval
3536
"""
3637

@@ -58,7 +59,9 @@ def __init__(self, max_session_records: int = 20, url: str = "") -> None:
5859

5960
logger.info(
6061
f"🗄️ SQLModel SQLite provider initialized: {connection_string}, "
61-
f"max_records={max_session_records}, retention_days={self._cleanup_service.retention_days}"
62+
f"max_records_per_session={max_session_records}, "
63+
f"max_total_sessions={self._cleanup_service.max_total_sessions}, "
64+
f"retention_days={self._cleanup_service.retention_days}"
6265
)
6366

6467
def _parse_sqlite_url(self, url: str) -> str:
@@ -86,6 +89,14 @@ def _cleanup_old_records(self) -> int:
8689
"""
8790
return self._cleanup_service.cleanup_old_records()
8891

92+
def _cleanup_excess_sessions(self) -> int:
93+
"""
94+
Remove least recently used sessions when total sessions exceed limit.
95+
This implements daily LRU cleanup to maintain max 2000 sessions for better memory management.
96+
Runs once per day - during the day session count can exceed limit without issues.
97+
"""
98+
return self._cleanup_service.cleanup_excess_sessions()
99+
89100
def _cleanup_old_messages(self, session_id: str) -> int:
90101
"""
91102
Remove old messages from a session using FIFO strategy.
@@ -164,10 +175,10 @@ async def save_conversation(
164175

165176
logger.info("✅ Successfully inserted record into conversation_history table")
166177

167-
# Daily cleanup: run once per day to remove old records
168-
self._cleanup_old_records()
178+
# Daily session LRU cleanup: maintain max 2000 sessions (runs once per day)
179+
self._cleanup_excess_sessions()
169180

170-
# Always perform LRU cleanup for this session (lightweight)
181+
# Per-session FIFO cleanup: maintain max 20 records per session (runs on every save)
171182
self._cleanup_old_messages(session_id)
172183

173184
return record_id

tests/test_conversation_history_lifecycle.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,126 @@ async def test_time_based_cleanup_integration(self):
198198
f"✅ After time-based cleanup: {len(records_after)} records (within retention)"
199199
)
200200

201+
@pytest.mark.asyncio
202+
async def test_lru_session_cleanup_lifecycle(self):
203+
"""Test LRU session cleanup: keeps most recently used sessions."""
204+
# Create provider with small session limit for testing
205+
db = SQLiteProvider(max_session_records=20)
206+
207+
# Override session limit for testing (normally 2000)
208+
db._cleanup_service.max_total_sessions = 3
209+
210+
print("\n🔄 TESTING LRU SESSION CLEANUP LIFECYCLE")
211+
print("=" * 60)
212+
213+
# PHASE 1: Create sessions with different activity patterns
214+
print("📝 PHASE 1: Creating sessions with different activity patterns...")
215+
216+
# Session A: Created first, but will be most recently used
217+
await db.save_conversation("session_A", "tool1", "input1", "output1")
218+
print(" Session A: Created (oldest creation time)")
219+
220+
# Session B: Created second
221+
await db.save_conversation("session_B", "tool1", "input1", "output1")
222+
print(" Session B: Created")
223+
224+
# Session C: Created third
225+
await db.save_conversation("session_C", "tool1", "input1", "output1")
226+
print(" Session C: Created")
227+
228+
# Session D: Created fourth
229+
await db.save_conversation("session_D", "tool1", "input1", "output1")
230+
print(" Session D: Created")
231+
232+
# Session E: Created fifth
233+
await db.save_conversation("session_E", "tool1", "input1", "output1")
234+
print(" Session E: Created")
235+
236+
# Add recent activity to Session A (making it most recently used)
237+
await db.save_conversation("session_A", "tool2", "recent_input", "recent_output")
238+
print(" Session A: Updated with recent activity (most recently used)")
239+
240+
# Verify all sessions exist
241+
current_count = db._cleanup_service.get_session_count()
242+
assert current_count == 5, f"Expected 5 sessions, got {current_count}"
243+
print(f"✅ Phase 1: {current_count} sessions created successfully")
244+
245+
# PHASE 2: Test LRU detection
246+
print("\n🔍 PHASE 2: Testing LRU detection...")
247+
248+
# Get least recently used sessions (should be B, C - oldest last activity)
249+
lru_sessions = db._cleanup_service.get_least_recently_used_sessions(2)
250+
print(f" LRU sessions to remove: {lru_sessions}")
251+
252+
# Should be sessions B and C (oldest last activity)
253+
assert "session_B" in lru_sessions, "Session B should be LRU"
254+
assert "session_C" in lru_sessions, "Session C should be LRU"
255+
assert "session_A" not in lru_sessions, "Session A should NOT be LRU (recent activity)"
256+
print("✅ Phase 2: LRU detection working correctly")
257+
258+
# PHASE 3: Trigger LRU cleanup
259+
print("\n🧹 PHASE 3: Triggering LRU session cleanup...")
260+
261+
# Force cleanup by mocking old cleanup time
262+
old_time = datetime.utcnow() - timedelta(days=2)
263+
db._cleanup_service.last_session_cleanup_time = old_time
264+
265+
# Trigger cleanup
266+
deleted_count = db._cleanup_service.cleanup_excess_sessions()
267+
print(f" Deleted {deleted_count} records")
268+
269+
# Verify session count is now at limit
270+
final_count = db._cleanup_service.get_session_count()
271+
assert final_count == 3, f"Expected 3 sessions after cleanup, got {final_count}"
272+
print(f"✅ Phase 3: Session count reduced to {final_count}")
273+
274+
# PHASE 4: Verify which sessions remain
275+
print("\n🔍 PHASE 4: Verifying remaining sessions...")
276+
277+
sessions_to_check = ["session_A", "session_B", "session_C", "session_D", "session_E"]
278+
remaining_sessions = []
279+
deleted_sessions = []
280+
281+
for session_id in sessions_to_check:
282+
records = await db.get_session_conversations(session_id)
283+
if records:
284+
remaining_sessions.append(session_id)
285+
last_activity = max(r.timestamp for r in records)
286+
print(f" ✅ {session_id}: {len(records)} records, last activity: {last_activity}")
287+
else:
288+
deleted_sessions.append(session_id)
289+
print(f" ❌ {session_id}: DELETED (was least recently used)")
290+
291+
# Verify correct sessions were kept/deleted
292+
assert "session_A" in remaining_sessions, "Session A should remain (most recent activity)"
293+
assert "session_D" in remaining_sessions, "Session D should remain (recent creation)"
294+
assert "session_E" in remaining_sessions, "Session E should remain (most recent creation)"
295+
assert "session_B" in deleted_sessions, "Session B should be deleted (LRU)"
296+
assert "session_C" in deleted_sessions, "Session C should be deleted (LRU)"
297+
298+
print("✅ Phase 4: LRU cleanup preserved active sessions correctly")
299+
300+
# PHASE 5: Verify Session A has both records (original + recent activity)
301+
print("\n📊 PHASE 5: Verifying session record preservation...")
302+
303+
session_a_records = await db.get_session_conversations("session_A")
304+
assert len(session_a_records) == 2, f"Session A should have 2 records, got {len(session_a_records)}"
305+
306+
# Check that both records exist
307+
sources = [r.source for r in session_a_records]
308+
assert "tool1" in sources, "Session A should have original tool1 record"
309+
assert "tool2" in sources, "Session A should have recent tool2 record"
310+
311+
print(f" Session A has {len(session_a_records)} records: {sources}")
312+
print("✅ Phase 5: Session record preservation working correctly")
313+
314+
print("\n🎯 LRU CLEANUP SUMMARY:")
315+
print(" - Sessions B, C deleted (least recently used)")
316+
print(" - Sessions A, D, E preserved (most recently used)")
317+
print(" - Session A preserved despite being oldest (recent activity)")
318+
print(" - LRU provides better UX than FIFO!")
319+
print("✅ LRU session cleanup lifecycle test PASSED!")
320+
201321
print("✅ Time-based cleanup integration working correctly")
202322

203323
@pytest.mark.asyncio
@@ -266,6 +386,7 @@ async def run_tests():
266386
await test_instance.test_save_retrieve_fifo_cleanup_lifecycle()
267387
await test_instance.test_multiple_sessions_isolation()
268388
await test_instance.test_time_based_cleanup_integration()
389+
await test_instance.test_lru_session_cleanup_lifecycle()
269390
await test_instance.test_edge_cases_and_error_handling()
270391
print("\n🎉 ALL CONVERSATION HISTORY LIFECYCLE TESTS PASSED!")
271392

0 commit comments

Comments
 (0)