fix(kanban): set journal_mode=WAL once per process, not per connection - #35869
fix(kanban): set journal_mode=WAL once per process, not per connection#35869Macgrady4Ever wants to merge 2 commits into
Conversation
…n (v2) journal_mode=WAL is a persistent, file-level property, so kanban_db.connect() does not need to re-issue PRAGMA journal_mode=WAL on every connection. Doing so takes an exclusive lock on every connect (the dashboard event-stream WebSocket reopens a connection every few seconds), and the DELETE fallback in apply_wal_with_fallback can flip the persisted mode out from under other live connections. Combined with sidecar (-wal/-shm) files being removed under a live DB, that re-toggling desynced WAL coordination across connections and corrupted the kanban DB (incident 2026-05-27). Add _WAL_DONE + _ensure_wal_once(): read journal_mode first and only apply WAL when the DB is not already WAL, cached once per process per path. Per-connection PRAGMAs (busy_timeout, synchronous, foreign_keys) still run on every connect. init_db() and remove_board() discard _WAL_DONE so a rebuilt DB re-enters WAL. Rebased onto upstream main (1fc7bdc). Upstream added _cross_process_init_lock to serialize first-connect WAL/schema setup across processes but still calls apply_wal_with_fallback on every connect. This patch adds an additional safety layer: skip the fallback call entirely once WAL is confirmed for the process. 205 tests passed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add three tests for the WAL-once guard introduced alongside this fix: * test_wal_pragma_not_reissued_on_reconnect — verifies apply_wal_with_fallback is NOT called on the second connect() once _WAL_DONE is populated. * test_wal_done_cleared_by_init_db — simulates a DB file reset (delete + recreate empty → DELETE mode); asserts init_db() evicts the stale _WAL_DONE entry so WAL is re-applied on the fresh file. * test_wal_done_cleared_by_remove_board — verifies remove_board() evicts _WAL_DONE so the path can re-enter WAL after the board is removed. Also update test_connect_falls_back_to_delete_on_locking_protocol to clear _WAL_DONE alongside _INITIALIZED_PATHS so the NFS-fallback code path is still exercised. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@alt-glitch — thanks for triaging this. A heads-up on the type-checker picture, in case it factored into review. This PR introduces zero new
All 4 are pre-existing on Blocking checks: I've filed #36181 to track the 4 pre-existing Let me know if you'd like anything adjusted here. |
|
Thanks for the focused WAL-safety investigation. This is already implemented on current
Automated hermes-sweeper review. |
Bug: Per-connection
PRAGMA journal_mode=WALcauses WAL/SHM desync and DB corruptionRoot cause
kanban_db.connect()callsapply_wal_with_fallback()on every connection, which issuesPRAGMA journal_mode=WALeach time. This is problematic for two reasons:1. Exclusive lock on every connect.
SQLite takes an exclusive lock when
PRAGMA journal_modeis evaluated, even when the mode is already WAL and no change is needed. With many concurrent connections — dispatcher workers plus a dashboard WebSocket that reconnects every few seconds — this creates a steady stream of exclusive-lock acquisitions under load.2. DELETE fallback risk on steady-state connections.
apply_wal_with_fallback()contains a code path that falls back to DELETE journal mode when WAL appears unsupported (NFS/SMB/FUSE heuristic). If this fallback fires on a steady-state connection — one opening a DB that is already in WAL mode with live-wal/-shmsidecar files — it flips the persisted journal mode out from under other active connections. Combined with the sidecar files still being present on disk, this desyncs WAL coordination across all open handles and silently corrupts the database.Incident (2026-05-27)
A real corruption event was triggered by the following sequence:
kanban.dbconnection.connect()call each time.PRAGMA journal_mode=WALviaapply_wal_with_fallback.-wal/-shmsidecar files became orphaned, WAL coordination broke down, and the database was corrupted.Reproducer (conceptual)
Fix
Add
_WAL_DONE: set[str]and_ensure_wal_once():journal_mode. If already WAL (persisted from a prior run) mark done immediately — no call toapply_wal_with_fallback. If not WAL (fresh or reset DB) call the fallback helper as before._WAL_DONEhit → immediate return. No PRAGMA, no exclusive lock, no fallback risk.init_db()andremove_board()discard the path from_WAL_DONEso a rebuilt or re-created DB re-enters WAL correctly.Relationship to
_cross_process_init_lockThe
_cross_process_init_lockadded in recent upstream commits serialises first-connect WAL/schema setup across processes and addresses a separate multi-process init race. It does not eliminate repeatedapply_wal_with_fallbackcalls on steady-state connections, because:connect(), not just the first.apply_wal_with_fallbacksits outside theif needs_init:guard and runs unconditionally.This patch is fully compatible and complementary:
_ensure_wal_onceis called inside_INIT_LOCK(the threading lock), so_WAL_DONEis updated atomically with respect to other threads in the same process.Changes
hermes_cli/kanban_db.py_WAL_DONE: set[str]module-level cache_ensure_wal_once(conn, resolved, *, db_label)called fromconnect()apply_wal_with_fallbackcall inconnect()with_ensure_wal_onceremove_board(): discard path from_WAL_DONEalongside_INITIALIZED_PATHSinit_db(): discard path from_WAL_DONEalongside_INITIALIZED_PATHStests/hermes_cli/test_kanban_db.pytest_wal_pragma_not_reissued_on_reconnect—apply_wal_with_fallbackmust not fire on secondconnect()once_WAL_DONEis populatedtest_wal_done_cleared_by_init_db— simulates a DB file reset (delete + recreate → DELETE mode); assertsinit_db()evicts the stale_WAL_DONEentry so WAL is re-applied on the fresh filetest_wal_done_cleared_by_remove_board—remove_board()must evict_WAL_DONEso the path re-enters WAL when the board is re-createdtest_connect_falls_back_to_delete_on_locking_protocol— also clears_WAL_DONEso the NFS-fallback path is still exercisedTest results
(205 existing + 3 new)
🤖 Generated with Claude Code