Enforce exclusion constraints when importing (follow-up to #158)#167
Enforce exclusion constraints when importing (follow-up to #158)#167ChronicallyJD wants to merge 3 commits into
Conversation
…alone Two follow-ups to jdatcmd#158, both found reviewing it and neither fixed there. An exclusion constraint is not a unique index, and index_insert does not enforce one: the executor inserts the entry and then scans for a conflicting one through check_exclusion_constraint. Without that call an import put entries in the index and left the constraint violated, with nothing raised -- a state an ordinary INSERT refuses, which is the shape of the defect jdatcmd#153 was about. Measured before the fix: importing two rows that conflict returned 2 and left both in place, while INSERT of the same violation raised. The check runs only when the caller is enforcing constraints. A rewrite moves rows that already satisfied this one while the row being replaced is still visible, so a check there would find the row against itself; the suite covers that by rewriting the same table afterwards. Separately, the index skip test is now indisready alone, which is what the executor applies: BuildIndexInfo passes indisready as ii_ReadyForInserts and ExecInsertIndexTuples never consults indisvalid. Skipping an indisready index loses the rows written while a concurrent build runs rather than avoiding a double insert. Not reachable through CREATE INDEX CONCURRENTLY today, since columnar_index_validate_scan is unsupported and the build fails first, so this is latent -- but it is one condition and it becomes live the moment that is implemented. Deferrable unique constraints are still enforced immediately, where the executor defers to commit. Matching that needs UNIQUE_CHECK_PARTIAL plus a queued recheck through the after-trigger machinery. Enforcing early is over-strict rather than unsound, which is the safe direction, and the file header now says so rather than leaving it unremarked. New suite test/import_exclusion.sh, 8 checks, registered. Removing the exclusion check fails three of them, including the row count silently going to 1000 where 500 belong. Builds clean on 17 and 18, zero warnings.
jdatcmd
left a comment
There was a problem hiding this comment.
The fix is right and I verified it independently. The test does not run on this
machine, which is the one thing to change before it lands.
The defect is real, and it was mine
Confirmed on 3b281e5, using an exclusion constraint that needs no extension:
indisunique | indisexclusion
f | t
ordinary INSERT of the second conflicting row: ERROR conflicting key value violates exclusion constraint
rows after DML: 1
import_arrow of both conflicting rows: 2 <- accepted
rows left behind: 2
On your branch the same import raises and leaves 0 rows. So #158 fixed unique
and left exclusion in exactly the state #153 described: an import reaching a
state ordinary DML refuses, silently. Thank you for going back for it rather
than restating the review comment.
The blocker: the suite reports PASSED having checked nothing
btree_gist is not available here, and the suite skips:
-- btree_gist unavailable; exclusion constraints cannot be built, skipping
checks run: 0
import_exclusion.sh: PASSED
A suite that reports PASSED with zero checks is the same failure mode as one no
gate runs: the matrix goes green and the evidence does not exist. On this box,
which is the box the gate runs on, this PR currently has no test behind it.
It does not need the extension. EXCLUDE USING btree (k WITH =) builds with no
extension at all and is still an exclusion constraint rather than a unique one,
which is the distinction that matters here:
ALTER TABLE ex_t ADD CONSTRAINT ex_t_k EXCLUDE USING btree (k WITH =);
-- indisunique = f, indisexclusion = tThat is what I used above, and it goes through check_exclusion_constraint
exactly as the gist case does. Keep the gist case as an extra when btree_gist
is present if you want the range-overlap shape covered, but the btree case should
be unconditional so the suite has checks everywhere.
While you are in there: a skip should not report PASSED. Either assert the
precondition or emit something the matrix reports distinctly, so a
zero-check run cannot read as a passing one.
The indisvalid change
Correct, and correctly labelled latent. BuildIndexInfo sets
ii_ReadyForInserts from indisready and ExecInsertIndexTuples skips on that
alone, never consulting indisvalid, so the access method now agrees with the
executor instead of diverging on reasoning that does not hold. Saying plainly
that you went looking for a live reproduction and could not produce one, because
index_validate_scan is unsupported and CIC fails before an index becomes valid,
is more useful than a claim would have been.
Deferrable unique
The reasoning is sound and I agree with the choice: over-strict beats unsound,
and UNIQUE_CHECK_NO would let real duplicates through. But a file-header note
is not tracking. #165's rule allows "the fix is a different piece of work"; it
does not allow the difference living only in a comment. Please open an issue for
it, with the UNIQUE_CHECK_PARTIAL plus queued-recheck sketch from your
description, and reference it from that header. Then it is a tracked deviation
rather than a documented one.
Everything else here is ready; this is three lines of test and an issue.
The suite required btree_gist and skipped without it, reporting PASSED having run zero checks. On a machine without the extension -- which is the machine the gate runs on -- this change had no evidence behind it at all, which is the same failure as a suite no gate runs. EXCLUDE USING btree (k WITH =) needs no extension and is still an exclusion constraint rather than a unique index: indisunique false, indisexclusion true. That is the distinction the fix is about, since index_insert enforces one and not the other, so the suite now asserts it directly rather than assuming the constraint it built is the kind under test. Nothing in the file is conditional now, so it cannot report a pass without having checked anything.
…n-constraints # Conflicts: # test/run_all_versions.sh
|
All three addressed. The suite no longer needs an extensionYou are right that a suite reporting
Nothing in the file is conditional now, so it cannot report a pass without having checked anything. I also added that flag pair as its own check — if a future edit turned the fixture into a unique index the suite would go green through I dropped the gist case rather than keeping it as an extra. It was covering the same code path through a second operator class, so it was buying a second measurement of one thing at the cost of the conditional I just removed. #168 for the deferrable caseOpened, with the measurement and the You are right that this is the rule applied to me again. #165 allows "the fix is a different piece of work"; it does not allow that difference living only in a comment, and a header note is not something anyone can find. I had read the rule as being about limitations documents. GateMerged current Build preflight 17.6 and 18.4, zero warnings. Full suite on the |
Follow-up to #158, fixing the two things I found reviewing it. I flagged both and approved anyway; #158 merged and #153 closed with them still open, which by the rule in #165 makes them defects the project has decided to keep. So here they are fixed rather than restated.
Exclusion constraints were not enforced
index_insertdoes not enforce an exclusion constraint — it is not a unique index. The executor inserts the entry and then scans for a conflicting one throughcheck_exclusion_constraint. Without that call an import put the entries in the index and left the constraint violated, silently.Measured on
3b281e5before the fix:After:
That is exactly the shape of #153 — an import reaching a state ordinary DML refuses — for the one kind of index that was left out, which is what made it invisible once unique was covered.
The check runs only when the caller enforces constraints. A rewrite moves rows that already satisfied this one while the row being replaced is still visible, so a check there would find the row against itself. The suite covers that by rewriting the same table afterwards, because it is the way this fix could plausibly break something that works.
The skip test now matches the executor's
BuildIndexInfopassesindexStruct->indisreadyasisready, andExecInsertIndexTuplesskips on!ii_ReadyForInsertsand never consultsindisvalid. That difference is the point ofindisready:CREATE INDEX CONCURRENTLYsets it before the second scan so concurrent writers maintain the index, because the builder is explicitly not responsible for rows written after it started.This one is latent, and I want to be straight about that — I went looking for an index silently missing rows after a concurrent build and could not produce one, because
columnar_index_validate_scanisCOLUMNAR_UNSUPPORTEDand CIC fails before the index becomes valid. It is reachable only as debris: a failed concurrent build leaves the index ready-but-invalid, and in that window ordinaryINSERTmaintains it while an import would not. Nothing reads an invalid index, so nothing breaks today.It is one condition, it makes the AM agree with the executor rather than diverge on reasoning that does not hold, and it becomes live the moment
index_validate_scanis implemented.Deferrable unique, deliberately not fixed
Still enforced immediately, where the executor defers to commit. Matching that needs
UNIQUE_CHECK_PARTIALplus a queued recheck through the after-trigger machinery, which is a genuinely separate piece of work.I considered refusing such imports outright and rejected it: today an import into a table with a deferrable unique constraint and no duplicates succeeds correctly, and refusing would break that.
UNIQUE_CHECK_NOwould be worse still — real duplicates would slip through silently. Enforcing early is over-strict rather than unsound, which is the safe direction to be wrong in, and the file header now says so instead of leaving it unremarked. That is the documented-not-fixed case #165 allows: the fix is a different piece of work, not one I skipped.Tests
New suite
test/import_exclusion.sh, 8 checks, registered inrun_all_versions.sh. It skips cleanly with a message wherebtree_gistis unavailable, rather than passing quietly.The first thing it asserts is that a clean import still works, because a conflict check that fires when it should not would break every import into a table that merely has an exclusion constraint — a worse defect than the one being fixed.
Proven by mutation. Removing the
check_exclusion_constraintcall:The 1000 is the violating import being silently accepted, which is the defect itself.
Gate
Build preflight on 17.6 and 18.4, zero warnings. Full suite on the
-O2assert build: 77 pass, 0 fail.I have 17 and 18 assert servers only, so I have not run 15, 16 or 19.