Skip to content

Deferrable unique constraints are enforced at insert time on the import path, not deferred to commit #168

Description

@ChronicallyJD

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions