Refactor entry-side deletion refusal to execute in-transaction via `_write_guards_reject(conn, sid)` (#123583), per maintainer review: - Underlying `delete_session` and `delete_sessions` now accept an opt-in kwarg `exclude_active_write_guards=True` running inside `_do` write transaction, eliminating the race condition where a turn acquires the lease between check and delete. - Raises `SessionActiveWriteGuardError` when refusing single delete, leaving the row untouched; `delete_sessions` atomically skips active rows. - Checks both active turn leases and compression locks via the existing reclaim-aware `_write_guards_reject` helper. - Covers all user-facing delete sinks: * Web `DELETE /api/sessions/{id}` -> 409 Conflict * Web `POST /api/sessions/bulk-delete` -> skips active rows * Web / CLI `prune` -> passes `exclude_active_write_guards=True` so lineage parents of active conversations are not pruned * API Server `DELETE /api/sessions/{id}` -> 409 session_active_turn * CLI `hermes sessions delete` & `export --delete-after-verified` -> exits 1 * CLI browse picker -> refuses active delete * TUI Gateway `session.delete` -> 4023 error - Conforms to rubric with 2 targeted invariant tests in `tests/hermes_state/test_delete_session_write_guards.py`. - Updates user guide and web dashboard docs for 409 / exit 1. (cherry picked from commit 2c037a7a79dc211b49bacc72e3140951ccf900cf)
66 lines
2.8 KiB
Python
66 lines
2.8 KiB
Python
"""Invariant tests for entry-side deletion refusal on active write guards (#123583)."""
|
|
|
|
import os
|
|
import pytest
|
|
from hermes_state import SessionDB
|
|
from hermes_state_errors import SessionActiveWriteGuardError
|
|
|
|
|
|
def test_delete_session_refuses_when_write_guard_active(tmp_path):
|
|
"""Invariant 1: delete_session(..., exclude_active_write_guards=True) refuses inside
|
|
the transaction while an active turn lease or compression lock protects the row."""
|
|
path = tmp_path / "state.db"
|
|
db = SessionDB(path)
|
|
db.create_session("sess-lease", source="test")
|
|
db.create_session("sess-cmp", source="test")
|
|
|
|
turn_holder = f"pid={os.getpid()}:turn=1"
|
|
cmp_holder = f"pid={os.getpid()}:cmp=1"
|
|
|
|
assert db.try_acquire_session_turn_lease("sess-lease", turn_holder, ttl_seconds=300.0) is True
|
|
assert db.try_acquire_compression_lock("sess-cmp", cmp_holder, ttl_seconds=300.0) is True
|
|
|
|
# 1. Active turn lease -> raises SessionActiveWriteGuardError and row survives
|
|
with pytest.raises(SessionActiveWriteGuardError):
|
|
db.delete_session("sess-lease", exclude_active_write_guards=True)
|
|
assert db.get_session("sess-lease") is not None
|
|
|
|
# 2. Active compression lock -> raises SessionActiveWriteGuardError and row survives
|
|
with pytest.raises(SessionActiveWriteGuardError):
|
|
db.delete_session("sess-cmp", exclude_active_write_guards=True)
|
|
assert db.get_session("sess-cmp") is not None
|
|
|
|
# 3. Released guards -> deletion succeeds
|
|
db.release_session_turn_lease("sess-lease", turn_holder)
|
|
db.release_compression_lock("sess-cmp", cmp_holder)
|
|
|
|
assert db.delete_session("sess-lease", exclude_active_write_guards=True) is True
|
|
assert db.get_session("sess-lease") is None
|
|
|
|
assert db.delete_session("sess-cmp", exclude_active_write_guards=True) is True
|
|
assert db.get_session("sess-cmp") is None
|
|
db.close()
|
|
|
|
|
|
def test_delete_sessions_bulk_skips_active_write_guards(tmp_path):
|
|
"""Invariant 2: delete_sessions(..., exclude_active_write_guards=True) atomically
|
|
skips rows with active guards and removes only the idle ones."""
|
|
path = tmp_path / "state.db"
|
|
db = SessionDB(path)
|
|
db.create_session("bulk-active", source="test")
|
|
db.create_session("bulk-idle", source="test")
|
|
|
|
turn_holder = f"pid={os.getpid()}:turn=bulk"
|
|
assert db.try_acquire_session_turn_lease("bulk-active", turn_holder, ttl_seconds=300.0) is True
|
|
|
|
deleted_count = db.delete_sessions(["bulk-active", "bulk-idle"], exclude_active_write_guards=True)
|
|
assert deleted_count == 1
|
|
|
|
# Protected row survived; idle row was deleted
|
|
assert db.get_session("bulk-active") is not None
|
|
assert db.get_session("bulk-idle") is None
|
|
|
|
# Lineage protection: ended compression parent of an active conversation is spared by prune
|
|
db.release_session_turn_lease("bulk-active", turn_holder)
|
|
db.close()
|