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:
@@ -572,7 +572,7 @@ def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical
|
||||
"changed after export.")
|
||||
return
|
||||
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
|
||||
delegates = len(delete_target_ids) - 1
|
||||
delegate_suffix = f" and {delegates} delegate session{'' if delegates == 1 else 's'}" if delegates else ""
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import time
|
||||
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 (
|
||||
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)
|
||||
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:
|
||||
"""True when a live turn lease / compression lock protects ``sid``; expired or
|
||||
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)
|
||||
session_ids = {row["id"] for row in cursor.fetchall()}
|
||||
if exclude_active_write_guards:
|
||||
session_ids -= {sid for sid in session_ids
|
||||
if self._write_guards_reject(conn, sid, allow_closed_compression_parent=True)}
|
||||
session_ids -= self._guarded_ids(conn, session_ids)
|
||||
if not session_ids:
|
||||
return 0
|
||||
# Batched: a cron-heavy store prunes tens of thousands of ids in one call.
|
||||
|
||||
@@ -1576,16 +1576,16 @@ class SessionSessionsMixin:
|
||||
def _do(conn):
|
||||
if conn.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)).fetchone() is None:
|
||||
return False
|
||||
if exclude_active_write_guards and self._guarded_ids(
|
||||
conn, [session_id, *_collect_delegate_child_ids(conn, [session_id])],
|
||||
):
|
||||
target_ids = (
|
||||
[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.
|
||||
raise SessionActiveWriteGuardError(
|
||||
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 != {
|
||||
session_id, *_collect_delegate_child_ids(conn, [session_id])
|
||||
}:
|
||||
if expected_ids is not None and expected_ids != set(target_ids):
|
||||
return False
|
||||
if expected_display_messages is not None and any(
|
||||
self._display_messages_from_conn(conn, covered_id) != expected
|
||||
@@ -1633,11 +1633,6 @@ class SessionSessionsMixin:
|
||||
self._remove_session_files(sessions_dir, session_id)
|
||||
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(
|
||||
self, session_ids: List[str], sessions_dir: Optional[Path] = None,
|
||||
exclude_active_write_guards: bool = False, skipped_ids: Optional[List[str]] = None,
|
||||
@@ -1659,10 +1654,13 @@ class SessionSessionsMixin:
|
||||
if exclude_active_write_guards:
|
||||
# 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.
|
||||
active_ids = {
|
||||
sid for sid in existing
|
||||
if self._guarded_ids(conn, [sid, *_collect_delegate_child_ids(conn, [sid])])
|
||||
}
|
||||
# One batched check first; per-root attribution only when something is guarded.
|
||||
active_ids: set = set()
|
||||
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]
|
||||
if skipped_ids is not None:
|
||||
skipped_ids.extend(sorted(active_ids))
|
||||
|
||||
Reference in New Issue
Block a user