Skip to content

SQLite table rebuild - #539

Merged
jeremydmiller merged 3 commits into
JasperFx:masterfrom
jakobt:jta/fix-sqlite-table-rebuild
Sep 2, 2026
Merged

jeremydmiller merged 3 commits into
JasperFx:masterfrom
jakobt:jta/fix-sqlite-table-rebuild

Conversation

@jakobt

@jakobt jakobt commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

SQLite can't ALTER most of a table, so TableDelta rebuilds it — create, copy, drop,
rename — and those four statements ran with no transaction, each one autocommitting.

Rebuilding a table that another table's foreign key references breaks the database. The
DROP fails on its implicit delete, after the replacement has been created, filled and
committed. The orphan _new table survives, CREATE TABLE IF NOT EXISTS no-ops on it at
the next start, and every run after that fails differently until someone drops it by hand.
Foreign keys are on by default in Microsoft.Data.Sqlite and in all three
SqlitePragmaSettings presets, so this is the ordinary case.

Two smaller ones: an AUTOINCREMENT table came out with a lower sqlite_sequence mark
than it went in with and reissued a used id, and a view over the table failed the rename
outright.

The rebuild now runs the way SQLite documents it — enforcement suspended, the whole thing
in one transaction, foreign_key_check before the commit so a rebuild that really would
dangle a reference rolls back rather than committing the orphan.

One behaviour change: a caller's IMigrationLogger that declines to rethrow used to let a
failed rebuild reach COMMIT half-applied. It's still handed the failure; the migration
no longer continues.

jakobt and others added 2 commits September 1, 2026 10:05
…tabase

SQLite cannot ALTER most of a table, so `TableDelta` rebuilds it: create a
new table, copy the rows, drop the old one, rename. It emitted those four
statements bare, and `SqliteMigrator.executeDelta` issued them with no
transaction, so each one autocommitted. Both defects below are reachable
today under `AutoCreate.All`, which is the path that gets here.

Rebuilding a table that another table's foreign key references wedged the
database. `DROP TABLE` performs an implicit delete, so with a child row
present it failed with `FOREIGN KEY constraint failed` — after the new
table had already been created and filled and committed. Measured:

    TABLES AFTER FAILURE: m_child, m_parent, m_parent_new
    SECOND ATTEMPT: UNIQUE constraint failed: m_parent_new.id

It never healed. `CREATE TABLE IF NOT EXISTS` no-ops on the leftover, the
copy repeats, and every later start fails differently until someone drops
the orphan by hand. `Microsoft.Data.Sqlite` enforces foreign keys on an
ordinary connection and `SqlitePragmaSettings` enables them in all three
presets, so this was the common case; a self-referencing key failed the
same way.

Separately, an `AUTOINCREMENT` table came out of a rebuild with a lower
`sqlite_sequence` high-water mark than it went in with — 3 before, 2
after — and handed the freed id back to the next insert. No error, and a
schema still saying `AUTOINCREMENT`, which is the one thing that keyword
promises not to do.

The apply path now follows SQLite's own documented procedure for this
(https://www.sqlite.org/lang_altertable.html): foreign key enforcement
off outside the transaction, the whole migration inside one, a scoped
`PRAGMA foreign_key_check` before the commit so a rebuild that really
would dangle a reference rolls back instead of committing the orphan, and
enforcement restored afterwards. `PRAGMA defer_foreign_keys` is not a
substitute — it survives inside the transaction where `foreign_keys` is a
no-op, but the deferred violation the implicit delete records is never
retired by re-inserting the rows, so the COMMIT itself fails.

Atomicity has to hold for a caller-supplied `IMigrationLogger` too.
`executeCommand` rethrows only for `DefaultMigrationLogger`; any other
logger is offered the failure and, if it declines to throw, the migration
carries on. That is coherent while each statement autocommits, but inside
the rebuild's transaction carrying on means reaching `COMMIT` with half a
rebuild applied — measured on such a logger as both `ra_dup` and
`ra_dup_new` present afterwards and no exception raised at all. The
rebuild path now still hands the failure to the logger, so nothing that
was reported before goes unreported, and then fails anyway. Migrations
with no rebuild in them are untouched.

`PRAGMA legacy_alter_table` is borrowed for the same stretch and restored
the same way, because with it off `ALTER TABLE ... RENAME TO` reparses the
whole schema and a view over the table being rebuilt failed the rename
outright with `error in view ...: no such table`. Both pragmas are
connection state the caller owns, so each is read first and put back to
the value it had, surviving a failed rebuild. Enforcement is then read
back, because `PRAGMA foreign_keys` is silently ignored inside a
transaction: without the read-back, a connection left mid-transaction
would go back to the caller with foreign keys disabled and nothing said.

The emitted SQL carries the `sqlite_sequence` mark onto the replacement
before the original is dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXHTRGQTNfn7rb9eJPfq4z
Two defects found reviewing the rebuild.

The AUTOINCREMENT carry-over named sqlite_sequence unqualified. It is per-database,
and an unqualified name resolves against temp first, so any temp AUTOINCREMENT table
on the connection sent all five references to temp.sqlite_sequence, where they matched
nothing and carried nothing over. The rebuilt table then reissued an id it had already
handed out -- the exact failure the carry-over exists to prevent. Measured on 3.51.0:
with the mark at 5 and rows above 2 deleted, the next insert took id 6 normally and
id 3 with a temp AUTOINCREMENT table present.

The temp table is now created in the schema of the table being rebuilt, rather than
always in main. Identical DDL for a main-schema table; it makes the qualification above
correct for every other one, where the rebuild previously copied out of temp and left
the replacement in main.

foreign_key_check ran regardless of whether enforcement was on to begin with. It reports
every violation in the table, not only the ones a rebuild could have caused, and a
database running with foreign_keys OFF is allowed to hold dangling rows -- so a rebuild
that touched none of them was refused and blamed for them. Now gated on the original
setting, which is step 10 of SQLite's own procedure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit 1e5e64d into JasperFx:master Sep 2, 2026
18 checks passed
jeremydmiller added a commit to jakobt/weasel that referenced this pull request Sep 2, 2026
Merging master brought in JasperFx#539, which rewrote executeDelta to run a table rebuild inside
a transaction with foreign key enforcement suspended, and gave executeCommand a
failureIsFatal argument so a failure inside that transaction cannot be swallowed.

The deferred foreign key block did not compile against the new signature. Git had
already placed it inside writeDeltasAsync, which is where it belongs -- a rebuild's
deferred keys are then added in the same transaction as the rebuild and roll back with
it -- so it only needed the flag passed through. Swallowing a failure there would let a
rebuild reach COMMIT with a key missing.

Neither PR's CI could see this: each compiles alone, and only the pair breaks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jeremydmiller added a commit that referenced this pull request Sep 2, 2026
* fix: apply a foreign key after the table it references when that table is created later

A table's foreign keys are written as part of its own create statement, so a key
pointing at a table the same migration has not created yet references something
that does not exist. Two tables referencing each other could therefore never be
created from scratch: neither can go first, the apply throws on the first ALTER,
and every later delta -- including the create of the table the key was waiting
for -- is abandoned. Re-running does not recover, because the half-created state
reproduces the same failing statement. On a large database this leaves tables and
constraints permanently uncreated.

A migration now holds back exactly the keys that would fail -- those whose
referenced table is created by a later delta in the same migration -- and applies
them after every delta has run. A key whose target already exists, or is created
earlier in the same migration, is left where it was, so the DDL generated for
schemas that never had the problem is byte-for-byte unchanged.

Only SQL Server and PostgreSQL can defer today. The other providers that apply
deltas one at a time get the same tail so that a provider whose tables become
deferrable later applies its held-back keys rather than silently dropping them;
it emits nothing for them as things stand. SQLite writes its foreign keys inline
in CREATE TABLE and never had the problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXHTRGQTNfn7rb9eJPfq4z

* fix(sqlite): carry failureIsFatal through to the deferred foreign keys

Merging master brought in #539, which rewrote executeDelta to run a table rebuild inside
a transaction with foreign key enforcement suspended, and gave executeCommand a
failureIsFatal argument so a failure inside that transaction cannot be swallowed.

The deferred foreign key block did not compile against the new signature. Git had
already placed it inside writeDeltasAsync, which is where it belongs -- a rebuild's
deferred keys are then added in the same transaction as the rebuild and roll back with
it -- so it only needed the flag passed through. Swallowing a failure there would let a
rebuild reach COMMIT with a key missing.

Neither PR's CI could see this: each compiles alone, and only the pair breaks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jeremy D. Miller <jeremydmiller@yahoo.com>
@jeremydmiller jeremydmiller mentioned this pull request Sep 2, 2026
jeremydmiller added a commit that referenced this pull request Sep 2, 2026
Clears 9.28's known upgrade break, makes the SQLite table rebuild safe against a
database that has foreign keys, and adds two capabilities.

- #542 closes #538. AssertPatchingIsValid no longer refuses a delta that can rebuild in
  place, which is what made a numeric or decimal SQLite column throw on 9.28 under every
  AutoCreate except All. CreateOrUpdate permits a rebuild; CreateOnly still refuses.
- #539. The SQLite rebuild runs the way SQLite documents it -- enforcement suspended, one
  transaction, foreign_key_check before the commit -- instead of four autocommitting
  statements that wedged the database when the table was referenced. Also fixes an
  AUTOINCREMENT table reissuing a used id, and a view breaking the rename.
- #540. Mutually referencing tables can be created from scratch. A migration holds back
  only the keys whose target it creates later, so a schema that never had the problem
  generates byte-for-byte identical DDL.
- #543 closes #541. FullTextIndexDefinition can index a pre-built tsvector, making
  setweight() weighting reachable for JasperFx/marten#5298.
- #545. SQLite delete-all names the schema instead of letting SQLite resolve by search
  order, which put temp first.

Minor rather than patch: #543 and #540 add API, and #539 and #542 change what an existing
migration does.

Ships with one known issue, #546: on SQLite, resetting identity against a schema that has
no AUTOINCREMENT table fails with "no such table: <schema>.sqlite_sequence". The
single-schema form of this predates 9.29.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@jeremydmiller

Copy link
Copy Markdown
Member

Merged into 9.29.0 — thanks, this was a good catch and the reproduction was thorough. I pushed one commit to your branch before merging (2a22a8a) with two fixes and two tests. Flagging it since it landed under you.

sqlite_sequence was named unqualified

writeAutoIncrementCarryOver referred to sqlite_sequence bare. It is per-database, and an unqualified name resolves against temp first — so on a connection holding any temp AUTOINCREMENT table, all five references in those two statements went to temp.sqlite_sequence, matched nothing, and carried nothing over. The rebuilt table then reissued an id it had already handed out, which is the failure the carry-over exists to prevent.

Measured on 3.51.0, running your exact SQL. Mark at 5, rows above 2 deleted:

  • control: next insert takes id 6 ✅
  • with CREATE TEMP TABLE scratch(id INTEGER PRIMARY KEY AUTOINCREMENT, ...) first: next insert takes id 3 — already used by a deleted row

Interesting bit is that #545, your own sibling PR, has a qualify helper whose comment describes this exact resolution order. The two PRs disagreed with each other.

This also needed the temp table to carry the schema of the table being rebuilt. new SqliteObjectName(name + "_new") defaults to main, so a temp-schema rebuild was building main."x_new", copying out of temp."x", dropping it, and leaving the replacement in main. Byte-identical DDL for a main-schema table, so nothing moved for the common case.

foreign_key_check ran unconditionally

pragma_foreign_key_check reports every violation in the table, not only the ones a rebuild could have caused — and a database deliberately running with foreign_keys OFF is allowed to hold dangling rows. Verified: with enforcement off, child(parent_id REFERENCES parent(id)) holding a row pointing at a nonexistent parent makes pragma_foreign_key_check('child','main') return a hit. So rebuilding parent threw and blamed the rebuild for rows it never touched.

Now gated on the setting captured at line 83, which is step 10 of SQLite's own 12-step procedure ("if foreign key constraints were originally enabled").

Tests

Two regression tests in rebuild_is_atomic.cs. I checked they fail first the honest way — reverting only the two fixed conditions and leaving the tests in place turns exactly those two red, with the other 22 still green.

One I did not fix

restorePragmasAsync infers "a transaction is still open" from foreign_keys failing to take the restored value. That only works when the original value was ON — when it was OFF you set the pragma to the value it already holds, so the read-back matches whether or not a transaction is open, and the guard passes in exactly the case its own error message says is dangerous. Left alone to keep the diff reviewable; worth a follow-up if you want it.

@jakobt
jakobt deleted the jta/fix-sqlite-table-rebuild branch September 2, 2026 15:57
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.

2 participants