public inbox for [email protected]
help / color / mirror / Atom feedFrom: Amit Langote <[email protected]>
To: Nikolay Samokhvalov <[email protected]>
Cc: pgsql-hackers mailing list <[email protected]>
Cc: Andrey Borodin <[email protected]>
Cc: Kirk Wolak <[email protected]>
Subject: Re: PG19 FK fast path: OOB write and missed FK checks during batched
Date: Tue, 9 Jun 2026 22:31:01 +0900
Message-ID: <CA+HiwqHUz50YqJn4XiNsSLN2c+9eYBy1af=y_dfdJTsz5BmbJg@mail.gmail.com> (raw)
In-Reply-To: <CA+HiwqGWUtxc7KECuT06aYTiwwxGBxM89qY_W64dQjYEoziXog@mail.gmail.com>
References: <CAM527d9exRCdWrhJOnAxk_vACg7sr_yPoaJp_+uCFY0qP8v=aw@mail.gmail.com>
<CA+HiwqGTOwRqkgrhqq6-nLyVGfGuAHMfoo+Ob2A4Z98ZkgwCmg@mail.gmail.com>
<CA+HiwqGWUtxc7KECuT06aYTiwwxGBxM89qY_W64dQjYEoziXog@mail.gmail.com>
On Mon, Jun 8, 2026 at 5:18 PM Amit Langote <[email protected]> wrote:
> On Sat, Jun 6, 2026 at 6:13 PM Amit Langote <[email protected]> wrote:
> > Thanks for the detailed report and reproducers. I’ve started looking into this.
>
> Continuing to look. Appended this to the open items list:
>
> https://wiki.postgresql.org/wiki/PostgreSQL_19_Open_Items#Open_Issues
Thanks again, Nik, for the thorough analysis and the reproducers --
they made all three easy to confirm and pin down. Patches attached:
0001 for defect 1, 0002 for defects 2 and 3.
0001 (defect 1): check and flush before writing the row rather than
after, and add a per-entry "flushing" flag so a re-entrant add on the
same entry during a flush takes the per-row path instead of touching
the mid-flush batch. The flag is cleared in a PG_FINALLY, which also
resets batch_count, so the entry stays reusable if a flush error is
caught by a savepoint.
0002 (defects 2 and 3): rather than track subxact membership per row,
confine batching to the top transaction level -- in RI_FKey_check,
when GetCurrentTransactionNestLevel() > 1, use the per-row path. I
went this way because per-entry subxact tracking isn't enough (one
entry's batch can mix rows from several levels, since the cache is
keyed by constraint), and flushing at subxact boundaries doesn't work
for deferred constraints. Once the cache only ever holds top-level
rows, a subxact abort has nothing of its own to discard, so
ri_FastPathSubXactCallback goes away -- that's what fixes your defect
2 reproducer. For defect 3, which is still reachable at the top level,
the same patch adds a cache-wide flag set while ri_FastPathEndBatch
iterates, so a re-entrant check during the scan takes the per-row path
instead of inserting into the cache being scanned.
The per-row path still bypasses SPI, so these stay well ahead of the
pre-19 check in terms of performance. I'd like to recover batching
across subtransactions properly in v20 but didn't want to rush it now.
On defect 3, can you check whether your reproducer still commits the
orphan with 0002 applied, or whether (like on my build) it now raises
the violation? I'd like to be sure the bucket-placement variation you
hit is actually covered. And of course any review of the patches is
welcome.
--
Thanks, Amit Langote
Attachments:
[application/octet-stream] v1-0001-Fix-out-of-bounds-write-in-RI-fast-path-batch-on-.patch (10.0K, 2-v1-0001-Fix-out-of-bounds-write-in-RI-fast-path-batch-on-.patch)
download | inline diff:
From 00fa1c1137009cd64fb04b718c1de4c8c130f08e Mon Sep 17 00:00:00 2001
From: Amit Langote <[email protected]>
Date: Tue, 9 Jun 2026 18:27:24 +0900
Subject: [PATCH v1 1/2] Fix out-of-bounds write in RI fast-path batch on
re-entry
The FK fast-path batching added in b7b27eb41a5 wrote the incoming row
into the batch array before checking whether the array was full:
fpentry->batch[fpentry->batch_count] = ExecCopySlotHeapTuple(newslot);
fpentry->batch_count++;
if (fpentry->batch_count >= RI_FASTPATH_BATCH_SIZE)
ri_FastPathBatchFlush(fpentry, fk_rel, riinfo);
batch_count is reset to zero only at the end of ri_FastPathBatchFlush(),
so it remains at RI_FASTPATH_BATCH_SIZE throughout a full-batch flush.
A flush runs user-defined cast functions and equality operators; if that
user code performs DML on the same FK table, ri_FastPathBatchAdd()
re-enters with batch_count == RI_FASTPATH_BATCH_SIZE and writes one past
the end of the array, corrupting the adjacent batch_count field. This
is reachable by an unprivileged table owner via an implicit cast with a
PL/pgSQL function and causes a SIGSEGV in assert-enabled builds.
Fix by checking whether the batch is full and flushing before writing
the new row, and by adding a "flushing" flag to RI_FastPathEntry that
routes re-entrant ri_FastPathBatchAdd() calls on a busy entry to the
per-row path (ri_FastPathCheck) instead of touching the mid-flush batch
array. The flag is set around the probe in ri_FastPathBatchFlush() and
cleared in a PG_FINALLY, which also resets batch_count, so the entry is
left empty and reusable if a flush error (including a reported FK
violation) is caught by a savepoint.
Add regression tests for both the re-entrant flush and reuse of an entry
after a flush error caught by a savepoint.
Reported-by: Nikolay Samokhvalov <[email protected]>
Discussion: https://postgr.es/m/CAM527d9exRCdWrhJOnAxk_vACg7sr_yPoaJp_+uCFY0qP8v=aw@mail.gmail.com
---
src/backend/utils/adt/ri_triggers.c | 58 ++++++++++++++++++-----
src/test/regress/expected/foreign_key.out | 56 ++++++++++++++++++++++
src/test/regress/sql/foreign_key.sql | 46 ++++++++++++++++++
3 files changed, 147 insertions(+), 13 deletions(-)
diff --git a/src/backend/utils/adt/ri_triggers.c b/src/backend/utils/adt/ri_triggers.c
index dc89c686394..453b83ce85d 100644
--- a/src/backend/utils/adt/ri_triggers.c
+++ b/src/backend/utils/adt/ri_triggers.c
@@ -249,6 +249,12 @@ typedef struct RI_FastPathEntry
*/
HeapTuple batch[RI_FASTPATH_BATCH_SIZE];
int batch_count;
+
+ /*
+ * true while this entry's batch is being flushed; guards against
+ * re-entrant ri_FastPathBatchAdd from user code run during the flush.
+ */
+ bool flushing;
} RI_FastPathEntry;
/*
@@ -2862,14 +2868,26 @@ ri_FastPathBatchAdd(RI_ConstraintInfo *riinfo,
RI_FastPathEntry *fpentry = ri_FastPathGetEntry(riinfo, fk_rel);
MemoryContext oldcxt;
+ /*
+ * If this entry is already being flushed, a cast function or an operator
+ * invoked during the flush has re-entered with DML on the same FK. Fall
+ * back to the per-row path rather than touching the batch array, which is
+ * mid-flush.
+ */
+ if (fpentry->flushing)
+ {
+ ri_FastPathCheck(riinfo, fk_rel, newslot);
+ return;
+ }
+
+ if (fpentry->batch_count >= RI_FASTPATH_BATCH_SIZE)
+ ri_FastPathBatchFlush(fpentry, fk_rel, riinfo);
+
oldcxt = MemoryContextSwitchTo(fpentry->flush_cxt);
fpentry->batch[fpentry->batch_count] =
ExecCopySlotHeapTuple(newslot);
fpentry->batch_count++;
MemoryContextSwitchTo(oldcxt);
-
- if (fpentry->batch_count >= RI_FASTPATH_BATCH_SIZE)
- ri_FastPathBatchFlush(fpentry, fk_rel, riinfo);
}
/*
@@ -2944,13 +2962,30 @@ ri_FastPathBatchFlush(RI_FastPathEntry *fpentry, Relation fk_rel,
}
Assert(riinfo->fpmeta);
- /* Skip array overhead for single-row batches. */
- if (riinfo->nkeys == 1 && fpentry->batch_count > 1)
- violation_index = ri_FastPathFlushArray(fpentry, fk_slot, riinfo,
- fk_rel, snapshot, scandesc);
- else
- violation_index = ri_FastPathFlushLoop(fpentry, fk_slot, riinfo,
- fk_rel, snapshot, scandesc);
+ /*
+ * The probe runs user-defined cast and equality functions. Set the
+ * flushing flag around it so a re-entrant ri_FastPathBatchAdd on this
+ * entry takes the per-row path, and clear it even on error so the entry
+ * is reusable if the error is caught by a savepoint.
+ */
+ Assert(!fpentry->flushing);
+ fpentry->flushing = true;
+ PG_TRY();
+ {
+ /* Skip array overhead for single-row batches. */
+ if (riinfo->nkeys == 1 && fpentry->batch_count > 1)
+ violation_index = ri_FastPathFlushArray(fpentry, fk_slot, riinfo,
+ fk_rel, snapshot, scandesc);
+ else
+ violation_index = ri_FastPathFlushLoop(fpentry, fk_slot, riinfo,
+ fk_rel, snapshot, scandesc);
+ }
+ PG_FINALLY();
+ {
+ fpentry->flushing = false;
+ fpentry->batch_count = 0;
+ }
+ PG_END_TRY();
SetUserIdAndSecContext(saved_userid, saved_sec_context);
UnregisterSnapshot(snapshot);
@@ -2966,9 +3001,6 @@ ri_FastPathBatchFlush(RI_FastPathEntry *fpentry, Relation fk_rel,
MemoryContextReset(fpentry->flush_cxt);
MemoryContextSwitchTo(oldcxt);
-
- /* Reset. */
- fpentry->batch_count = 0;
}
/*
diff --git a/src/test/regress/expected/foreign_key.out b/src/test/regress/expected/foreign_key.out
index 8b3b268de0f..fa80d9c915f 100644
--- a/src/test/regress/expected/foreign_key.out
+++ b/src/test/regress/expected/foreign_key.out
@@ -3712,3 +3712,59 @@ INSERT INTO fp_pk_dup VALUES (1);
CREATE TABLE fp_fk_dup (a int REFERENCES fp_pk_dup);
INSERT INTO fp_fk_dup SELECT 1 FROM generate_series(1, 100);
DROP TABLE fp_fk_dup, fp_pk_dup;
+-- Re-entrant FK fast-path: DML on the same FK table from a cast function
+-- during a full-batch flush must not corrupt the batch array.
+CREATE TABLE fp_reentry_pk (id int PRIMARY KEY);
+INSERT INTO fp_reentry_pk VALUES (1), (2);
+CREATE TYPE fp_vch AS (v int);
+CREATE FUNCTION fp_vcast(fp_vch) RETURNS int LANGUAGE plpgsql AS $$
+BEGIN
+ IF $1.v = 1 THEN
+ INSERT INTO fp_reentry_fk VALUES (row(2)::fp_vch);
+ END IF;
+ RETURN $1.v;
+END$$;
+CREATE CAST (fp_vch AS int) WITH FUNCTION fp_vcast(fp_vch) AS IMPLICIT;
+CREATE TABLE fp_reentry_fk (a fp_vch
+ REFERENCES fp_reentry_pk (id));
+-- Fill exactly one batch so the flush fires; the cast re-enters with DML
+-- on the same FK and must take the per-row path.
+INSERT INTO fp_reentry_fk SELECT row(1)::fp_vch FROM generate_series(1, 64);
+SELECT a, count(*) FROM fp_reentry_fk GROUP BY a ORDER BY a;
+ a | count
+-----+-------
+ (1) | 64
+ (2) | 64
+(2 rows)
+
+DROP TABLE fp_reentry_fk, fp_reentry_pk;
+DROP CAST (fp_vch AS int);
+DROP FUNCTION fp_vcast(fp_vch);
+DROP TYPE fp_vch;
+-- Flush error caught by a savepoint must leave the entry empty and reusable.
+CREATE TABLE fp_reentry_pk2 (id int PRIMARY KEY);
+INSERT INTO fp_reentry_pk2 VALUES (1);
+CREATE TABLE fp_reentry_fk2 (a int REFERENCES fp_reentry_pk2 (id));
+DO $$
+BEGIN
+ -- A batch containing a violating row; the flush reports the violation.
+ BEGIN
+ INSERT INTO fp_reentry_fk2 SELECT CASE WHEN g = 32 THEN 999 ELSE 1 END
+ FROM generate_series(1, 64) g;
+ EXCEPTION WHEN foreign_key_violation THEN
+ RAISE NOTICE 'caught fk violation';
+ END;
+
+ -- Reuse the same FK with a full batch in the same transaction. The
+ -- entry must be empty after the caught violation: no stale rows from the
+ -- rolled-back batch (in particular no 999), and no array overflow.
+ INSERT INTO fp_reentry_fk2 SELECT 1 FROM generate_series(1, 64);
+END$$;
+NOTICE: caught fk violation
+SELECT count(*), max(a) FROM fp_reentry_fk2; -- 64 rows, max 1
+ count | max
+-------+-----
+ 64 | 1
+(1 row)
+
+DROP TABLE fp_reentry_fk2, fp_reentry_pk2;
diff --git a/src/test/regress/sql/foreign_key.sql b/src/test/regress/sql/foreign_key.sql
index 7eb86b188f0..0f2ce8f76f5 100644
--- a/src/test/regress/sql/foreign_key.sql
+++ b/src/test/regress/sql/foreign_key.sql
@@ -2680,3 +2680,49 @@ INSERT INTO fp_pk_dup VALUES (1);
CREATE TABLE fp_fk_dup (a int REFERENCES fp_pk_dup);
INSERT INTO fp_fk_dup SELECT 1 FROM generate_series(1, 100);
DROP TABLE fp_fk_dup, fp_pk_dup;
+
+-- Re-entrant FK fast-path: DML on the same FK table from a cast function
+-- during a full-batch flush must not corrupt the batch array.
+CREATE TABLE fp_reentry_pk (id int PRIMARY KEY);
+INSERT INTO fp_reentry_pk VALUES (1), (2);
+CREATE TYPE fp_vch AS (v int);
+CREATE FUNCTION fp_vcast(fp_vch) RETURNS int LANGUAGE plpgsql AS $$
+BEGIN
+ IF $1.v = 1 THEN
+ INSERT INTO fp_reentry_fk VALUES (row(2)::fp_vch);
+ END IF;
+ RETURN $1.v;
+END$$;
+CREATE CAST (fp_vch AS int) WITH FUNCTION fp_vcast(fp_vch) AS IMPLICIT;
+CREATE TABLE fp_reentry_fk (a fp_vch
+ REFERENCES fp_reentry_pk (id));
+-- Fill exactly one batch so the flush fires; the cast re-enters with DML
+-- on the same FK and must take the per-row path.
+INSERT INTO fp_reentry_fk SELECT row(1)::fp_vch FROM generate_series(1, 64);
+SELECT a, count(*) FROM fp_reentry_fk GROUP BY a ORDER BY a;
+DROP TABLE fp_reentry_fk, fp_reentry_pk;
+DROP CAST (fp_vch AS int);
+DROP FUNCTION fp_vcast(fp_vch);
+DROP TYPE fp_vch;
+
+-- Flush error caught by a savepoint must leave the entry empty and reusable.
+CREATE TABLE fp_reentry_pk2 (id int PRIMARY KEY);
+INSERT INTO fp_reentry_pk2 VALUES (1);
+CREATE TABLE fp_reentry_fk2 (a int REFERENCES fp_reentry_pk2 (id));
+DO $$
+BEGIN
+ -- A batch containing a violating row; the flush reports the violation.
+ BEGIN
+ INSERT INTO fp_reentry_fk2 SELECT CASE WHEN g = 32 THEN 999 ELSE 1 END
+ FROM generate_series(1, 64) g;
+ EXCEPTION WHEN foreign_key_violation THEN
+ RAISE NOTICE 'caught fk violation';
+ END;
+
+ -- Reuse the same FK with a full batch in the same transaction. The
+ -- entry must be empty after the caught violation: no stale rows from the
+ -- rolled-back batch (in particular no 999), and no array overflow.
+ INSERT INTO fp_reentry_fk2 SELECT 1 FROM generate_series(1, 64);
+END$$;
+SELECT count(*), max(a) FROM fp_reentry_fk2; -- 64 rows, max 1
+DROP TABLE fp_reentry_fk2, fp_reentry_pk2;
--
2.47.3
[application/octet-stream] v1-0002-Confine-RI-fast-path-batching-to-the-top-transact.patch (10.9K, 3-v1-0002-Confine-RI-fast-path-batching-to-the-top-transact.patch)
download | inline diff:
From d136a8f5edc723899daafb59377930ee7fec3838 Mon Sep 17 00:00:00 2001
From: Amit Langote <[email protected]>
Date: Tue, 9 Jun 2026 22:11:54 +0900
Subject: [PATCH v1 2/2] Confine RI fast-path batching to the top transaction
level
The FK fast-path batching added in b7b27eb41a5 buffers rows in a
transaction-lived cache (ri_fastpath_cache) keyed by constraint OID.
Running user-defined cast and equality functions during a batch flush,
together with the cache's lifetime and iteration, exposed two defects
reachable by an unprivileged table owner.
First, on subtransaction abort ri_FastPathSubXactCallback discarded the
entire cache. An entry's batch holds rows buffered by the enclosing
transaction, not just the aborting subxact -- the cache is keyed by
constraint, so a single entry can mix rows from multiple subxact levels.
An internal subxact abort during after-trigger firing (e.g. a PL/pgSQL
BEGIN ... EXCEPTION block) therefore dropped buffered rows of the outer
transaction without running their FK checks, letting orphan rows commit
behind a constraint that still reported itself valid. The discard also
left relations opened by the batch unclosed, producing "resource was not
closed" warnings.
Second, ri_FastPathEndBatch flushes by iterating the cache with
hash_seq_search. If flush-time user code inserts into a different
fast-path FK table, a new entry is added to the cache mid-scan; it may
land in a bucket the scan has already passed and never be reached, and
ri_FastPathTeardown then destroys the cache without flushing it,
silently dropping that check.
Cleanly unwinding the cache on subxact abort would require tracking the
originating subxact of each buffered row, since rows from different
levels share an entry (the cache is keyed by constraint) and deferred
constraints cannot be flushed early at a subxact boundary. Rather than
add that bookkeeping, confine batching to the top transaction level: in
RI_FKey_check, when GetCurrentTransactionNestLevel() > 1, use the
per-row fast path (ri_FastPathCheck) instead of buffering. Rows checked
inside a subtransaction are then verified immediately and roll back
cleanly with their subtransaction, and the cache only ever holds
top-level rows. With the cache confined to the top level, a
subtransaction abort has nothing of its own to discard, so
ri_FastPathSubXactCallback is removed along with its registration.
For the second defect, add a cache-wide flag (ri_fastpath_flushing) set
while ri_FastPathEndBatch iterates the cache. A re-entrant FK check
arriving while the flag is set takes the per-row path rather than adding
an entry to the cache being scanned, so no entry can be missed and torn
down unflushed. The flag is cleared in a PG_FINALLY so a flush that
throws (a reported violation or an error from user code) does not leave
it stuck.
The per-row fast path still bypasses SPI and stays well ahead of the
pre-19 SPI-based check. A fuller fix that preserves batching across
subtransactions -- whether by tracking the originating subxact of each
buffered row or by per-subxact cache stacks merged into the parent on
commit -- is left for a future release.
The subtransaction-abort case is covered by a new regression test. The
mid-scan cross-table case depends on hash bucket placement and so is not
reliably reproducible in a portable test, but the flag prevents it by
construction.
Reported-by: Nikolay Samokhvalov <[email protected]>
Discussion: https://postgr.es/m/CAM527d9exRCdWrhJOnAxk_vACg7sr_yPoaJp_+uCFY0qP8v=aw@mail.gmail.com
---
src/backend/utils/adt/ri_triggers.c | 77 +++++++++++++++--------
src/test/regress/expected/foreign_key.out | 24 +++++++
src/test/regress/sql/foreign_key.sql | 23 +++++++
3 files changed, 97 insertions(+), 27 deletions(-)
diff --git a/src/backend/utils/adt/ri_triggers.c b/src/backend/utils/adt/ri_triggers.c
index 453b83ce85d..9b49d87279f 100644
--- a/src/backend/utils/adt/ri_triggers.c
+++ b/src/backend/utils/adt/ri_triggers.c
@@ -267,6 +267,7 @@ static dclist_head ri_constraint_cache_valid_list;
static HTAB *ri_fastpath_cache = NULL;
static bool ri_fastpath_callback_registered = false;
+static bool ri_fastpath_flushing = false;
/*
* Local function prototypes
@@ -469,14 +470,31 @@ RI_FKey_check(TriggerData *trigdata)
*/
if (ri_fastpath_is_applicable(riinfo))
{
- if (AfterTriggerIsActive())
+ if (AfterTriggerIsActive() &&
+ GetCurrentTransactionNestLevel() == 1 &&
+ !ri_fastpath_flushing)
{
/* Batched path: buffer and probe in groups */
ri_FastPathBatchAdd(riinfo, fk_rel, newslot);
}
else
{
- /* ALTER TABLE validation: per-row, no cache */
+ /*
+ * Per-row path, used when batching is not safe or not
+ * applicable:
+ *
+ * - ALTER TABLE validation, where no after-trigger firing is
+ * active;
+ *
+ * - any FK check inside a subtransaction, since the batch cache
+ * is confined to the top transaction level (it cannot be
+ * cleanly unwound on subxact abort);
+ *
+ * - a re-entrant check from user cast/operator code running
+ * during a batch flush, since adding a cache entry while
+ * ri_FastPathEndBatch is iterating the cache could leave it
+ * unflushed.
+ */
ri_FastPathCheck(riinfo, fk_rel, newslot);
}
return PointerGetDatum(NULL);
@@ -4170,19 +4188,41 @@ ri_FastPathEndBatch(void *arg)
if (ri_fastpath_cache == NULL)
return;
- /* Flush any partial batches -- can throw ERROR */
- hash_seq_init(&status, ri_fastpath_cache);
- while ((entry = hash_seq_search(&status)) != NULL)
+ /*
+ * Set a flag for the duration of the scan so that any FK check triggered
+ * by user cast or operator code during a flush takes the per-row path
+ * instead of adding a new entry to the cache we are iterating. A new
+ * entry could land in an already-scanned bucket and then be torn down
+ * unflushed below.
+ *
+ * The flush can throw ERROR (a reported constraint violation, or an error
+ * from the user code it runs). In that case ri_FastPathTeardown below is
+ * skipped; the ResourceOwner and the transaction-end callback handle
+ * resource cleanup on the abort path. The PG_FINALLY only resets the
+ * flag and deliberately does not attempt teardown.
+ */
+ Assert(!ri_fastpath_flushing);
+ ri_fastpath_flushing = true;
+ PG_TRY();
{
- if (entry->batch_count > 0)
+ hash_seq_init(&status, ri_fastpath_cache);
+ while ((entry = hash_seq_search(&status)) != NULL)
{
- Relation fk_rel = table_open(entry->fk_relid, AccessShareLock);
- RI_ConstraintInfo *riinfo = ri_LoadConstraintInfo(entry->conoid);
+ if (entry->batch_count > 0)
+ {
+ Relation fk_rel = table_open(entry->fk_relid, AccessShareLock);
+ RI_ConstraintInfo *riinfo = ri_LoadConstraintInfo(entry->conoid);
- ri_FastPathBatchFlush(entry, fk_rel, riinfo);
- table_close(fk_rel, NoLock);
+ ri_FastPathBatchFlush(entry, fk_rel, riinfo);
+ table_close(fk_rel, NoLock);
+ }
}
}
+ PG_FINALLY();
+ {
+ ri_fastpath_flushing = false;
+ }
+ PG_END_TRY();
ri_FastPathTeardown();
}
@@ -4236,22 +4276,6 @@ ri_FastPathXactCallback(XactEvent event, void *arg)
ri_fastpath_callback_registered = false;
}
-static void
-ri_FastPathSubXactCallback(SubXactEvent event, SubTransactionId mySubid,
- SubTransactionId parentSubid, void *arg)
-{
- if (event == SUBXACT_EVENT_ABORT_SUB)
- {
- /*
- * ResourceOwner already released relations. NULL the static pointers
- * so the still-registered batch callback becomes a no-op for the rest
- * of this transaction.
- */
- ri_fastpath_cache = NULL;
- ri_fastpath_callback_registered = false;
- }
-}
-
/*
* ri_FastPathGetEntry
* Look up or create a per-batch cache entry for the given constraint.
@@ -4276,7 +4300,6 @@ ri_FastPathGetEntry(const RI_ConstraintInfo *riinfo, Relation fk_rel)
if (!ri_fastpath_xact_callback_registered)
{
RegisterXactCallback(ri_FastPathXactCallback, NULL);
- RegisterSubXactCallback(ri_FastPathSubXactCallback, NULL);
ri_fastpath_xact_callback_registered = true;
}
diff --git a/src/test/regress/expected/foreign_key.out b/src/test/regress/expected/foreign_key.out
index fa80d9c915f..3f99a4a59cc 100644
--- a/src/test/regress/expected/foreign_key.out
+++ b/src/test/regress/expected/foreign_key.out
@@ -3768,3 +3768,27 @@ SELECT count(*), max(a) FROM fp_reentry_fk2; -- 64 rows, max 1
(1 row)
DROP TABLE fp_reentry_fk2, fp_reentry_pk2;
+-- Subtransaction abort during after-trigger firing must not drop FK checks
+-- for rows buffered earlier in the same statement. Batching is confined to
+-- the top transaction level and the buffered batch is no longer discarded on
+-- subxact abort, so the violating rows are detected.
+CREATE TABLE fp_subxact_pk (id int PRIMARY KEY);
+INSERT INTO fp_subxact_pk SELECT g FROM generate_series(1, 10) g;
+CREATE TABLE fp_subxact_fk (a int, tag text);
+ALTER TABLE fp_subxact_fk ADD CONSTRAINT fp_subxact_fk_fkey
+ FOREIGN KEY (a) REFERENCES fp_subxact_pk (id);
+CREATE FUNCTION fp_abort_subxact() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF NEW.tag = 'boom' THEN
+ BEGIN PERFORM 1/0; EXCEPTION WHEN division_by_zero THEN NULL; END;
+ END IF;
+ RETURN NEW;
+END$$;
+CREATE TRIGGER fp_subxact_trg AFTER INSERT ON fp_subxact_fk
+ FOR EACH ROW EXECUTE FUNCTION fp_abort_subxact();
+INSERT INTO fp_subxact_fk VALUES (999, 'bad'), (0, 'boom'), (1, 'ok');
+ERROR: insert or update on table "fp_subxact_fk" violates foreign key constraint "fp_subxact_fk_fkey"
+DETAIL: Key (a)=(999) is not present in table "fp_subxact_pk".
+DROP TRIGGER fp_subxact_trg ON fp_subxact_fk;
+DROP FUNCTION fp_abort_subxact();
+DROP TABLE fp_subxact_fk, fp_subxact_pk;
diff --git a/src/test/regress/sql/foreign_key.sql b/src/test/regress/sql/foreign_key.sql
index 0f2ce8f76f5..18c3e166f02 100644
--- a/src/test/regress/sql/foreign_key.sql
+++ b/src/test/regress/sql/foreign_key.sql
@@ -2726,3 +2726,26 @@ BEGIN
END$$;
SELECT count(*), max(a) FROM fp_reentry_fk2; -- 64 rows, max 1
DROP TABLE fp_reentry_fk2, fp_reentry_pk2;
+
+-- Subtransaction abort during after-trigger firing must not drop FK checks
+-- for rows buffered earlier in the same statement. Batching is confined to
+-- the top transaction level and the buffered batch is no longer discarded on
+-- subxact abort, so the violating rows are detected.
+CREATE TABLE fp_subxact_pk (id int PRIMARY KEY);
+INSERT INTO fp_subxact_pk SELECT g FROM generate_series(1, 10) g;
+CREATE TABLE fp_subxact_fk (a int, tag text);
+ALTER TABLE fp_subxact_fk ADD CONSTRAINT fp_subxact_fk_fkey
+ FOREIGN KEY (a) REFERENCES fp_subxact_pk (id);
+CREATE FUNCTION fp_abort_subxact() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF NEW.tag = 'boom' THEN
+ BEGIN PERFORM 1/0; EXCEPTION WHEN division_by_zero THEN NULL; END;
+ END IF;
+ RETURN NEW;
+END$$;
+CREATE TRIGGER fp_subxact_trg AFTER INSERT ON fp_subxact_fk
+ FOR EACH ROW EXECUTE FUNCTION fp_abort_subxact();
+INSERT INTO fp_subxact_fk VALUES (999, 'bad'), (0, 'boom'), (1, 'ok');
+DROP TRIGGER fp_subxact_trg ON fp_subxact_fk;
+DROP FUNCTION fp_abort_subxact();
+DROP TABLE fp_subxact_fk, fp_subxact_pk;
--
2.47.3
view thread (14+ messages) latest in thread
reply
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Reply to all the recipients using the --to and --cc options:
reply via email
To: [email protected]
Cc: [email protected], [email protected], [email protected], [email protected]
Subject: Re: PG19 FK fast path: OOB write and missed FK checks during batched
In-Reply-To: <CA+HiwqHUz50YqJn4XiNsSLN2c+9eYBy1af=y_dfdJTsz5BmbJg@mail.gmail.com>
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox