Repository navigation
fix(gateway): preserve and name a pending message whose session is gone, instead of an FK rejection - #133366
fix(gateway): preserve and name a pending message whose session is gone, instead of an FK rejection#133366devtroniu wants to merge 3 commits into
Conversation
…ne, instead of an FK rejection
Recovery replays flush files through `SessionDB.append_message`, and `messages.session_id`
foreign-keys `sessions.id`. A payload whose session row no longer exists — state.db rebuilt from a
backup while the spool survived, a session pruned or its store retired — can never be replayed, so
the append was rejected and the operator got one bare
WARNING gateway.shutdown_flush: Failed to recover pending message from <path>: FOREIGN KEY constraint failed
per payload, per restart, forever; the file (the only surviving copy of the message) stayed in
place, unnamed and unexplained.
Measured on a firm instance: two cap-dropped transcript spools warned at 2026-10-05 09:16:49Z and
again 10:40:26Z, their session `20260929_140547_994b400d` absent from every store on the host.
The pass now probes the target store for the payload's session (`get_session`) before appending,
and translates a foreign-key rejection that races past the probe. A stranded payload is kept,
counted and reported by condition — one WARNING per pass naming the file and the session, one DEBUG
per payload — so the line names what the operator can act on. No message is dropped, and no append
is attempted for a session that cannot accept one.
Tests: tests/gateway/test_shutdown_flush.py covers the measured transcript-spool shape, the slot
shape, the probe/append race, and the case that matters most in a pass — a stranded payload must not
strand the recoverable ones next to it.
…ason on the line `scripts/check` (code health) is right: `_store_holds_session` catches `Exception`. The catch is the point of the helper — a store that cannot be probed must not be read as "the session is gone", so every failure means the append decides — and the reviewed waiver carries that reason.
…-recovery-survives-missing-session
|
Verified at 4ef8fa0: shutdown-flush suite passes 15/15. |
|
Another data point from a self-hosted install ( This PR improves the diagnosis and keeps the original files for recovery, but they remain eligible for replay on every later start. In the installed revision, Could the new warning point operators to a documented action that preserves the evidence? For example, once the operator has confirmed which store owns the session and that the session will not be restored, renaming I would avoid quarantining automatically just because Since #133932 has merged, it would also be worth testing this change against its ordered, full-fidelity recovery and its per-session hold-back. |
What does this PR do?
Fixes a boot-time recovery pass that can never succeed for some payloads and reports each of them, on every restart, as a bare database error.
gateway/shutdown_flush.recover_pending_to_db()replays flush files by callingSessionDB.append_message().messages.session_idforeign-keyssessions.id, so a payload whose session has no row in the target store —state.dbrebuilt from a backup while the shutdown spool survived, a session pruned/archived out, a profile store retired — can never be replayed. Until now the append was attempted anyway, the rejection was caught by the generic handler, and the operator got:once per payload, per restart, indefinitely. The file is correctly kept (it is the only surviving copy of the message), but nothing says why it will never be recovered or which session is gone.
Measured on a live deployment (2026-10-05): two cap-dropped transcript spools,
pending-090b734e12244ee4b1c3528a3f991e89.jsonandpending-492f148d17874ce5a9512200cfa1c4be.json, warned at 09:16:49Z and again at 10:40:26Z. Their session20260929_140547_994b400dhas no row in any store on the host (checked against everystate.dbunder the Hermes home).The fix probes the target store for the payload's session before appending, and translates a foreign-key rejection that races past the probe (session row vanishing mid-pass). A payload that cannot be replayed is kept, counted, and reported by condition, so the line names what an operator can act on:
The payload itself is untouched and still on disk, so the existing operator path (re-home or archive by hand) works on a named, explained file. Nothing is dropped, and no append is attempted for a session that cannot accept one — the raw driver message is never echoed.
Related Issue
Fixes # (no issue filed for this; the report and the two spool files come from a self-hosted firm deployment, and
get_sessionwas used as the probe instead of inventing a new API — happy to file an issue first if maintainers prefer that order.)Type of Change
Changes Made
gateway/shutdown_flush.pyMissingSessionError— the named condition (carriessession_id), replacing the raw rejection._store_holds_session()— probesdb.get_session(session_id); a store that cannot be probed (no method, or a probe that raises) counts as holding the session, so duck-typed backends keep the previous behaviour._append_or_name_the_condition()— appends, and raisesMissingSessionErrorwhen the driver rejects with a foreign-key failure (the probe→append race).recover_pending_to_db()— catchesMissingSessionErrorper file, keeps the file, accumulates the stranded payloads, and logs one WARNING per pass naming each file and its session. The genericFailed to recover pending message …path is unchanged for every other failure.tests/gateway/test_shutdown_flush.py— four tests: the measured transcript-spool shape, the slot shape, the probe/append race, and the property that matters most in one pass (a stranded payload must not strand the recoverable ones beside it). Each asserts the file survives, the session id is named, and neitherFOREIGN KEYnor the oldFailed to recover pending messagetext is logged.How to Test
python -m pytest tests/gateway/test_shutdown_flush.py -q→ 15 passed (11 pre-existing + 4 new);scripts/run_tests.sh tests/gateway/test_pending_queue_spool.py tests/gateway/test_profile_spool_recovery.py tests/gateway/test_session_messages_shutdown_preserve.py→ 9 passed at the base revision and unchanged on the branch.4 failed, 11 passed(the four fail as assertions — no collection error, since the file imports nothing new).state.dbwith no matching session row): at the parent revision it printsFOREIGN KEY lines: 2; on this branchFOREIGN KEY lines: 0, one WARNING naming both files and the session, both files still on disk with their JSON intact.Checklist
Code
fix(gateway): …)mainthat kept the branch current; two files)pytest tests/ -q— not run in full here (the checkout's full suite is far beyond a single-session budget); the touched surface and its guard suites are quoted abovepython scripts/check— see the run quoted in the PR conversationDocumentation & Housekeeping
recover_pending_to_db, the new helpers) — no README/docs change appliesScreenshots / Logs
Parent revision, real payloads, temp store:
On this branch, identical inputs: