Skip to content

Give the cross-process migration writer an explicit caller-owned transaction - #20

Closed
Parad0x-Labs wants to merge 1 commit into
mainfrom
mission/migration-writer-transaction-repair
Closed

Parad0x-Labs wants to merge 1 commit into
mainfrom
mission/migration-writer-transaction-repair

Conversation

@Parad0x-Labs

Copy link
Copy Markdown
Owner

Give the cross-process migration writer an explicit caller-owned transaction

Base: main @ 8153a9697f3c022a635bc95fa450ba7b5720e3c9 · Head: mission/migration-writer-transaction-repair @ 50e790b2bf4a62145d73ec1e4f4f55bc3473acfb
Changed: tests/test_run_migrations_cross_process.py only (+69). No production code.

Failure

test_concurrent_first_use_migrations_lose_no_committed_row_and_poison_no_process fails on Linux CI: the concurrent writer subprocess records OperationalError: database schema has changed (errors: 1) and the test asserts errors == 0. Observed at the pinned baseline in runs 35615031134, 35622837193 (leg 1), 35626506389 (leg A).

Root cause — measured, then inferred (kept distinct)

Measured (instrumented capture, run 35622837193 leg 2, Python 3.12.13 / SQLite 3.45.1): the error is raised by the writer's bare autocommit CREATE TABLE IF NOT EXISTS call — sqlite_errorcode 17, SQLITE_SCHEMA, exactly once per round, all four rounds. Committed rows == durable rows and integrity_check ok throughout: nothing is corrupted; the surfaced error itself is the failure.

Inferred (not traced at prepare/step granularity): SQLITE_SCHEMA's documented meaning is that the schema changed after the statement was prepared — i.e. a concurrent first-use migration committed while the statement was in flight. That interleaving is an inference from documented semantics, not a captured timeline.

Repair

storage.db.get_connection's contract makes the caller owner of the transaction, and the production stores already serialize their write paths on BEGIN IMMEDIATE. The test writer now:

  • opens BEGIN IMMEDIATE before its schema-touching work, holding the database's write lock across CREATE and INSERT (documented SQLite locking), so each migration commit lands entirely before or after the iteration;
  • on a failed iteration, rolls back its own statements and closes its handle, recording rollback/close problems rather than swallowing them;
  • keeps the existing concurrency shape (8 independent migrator processes + writer behind a file barrier, 4 rounds) and every original assertion.

Adds one regression case: a failed caller transaction rolls back its own statements while previously committed rows survive and the store stays openable.

Evidence

  • Runner A/B, same environment, same pinned baseline (run 35626506389): leg A (baseline tree, original writer) FAILED with errors: 1 OperationalError: database schema has changed; leg B (only this file applied, same node) PASSED; leg C cumulative cross-process + singleton suites 17 passed, pinned ruff clean.
  • Local (Python 3.12.13 / SQLite 3.50.4): same 17 passed; migration + storage-db contract family 56 passed. 5 failures in tests/test_legacy_migration_repair.py are independent of this change (its git show 7ff50e8d… history pin is absent from the public repo; also failing on main CI run 35570948370) — needs its own bounded repair.
  • Injected-failure probe (local, one injected error after INSERT with the transaction open): recorded exactly once, the failed iteration fully rolled back (durable rows == committed rows, values exactly the committed set), no lingering write lock — later iterations kept committing — integrity ok.

Limitations

  • The race is Linux-runner-timed; local macOS did not reproduce it, so the reproduction and repair evidence is the runner A/B above (run IDs are the durable record).
  • The statement-level interleaving remains inferred from SQLITE_SCHEMA's documented semantics.

Identity note

Code is byte-identical to reviewed f88e9723379a856735a722ac29649fdf1d9f72e8; the only delta is explanatory wording (comment + commit message) that separates the measured capture from the inferred mechanism. The published branch repair/migration-writer-transaction (which carries two extra diagnostic-workflow commits) and diagnostic/migration-writer-capture are untouched.

Measured on the runner (instrumented capture, run 35622837193, SQLite 3.45.1):
the writer's bare autocommit CREATE TABLE raised OperationalError SQLITE_SCHEMA
(17, "database schema has changed"), exactly once per round, in all four rounds;
runs 35615031134 and 35626506389 leg A showed the same failing assertion at the
pinned baseline. The interleaving was not traced directly: SQLITE_SCHEMA's
documented meaning is that the schema changed after the statement was prepared,
so "a concurrent first-use migration committed while the statement was in
flight" is an inference from that documented semantics, not a captured
timeline. storage.db's contract makes the caller owner of the transaction, so
the writer now opens BEGIN IMMEDIATE before its schema-touching work: the
iteration takes the database's write lock across CREATE and INSERT (documented
SQLite locking; the production stores already serialize their write paths on
it), so a migration commit lands entirely before or after the iteration. A
failed iteration rolls back its own statements and closes its handle,
recording rollback/close problems rather than swallowing them. A distinct
regression case pins that a failed caller transaction rolls back its own
statements while previously committed rows survive.
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.

1 participant