SQLite table rebuild - #539
Conversation
…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>
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>
* 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>
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>
|
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 can't
ALTERmost of a table, soTableDeltarebuilds 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
DROPfails on its implicit delete, after the replacement has been created, filled andcommitted. The orphan
_newtable survives,CREATE TABLE IF NOT EXISTSno-ops on it atthe next start, and every run after that fails differently until someone drops it by hand.
Foreign keys are on by default in
Microsoft.Data.Sqliteand in all threeSqlitePragmaSettingspresets, so this is the ordinary case.Two smaller ones: an
AUTOINCREMENTtable came out with a lowersqlite_sequencemarkthan 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_checkbefore the commit so a rebuild that really woulddangle a reference rolls back rather than committing the orphan.
One behaviour change: a caller's
IMigrationLoggerthat declines to rethrow used to let afailed rebuild reach
COMMIThalf-applied. It's still handed the failure; the migrationno longer continues.