Split out of #167 at review request, so the deviation is tracked rather than living in a file header.
Status. Diagnosed, and a prototype has shown the fix works. This is a well-specified piece of work, not an open question. See the thread for the prototype and its evidence.
The behaviour
ColumnarIndexInsertRow decides on indisunique alone:
if (enforceUnique && st->rels[i]->rd_index->indisunique)
check = UNIQUE_CHECK_YES;
The executor also consults indimmediate. So a UNIQUE ... DEFERRABLE INITIALLY DEFERRED constraint is checked at commit through ordinary DML and at insert time through import_arrow / import_parquet.
Measured, with controls
|
transient collision, deferred constraint |
heap, ordinary INSERT |
COMMIT |
pgcolumnar, ordinary INSERT |
COMMIT |
pgcolumnar, import_arrow |
ERROR: duplicate key value violates unique constraint |
The middle row is the important one: pgcolumnar's ordinary insert path defers correctly. This is not the access method lacking something. It is the import path bypassing the executor, which is what maintains indexes.
The collision must exist when the row is inserted and be gone by commit. Deleting the conflicting row first makes the import succeed and proves nothing.
The fix: use the executor's index maintenance
Not a patch to the hand-rolled path. Give the import path a real executor context and let core do it:
EState with a range table and RTEPermissionInfo, InitResultRelInfo, ExecOpenIndices, AfterTriggerBeginQuery() / AfterTriggerEndQuery(estate) bracketing the load, and the enforcing path routed through ExecInsertIndexTuples + ExecARInsertTriggers.
ExecInsertIndexTuples selects UNIQUE_CHECK_PARTIAL for a non-immediate index by itself, and ExecARInsertTriggers queues the recheck. So indimmediate stops being ours to get right, and so do partial-index predicates, expression indexes, exclusion constraints and unique checking, all of which columnar_index.c currently re-implements. Each is a place our copy can drift from core, and #153 and #167 were both that drift found late.
The rewrite caller in columnar_vacuum.c keeps the hand-rolled path: it deliberately enforces nothing, and ExecInsertIndexTuples has no mode for that.
Two preconditions, which is what the crashes were
A prototype crashed twice. Neither cause was the trigger machinery being unusable outside the executor; both were preconditions it assumes and does not check for you.
One. ExecInsertIndexTuples requires the slot to carry its relation:
Assert(slot->tts_tableOid == RelationGetRelid(heapRelation)) execIndexing.c:329 (17), 341 (18)
MakeSingleTupleTableSlot does not set tts_tableOid. The hand-rolled path never needed it because it passes the TID and heap relation to index_insert directly. One assignment.
Two. A statement that queues an after-trigger event must hold its lock until commit. ExecGetTriggerResultRel reopens the relation with NoLock, and says why:
* Open the target relation's relcache entry. We assume that an
* appropriate lock is still held by the backend from whenever the trigger
* event got queued, so we need take no new lock here.
columnar_import_arrow does table_close(rel, RowExclusiveLock) on the way out, dropping it while a queued event still refers to the table, and the deferred fire at COMMIT asserts. table_close(rel, NoLock) is the change. columnar_parquet_reader.c has the same shape at line 2891; columnar_vacuum.c closes with ShareUpdateExclusiveLock and must keep doing so, since the rewrite queues nothing.
Both crashes were legible only because the build was assert-enabled. On a non-assert build they are a segfault and a silent wrong behaviour.
What the prototype settled, and what it did not
Settled:
- the executor route works and defers correctly
- the full suite still passes (80, 0 fail, PG18) including
import_exclusion, arrow_import, parquet_import, native_index, unique_conc
Not settled:
- that
columnar_index.c's duplicated indimmediate, partial-predicate and exclusion handling can then be deleted cleanly, which is the larger prize
- the
EState lifetime under an immediate-but-deferrable constraint fired mid-transaction
- anything on PG15, 16, 17 or 19
What the tests must separate
"The import committed" is true both when deferral works and when the check quietly disappeared. These distinguish them, and every one must match heap:
| case |
expected |
| deferred, transient collision |
COMMIT |
| deferred, permanent duplicate |
ERROR at commit, import rolled back |
IMMEDIATE constraint, duplicate |
ERROR at insert time |
SET CONSTRAINTS ... IMMEDIATE mid-transaction |
ERROR there |
| exclusion constraint |
still enforced |
Rows two and four are what catch a fix that merely stopped enforcing. A row count alone does not: it reads the same whether the import commits or aborts.
Priority
Below #155. The workload this blocks is narrow: an import whose rows transiently collide with rows the same transaction removes. Enforcing early is over-strict rather than unsound, which is the safe direction to be wrong in while this is open.
Split out of #167 at review request, so the deviation is tracked rather than living in a file header.
The behaviour
ColumnarIndexInsertRowdecides onindisuniquealone:The executor also consults
indimmediate. So aUNIQUE ... DEFERRABLE INITIALLY DEFERREDconstraint is checked at commit through ordinary DML and at insert time throughimport_arrow/import_parquet.Measured, with controls
INSERTINSERTimport_arrowERROR: duplicate key value violates unique constraintThe middle row is the important one: pgcolumnar's ordinary insert path defers correctly. This is not the access method lacking something. It is the import path bypassing the executor, which is what maintains indexes.
The collision must exist when the row is inserted and be gone by commit. Deleting the conflicting row first makes the import succeed and proves nothing.
The fix: use the executor's index maintenance
Not a patch to the hand-rolled path. Give the import path a real executor context and let core do it:
EStatewith a range table andRTEPermissionInfo,InitResultRelInfo,ExecOpenIndices,AfterTriggerBeginQuery()/AfterTriggerEndQuery(estate)bracketing the load, and the enforcing path routed throughExecInsertIndexTuples+ExecARInsertTriggers.ExecInsertIndexTuplesselectsUNIQUE_CHECK_PARTIALfor a non-immediate index by itself, andExecARInsertTriggersqueues the recheck. Soindimmediatestops being ours to get right, and so do partial-index predicates, expression indexes, exclusion constraints and unique checking, all of whichcolumnar_index.ccurrently re-implements. Each is a place our copy can drift from core, and #153 and #167 were both that drift found late.The rewrite caller in
columnar_vacuum.ckeeps the hand-rolled path: it deliberately enforces nothing, andExecInsertIndexTupleshas no mode for that.Two preconditions, which is what the crashes were
A prototype crashed twice. Neither cause was the trigger machinery being unusable outside the executor; both were preconditions it assumes and does not check for you.
One.
ExecInsertIndexTuplesrequires the slot to carry its relation:MakeSingleTupleTableSlotdoes not settts_tableOid. The hand-rolled path never needed it because it passes the TID and heap relation toindex_insertdirectly. One assignment.Two. A statement that queues an after-trigger event must hold its lock until commit.
ExecGetTriggerResultRelreopens the relation withNoLock, and says why:columnar_import_arrowdoestable_close(rel, RowExclusiveLock)on the way out, dropping it while a queued event still refers to the table, and the deferred fire at COMMIT asserts.table_close(rel, NoLock)is the change.columnar_parquet_reader.chas the same shape at line 2891;columnar_vacuum.ccloses withShareUpdateExclusiveLockand must keep doing so, since the rewrite queues nothing.Both crashes were legible only because the build was assert-enabled. On a non-assert build they are a segfault and a silent wrong behaviour.
What the prototype settled, and what it did not
Settled:
import_exclusion,arrow_import,parquet_import,native_index,unique_concNot settled:
columnar_index.c's duplicatedindimmediate, partial-predicate and exclusion handling can then be deleted cleanly, which is the larger prizeEStatelifetime under an immediate-but-deferrable constraint fired mid-transactionWhat the tests must separate
"The import committed" is true both when deferral works and when the check quietly disappeared. These distinguish them, and every one must match heap:
IMMEDIATEconstraint, duplicateSET CONSTRAINTS ... IMMEDIATEmid-transactionRows two and four are what catch a fix that merely stopped enforcing. A row count alone does not: it reads the same whether the import commits or aborts.
Priority
Below #155. The workload this blocks is narrow: an import whose rows transiently collide with rows the same transaction removes. Enforcing early is over-strict rather than unsound, which is the safe direction to be wrong in while this is open.