Repository navigation
Score the foreign key and IGNORE_DUP_KEY properties (#917, #916) - #920
erikdarlingdata merged 2 commits into
Conversation
…rikdarlingdata#917, erikdarlingdata#916) Two properties index_priority could not see, so a pair holding one of them was settled on index_name - by the alphabet. A foreign key is backed by this index, and IGNORE_DUP_KEY decides what an insert of a duplicate key does. Both terms outrank the seek, scan and include terms, which are the ones that can differ between the two structures of a pair. The foreign key term outranks the IGNORE_DUP_KEY one as well, because the order decides the case where one structure backs a key and the other skips duplicates: keeping the key's index and giving it IGNORE_DUP_KEY is the one outcome that changes nothing. A keeper that does not skip, whose loser does, takes the option over. A rebuilt keeper carries it in its WITH list, generalising what erikdarlingdata#914 did for a replaced constraint; one that is not rebuilt gets ALTER INDEX ... SET (IGNORE_DUP_KEY = ON) at sort_order 6, before the disables, because while either structure skips SQL Server skips. Measured on 2022 CU27 throughout, including the counterfactuals: without the carry-over both cases fail with 2601. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…invalidated (erikdarlingdata#917) The four CI matrix jobs failed on two assertions in rule_coverage_test.py, and they were asserting the behaviour this branch deliberately changes. K1's fixture backs its foreign key with the index that loses on NAME. Before index_priority could see a foreign key, the pair was settled on index_name, the FK index was picked as the loser, and the erikdarlingdata#902 guard had to rescue it and label the row KEPT - FOREIGN KEY. The term decides the pair now, so the FK index wins it and nothing ever tries to disable it - so no guard row is produced, which is exactly what the two assertions tripped over. K1 is now the mirror of K2: the FK-backed index wins, absorbs the duplicate's includes, and the duplicate is the one disabled. K3 keeps the guard assertion, because both of its indexes back a key and neither may go; it passed throughout. Absorbing the includes is safe on an index a foreign key is backed by, which is the part worth stating rather than assuming. The winner's rebuild is CREATE INDEX ... WITH (DROP_EXISTING = ON), which keeps the key columns and the uniqueness the constraint needs. Measured on SQL Server 2022 CU27 (16.0.4295.3) against this fixture: the rebuild succeeds and sys.foreign_keys still reports key_index_id pointing at ux_z3_code afterwards. A plain DROP INDEX would be refused, and no winner ever gets one. All four harnesses green: 56, 30, 94 and 4 assertions.
|
All four matrix jobs were red, and the two failing assertions were mine - in K1's fixture backs its foreign key with the index that loses on name: The term decides the pair now. The FK index wins it, so nothing tries to disable it, so there is no guard row to find. The failure was the test holding on to the premise the fix removes. K1 is now the mirror of K2 - FK-backed index wins, absorbs the duplicate's includes, duplicate disabled - and K3 keeps its guard assertion, because both of its indexes back a key and neither may go. K3 passed throughout. The part I did not want to assume: whether absorbing includes is safe on an index a foreign key is backed by. The winner's rebuild is So the generated script is executable and leaves the constraint intact, which is what makes "the FK index wins and merges" the right outcome rather than a convenient one. That measurement is in the test comment, with the build it was taken on. How I ran it. I rebuilt the job locally rather than pushing at CI:
The count went to 94 because K1 gained two assertions beyond the corrected pair: that the FK-backed winner's script really carries |
Closes #917. Closes #916.
One change for both, as you asked, because the order of the two terms decides the case where they meet.
The terms
Current score: clustered 1000, unique index 500 / unique constraint 50, seeks 200, scans 100, includes 50. Two added:
Each has to outrank every term that can differ between the two structures of a pair, which is seeks + scans + includes, 350 together. 400 clears that.
The foreign key term then has to outrank the IGNORE_DUP_KEY one plus those three, 750, so 800. That ordering is your interaction case from #916: a strict unique index backing a key against an IGNORE_DUP_KEY duplicate backing nothing. Keeping the key's index and giving it the option is the one outcome where nothing changes, and I measured both directions on 2022 CU27.
On your note about the clustered 1000: neither term reaches it alone. The sum can pass it, and that is unreachable because a clustered index and a primary key carry
is_eligible_for_dedupe = 0and never enter a pair. Keeping the sums under 1000 is not possible anyway - the ordering forces the foreign key term past 700, and a unique index already carries 500.The carry-over
The term makes the skipper win its own pair, so the common case needs nothing carried. Two things still reach a keeper that does not skip:
A rebuilt keeper carries the option in its
WITHlist. That arm existed from #914 for a replaced constraint; it now reads#ignore_dup_key_carried, which is the whole population, so the two consumers share one definition. A keeper that is not rebuilt gets its own row,sort_order 6, between the merges and the disables. That ordering is the point rather than cosmetics: while either structure skips, SQL Server skips, so the keeper has to be skipping before the loser is disabled.Your #916 case 3 is the one #914 writes no script for - a Rule 7 replacement that is already unique, where the rebuild would add neither a column nor uniqueness. That is now the ALTER.
Two things I measured that neither issue has
ALTER INDEX ... SET (IGNORE_DUP_KEY = ON)is refused against a unique constraint's backing index. Error 1979, "Cannot use index option ignore_dup_key to alter index '...' as it enforces a primary or unique constraint". So a constraint keeper cannot be given the option after the fact, and your option 1 is unavailable for it. Rebuilding one means dropping and re-adding the constraint, a window with no uniqueness in it for the sake of removing a duplicate.That case is unreachable, and the term ordering is why. A constraint keeper only comes out of Rule 7.5b, which pairs two constraints, and there the skipping one outranks the strict one (450 against 50 plus usage, which caps at 400). The only term that could lift a strict constraint past it is the foreign key one, and the rules exclude a column a foreign key references from that pairing: I built that exact pair as a fixture and it reaches no dedupe rule at all, just a compression row each.
I started by building your option 2 for it - keep both, with a
KEPT - IGNORE_DUP_KEYrow - and then took it back out when the fixture proved nothing reaches it. Dead code with a passing test is worse than no code. What stayed is one predicate:keeper_is_unique_constraintfilters the script, so a change to the rules' foreign key exclusion cannot turn into a generated script that errors. Tell me if you would rather have the guard anyway.Your three points from #917
DROP_EXISTINGwith the merged list, so promoting the backing index absorbs the loser's includes rather than losing them with the DISABLE.What this changes in the existing tests
Group 13's fixture has the backing index sorting last, which is exactly the arm where #913's guard used to fire. It wins on the term now, so:
KEPT - FOREIGN KEYrow, because the guard had nothing to do. Drawing one where the index won on merit would claim the guard did something it never did, which is the distinction your Never disable an index a foreign key is backed by (#902) #913 review asked for.The guard stays as the belt for the rules that do not read priority.
Verified
run_tests.py: 56 passed, 0 failed on 2022 CU27, group 17 added with eight assertions.End to end, applying the generated scripts in the order the results give them and rolling back:
rule_coverage_test.pyandfixture_cases_test.pyI did not run: the first wants 14 days of uptime and the second a real StackOverflow database, and this container has neither.