fix(sessions): share the write-guard filter and trim repeated walks

prune_sessions hand-rolled the same "guarded by a live lease/lock"
comprehension as the new _guarded_ids helper, so the two could drift on
the next guard change. Move _guarded_ids next to _write_guards_reject in
the maintenance mixin and have prune call it.

delete_session walked the delegate tree up to three times in one write
transaction; compute the target ids once for both the guard check and the
expected-ids fence. delete_sessions ran a per-root guard walk for every
selected id; do one batched check over all roots and their children first
and only attribute per root when something is actually guarded.

The CLI export --delete message repeated "session 'X'" because the
exception text already names the session.
This commit is contained in:
kshitijk4poor
2026-09-27 16:34:22 +05:30
committed by kshitij
parent e78e7ccdeb
commit 230f89b47b
3 changed files with 21 additions and 19 deletions

View File

@@ -572,7 +572,7 @@ def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical
"changed after export.") "changed after export.")
return return
except SessionActiveWriteGuardError as exc: except SessionActiveWriteGuardError as exc:
print(f"Exported, but session '{resolved_session_id}' was not deleted because it is active: {exc}") print(f"Exported, but not deleted: {exc}")
return return
delegates = len(delete_target_ids) - 1 delegates = len(delete_target_ids) - 1
delegate_suffix = f" and {delegates} delegate session{'' if delegates == 1 else 's'}" if delegates else "" delegate_suffix = f" and {delegates} delegate session{'' if delegates == 1 else 's'}" if delegates else ""

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import logging import logging
import time import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, Iterable, List, Optional, Tuple
from hermes_state_common import ( from hermes_state_common import (
AUTO_VACUUM_MIN_FREELIST_RATIO, _id_chunks, _non_continuation_child_sql, _placeholders, _sql_session_last_active, AUTO_VACUUM_MIN_FREELIST_RATIO, _id_chunks, _non_continuation_child_sql, _placeholders, _sql_session_last_active,
@@ -118,6 +118,11 @@ class SessionMaintenanceMixin:
self._remove_session_files(sessions_dir, sid) self._remove_session_files(sessions_dir, sid)
return len(removed_ids) return len(removed_ids)
def _guarded_ids(self, conn, ids: Iterable[str]) -> set:
"""Ids in *ids* protected by a live turn lease / compression lock. Idle compression-ended
parents are closed, not live, so they are not guarded (prune and delete share this)."""
return {sid for sid in ids if self._write_guards_reject(conn, sid, allow_closed_compression_parent=True)}
def _write_guards_reject(self, conn, sid: str, **kwargs) -> bool: def _write_guards_reject(self, conn, sid: str, **kwargs) -> bool:
"""True when a live turn lease / compression lock protects ``sid``; expired or """True when a live turn lease / compression lock protects ``sid``; expired or
dead-holder guards are reclaimed and fenced as a side effect.""" dead-holder guards are reclaimed and fenced as a side effect."""
@@ -308,8 +313,7 @@ class SessionMaintenanceMixin:
cursor = conn.execute(f"SELECT s.id FROM sessions s WHERE {where}", where_params) cursor = conn.execute(f"SELECT s.id FROM sessions s WHERE {where}", where_params)
session_ids = {row["id"] for row in cursor.fetchall()} session_ids = {row["id"] for row in cursor.fetchall()}
if exclude_active_write_guards: if exclude_active_write_guards:
session_ids -= {sid for sid in session_ids session_ids -= self._guarded_ids(conn, session_ids)
if self._write_guards_reject(conn, sid, allow_closed_compression_parent=True)}
if not session_ids: if not session_ids:
return 0 return 0
# Batched: a cron-heavy store prunes tens of thousands of ids in one call. # Batched: a cron-heavy store prunes tens of thousands of ids in one call.

View File

@@ -1576,16 +1576,16 @@ class SessionSessionsMixin:
def _do(conn): def _do(conn):
if conn.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)).fetchone() is None: if conn.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)).fetchone() is None:
return False return False
if exclude_active_write_guards and self._guarded_ids( target_ids = (
conn, [session_id, *_collect_delegate_child_ids(conn, [session_id])], [session_id, *_collect_delegate_child_ids(conn, [session_id])]
): if exclude_active_write_guards or expected_ids is not None else None
)
if exclude_active_write_guards and self._guarded_ids(conn, target_ids):
# Delegate children cascade with the root, so a guard on any of them refuses too. # Delegate children cascade with the root, so a guard on any of them refuses too.
raise SessionActiveWriteGuardError( raise SessionActiveWriteGuardError(
f"session '{session_id}' (or a delegate child) has an active turn lease or compression lock" f"session '{session_id}' (or a delegate child) has an active turn lease or compression lock"
) )
if expected_ids is not None and expected_ids != { if expected_ids is not None and expected_ids != set(target_ids):
session_id, *_collect_delegate_child_ids(conn, [session_id])
}:
return False return False
if expected_display_messages is not None and any( if expected_display_messages is not None and any(
self._display_messages_from_conn(conn, covered_id) != expected self._display_messages_from_conn(conn, covered_id) != expected
@@ -1633,11 +1633,6 @@ class SessionSessionsMixin:
self._remove_session_files(sessions_dir, session_id) self._remove_session_files(sessions_dir, session_id)
return deleted return deleted
def _guarded_ids(self, conn, ids: List[str]) -> set:
"""Ids in *ids* protected by a live turn lease / compression lock. Idle compression-ended
parents are closed, not live, so they are not guarded (matches prune)."""
return {sid for sid in ids if self._write_guards_reject(conn, sid, allow_closed_compression_parent=True)}
def delete_sessions( def delete_sessions(
self, session_ids: List[str], sessions_dir: Optional[Path] = None, self, session_ids: List[str], sessions_dir: Optional[Path] = None,
exclude_active_write_guards: bool = False, skipped_ids: Optional[List[str]] = None, exclude_active_write_guards: bool = False, skipped_ids: Optional[List[str]] = None,
@@ -1659,10 +1654,13 @@ class SessionSessionsMixin:
if exclude_active_write_guards: if exclude_active_write_guards:
# A root is skipped when it or any delegate child it would cascade is guarded, so the # A root is skipped when it or any delegate child it would cascade is guarded, so the
# cascade below never deletes a guarded row reported back as kept. # cascade below never deletes a guarded row reported back as kept.
active_ids = { # One batched check first; per-root attribution only when something is guarded.
sid for sid in existing active_ids: set = set()
if self._guarded_ids(conn, [sid, *_collect_delegate_child_ids(conn, [sid])]) if self._guarded_ids(conn, [*existing, *_collect_delegate_child_ids(conn, existing)]):
} active_ids = {
sid for sid in existing
if self._guarded_ids(conn, [sid, *_collect_delegate_child_ids(conn, [sid])])
}
existing = [sid for sid in existing if sid not in active_ids] existing = [sid for sid in existing if sid not in active_ids]
if skipped_ids is not None: if skipped_ids is not None:
skipped_ids.extend(sorted(active_ids)) skipped_ids.extend(sorted(active_ids))