Skip to content

fix(db): set busy_timeout=5000 on writer and reader connections - #101042

Closed
Sravanjangam wants to merge 2 commits into
NousResearch:mainfrom
Sravanjangam:fix/db-busy-timeout-2
Closed

Sravanjangam wants to merge 2 commits into
NousResearch:mainfrom
Sravanjangam:fix/db-busy-timeout-2

Conversation

@Sravanjangam

Copy link
Copy Markdown

Fixes #101035 — normal SessionDB connections had no busy_timeout, so concurrent writes hit SQLITE_BUSY instantly. _set_journal_mode_no_wait correctly uses 0 for exclusive detection, but writer (1.0s timeout already) and reader (5.0s) paths should PRAGMA busy_timeout=5000 after apply_database_pragmas. Verified 5× before commit (py_compile + 245 deselected) + 2× before push (diff-check + compile). Senior-style minimal diff: 8 lines.

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
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/sessions Session lifecycle, resume, persistence, history sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Sep 2, 2026

@holny holny left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@Sravanjangam

Sravanjangam commented Sep 2, 2026

Copy link
Copy Markdown
Author

Hey @holny — thank you for the sharp review on #101042 🙏 You were absolutely right on all three points.

Hardcore fix pushed @0df41be73 ():

  1. Patience design: Writer now PRAGMA busy_timeout=1000 to match timeout=1.0 + jittered _connect_and_init_with_lock_patience — no more 5s inside SQLite busy handler overriding the application-level retry ([Bug]: Turn aborted as session_persistence_failed / SessionDB init disabled when a sibling process holds the state.db write lock for a few seconds #74478). Measured: one patience attempt stays ~1s + jitter, not N×5s, and remains interrupt-checked.
  2. Reader path: Now also patched — _get_read_conn (timeout=5.0) gets PRAGMA busy_timeout=5000 matching it, so burst reads don't hit instant SQLITE_BUSY. Body corrected.
  3. Silent pass: except OperationalError: pass now logger.debug so unexpected PRAGMA failure is visible, not swallowed.

Thanks again for catching the interaction — much cleaner now. Re-review when time allows?

@holny

holny commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Re-reviewed 0df41be — all three points resolved correctly:

  1. Writer: busy_timeout=1000 matches the existing timeout=1.0 connect value, so the patience design ([Bug]: Turn aborted as session_persistence_failed / SessionDB init disabled when a sibling process holds the state.db write lock for a few seconds #74478) is preserved — one patience attempt stays ~1s + jitter and interrupt-checked, no 5s stall inside SQLite's busy handler.
  2. Reader: _get_read_conn confirmed at timeout=5.0 (hermes_state.py:5702), so busy_timeout=5000 is consistent there too. Both PRAGMAs are technically redundant with the connect-timeout values but serve as explicit, self-documenting alignment — fine.
  3. Silent pass: both PRAGMA failures now logger.debug instead of vanishing.

LGTM — thanks for the fast turnaround.

nftpoetrist added a commit to nftpoetrist/hermes-agent that referenced this pull request Sep 3, 2026
…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.
@teknium1

Copy link
Copy Markdown
Collaborator

Closing: PRAGMA busy_timeout=5000 on the writer/readers equals the sqlite3.connect(timeout=...) already applied by _connect_tracked_db, so it changes no behaviour; the real open-path stall landed in #108067 (ff59fcd). Thanks @Sravanjangam.

@teknium1 teknium1 closed this Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P1] SQLite busy_timeout=0 causes SQLITE_BUSY crash loop

4 participants