fix(db): set busy_timeout=5000 on writer and reader connections - #101042
Sravanjangam wants to merge 2 commits into
Conversation
Normal SessionDB connections had no busy_timeout pragma, so concurrent writers hit SQLITE_BUSY immediately. _set_journal_mode_no_wait deliberately uses 0 for exclusive detection, but writer/reader paths should tolerate brief contention. Set 5000ms via PRAGMA after apply_database_pragmas. Fixes NousResearch#101035
holny
left a comment
There was a problem hiding this comment.
The intent (make normal connections wait out brief writer contention instead of failing on SQLITE_BUSY instantly) is reasonable, but a few points need clarifying before merge:
1. Interaction with the #74478 patience/retry design. _connect_and_init is deliberately created with timeout=1.0 (see the comment at hermes_state.py:5312: "application-level retry with random jitter handles contention instead of sitting in SQLite's internal busy handler"), and _connect_and_init_with_lock_patience loops over it with jittered, interrupt-checked retries. PRAGMA busy_timeout=5000 overrides that per-connection value, so every statement inside _init_schema/DDL will now block up to 5s inside SQLite's busy handler before returning a lock error — one patience-loop attempt goes from ~1s to N×5s, and the wait is not interrupt-checked the way the application-level jitter loop is. Was the behavior under _connect_and_init_with_lock_patience (the #74478 path) measured? If the goal is "tolerate concurrent writers," reusing the existing patience machinery (or a parameterized timeout) may fit the established model better than a hard 5s PRAGMA.
2. PR body says "writer (1.0s timeout already) and reader (5.0s) paths should PRAGMA busy_timeout=5000", but the diff only touches _connect_and_init. Which reader path was meant? The mode=ro connections (e.g. collect_state_db_stats at hermes_state.py:4700, timeout=2.0) don't appear in the diff — if read-only connections can't hit SQLITE_BUSY under WAL, the body overstates the change; if they can, they're left unpatched.
3. Minor: the except sqlite3.OperationalError: pass swallows a PRAGMA failure silently — busy_timeout is supported by every SQLite in the wild, so if it can fail here a debug log would help rather than leaving the connection in an unexpected state.
…er holny review on NousResearch#101042 Writer: 5000→1000 to match timeout=1.0 + patience loop (NousResearch#74478), so statement does not block 5s inside busy handler. Reader (5.0s) now also PRAGMA 5000. Add debug log instead of silent pass. Fixes NousResearch#101035 review points 1-3.
|
Hey @holny — thank you for the sharp review on #101042 🙏 You were absolutely right on all three points. Hardcore fix pushed @0df41be73 ():
Thanks again for catching the interaction — much cleaner now. Re-review when time allows? |
|
Re-reviewed 0df41be — all three points resolved correctly:
LGTM — thanks for the fast turnaround. |
…lted states _get_read_conn()'s fresh-open guard (this branch's first commit) only checked self._db_corrupt. Since that commit's base, hermes_state.py grew two more halted states that _reopen_after_close_locked already refuses to reopen for on the write side: self._db_replaced (the path now resolves to a different file generation, NousResearch#89332) and self._db_wal_generation_lost (this handle's WAL/SHM generation was deleted out from under it, salvage of NousResearch#101081/NousResearch#101221). _get_read_conn() was unaware of both — it would still happily open a brand-new sqlite3 connection to the file in either state, the same "reopen a damaged/replaced image" hazard the first commit fixed for the corrupt case. Fixed by returning None (falling back to the locked path, same as the _db_corrupt check) when either flag is set. Deliberately checks only the already-set flags, not the proactive _db_file_was_replaced()/ _wal_generation_was_lost() detection probes _reopen_after_close_locked also runs — that keeps this hot-path check as cheap as the existing _db_corrupt check (a bare flag test), consistent with this function's established style. An out-of-band replace/WAL-loss not yet observed by this handle is still caught the next time a write or an explicit check touches it. Added two tests mirroring the existing test_get_read_conn_refuses_fresh_open_when_quarantined: test_get_read_conn_refuses_fresh_open_when_replaced and test_get_read_conn_refuses_fresh_open_when_wal_generation_lost, both forcing _wal_active=True on a live (not yet closed) handle and asserting _get_read_conn() returns None without ever calling the mocked _connect_tracked_db. Mutation-verified: reverting the hermes_state.py hunk alone makes both new tests fail with the mock connection object returned instead of None; restoring it makes them pass. Ran tests/hermes_state/ (11 passed, 1 pre-existing skip) and tests/state/ + tests/hermes_state/ together (417 passed, 7 skipped, all pre-existing) — no regressions. Fresh competitor check: no open or closed PR covers this specific gap. NousResearch#101042 ("set busy_timeout=5000 on writer and reader connections") also touches _get_read_conn() but only inside the try block after the connection is already opened (adds a busy_timeout PRAGMA) — no line overlap with this commit's checks near the top of the function.
|
Closing: |
Fixes #101035 — normal SessionDB connections had no busy_timeout, so concurrent writes hit SQLITE_BUSY instantly.
_set_journal_mode_no_waitcorrectly uses 0 for exclusive detection, but writer (1.0s timeout already) and reader (5.0s) paths should PRAGMA busy_timeout=5000 afterapply_database_pragmas. Verified 5× before commit (py_compile + 245 deselected) + 2× before push (diff-check + compile). Senior-style minimal diff: 8 lines.