11import hashlib
22import re
33from contextlib import contextmanager
4- from datetime import datetime
4+ from datetime import datetime , timedelta
55from pathlib import Path
66from typing import Any , Dict , List , Optional
77
@@ -39,7 +39,12 @@ class LMDBConversationStore(metaclass=Singleton):
3939
4040 HASH_LOOKUP_PREFIX = "hash:"
4141
42- def __init__ (self , db_path : Optional [str ] = None , max_db_size : Optional [int ] = None ):
42+ def __init__ (
43+ self ,
44+ db_path : Optional [str ] = None ,
45+ max_db_size : Optional [int ] = None ,
46+ retention_days : Optional [int ] = None ,
47+ ):
4348 """
4449 Initialize LMDB store.
4550
@@ -52,9 +57,12 @@ def __init__(self, db_path: Optional[str] = None, max_db_size: Optional[int] = N
5257 db_path = g_config .storage .path
5358 if max_db_size is None :
5459 max_db_size = g_config .storage .max_size
60+ if retention_days is None :
61+ retention_days = g_config .storage .retention_days
5562
5663 self .db_path : Path = Path (db_path )
5764 self .max_db_size : int = max_db_size
65+ self .retention_days : int = max (0 , int (retention_days ))
5866 self ._env : lmdb .Environment | None = None
5967
6068 self ._ensure_db_path ()
@@ -310,6 +318,78 @@ def keys(self, prefix: str = "", limit: Optional[int] = None) -> List[str]:
310318
311319 return keys
312320
321+ def cleanup_expired (self , retention_days : Optional [int ] = None ) -> int :
322+ """
323+ Delete conversations older than the given retention period.
324+
325+ Args:
326+ retention_days: Optional override for retention period in days.
327+
328+ Returns:
329+ Number of conversations removed.
330+ """
331+ retention_value = (
332+ self .retention_days if retention_days is None else max (0 , int (retention_days ))
333+ )
334+ if retention_value <= 0 :
335+ logger .debug ("Retention cleanup skipped because retention is disabled." )
336+ return 0
337+
338+ cutoff = datetime .now () - timedelta (days = retention_value )
339+ expired_entries : list [tuple [str , ConversationInStore ]] = []
340+
341+ try :
342+ with self ._get_transaction (write = False ) as txn :
343+ cursor = txn .cursor ()
344+
345+ for key_bytes , value_bytes in cursor :
346+ key_str = key_bytes .decode ("utf-8" )
347+ if key_str .startswith (self .HASH_LOOKUP_PREFIX ):
348+ continue
349+
350+ try :
351+ storage_data = orjson .loads (value_bytes ) # type: ignore[arg-type]
352+ conv = ConversationInStore .model_validate (storage_data )
353+ except Exception as exc :
354+ logger .warning (f"Failed to decode record for key { key_str } : { exc } " )
355+ continue
356+
357+ timestamp = conv .created_at or conv .updated_at
358+ if not timestamp :
359+ continue
360+
361+ if timestamp < cutoff :
362+ expired_entries .append ((key_str , conv ))
363+ except Exception as exc :
364+ logger .error (f"Failed to scan LMDB for retention cleanup: { exc } " )
365+ raise
366+
367+ if not expired_entries :
368+ return 0
369+
370+ removed = 0
371+ try :
372+ with self ._get_transaction (write = True ) as txn :
373+ for key_str , conv in expired_entries :
374+ key_bytes = key_str .encode ("utf-8" )
375+ if not txn .delete (key_bytes ):
376+ continue
377+
378+ message_hash = _hash_conversation (conv .client_id , conv .model , conv .messages )
379+ if message_hash and key_str != message_hash :
380+ txn .delete (f"{ self .HASH_LOOKUP_PREFIX } { message_hash } " .encode ("utf-8" ))
381+ removed += 1
382+ except Exception as exc :
383+ logger .error (f"Failed to delete expired conversations: { exc } " )
384+ raise
385+
386+ if removed :
387+ logger .info (
388+ f"LMDB retention cleanup removed { removed } conversation(s) older than { cutoff .isoformat ()} ."
389+ )
390+
391+ return removed
392+
313393 def stats (self ) -> Dict [str , Any ]:
314394 """
315395 Get database statistics.
0 commit comments