fix(store): run SQLite startup migrations inside one transaction (BACKLOG #1586) - #1253
Merged
Merged
Conversation
…KLOG #1586) MessageStore.open ran _migrate with no BEGIN, so each ALTER TABLE ... ADD COLUMN committed on its own. A failure between an ADD and its paired backfill (users.password_claimed_at, users.notify_email) left the column present, and the column-missing guard then skipped the backfill forever. _migrate and its commit now run inside _writer_txn, placed after executescript(_SCHEMA) and after the connection PRAGMAs. Earlier spots fail: foreign_keys=ON is a no-op inside a transaction, journal_mode=WAL raises, and executescript COMMITs first. The #1670 cleanup is unchanged. New tests interrupt after every column addition on a hand-built legacy database and require the file unchanged, then two reopens that match a clean migration, plus foreign_keys=1 and WAL on an opened store.
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Sep 20, 2026
|
Security failed while this pull request was in the merge queue, so the queue ejected it. Its own head can still be green: the queue revalidates the merge, and the path gates that skip on a pull request run there. Read the run before retrying. https://github.com/MEFORORG/MessageFoundry/actions/runs/35498606442 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
MessageStore.opennow runs_migrateand its commit inside_writer_txn, the store's existing writer-transaction helper. Before this, the SQLite store ran its startup migrations with noBEGIN. EachALTER TABLE ... ADD COLUMNcommitted on its own. A failure between an ADD and its paired backfill (users.password_claimed_at, #1245;users.notify_email, #1139) left the column present. The next open's column-missing guard then skipped the backfill forever.The fix is small; the placement is what matters. The transaction opens after the connection PRAGMAs and after
executescript(_SCHEMA), and nowhere earlier works:PRAGMA journal_mode=WALraisescannot change into wal mode from within a transaction, andPRAGMA foreign_keys=ONis a silent no-op.executescript, its implicit COMMIT ends the transaction before_migrateruns._writer_txnrolls back onBaseExceptionwith its bounded, shielded unwind. It needs a lock, soopenpasses a freshasyncio.Lock(); nothing else can reach this connection yet. The #1670 cleanup inopen'sexcept BaseExceptionarm is unchanged and still closes the connection; the new test asserts its worker thread is gone.I also corrected two comments whose premise this change makes false: the FIFO index swap note in
_migrate, and the docstring oftest_partial_migration_converges_and_stays_correctintests/test_fifo_index_migration.py.Where this disagreed with the brief
_writer_txnrather than a bareBEGIN.tests/test_writer_txn_is_the_only_begin.pypins everyBEGINinstore.pyto_writer_txnand_read. A bareBEGINinopenwould red it. Reusing the helper keeps that guard unchanged and gets the BaseException unwind for free.BEGINbefore the PRAGMAs is loud, not only silent. The brief expected only the foreign-keys no-op. Measured:journal_mode=WALraises first, so every open fails.usersas it stood before any column_migrateadds, primary key included, then runs_SCHEMAover it with the stdlib driver.DROP COLUMNfails onpassword_claimed_atbecause of its trailing comment, and the rebuild workaround intest_auth_service.pyloses the primary key.Readings 1, 3 and 4 in the brief matched my measurement (Python 3.14.6, SQLite 3.50.4, aiosqlite 0.22.1,
isolation_level == ''). Nothing in_migrateor_migrate_outbox_to_queuecommits, runs a PRAGMA that needs to sit outside a transaction, or callsexecutescript.Placement proof
I moved the transaction to each wrong spot, ran
tests/test_backlog1586_sqlite_migrate_txn.py, and reverted. "interrupted backfill" is the test parametrized over the two backfilled columns. "any addition" interrupts after every ADD the clean run issues (11 on this file). "PRAGMAs" checksforeign_keys == 1andjournal_mode == 'wal'.channel_scopeADDcannot change into wal modesynchronousandforeign_keysforeign_keysread 0executescriptexecutescript, via_writer_txn)The defect reproduces at origin/main. With the on-disk assertion disabled, the row's own acceptance check still fails at A: after the reopen, alice's
password_claimed_atis NULL where a clean migration gives 1000.0.Checks run
ruff check messagefoundry tests: passruff format --check messagefoundry tests: pass (1096 files)mypy messagefoundry(strict): pass, 275 filestest_backlog1586_sqlite_migrate_txn.py,test_auth_store.py,test_auth_service.py,test_fifo_index_migration.py,test_retention.py,test_store_schema_hash.py,test_writer_txn_is_the_only_begin.py,test_backlog1548_writer_txn_cancel_unwind.py,test_store.py. The 6 skips are the SQL Server and Postgres cases, which needMEFOR_TEST_*env./simplify: the four review agents ended without returning a report, so I reviewed the diff myself. One cleanup applied: the sqlite3 read helpers usecontextlib.closing.Checks NOT run
One thing I did not measure
_writer_txnissues a deferredBEGIN. On a database that needs migrating, the first write inside the transaction upgrades a read lock. If another process commits to the same file in that window, SQLite can return SQLITE_BUSY without running the busy handler. Before this change, each ALTER ran in its own autocommit and waited onbusy_timeout. The failure is clean (rollback,openraises, the next open retries). It only matters when two processes open one SQLite file during an upgrade, and I did not test that case.Ledger banner text for the Lander
#1586, landed in this PR (pending merge):
MessageStore.opennow runs the SQLite startup migrations inside one transaction through_writer_txn. It opens after the connection PRAGMAs and afterexecutescript(_SCHEMA), and rolls back on any failure. An interruptedALTER TABLE ... ADD COLUMNnow leaves the file unchanged, so thepassword_claimed_atandnotify_emailbackfills run on the next open.tests/test_backlog1586_sqlite_migrate_txn.pyinterrupts after every column addition on a hand-built legacy database. It requires the file unchanged, then two reopens that match a clean migration, plusforeign_keys=1and WAL on the opened store. Each wrong placement fails at least one of those tests (table in the PR body).