Skip to content

Commit a6874a6

Browse files
doriwaldori
andauthored
feat: remain 2000 session in memory using LRU (to remain old active s… (#20)
* feat: remain 2000 session in memory using LRU (to remain old active sessions) and remain 20 records per session with FIFO * fix: reformat * fix: reformat * fix: reformat * fix: reformat * fix: reformat * fix: reformat * fix: reformat * fix: UTC * fix: import order * fix: import order in tests * fix: import utc * fix: import utc * fix: ruff fix * fix: ruff fix * fix: s "str" has no attribute "in_" [attr-defined] * fix: reduce max session and increase retention day * fix: Clean old session if needed - check is done on create new session * fix: remove unused imports * fix: remove unused imports * fix: remove reformat * fix: remove reformat * fix: remove reformat * fix: remove reformat * fix: remove reformat * fix: maybe fix reformat * fix: maybe fix reformat * fix: should fix reformat --------- Co-authored-by: dori <[email protected]>
1 parent 6919eb1 commit a6874a6

File tree

6 files changed

+368
-76
lines changed

6 files changed

+368
-76
lines changed

src/mcp_as_a_judge/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@
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 = 50 # Maximum total sessions to keep (LRU cleanup)
Lines changed: 129 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
"""
22
Database cleanup service for conversation history records.
33
4-
This service handles time-based cleanup operations for conversation history records,
5-
removing records older than the retention period (default: 1 day).
4+
This service handles LRU-based cleanup operations for conversation history records,
5+
removing least recently used sessions when session limits are exceeded.
66
"""
77

8-
from datetime import datetime, timedelta
9-
10-
from sqlalchemy import Engine
8+
from sqlalchemy import Engine, func
119
from sqlmodel import Session, select
1210

13-
from mcp_as_a_judge.constants import RECORD_RETENTION_DAYS
11+
from mcp_as_a_judge.constants import MAX_TOTAL_SESSIONS
1412
from mcp_as_a_judge.db.interface import ConversationRecord
1513
from mcp_as_a_judge.logging_config import get_logger
1614

@@ -20,10 +18,20 @@
2018

2119
class ConversationCleanupService:
2220
"""
23-
Service for cleaning up old conversation history records.
21+
Service for cleaning up conversation history records.
22+
23+
Implements session-based LRU cleanup strategy:
24+
- Maintains session limit by removing least recently used sessions
25+
- Runs immediately when new sessions are created and limit is exceeded
26+
27+
LRU vs FIFO for Better User Experience:
28+
- LRU (Least Recently Used): Keeps sessions that users are actively using,
29+
even if they're old
30+
- FIFO (First In, First Out): Would remove oldest sessions regardless of
31+
recent activity
32+
- LRU provides better UX because active conversations are preserved longer
2433
25-
Handles time-based cleanup: Removes records older than retention period.
26-
Note: LRU cleanup is handled by the SQLite provider during save operations.
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,48 +42,134 @@ def __init__(self, engine: Engine) -> None:
3442
engine: SQLAlchemy engine for database operations
3543
"""
3644
self.engine = engine
37-
self.retention_days = RECORD_RETENTION_DAYS
38-
self.last_cleanup_time = datetime.utcnow()
45+
self.max_total_sessions = MAX_TOTAL_SESSIONS
3946

40-
def cleanup_old_records(self) -> int:
47+
def get_session_count(self) -> int:
4148
"""
42-
Remove records older than retention_days.
43-
This runs once per day to avoid excessive cleanup operations.
49+
Get the total number of unique sessions in the database.
4450
4551
Returns:
46-
Number of records deleted
52+
Number of unique sessions
4753
"""
48-
# Only run cleanup once per day
49-
if (datetime.utcnow() - self.last_cleanup_time).days < 1:
50-
return 0
54+
with Session(self.engine) as session:
55+
# Count distinct session_ids
56+
count_stmt = select(
57+
func.count(func.distinct(ConversationRecord.session_id))
58+
)
59+
result = session.exec(count_stmt).first()
60+
return result or 0
61+
62+
def get_least_recently_used_sessions(self, limit: int) -> list[str]:
63+
"""
64+
Get session IDs of the least recently used sessions.
5165
52-
cutoff_date = datetime.utcnow() - timedelta(days=self.retention_days)
66+
Uses LRU strategy: finds sessions with the oldest "last activity" timestamp.
67+
Last activity = MAX(timestamp) for each session (most recent record in session).
5368
69+
Args:
70+
limit: Number of session IDs to return
71+
72+
Returns:
73+
List of session IDs ordered by last activity (oldest first)
74+
"""
5475
with Session(self.engine) as session:
55-
# Count old records
56-
old_count_stmt = select(ConversationRecord).where(
57-
ConversationRecord.timestamp < cutoff_date
76+
# Find sessions with oldest last activity (LRU)
77+
# GROUP BY session_id, ORDER BY MAX(timestamp) ASC to get least
78+
# recently used
79+
lru_stmt = (
80+
select(
81+
ConversationRecord.session_id,
82+
func.max(ConversationRecord.timestamp).label("last_activity"),
83+
)
84+
.group_by(ConversationRecord.session_id)
85+
.order_by(func.max(ConversationRecord.timestamp).asc())
86+
.limit(limit)
5887
)
59-
old_records = session.exec(old_count_stmt).all()
60-
old_count = len(old_records)
6188

62-
if old_count == 0:
63-
logger.info(
64-
f"🧹 Daily cleanup: No records older than {self.retention_days} days"
89+
results = session.exec(lru_stmt).all()
90+
return [result[0] for result in results]
91+
92+
def delete_sessions(self, session_ids: list[str]) -> int:
93+
"""
94+
Bulk delete all records for the given session IDs.
95+
96+
Args:
97+
session_ids: List of session IDs to delete
98+
99+
Returns:
100+
Number of records deleted
101+
"""
102+
if not session_ids:
103+
return 0
104+
105+
with Session(self.engine) as session:
106+
# Count records before deletion for logging
107+
count_stmt = select(ConversationRecord).where(
108+
ConversationRecord.session_id.in_( # type: ignore[attr-defined]
109+
session_ids
65110
)
66-
self.last_cleanup_time = datetime.utcnow()
67-
return 0
111+
)
112+
records_to_delete = session.exec(count_stmt).all()
113+
delete_count = len(records_to_delete)
68114

69-
# Delete old records
70-
for record in old_records:
115+
# Bulk delete all records for these sessions
116+
for record in records_to_delete:
71117
session.delete(record)
72118

73119
session.commit()
74120

75-
# Reset cleanup tracking
76-
self.last_cleanup_time = datetime.utcnow()
121+
logger.info(
122+
f"🗑️ Deleted {delete_count} records from {len(session_ids)} sessions: "
123+
f"{', '.join(session_ids[:3])}{'...' if len(session_ids) > 3 else ''}"
124+
)
125+
126+
return delete_count
127+
128+
def cleanup_excess_sessions(self) -> int:
129+
"""
130+
Remove least recently used sessions when total sessions exceed
131+
MAX_TOTAL_SESSIONS.
132+
133+
This implements LRU (Least Recently Used) cleanup strategy:
134+
- Keeps sessions that users are actively using (better UX than FIFO)
135+
- Runs immediately when session limit is exceeded (no daily restriction)
136+
- Removes entire sessions (all records for those session_ids)
137+
- Called every time a new session is created to maintain session limit
138+
139+
Returns:
140+
Number of records deleted
141+
"""
142+
current_session_count = self.get_session_count()
77143

144+
if current_session_count <= self.max_total_sessions:
78145
logger.info(
79-
f"🧹 Daily cleanup: Deleted {old_count} records older than {self.retention_days} days"
146+
f"🧹 Session LRU cleanup: {current_session_count} sessions "
147+
f"(max: {self.max_total_sessions}) - no cleanup needed"
80148
)
81-
return old_count
149+
return 0
150+
151+
# Calculate how many sessions to remove
152+
sessions_to_remove = current_session_count - self.max_total_sessions
153+
154+
logger.info(
155+
f"🧹 Session LRU cleanup: {current_session_count} sessions exceeds limit "
156+
f"({self.max_total_sessions}), removing {sessions_to_remove} "
157+
f"least recently used sessions"
158+
)
159+
160+
# Get least recently used sessions
161+
lru_session_ids = self.get_least_recently_used_sessions(sessions_to_remove)
162+
163+
if not lru_session_ids:
164+
logger.warning("🧹 No sessions found for LRU cleanup")
165+
return 0
166+
167+
# Delete all records for these sessions
168+
deleted_count = self.delete_sessions(lru_session_ids)
169+
170+
logger.info(
171+
f"✅ Session LRU cleanup completed: removed {sessions_to_remove} sessions, "
172+
f"deleted {deleted_count} records"
173+
)
174+
175+
return deleted_count

src/mcp_as_a_judge/db/db_config.py

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

1414

@@ -61,7 +61,7 @@ class DatabaseConfig:
6161
def __init__(self) -> None:
6262
self.url = DATABASE_URL
6363
self.max_session_records = MAX_SESSION_RECORDS
64-
self.record_retention_days = RECORD_RETENTION_DAYS
64+
self.max_total_sessions = MAX_TOTAL_SESSIONS
6565

6666

6767
class Config:

src/mcp_as_a_judge/db/providers/sqlite_provider.py

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"""
77

88
import uuid
9-
from datetime import datetime
9+
from datetime import UTC, datetime
1010

1111
from sqlalchemy import create_engine
1212
from sqlmodel import Session, SQLModel, asc, desc, select
@@ -29,8 +29,10 @@ 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. Session-based LRU cleanup (runs when new sessions are created,
34+
removes least recently used)
35+
2. Per-session FIFO cleanup (max 20 records per session, runs on every save)
3436
- Session-based conversation retrieval
3537
"""
3638

@@ -50,15 +52,16 @@ def __init__(self, max_session_records: int = 20, url: str = "") -> None:
5052

5153
self._max_session_records = max_session_records
5254

53-
# Initialize cleanup service for time-based cleanup
55+
# Initialize cleanup service for LRU session cleanup
5456
self._cleanup_service = ConversationCleanupService(engine=self.engine)
5557

5658
# Create tables
5759
self._create_tables()
5860

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

6467
def _parse_sqlite_url(self, url: str) -> str:
@@ -79,12 +82,14 @@ def _create_tables(self) -> None:
7982
SQLModel.metadata.create_all(self.engine)
8083
logger.info("📋 Created conversation_history table with SQLModel")
8184

82-
def _cleanup_old_records(self) -> int:
85+
def _cleanup_excess_sessions(self) -> int:
8386
"""
84-
Remove records older than retention_days using the cleanup service.
85-
This runs once per day to avoid excessive cleanup operations.
87+
Remove least recently used sessions when total sessions exceed limit.
88+
This implements LRU cleanup to maintain session limit for better memory
89+
management.
90+
Runs immediately when new sessions are created and limit is exceeded.
8691
"""
87-
return self._cleanup_service.cleanup_old_records()
92+
return self._cleanup_service.cleanup_excess_sessions()
8893

8994
def _cleanup_old_messages(self, session_id: str) -> int:
9095
"""
@@ -100,8 +105,8 @@ def _cleanup_old_messages(self, session_id: str) -> int:
100105
current_count = len(current_records)
101106

102107
logger.info(
103-
f"🧹 FIFO cleanup check for session {session_id}: {current_count} records "
104-
f"(max: {self._max_session_records})"
108+
f"🧹 FIFO cleanup check for session {session_id}: "
109+
f"{current_count} records (max: {self._max_session_records})"
105110
)
106111

107112
if current_count <= self._max_session_records:
@@ -132,22 +137,36 @@ def _cleanup_old_messages(self, session_id: str) -> int:
132137
session.commit()
133138

134139
logger.info(
135-
f"✅ LRU cleanup completed: removed {len(old_records)} records from session {session_id}"
140+
f"✅ LRU cleanup completed: removed {len(old_records)} records "
141+
f"from session {session_id}"
136142
)
137143
return len(old_records)
138144

145+
def _is_new_session(self, session_id: str) -> bool:
146+
"""Check if this is a new session (no existing records)."""
147+
with Session(self.engine) as session:
148+
existing_record = session.exec(
149+
select(ConversationRecord)
150+
.where(ConversationRecord.session_id == session_id)
151+
.limit(1)
152+
).first()
153+
return existing_record is None
154+
139155
async def save_conversation(
140156
self, session_id: str, source: str, input_data: str, output: str
141157
) -> str:
142158
"""Save a conversation record to SQLite database with LRU cleanup."""
143159
record_id = str(uuid.uuid4())
144-
timestamp = datetime.utcnow()
160+
timestamp = datetime.now(UTC)
145161

146162
logger.info(
147163
f"💾 Saving conversation to SQLModel SQLite DB: record {record_id} "
148164
f"for session {session_id}, source {source} at {timestamp}"
149165
)
150166

167+
# Check if this is a new session before saving
168+
is_new_session = self._is_new_session(session_id)
169+
151170
# Create new record
152171
record = ConversationRecord(
153172
id=record_id,
@@ -164,10 +183,13 @@ async def save_conversation(
164183

165184
logger.info("✅ Successfully inserted record into conversation_history table")
166185

167-
# Daily cleanup: run once per day to remove old records
168-
self._cleanup_old_records()
186+
# Session LRU cleanup: only run when a new session is created
187+
if is_new_session:
188+
logger.info(f"🆕 New session detected: {session_id}, running LRU cleanup")
189+
self._cleanup_excess_sessions()
169190

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

173195
return record_id

0 commit comments

Comments
 (0)