fix(db): restore a foreign key its own rows violate - #290
Merged
Conversation
addMissingForeignKeys() adds a constraint with a plain ALTER TABLE, and InnoDB validates the existing rows when it does. That contradicts the premise of the repair: the reason an install is missing a constraint is that it spent time without one, so the rows it would have prevented are already there. With FOREIGN_KEY_CHECKS on, InnoDB answers errno 1452 and refuses precisely the keys most worth putting back — and the failure is only logged, so the constraint is lost without anything saying so. It worked anywhere it was actually reached, because update() and Restore::run() both set FOREIGN_KEY_CHECKS = 0 around their own migration runs, and reconcileForeignKeys()' docblock already describes adding under that setting as the intended behaviour. Nothing established it; the method inherited it from whichever caller happened to be on the stack. Establish it here instead. The caller cannot know the setting is needed — it is a property of what this method does, not of when it is called. Found through MigrationsTest::testForeignKeysSurviveDropAndRestore, which called restoreForeignKeys() directly, without the ambient setting, and lost texttags.fk_texttags_text_tag on every run where the test database happened to hold rows violating it. Reproduced on the test schema: texttags rows: 3 text_tags rows: 0 orphans: 3 ADD CONSTRAINT, FOREIGN_KEY_CHECKS=1 -> ERROR 1452 ADD CONSTRAINT, FOREIGN_KEY_CHECKS=0 -> added The test read as flaky because it is self-concealing: a run that fails to restore the key leaves it dropped, so the next run captures a "before" set without it and passes. The new test covers the case directly, with a deliberately violating row.
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.
MigrationsTest::testForeignKeysSurviveDropAndRestorehas been failing in full-suite runs while passing in isolation. It is not flaky — it was catching a real defect in the foreign-key repair, and hiding it at the same time.The defect
addMissingForeignKeys()adds a constraint with a plainALTER TABLE ... ADD CONSTRAINT, and InnoDB validates the existing rows when it does.That contradicts the premise of the repair. The reason an install is missing a constraint is that it spent time without one, so the rows the constraint would have prevented are already in the database —
reconcileForeignKeys()'s own docblock says exactly this:But nothing established that setting.
update()andRestore::run()both happen to set it around their own migration runs, so the repair worked from there and nowhere else — the method inherited the condition from whichever caller was on the stack.With checks on, InnoDB answers errno 1452 and refuses precisely the keys most worth restoring. The failure is caught and logged, so the constraint is silently lost.
Reproduced on the test schema
Why it looked flaky
The test is self-concealing. A run that fails to restore the key leaves it dropped, so the next run's
captureForeignKeys()"before" set does not contain it, nothing is expected back, and the test passes. It only fails again once something restores the key — a migration run in an earlier test, for instance. Pass and fail alternate on a schedule that has nothing to do with the code under test.The fix
addMissingForeignKeys()setsFOREIGN_KEY_CHECKS = 0for its own work and restores the previous value in afinally. The caller cannot know the setting is needed; it is a property of what the method does, not of when it is called. The add loop moves toaddForeignKeysUnchecked()unchanged.The two existing callers already set it, so their behaviour is identical.
Scope beyond the test
Restore::run()callsrestoreForeignKeys()inside afinallythat also sets checks back on — the ordering is correct today, but the dependency was invisible. Any future caller ofreconcileForeignKeys()(admin repair tooling is the obvious one) would have silently dropped constraints on exactly the installs it was written to repair.Checks
New test asserts the case directly, with a deliberately violating row; it fails without the fix (
Failed asserting that an array contains 'texttags.fk_texttags_text_tag') and passes with it.Psalm 0 errors · PHPCS clean · PHPUnit 9131 pass, 0 failures, across two consecutive full runs — the first time this suite has been green end to end in this branch's history. The
texttagsconstraint is present afterwards with its 3 violating rows still in place, which is the intended repair semantics.