Skip to content

Fix #106: pre-claim schema_migrations before running DDL - #122

Merged
emp3thy merged 3 commits into
mainfrom
auto-fix/issue-106-migration-preclaim
Jul 31, 2026
Merged

Fix #106: pre-claim schema_migrations before running DDL#122
emp3thy merged 3 commits into
mainfrom
auto-fix/issue-106-migration-preclaim

Conversation

@emp3thy

@emp3thy emp3thy commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Closes #106

Summary

Two MCP servers starting against the same DB both read the applied set before either commits, then both call executescript and race — SQLite serialises writers so one succeeds and the other dies with table observation_trigram_fts already exists, killing that session's server (server.py:165-168 calls apply_migrations unconditionally at every start).

Contend on the version row via INSERT OR IGNORE INTO schema_migrations (version) VALUES (?) before any DDL runs, then commit and inspect rowcount. SQLite's writer serialisation gives us one winner (rowcount == 1, proceeds with the DDL) and one loser (rowcount == 0, trusts the winner and skips). On executescript failure we release the claim so the next start retries rather than skipping past a half-applied migration.

Two regression tests: one monkeypatches _applied_versions to reproduce the stale-snapshot window (concurrent runner has already recorded the row + created the table between our snapshot and our claim), the other pins the claim-release-on-failure contract. Full tests/db/ suite passes (111/111).

Confidence

~90%. The fix is precisely one of the two options the issue names ("Take an exclusive claim before executing (INSERT INTO schema_migrations ... ON CONFLICT DO NOTHING, commit, proceed only if rowcount == 1)"): a self-contained change in one function, with two focused regression tests and no touch to the migration files themselves. The atomicity-of-executescript concern the issue notes as related (#27) is deliberately out of scope; the claim-release path preserves the pre-fix "failure ⇒ not marked applied" contract.


This PR was generated by a scheduled Claude routine that scans open issues and opens PRs only when confidence is ≥ 90%.


Generated by Claude Code

Two MCP servers starting against the same DB both read `applied` before
either commits, then both call `executescript`; SQLite serialises the
writes so one succeeds and the other dies with "table already exists",
killing that session's server. Contend on the version row via
`INSERT OR IGNORE` before any DDL runs — SQLite serialises writers, so
one process wins (rowcount == 1) and runs the migration while the other
sees rowcount == 0 and trusts the winner. On DDL failure, release the
claim so the next start retries rather than skipping a half-applied
migration.

Two regression tests: one simulates a concurrent runner by pre-recording
the row + creating the target table between `_applied_versions` and the
claim, the other pins the claim-release-on-failure contract.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Claude BugBot Analysis

Found 1 potential bug in this PR.

high: 1

The new concurrent-safe migration claim mechanism improves the common race case, but introduces a new failure mode: if the winning process's DDL fails after a loser has already skipped (trusting the winner), the loser silently continues with an incomplete schema instead of also failing.

Comment thread better_memory/db/schema.py Outdated
BugBot flagged: the first cut of #106 let a losing process skip the
version as soon as it saw a claim row, so if the winner's DDL then
failed, the loser returned from apply_migrations with a broken schema
(the winner's rollback deleted its claim but the loser was already
past that version).

Two-phase claim closes that gap: the claim row is INSERTed with
applied_at explicitly NULL, meaning "in progress"; the winner UPDATEs
it to CURRENT_TIMESTAMP only after executescript succeeds, or DELETEs
it on failure. _applied_versions filters to applied_at IS NOT NULL,
and losers _wait_for_peer poll on the row: transition to non-NULL is
"skip", disappearance is "peer failed, retry the claim ourselves",
and a bounded timeout raises rather than silently returning with a
partial schema — the whole point of the fix.

Three regression tests: peer-failed-and-loser-retries, stuck-peer
times out, _applied_versions excludes in-progress claims. The
already-completed-peer test now sets applied_at explicitly non-NULL
to match the two-phase contract.

emp3thy commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Good catch — pushed 686e856 to close this.

The first cut trusted the bare claim row, so a loser that skipped past a version whose winner then failed would return with a broken schema. The fix is a two-phase claim: the row is inserted with applied_at explicitly NULL to mean "in progress", the winner UPDATEs it to a timestamp only after executescript succeeds (or DELETEs it on failure), and _applied_versions filters to applied_at IS NOT NULL so a live claim never looks like a completed apply. Losers now _wait_for_peer poll the row: transition to non-NULL is "skip", disappearance is "peer failed, retry the claim ourselves", and a bounded 120s per-version timeout raises OperationalError rather than silently returning with a partial schema — that's the whole point.

Three new regression tests cover the peer-failed-then-loser-retries path, the stuck-peer timeout, and the _applied_versions filter. Full tests/db/ (114 tests) passes.


Generated by Claude Code

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Claude BugBot Analysis

Found 1 potential bug in this PR.

high: 1

The new two-phase claim protocol correctly fixes the prior 'loser trusts winner silently' race via _wait_for_peer, but the claim row can still be leaked permanently (causing all future runs to hang 120s then error) if sql_file.read_text() or _mark_complete() fails, since only executescript() is covered by the release-on-failure handler.

Comment thread better_memory/db/schema.py Outdated
BugBot flagged: only executescript was inside the try/except that
releases the claim, so a failure in sql_file.read_text (OSError /
UnicodeDecodeError) BEFORE the try — or in _mark_complete AFTER it
— would leak the row with applied_at IS NULL forever, making every
future start poll _CLAIM_WAIT_SECONDS then raise until someone
hand-cleans schema_migrations.

Move read_text inside the try, and move _mark_complete inside too so
a transient failure on the completion UPDATE also releases the claim.
Two regression tests monkeypatch each failure point and assert no row
survives.

emp3thy commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Also a real leak — pushed c8fa8c0.

sql_file.read_text() was running before the try, and _mark_complete() after it, so an OSError/UnicodeDecodeError on the read or a transient error on the final UPDATE would leave the row at applied_at IS NULL permanently and make every future start poll _CLAIM_WAIT_SECONDS then error out. Both are now inside the same try that calls _release_claim on failure. Two new tests monkeypatch each failure point (Path.read_text and _mark_complete) and assert the claim row is gone after the exception. 116/116 in tests/db/ pass.


Generated by Claude Code

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Claude BugBot Analysis

The rewritten two-phase claim protocol in better_memory/db/schema.py correctly fixes both previously reported issues (loser no longer blindly trusts the winner, and the claim row is now released on any failure during read_text, executescript, or _mark_complete); no new bugs were found in the diff.

No bugs were detected in this PR.

@emp3thy
emp3thy merged commit 0c78c4f into main Jul 31, 2026
3 checks passed
@emp3thy
emp3thy deleted the auto-fix/issue-106-migration-preclaim branch July 31, 2026 16:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Concurrent apply_migrations re-runs a non-idempotent migration and kills server startup (schema.py:67)

2 participants