fix(state): retry _connect_and_init when concurrent repair is in prog… - #43742
fix(state): retry _connect_and_init when concurrent repair is in prog…#43742MorAlekss wants to merge 1 commit into
Conversation
|
Reviewed the concurrent-repair retry logic — the approach is sound. Verification notes:
LGTM. Good fix for the race condition when multiple threads/processes detect a malformed DB simultaneously. |
Morad37
left a comment
There was a problem hiding this comment.
Solid fix for the concurrent repair race. The retry-with-jitter matches the existing write-contention pattern and the test that simulates a competing thread finishing mid-retry is well crafted.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for addressing a real recovery race: current main re-raises when an in-process caller loses _claim_repair_attempt() (hermes_state.py:1006).
Problems
_claim_repair_attempt()is deliberately process-local (hermes_state.py:479-491), so the new “Another thread/process” path cannot coordinate two processes. Each process can claim independently and enter repair. Please either scope this change to threads or add real inter-process coordination.- The new test directly calls
repair_state_db_schema()after forcing every claim to returnFalse(tests/test_state_db_malformed_repair.py:262-278). It does not exercise a winningSessionDBinitializer and a losing initializer sharing the real claim guard.
Suggested changes
- Add a coordinated two-
SessionDBregression test: block the winning repair after it obtains the actual claim, start the losing initializer, release repair, and assert both initializations succeed. - Salvage this against the current initializer at
hermes_state.py:946-1035; GitHub currently marks the branch conflicting.
Automated hermes-sweeper review.
| raise | ||
| if not _claim_repair_attempt(self.db_path): | ||
| # Another thread/process is already repairing. Wait for it |
There was a problem hiding this comment.
_claim_repair_attempt() is only a module-level set protected by threading.Lock (hermes_state.py:479-491), so it cannot identify a repair owned by another process. Please scope this comment/behavior to same-process callers, or add an inter-process coordination primitive.
|
|
||
| # Simulate the "other thread" finishing repair between retry attempts. | ||
| def delayed_repair(): | ||
| time.sleep(0.005) |
There was a problem hiding this comment.
This repairs the DB directly while every claim is forced to fail, so it does not exercise a real winning SessionDB repair claim. Coordinate two SessionDB initializations instead: block the winner after the real claim, then verify the loser opens when that repair completes.
There was a problem hiding this comment.
Both issues addressed. The branch has been rebased on current main — the merge conflict in tests/test_state_db_malformed_repair.py is resolved.
Comment scoped to threads: the "Another thread/process" comment has been updated to "Another thread is already repairing" with an explicit note that _claim_repair_attempt() is process-local a module-level set behind a threading.Lock so it serialises threads within one process only, not separate processes.
Coordinated two-opener regression test: added test_concurrent_repair_serialises_two_sessiondb_openers in tests/test_state_db_malformed_repair.py. The test uses the real _claim_repair_attempt guard (with a fresh _repair_attempted_paths via monkeypatch) so winner and loser are determined by the actual mechanism, not a fake claim. The winning opener is blocked inside repair_state_db_schema via a threading.Event barrier after obtaining the real claim. The losing opener is started while the winner holds the barrier so it is inside its retry loop. The barrier is released, both threads join, and the test asserts both SessionDB instances open cleanly, both see the recovered session row, and repair_state_db_schema was called exactly once proving the loser reused the winner's repair rather than performing its own.
5e5911d to
434f684
Compare
…nated two-opener regression test
`repair_state_db_schema()` performs `PRAGMA writable_schema=ON` + `sqlite_master` surgery + `VACUUM` on a private connection. The only guard around it is `_repair_attempt_lock`, a `threading.Lock`, whose docstring claims it "serialises concurrent web_server / gateway opens" — but a threading lock covers threads inside one interpreter, not processes. A normal host runs four independent processes against the same state.db: the gateway service, the Desktop app's own `hermes serve` backend (it spawns one per launch, not a thin client), interactive CLI sessions, and the TUI slash worker. When two of them hit a malformed DB, both entered the critical section and each ran the full surgery while the other was mid-rewrite. Observed as a repair/re-corrupt cascade: the DB is repaired, then re-corrupts minutes later, repeatedly. Two fixes: 1. Wrap the surgery in a bounded `flock` on `<db>.repair.lock`. `flock` is the right primitive — the kernel drops it when the holder dies, so a crashed repairer cannot wedge future repairs the way a pidfile would. The acquire is bounded (#36644's failure shape) and, unlike the kanban init lock, a caller that times out must NOT proceed: here "proceed anyway" is exactly the unsafe interleaving. It re-probes instead, and reports success if the holder already healed the file. Under the lock, the existing `_db_opens_cleanly()` check becomes a double-check: a queued process finds the DB healthy and returns `already_healthy` rather than re-running surgery on a repaired DB. 2. Bump the schema cookie after direct `sqlite_master` edits. Ordinary DDL bumps it for free and every other connection compares it before running a prepared statement — that is how they learn to drop a cached schema. Editing `sqlite_master` under `writable_schema=ON` does not, so live connections in other processes kept writing `messages` rows through triggers into `messages_fts*` shadow tables the surgery had just deleted. SQLite's writable_schema docs call out incrementing `schema_version` as the required companion to such an edit. Tests: four new cases in tests/test_state_db_malformed_repair.py, all using real child processes and a real flock. All four fail on main and pass with this change; the concurrency case asserts exactly one `malformed-backup-*` file is produced by two simultaneous repairers (two on main). Full state suite: 558 passed. Complements #43742, which makes the *in-process* claim loser retry rather than raise; it explicitly leaves `repair_state_db_schema()` unchanged and does nothing cross-process. The two are independent and compose. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Adds a retry loop to
SessionDB.__init__()when_claim_repair_attempt()returns
False, indicating another thread or process is already repairinga malformed
state.db. Previously the losing caller raised immediatelywith
sqlite3.DatabaseError, surfacing a misleading "Session database notavailable" error even though the database was being healed concurrently.
Root cause
_connect_and_init()inhermes_state.pycatchessqlite3.DatabaseErrorfor malformed schema errors and attempts automatic repair via
repair_state_db_schema(). The repair path is guarded by_claim_repair_attempt(), which ensures only one caller performs therepair per process.
When a second caller loses the claim race (
_claim_repair_attempt()returns
False), the previous implementation immediately re-raised theoriginal exception. This gave concurrent callers no opportunity to wait
for the repair to complete and retry initialization.
Practical scenario: two threads or processes attempt to open
state.dbat startup after a crash left the database with a malformed schema. One
caller successfully claims and performs the repair; the other fails with
sqlite3.DatabaseErrorinstead of benefiting from the ongoing repair.Behavioral change
Before: if
_claim_repair_attempt()returnedFalse,SessionDB.__init__()immediately raised the malformed-schema exception. The caller recorded
_last_init_errorand degraded to "Session database not available".After: when the error is identified as a malformed-schema error and
_claim_repair_attempt()returnsFalse, the losing caller enters abounded retry loop. The loop reuses the existing retry configuration from
_execute_write()(_WRITE_MAX_RETRIES,_WRITE_RETRY_MIN_S,_WRITE_RETRY_MAX_S) and sleeps for a random 20-150ms between attempts.On each iteration
_connect_and_init()is retried. Once the winningcaller completes repair, the retrying caller connects successfully
without surfacing an error.
If retries are exhausted, the original exception is re-raised. Likewise,
non-malformed database errors continue to be raised immediately. In both
cases the existing
_last_init_errorand degradation behavior remainunchanged.
What changed
hermes_state.pySplit the previous combined condition
into separate malformed-error and repair-claim checks
Added a bounded retry path when:
is_malformed_db_error(exc)isTrue, and_claim_repair_attempt()returnsFalseThe retry loop reuses the existing
_execute_write()retry constants(
_WRITE_MAX_RETRIES,_WRITE_RETRY_MIN_S,_WRITE_RETRY_MAX_S) rather than introducing new configurationEach retry waits using the same
random.uniform(...)+time.sleep(...)jitter pattern already used by_execute_write()(approximately 20-150ms per attempt, up to_WRITE_MAX_RETRIESattempts)_connect_and_init()is retried until repair completes or retryattempts are exhausted
tests/test_state_db_malformed_repair.pyimport threadingimport timetest_retry_when_concurrent_repair_completesThe test:
_claim_repair_attempt()to always returnFalse,ensuring the code exercises the retry path rather than the repair path
SessionDBinitialization succeeds once the delayedrepair completes
What is NOT changed
Happy path (single caller, successful repair) is unchanged
_claim_repair_attempt()logic is unchangedrepair_state_db_schema()is unchangedNon-malformed database errors are still raised immediately
All 10 existing tests in
test_state_db_malformed_repair.pycontinue topass, including repair-path coverage such as:
test_repair_preserves_sessions_and_messagestest_repaired_db_search_workstest_sessiondb_auto_heals_on_opentest_auto_heal_attempted_once_per_processNo behavior changes occur when
state.dbis healthyExisting
_last_init_errorhandling and degradation behavior remainunchanged when initialization ultimately fails