fix(store): retry the open-time schema window on SQLITE_SCHEMA - #1321
Conversation
There was a problem hiding this comment.
Sorry @robotrocketscience, you have reached your weekly rate limit of 500000 diff characters.
Please try again later or upgrade to continue using Sourcery
📝 WalkthroughWalkthroughSQLite schema-change errors during ChangesStore initialization schema retries
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant MemoryStore
participant RetryWrapper
participant SQLite
participant TestHarness
TestHarness->>MemoryStore: initialize store
MemoryStore->>RetryWrapper: run _apply_open_schema
RetryWrapper->>SQLite: execute schema operation
SQLite-->>RetryWrapper: schema has changed
RetryWrapper->>SQLite: re-prepare and retry
SQLite-->>MemoryStore: initialization result
MemoryStore-->>TestHarness: usable store or bounded error
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
PR-size soft capThis PR is over the advisory size threshold:
Bigger PRs collide with more open work, which under the parallel-session workflow tends to produce repeated This is advisory only — nothing is blocked. If the size is intentional (large refactor, module removal, generated code), apply the |
Reviewer's GuideAdds targeted retry handling for SQLITE_SCHEMA during store open by introducing helpers that re-run open-time schema operations when SQLite reports "database schema has changed", relocating the schema battery into an idempotent helper and covering both DDL and reads under a bounded retry window, with focused tests and changelog update. Sequence diagram for store open-time schema retry on SQLITE_SCHEMAsequenceDiagram
participant MemoryStore__init__
participant retry_on_schema_change as _retry_on_schema_change
participant apply_open_schema as _apply_open_schema
participant execute_reprepare as _execute_reprepare
participant sqlite3_connection as sqlite3_Connection
MemoryStore__init__->>retry_on_schema_change: _retry_on_schema_change(self._apply_open_schema)
loop attempts <= _SCHEMA_RETRY_ATTEMPTS
retry_on_schema_change->>apply_open_schema: call
apply_open_schema->>sqlite3_connection: _drop_stale_ingest_log
apply_open_schema->>execute_reprepare: _execute_reprepare(self._conn, stmt)
execute_reprepare->>retry_on_schema_change: _retry_on_schema_change(lambda: conn.execute(stmt))
retry_on_schema_change->>sqlite3_connection: conn.execute(stmt)
alt [schema has changed]
sqlite3_connection-->>retry_on_schema_change: OperationalError("database schema has changed")
retry_on_schema_change-->>retry_on_schema_change: retry (re-prepare statement/window)
else [other OperationalError or attempts exhausted]
sqlite3_connection-->>retry_on_schema_change: OperationalError
retry_on_schema_change-->>MemoryStore__init__: raise
end
apply_open_schema->>sqlite3_connection: self._commit()
apply_open_schema->>MemoryStore__init__: return _resolve_local_scope_id()
end
MemoryStore__init__-->>MemoryStore__init__: self._local_scope_id = scope_id
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
[claim:review:Setr:2026-08-04T05:02:22Z] |
MemoryStore.__init__ could raise `sqlite3.OperationalError: database schema has changed` when a second process opened the same store: SQLite raises SQLITE_SCHEMA when the schema cookie moves between a statement's prepare and its step, and two openers both run the CREATE TABLE IF NOT EXISTS battery. busy_timeout=5000 does not cover it -- that pragma retries `database is locked`, a different error -- so the constructor failed outright and took a required CI check down at random (#1310). New module-level `_retry_on_schema_change` re-runs an idempotent operation on that one message only; every other OperationalError propagates so a malformed statement stays loud, and attempts are bounded (3) so a persistently changing schema fails rather than spins. `_execute_reprepare` is its statement-level form, used by the _SCHEMA, _MIGRATIONS and _POST_MIGRATION_INDEXES loops so the common case re-prepares one statement instead of restarting the battery. The whole window -- not just the four DDL loops the traceback named -- is wrapped, because the reads are exposed to the same race: the _drop_stale_ingest_log probe, the `SELECT 1 FROM schema_meta` store-generation read, the origin-backfill marker read and its writes, and `_resolve_local_scope_id` were all still bare `self._conn.execute` calls. Those move into `_apply_open_schema`, called once through the retry; a per-call-site patch would have left gaps as the constructor grows. The window is idempotent by construction (IF NOT EXISTS, OR IGNORE / OR REPLACE, schema_meta-marker gated), so a second pass is a no-op. `_invalidation_callbacks` is initialised before the window instead of after; nothing in the window registers or fires a callback (`_commit` does not), so that hoist is inert. Correcting the issue's rationale: the `_MIGRATIONS` try/except three lines below did NOT already tolerate this class. It admits only "duplicate column name" and "no such column" and re-raises everything else, including "database schema has changed" -- so there was no asymmetry to close, there was simply no handling anywhere. `_drop_stale_ingest_log` (issue AC3) has the same exposure: its sqlite_master SELECT, PRAGMA table_info and COUNT(*) are ordinary prepared statements. It is inside the retried window and is idempotent, so it is covered without a change of its own.
Guards the #1310 retry. Honest about reach: this proves the retry is WIRED, not that the CI flake is eliminated. Real-concurrency reproduction was attempted during triage and did not fire (0/180 rounds locally), so a race-shaped test would assert nothing; fault injection is the only arm that separates fixed from broken deterministically. A wrapper connection raises OperationalError("database schema has changed") the first time a matching statement is executed, then succeeds. Two mechanics the wrapper has to respect: __getattr__ is not consulted for dunder lookup, so __enter__/__exit__ are forwarded explicitly (a one-shot migration uses `with self._conn:`), and `aelfrice.store.sqlite3` is the stdlib module itself, so patching `connect` is process-global -- each patch brackets exactly one MemoryStore(...) call and is restored in a finally. Arms: the _SCHEMA DDL loop; the bare `SELECT 1 FROM schema_meta` read; the `INSERT OR REPLACE INTO schema_meta` from _resolve_local_scope_id; a non-schema OperationalError still propagating; bounded attempts on a permanently changing schema; plus direct unit arms on both helpers. Each open arm asserts the injection actually fired, so it cannot pass vacuously. Mutations applied to src/aelfrice/store.py, each with the test file staged first, all confirmed failing: 1. `_retry_on_schema_change(self._apply_open_schema)` -> `self._apply_open_schema()` in __init__ (drop the window wrapper, keep per-statement retry): generation_read and scope_id_write FAIL, schema_ddl still passes. This is the arm that proves the window wrapper is load-bearing beyond the DDL loops. 2. Early `return op()` at the top of `_retry_on_schema_change` (no retry at all): 6 tests FAIL, including all three open arms. 3. Drop the `_SCHEMA_CHANGED_MSG not in str(e)` guard so every OperationalError is retried: the two "stays loud" tests FAIL. 4. `i == attempts - 1` -> `i == attempts` (off-by-one, never the last pass): both bounded-attempts tests FAIL.
crate-ci/typos splits `SELECTs` and reports the stem as a misspelling of SELECT. Reworded to 'queries'; no semantic change (#1310).
|
[claim:review:Kulili:2026-08-04T05:06:32Z] |
|
[release:review:Kulili:2026-08-04T05:06:37Z] |
db66eac to
7f1a930
Compare
Review — no defects found; rebased onto main (
|
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/test_store_schema_race.py (1)
258-259: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTighten the bound to exact equality.
The count here is deterministic. The first
_SCHEMAstatement fails 3 times inside_execute_reprepare, the outer_retry_on_schema_changeretries the window 3 times, so the injection fires exactly 9 times.
<= 9also passes when retry coverage regresses. Removing the outer window retry gives 3 firings. Removing_execute_repreparefrom the DDL loop also gives 3 firings. Lowering_SCHEMA_RETRY_ATTEMPTSto 2 gives 4. All three satisfy<= 9, so this test would stay green against the composition it exists to pin. Every sibling test in this file asserts an exact call count.
== 9keeps the hang guard and adds a guard on both retry layers.♻️ Proposed change to pin the exact count
# 3 outer window attempts x 3 statement-level attempts, not unbounded. - assert made[0].fired <= 9 + assert made[0].fired == 9, "both retry layers must be in the path"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_store_schema_race.py` around lines 258 - 259, Update the assertion for the schema injection counter in the race test to require exactly 9 firings instead of allowing any value up to 9, preserving the existing hang guard while validating both retry layers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@tests/test_store_schema_race.py`:
- Around line 258-259: Update the assertion for the schema injection counter in
the race test to require exactly 9 firings instead of allowing any value up to
9, preserving the existing hang guard while validating both retry layers.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 5c688963-21ac-4ffc-97db-fbb95844474b
📒 Files selected for processing (3)
CHANGELOG/v4.mdsrc/aelfrice/store.pytests/test_store_schema_race.py
|
merge-train: merged 7f1a930 → |
|
[release:review:Setr:2026-08-04T05:13:30Z] |
Closes #1310.
MemoryStore.__init__executes its whole schema battery — four DDL loops plus a handful of reads and writes — with bareconn.executecalls. SQLite raisesSQLITE_SCHEMA("database schema has changed") when another process commits a schema change between prepare and step, so a concurrent open fails at random. It has been failing a required CI check.What changed
Two module-level helpers in
store.py:_execute_reprepare(conn, stmt, attempts=3)— re-prepares a single statement against the new schema cookie. AnyOperationalErrorthat is not the cookie race re-raises immediately and stays loud._retry_on_schema_change(op, attempts=3)— retries a whole callable on the same condition, bounded so a persistently-changing schema fails rather than spins.The open-time schema battery moved verbatim into a new
_apply_open_schema(), which is invoked through the window-level retry.Scope is wider than the issue's suggested fix, and deliberately so
The issue proposed a 2-line
try/exceptaround the_SCHEMAloop. That is not sufficient: after such a patch, five more bareself._conn.executecalls remain in the same constructor (theschema_metaprobe, thestore_generationinsert, theschema_metaread, and_resolve_local_scope_id). Reads are exposed toSQLITE_SCHEMAtoo — which is the issue's own reasoning — and a per-call-site patch leaves new gaps as the constructor grows.This is measured, not asserted. Dropping the window wrapper while keeping per-statement retries fails
test_open_survives_injected_schema_change[generation_read]and[scope_id_write], while[schema_ddl]still passes — i.e. the whole-window wrapper covers exactly the sites a loop-only patch would miss.Correction to the issue's rationale
The issue states the existing
_MIGRATIONStry/exceptalready tolerates this class. It does not: it admits only"duplicate column name"and"no such column".What the tests prove, and what they do not
Real-concurrency reproduction was attempted and did not fire — 0 of 180 rounds locally. The suite therefore uses deterministic fault injection: a connection whose
execute()raisesOperationalError("database schema has changed")on the Nth call and then succeeds. The module docstring and the commit message both say plainly that these tests prove the retry is wired, not that the CI flake is eliminated. That distinction is the honest claim and should not be softened in review.Four mutations, each verified to go red (file staged before mutating, restored after):
return op()at the top of_retry_on_schema_changeOperationalErrori == attempts)Reviewer's checklist
_apply_open_schema(); the only intended deltas are threeself._conn.execute(stmt)→_execute_reprepare(...), two comments, and a trailingreturn.self._invalidation_callbacks = []now initialises before the schema window rather than afterself._commit(). Argued inert — nothing in the window registers or fires a callback, and_commitdoes not call_fire_invalidation— but it is the single ordering change in the diff.IF NOT EXISTS/OR IGNORE/OR REPLACE/ marker-gated), and the docstring states the contract — but nothing enforces it, so a future non-idempotent statement added there would be re-run on retry.test_open_gives_up_on_a_persistently_changing_schema."schema has changed". CPython'ssqlite3does not surfaceSQLITE_SCHEMAdistinctly through this path, so the message is the available signal; a future wording change would silently disable the retry.Verification
tests/test_store_schema_race.py→ 9 passed.scripts/check_migration_policy.py→ "OK: no new migration entries".vulture src --min-confidence 80→ only the pre-existingingest.py:113hit.[Unreleased] ### Fixed— insert-only.Not done
No retry added to
_run_guarded_migration's one-shot passes: they already catchExceptionand degrade, soSQLITE_SCHEMAthere cannot make a store unopenable. It would be recorded as a failed migration — worth a follow-up, out of scope here.Summary by Sourcery
Add bounded retries for store open-time schema operations to handle SQLITE_SCHEMA races when multiple processes open the same store concurrently.
Bug Fixes:
database schema has changederrors during open-time schema setup while still surfacing other OperationalError conditions.Enhancements:
_apply_open_schemahelper and centralize schema-change retry behaviour in_retry_on_schema_changeand_execute_reprepare.Documentation:
Tests:
Summary by CodeRabbit
Bug Fixes
Tests
Documentation