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)
152 lines
4.7 KiB
Python
152 lines
4.7 KiB
Python
"""Dashboard session routes must run SessionDB open + work off the event loop.
|
|
|
|
Regression for #60747: /search, /stats, /{id} (and bulk delete) ran FTS and
|
|
SQLite inline on the loop thread, so a large state.db froze every other
|
|
dashboard request (rename too). These drive the real route handlers and record which
|
|
thread every SessionDB call lands on.
|
|
"""
|
|
|
|
import asyncio
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
import hermes_cli.web_models as _web_models
|
|
import hermes_cli.web_routers.sessions as _rt_sessions
|
|
import hermes_cli.web_server_sessions as _web_server_sessions
|
|
|
|
|
|
def test_bulk_delete_sessiondb_work_runs_off_event_loop(monkeypatch):
|
|
loop_thread = threading.get_ident()
|
|
db_threads: list[int] = []
|
|
db_modes: list[bool] = []
|
|
|
|
class _DB:
|
|
def delete_sessions(self, ids, **kwargs):
|
|
db_threads.append(threading.get_ident())
|
|
assert ids == ["one", "two"]
|
|
return 2
|
|
|
|
def close(self):
|
|
db_threads.append(threading.get_ident())
|
|
|
|
def _open_db(profile=None, *, read_only):
|
|
assert profile is None
|
|
db_modes.append(read_only)
|
|
return _DB()
|
|
|
|
monkeypatch.setattr(_web_server_sessions, "_open_session_db_for_profile", _open_db)
|
|
|
|
result = asyncio.run(
|
|
_rt_sessions.bulk_delete_sessions_endpoint(
|
|
_web_models.BulkDeleteSessions(ids=["one", "two"])
|
|
)
|
|
)
|
|
|
|
assert result == {"ok": True, "deleted": 2}
|
|
assert db_modes == [False]
|
|
assert db_threads
|
|
assert all(thread_id != loop_thread for thread_id in db_threads)
|
|
|
|
|
|
class _ReadDB:
|
|
"""Read-only stand-in that records which thread every SessionDB call runs on."""
|
|
|
|
def __init__(self, threads: list[int]):
|
|
self._threads = threads
|
|
|
|
def _hit(self, value=None):
|
|
self._threads.append(threading.get_ident())
|
|
return value
|
|
|
|
def session_count(self, **_kw):
|
|
return self._hit(3)
|
|
|
|
def message_count(self):
|
|
return self._hit(7)
|
|
|
|
def session_count_by_source(self, **_kw):
|
|
return self._hit({"cli": 3})
|
|
|
|
def get_session(self, sid):
|
|
return self._hit({"id": sid, "title": "t"})
|
|
|
|
def get_session_rich_row(self, sid):
|
|
return self._hit(None)
|
|
|
|
def get_compression_tip(self, root_id):
|
|
return self._hit(None)
|
|
|
|
def search_sessions_by_id(self, q, **_kw):
|
|
return self._hit([{"id": "sess-1", "preview": "hello", "started_at": 1.0}])
|
|
|
|
def search_messages(self, **_kw):
|
|
return self._hit([])
|
|
|
|
def close(self):
|
|
self._hit()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"call",
|
|
[
|
|
pytest.param(lambda: _rt_sessions.search_sessions(q="hello"), id="search"),
|
|
pytest.param(lambda: _rt_sessions.get_session_stats(), id="stats"),
|
|
pytest.param(lambda: _rt_sessions.get_session_detail("sess-1"), id="detail"),
|
|
],
|
|
)
|
|
def test_session_read_handlers_run_sessiondb_work_off_event_loop(monkeypatch, call):
|
|
"""Regression for #60747: /search, /stats and /{id} ran FTS + SQLite inline on the
|
|
loop thread, so a large state.db froze every other dashboard request."""
|
|
loop_thread = threading.get_ident()
|
|
db_threads: list[int] = []
|
|
|
|
def _open_db(profile=None, *, read_only):
|
|
assert read_only is True
|
|
return _ReadDB(db_threads)
|
|
|
|
monkeypatch.setattr(_web_server_sessions, "_open_session_db_for_profile", _open_db)
|
|
monkeypatch.setattr(_rt_sessions, "_resolve_session_id", lambda db, sid: sid)
|
|
|
|
asyncio.run(call())
|
|
|
|
assert db_threads, "handler never touched the SessionDB"
|
|
assert all(thread_id != loop_thread for thread_id in db_threads)
|
|
|
|
|
|
def test_session_rename_runs_writer_open_and_update_off_event_loop(monkeypatch):
|
|
loop_thread = threading.get_ident()
|
|
db_threads: list[int] = []
|
|
|
|
class _DB:
|
|
def set_session_title(self, sid, title):
|
|
db_threads.append(threading.get_ident())
|
|
assert (sid, title) == ("sess-1", "renamed")
|
|
|
|
def get_session_title(self, sid):
|
|
db_threads.append(threading.get_ident())
|
|
assert sid == "sess-1"
|
|
return "renamed"
|
|
|
|
def close(self):
|
|
db_threads.append(threading.get_ident())
|
|
|
|
def _open_db(profile=None, *, read_only):
|
|
db_threads.append(threading.get_ident())
|
|
assert profile is None
|
|
assert read_only is False
|
|
return _DB()
|
|
|
|
monkeypatch.setattr(_web_server_sessions, "_open_session_db_for_profile", _open_db)
|
|
monkeypatch.setattr(_rt_sessions, "_resolve_session_id", lambda db, sid: sid)
|
|
|
|
result = asyncio.run(
|
|
_rt_sessions.rename_session_endpoint(
|
|
"sess-1", _web_models.SessionRename(title="renamed")
|
|
)
|
|
)
|
|
|
|
assert result == {"ok": True, "title": "renamed"}
|
|
assert db_threads
|
|
assert all(thread_id != loop_thread for thread_id in db_threads)
|