From b58df3235542db5069e45c79ac98ec60fb72240a Mon Sep 17 00:00:00 2001
From: Amit Langote <amitlan@postgresql.org>
Date: Tue, 18 Aug 2026 22:35:24 +0900
Subject: [PATCH v2 1/3] Track RI fast-path FK-check batches per subtransaction

Commit 4113873 confined RI fast-path batching to the top transaction
level to avoid mishandling the batch cache on subtransaction abort.
That disabled batching for a foreign-key load wrapped in a savepoint
(e.g. "BEGIN; SAVEPOINT s; COPY fk_table FROM ..."), a surprising
performance cliff, and departed from the usual per-subtransaction
resource handling.

Handle the cache per subtransaction instead.  Add AtEOSubXact_RI(),
called from CommitSubTransaction() and AbortSubTransaction() after
ResourceOwnerRelease().  On abort, it removes only entries opened by
the ending subtransaction, whose resources have just been released,
while leaving entries opened by an outer level intact.  Thus, an inner
subtransaction abort during outer-level trigger firing does not discard
the outer statement's batch.  On commit, no matching entry is expected,
because its batch should already have been flushed at statement end.
Each entry records the subtransaction that opened its resources.

A batch callback is attached to the current after-trigger query depth
and runs when that depth ends.  An entry created during nested trigger
firing therefore needs a callback at the nested depth; relying on the
outer callback checks it too late, with the wrong snapshot and
potentially after the ResourceOwner holding its relations has gone
away.  The existing global registration flag prevented that callback
from being registered once an outer firing cycle had registered one.

Replacing the global flag with per-depth registration is not sufficient
while the cache is keyed only by constraint OID.  If nested firing
checks the same constraint, it finds the outer entry rather than
creating one at the nested depth, so there is still nothing to cause
registration of the nested callback.

Key the cache by both constraint OID and query depth.  The first entry
created at each depth registers a callback for that depth, and
ri_FastPathEndBatch() flushes and removes only entries belonging to the
depth being ended.  Add AfterTriggerCurrentQueryDepth() so
ri_triggers.c can obtain the current depth; depth -1 represents
deferred firing.

Add regression coverage for nested firing with different constraints
inside a subtransaction, nested firing of the same constraint, a nested
cursor portal whose resources must not outlive it, and deferred firing
at query depth -1.

Reported-by: Noah Misch [noah@leadboat.com](mailto:noah@leadboat.com)
Reported-by: Nikolay Samokhvalov [nik@postgres.ai](mailto:nik@postgres.ai)
Reported-by: Peter Geoghegan [pg@bowt.ie](mailto:pg@bowt.ie)
Discussion: https://postgr.es/m/20260705222115.be.noahmisch@microsoft.com
Discussion: https://postgr.es/m/CAH2-Wz=D533JbF_ak_Pc8kP0FKse-ju8DnMxtjvY==yHsP4xgw@mail.gmail.com
Backpatch-through: 19
---
 src/backend/access/transam/xact.c         |   2 +
 src/backend/commands/trigger.c            |  14 ++
 src/backend/utils/adt/ri_triggers.c       | 217 ++++++++++++++++++----
 src/include/commands/trigger.h            |   3 +
 src/test/regress/expected/foreign_key.out | 133 +++++++++++++
 src/test/regress/sql/foreign_key.sql      | 111 +++++++++++
 6 files changed, 449 insertions(+), 31 deletions(-)

diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index 5e08415e50c..aca92507ebd 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -5244,6 +5244,7 @@ CommitSubTransaction(void)
 					  s->parent->subTransactionId);
 	AtEOSubXact_HashTables(true, s->nestingLevel);
 	AtEOSubXact_PgStat(true, s->nestingLevel);
+	AtEOSubXact_RI(true, s->subTransactionId, s->parent->subTransactionId);
 	AtSubCommit_Snapshot(s->nestingLevel);
 
 	/*
@@ -5418,6 +5419,7 @@ AbortSubTransaction(void)
 						  s->parent->subTransactionId);
 		AtEOSubXact_HashTables(false, s->nestingLevel);
 		AtEOSubXact_PgStat(false, s->nestingLevel);
+		AtEOSubXact_RI(false, s->subTransactionId, s->parent->subTransactionId);
 		AtSubAbort_Snapshot(s->nestingLevel);
 	}
 
diff --git a/src/backend/commands/trigger.c b/src/backend/commands/trigger.c
index b7881bf4a29..ce39ce2e9a2 100644
--- a/src/backend/commands/trigger.c
+++ b/src/backend/commands/trigger.c
@@ -6942,3 +6942,17 @@ AfterTriggerIsActive(void)
 {
 	return afterTriggers.firing_depth > 0;
 }
+
+/*
+ * AfterTriggerCurrentQueryDepth
+ *		Return the current after-trigger query nesting depth.
+ *
+ * Lets a batch-callback registrant (e.g. the RI fast path) associate cached
+ * state with the firing cycle that created it, so a nested cycle's callback
+ * acts only on its own entries.  Returns -1 outside any query level.
+ */
+int
+AfterTriggerCurrentQueryDepth(void)
+{
+	return afterTriggers.query_depth;
+}
diff --git a/src/backend/utils/adt/ri_triggers.c b/src/backend/utils/adt/ri_triggers.c
index 5d843ce8a0d..1f4d36285c0 100644
--- a/src/backend/utils/adt/ri_triggers.c
+++ b/src/backend/utils/adt/ri_triggers.c
@@ -216,13 +216,27 @@ typedef struct RI_CompareHashEntry
  */
 #define RI_FASTPATH_BATCH_SIZE	64
 
+/*
+ * RI_FastPathKey
+ *		Hash key for an RI_FastPathEntry.
+ *
+ * A constraint can be checked in nested trigger-firing cycles.  Each cycle
+ * must have a separate entry so that its rows are checked with that cycle's
+ * snapshot and its resources are released by that cycle's callback.
+ */
+typedef struct RI_FastPathKey
+{
+	Oid			conoid;			/* pg_constraint OID */
+	int			query_depth;	/* after-trigger query depth */
+} RI_FastPathKey;
+
 /*
  * RI_FastPathEntry
- *		Per-constraint cache of resources needed by ri_FastPathBatchFlush().
+ *		Per-constraint, per-firing-cycle cache of resources needed by
+ *		ri_FastPathBatchFlush().
  *
- * One entry per constraint, keyed by pg_constraint OID.  Created lazily
- * by ri_FastPathGetEntry() on first use within a trigger-firing batch
- * and torn down by ri_FastPathTeardown() at batch end.
+ * Created lazily by ri_FastPathGetEntry() on first use within a
+ * trigger-firing batch and torn down by ri_FastPathTeardown() at batch end.
  *
  * FK tuples are buffered in batch[] across trigger invocations and
  * flushed when the buffer fills or the batch ends.
@@ -236,7 +250,7 @@ typedef struct RI_CompareHashEntry
  */
 typedef struct RI_FastPathEntry
 {
-	Oid			conoid;			/* hash key: pg_constraint OID */
+	RI_FastPathKey key;			/* hash key */
 	Oid			fk_relid;		/* for ri_FastPathEndBatch() */
 	Relation	pk_rel;
 	Relation	idx_rel;
@@ -258,6 +272,14 @@ typedef struct RI_FastPathEntry
 	 * re-entrant ri_FastPathBatchAdd from user code run during the flush.
 	 */
 	bool		flushing;
+
+	/*
+	 * Subtransaction whose resource owner opened this entry's relations.
+	 * AtEOSubXact_RI() drops only entries matching an aborting subxact, so a
+	 * subxact abort during outer-level trigger firing leaves the outer batch
+	 * intact.
+	 */
+	SubTransactionId subid;
 } RI_FastPathEntry;
 
 /*
@@ -269,7 +291,6 @@ static HTAB *ri_compare_cache = NULL;
 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;
 
 /*
@@ -356,7 +377,7 @@ pg_noreturn static void ri_ReportViolation(const RI_ConstraintInfo *riinfo,
 static RI_FastPathEntry *ri_FastPathGetEntry(const RI_ConstraintInfo *riinfo,
 											 Relation fk_rel);
 static void ri_FastPathEndBatch(void *arg);
-static void ri_FastPathTeardown(void);
+static void ri_FastPathTeardown(int depth);
 
 
 /*
@@ -473,9 +494,7 @@ RI_FKey_check(TriggerData *trigdata)
 	 */
 	if (ri_fastpath_is_applicable(riinfo))
 	{
-		if (AfterTriggerIsActive() &&
-			GetCurrentTransactionNestLevel() == 1 &&
-			!ri_fastpath_flushing)
+		if (AfterTriggerIsActive() && !ri_fastpath_flushing)
 		{
 			/* Batched path: buffer and probe in groups */
 			ri_FastPathBatchAdd(riinfo, fk_rel, newslot);
@@ -483,15 +502,11 @@ RI_FKey_check(TriggerData *trigdata)
 		else
 		{
 			/*
-			 * Per-row path, used when batching is not safe or not applicable:
+			 * Per-row path, used when batching is 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
@@ -4238,6 +4253,7 @@ ri_FastPathEndBatch(void *arg)
 {
 	HASH_SEQ_STATUS status;
 	RI_FastPathEntry *entry;
+	int			my_depth = (int) (intptr_t) arg;
 
 	if (ri_fastpath_cache == NULL)
 		return;
@@ -4262,10 +4278,13 @@ ri_FastPathEndBatch(void *arg)
 		hash_seq_init(&status, ri_fastpath_cache);
 		while ((entry = hash_seq_search(&status)) != NULL)
 		{
-			if (entry->batch_count > 0)
+			/* Flush only entries created in the cycle now ending. */
+			if (entry->key.query_depth == my_depth && entry->batch_count > 0)
 			{
 				Relation	fk_rel = table_open(entry->fk_relid, AccessShareLock);
-				RI_ConstraintInfo *riinfo = ri_LoadConstraintInfo(entry->conoid);
+				RI_ConstraintInfo *riinfo;
+
+				riinfo = ri_LoadConstraintInfo(entry->key.conoid);
 
 				ri_FastPathBatchFlush(entry, fk_rel, riinfo);
 				table_close(fk_rel, NoLock);
@@ -4278,17 +4297,26 @@ ri_FastPathEndBatch(void *arg)
 	}
 	PG_END_TRY();
 
-	ri_FastPathTeardown();
+	/*
+	 * Release this cycle's entries and remove them from the cache; leave
+	 * outer cycles' entries for their own callbacks.  Destroy the cache once
+	 * empty.
+	 */
+	ri_FastPathTeardown(my_depth);
 }
 
 /*
  * ri_FastPathTeardown
- *		Tear down all cached fast-path state.
+ *		Release and remove the cached entries of one firing cycle, and drop
+ *		the cache once it holds no more entries.
  *
- * Called from ri_FastPathEndBatch() after flushing any remaining rows.
+ * Called from ri_FastPathEndBatch() with the depth of the cycle that is
+ * ending: it releases only that cycle's entries, leaving an outer cycle's
+ * still-live entries for their own callbacks.  The cache (and its static
+ * pointer) go away once the last entry is removed.
  */
 static void
-ri_FastPathTeardown(void)
+ri_FastPathTeardown(int depth)
 {
 	HASH_SEQ_STATUS status;
 	RI_FastPathEntry *entry;
@@ -4299,6 +4327,8 @@ ri_FastPathTeardown(void)
 	hash_seq_init(&status, ri_fastpath_cache);
 	while ((entry = hash_seq_search(&status)) != NULL)
 	{
+		if (entry->key.query_depth != depth)
+			continue;
 		if (entry->idx_rel)
 			index_close(entry->idx_rel, NoLock);
 		if (entry->pk_rel)
@@ -4309,11 +4339,15 @@ ri_FastPathTeardown(void)
 			ExecDropSingleTupleTableSlot(entry->fk_slot);
 		if (entry->flush_cxt)
 			MemoryContextDelete(entry->flush_cxt);
+		hash_search(ri_fastpath_cache, &entry->key, HASH_REMOVE, NULL);
 	}
 
-	hash_destroy(ri_fastpath_cache);
-	ri_fastpath_cache = NULL;
-	ri_fastpath_callback_registered = false;
+	if (hash_get_num_entries(ri_fastpath_cache) == 0)
+	{
+		hash_destroy(ri_fastpath_cache);
+		ri_fastpath_cache = NULL;
+		ri_fastpath_flushing = false;
+	}
 }
 
 /*
@@ -4359,7 +4393,6 @@ AtEOXact_RI(bool isCommit)
 	 * memory-context reset; here we only drop the references to it.
 	 */
 	ri_fastpath_cache = NULL;
-	ri_fastpath_callback_registered = false;
 
 	/*
 	 * Also clear the in-flush flag.  ri_FastPathEndBatch() already clears it
@@ -4370,6 +4403,90 @@ AtEOXact_RI(bool isCommit)
 	ri_fastpath_flushing = false;
 }
 
+/*
+ * AtEOSubXact_RI
+ *		Reset fast-path batching state at subtransaction end.
+ *
+ * Called from CommitSubTransaction() with isCommit true and from
+ * AbortSubTransaction() with isCommit false, in both cases after the
+ * subtransaction's ResourceOwnerRelease().
+ *
+ * The fast-path cache is created and torn down within a single trigger-firing
+ * batch (ri_FastPathEndBatch(), an AfterTriggerBatchCallback), so at a normal
+ * subtransaction boundary it is already empty and this is a no-op.
+ *
+ * The exception is a batch flush that errors out partway and is caught by this
+ * subtransaction (e.g. a PL/pgSQL EXCEPTION block): ri_FastPathEndBatch()'s
+ * teardown was skipped, so the cache still points at entries whose relations
+ * were opened under this subtransaction's resource owner.  That owner has just
+ * released those relations (this runs after ResourceOwnerRelease()), so the
+ * entries are now stale.  Drop the cache so a later statement in the parent
+ * doesn't reuse it.  Like AtEOXact_RI(), this touches only backend-local state
+ * and the hash table's own memory -- no relations, locks or buffers, which the
+ * ResourceOwner already handled.  The entries' slots and flush contexts live in
+ * TopTransactionContext and are freed by its end-of-transaction reset.
+ */
+void
+AtEOSubXact_RI(bool isCommit, SubTransactionId mySubid,
+			   SubTransactionId parentSubid)
+{
+	HASH_SEQ_STATUS status;
+	RI_FastPathEntry *entry;
+	long		remaining;
+
+	if (ri_fastpath_cache == NULL)
+		return;
+
+	/*
+	 * Drop only entries whose relations were opened under the ending
+	 * subtransaction's resource owner.  On abort that owner has just released
+	 * those relations (this runs after ResourceOwnerRelease()), so the entry
+	 * is stale and must go, but entries opened by an outer level -- e.g. an
+	 * outer statement's batch, mid-build when an inner subxact fired and
+	 * aborted -- must be left untouched.
+	 *
+	 * On commit the entry, if any, would have been flushed and torn down at
+	 * the end of its statement (ri_FastPathEndBatch()); reaching here with a
+	 * matching entry is not expected, but reassign it to the parent so it is
+	 * still cleaned up, rather than leaving it under a vanished subxact id.
+	 *
+	 * We touch no relations, locks or buffers -- the ResourceOwner handled
+	 * those.  The entry's slots and flush context are memory in
+	 * TopTransactionContext, freed at end-of-transaction reset; we only
+	 * remove the hash entry so it is not reused or torn down again.
+	 */
+	hash_seq_init(&status, ri_fastpath_cache);
+	while ((entry = hash_seq_search(&status)) != NULL)
+	{
+		if (entry->subid != mySubid)
+			continue;
+
+		if (isCommit)
+		{
+			/*
+			 * A committing subxact's entry should already have been flushed
+			 * and torn down at its statement's end (ri_FastPathEndBatch()),
+			 * so we don't expect to find one here.  If we do, reassign it to
+			 * the parent so it's still cleaned up rather than left under a
+			 * subxact id that no longer exists.
+			 */
+			Assert(false);
+			entry->subid = parentSubid;
+		}
+		else
+			hash_search(ri_fastpath_cache, &entry->key, HASH_REMOVE, NULL);
+	}
+
+	/* If that emptied the cache, drop it so the next batch starts clean. */
+	remaining = hash_get_num_entries(ri_fastpath_cache);
+	if (remaining == 0)
+	{
+		hash_destroy(ri_fastpath_cache);
+		ri_fastpath_cache = NULL;
+		ri_fastpath_flushing = false;
+	}
+}
+
 /*
  * ri_FastPathGetEntry
  *		Look up or create a per-batch cache entry for the given constraint.
@@ -4383,15 +4500,20 @@ AtEOXact_RI(bool isCommit)
 static RI_FastPathEntry *
 ri_FastPathGetEntry(const RI_ConstraintInfo *riinfo, Relation fk_rel)
 {
+	RI_FastPathKey key;
 	RI_FastPathEntry *entry;
 	bool		found;
+	int			cur_depth = AfterTriggerCurrentQueryDepth();
+
+	key.conoid = riinfo->constraint_id;
+	key.query_depth = cur_depth;
 
 	/* Create hash table on first use in this batch */
 	if (ri_fastpath_cache == NULL)
 	{
 		HASHCTL		ctl;
 
-		ctl.keysize = sizeof(Oid);
+		ctl.keysize = sizeof(RI_FastPathKey);
 		ctl.entrysize = sizeof(RI_FastPathEntry);
 		ctl.hcxt = TopTransactionContext;
 		ri_fastpath_cache = hash_create("RI fast-path cache",
@@ -4400,7 +4522,7 @@ ri_FastPathGetEntry(const RI_ConstraintInfo *riinfo, Relation fk_rel)
 										HASH_ELEM | HASH_BLOBS | HASH_CONTEXT);
 	}
 
-	entry = hash_search(ri_fastpath_cache, &riinfo->constraint_id,
+	entry = hash_search(ri_fastpath_cache, &key,
 						HASH_ENTER, &found);
 
 	if (!found)
@@ -4457,15 +4579,48 @@ ri_FastPathGetEntry(const RI_ConstraintInfo *riinfo, Relation fk_rel)
 												 ALLOCSET_SMALL_SIZES);
 		MemoryContextSwitchTo(oldcxt);
 
-		/* Ensure cleanup at end of this trigger-firing batch */
-		if (!ri_fastpath_callback_registered)
+		/*
+		 * Ensure ri_FastPathEndBatch() is registered for THIS firing cycle.
+		 * RegisterAfterTriggerBatchCallback() appends to the current
+		 * after-trigger query depth's callback list and fires at that depth's
+		 * AfterTriggerEndQuery(), so a nested cycle needs its own
+		 * registration; a single global latch would leave the nested cycle's
+		 * list empty and its batch unflushed.  Pass the depth as the callback
+		 * arg so the callback flushes only its own cycle's entries.
+		 */
 		{
-			RegisterAfterTriggerBatchCallback(ri_FastPathEndBatch, NULL);
-			ri_fastpath_callback_registered = true;
+			bool		depth_registered = false;
+			HASH_SEQ_STATUS reg_status;
+			RI_FastPathEntry *other;
+
+			/*
+			 * Register the callback once per firing cycle (query depth),
+			 * including the deferred cycle at depth -1.  Rather than track
+			 * registered depths separately, check whether any other cache
+			 * entry was already created at this depth: if so, its creation
+			 * already registered the callback for this cycle.  (The
+			 * just-created entry is already in the hash, so skip it by
+			 * pointer.)
+			 */
+			hash_seq_init(&reg_status, ri_fastpath_cache);
+			while ((other = hash_seq_search(&reg_status)) != NULL)
+			{
+				if (other != entry && other->key.query_depth == cur_depth)
+				{
+					depth_registered = true;
+					hash_seq_term(&reg_status);
+					break;
+				}
+			}
+
+			if (!depth_registered)
+				RegisterAfterTriggerBatchCallback(ri_FastPathEndBatch,
+												  (void *) (intptr_t) cur_depth);
 		}
 
 		entry->flushing = false;
 		entry->batch_count = 0;
+		entry->subid = GetCurrentSubTransactionId();
 	}
 
 	return entry;
diff --git a/src/include/commands/trigger.h b/src/include/commands/trigger.h
index 0c3d485abf4..fecdb785f35 100644
--- a/src/include/commands/trigger.h
+++ b/src/include/commands/trigger.h
@@ -309,7 +309,10 @@ typedef void (*AfterTriggerBatchCallback) (void *arg);
 extern void RegisterAfterTriggerBatchCallback(AfterTriggerBatchCallback callback,
 											  void *arg);
 extern bool AfterTriggerIsActive(void);
+extern int	AfterTriggerCurrentQueryDepth(void);
 
 extern void AtEOXact_RI(bool isCommit);
+extern void AtEOSubXact_RI(bool isCommit, SubTransactionId mySubid,
+						   SubTransactionId parentSubid);
 
 #endif							/* TRIGGER_H */
diff --git a/src/test/regress/expected/foreign_key.out b/src/test/regress/expected/foreign_key.out
index 120d3319451..db2dbf59899 100644
--- a/src/test/regress/expected/foreign_key.out
+++ b/src/test/regress/expected/foreign_key.out
@@ -3846,3 +3846,136 @@ 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;
+-- Re-entrant fast-path check inside a committing subtransaction.  An AFTER
+-- trigger on one FK table runs FK DML on a second FK table inside a PL/pgSQL
+-- BEGIN ... EXCEPTION block, so the inner check batches in its own
+-- trigger-firing cycle nested in the outer check's.  The inner cycle must
+-- register its own end-of-batch callback and flush -- otherwise its FK check
+-- is skipped (an orphan commits) and its relations leak.
+CREATE TABLE fp_inner_pk (id int PRIMARY KEY);
+INSERT INTO fp_inner_pk VALUES (1);
+CREATE TABLE fp_inner_fk (a int REFERENCES fp_inner_pk (id));
+CREATE TABLE fp_outer_pk (id int PRIMARY KEY);
+INSERT INTO fp_outer_pk SELECT g FROM generate_series(1, 64) g;
+CREATE FUNCTION fp_reentry_subxact() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+    IF NEW.a = 32 THEN
+        BEGIN
+            INSERT INTO fp_inner_fk VALUES (999);  -- violates; must be caught
+        EXCEPTION WHEN foreign_key_violation THEN
+            NULL;
+        END;
+    END IF;
+    RETURN NEW;
+END$$;
+CREATE TABLE fp_outer_fk (a int REFERENCES fp_outer_pk (id));
+CREATE TRIGGER fp_reentry_subxact_trg AFTER INSERT ON fp_outer_fk
+    FOR EACH ROW EXECUTE FUNCTION fp_reentry_subxact();
+INSERT INTO fp_outer_fk SELECT g FROM generate_series(1, 64) g;
+SELECT count(*) AS outer_rows FROM fp_outer_fk;   -- 64, outer batch intact
+ outer_rows 
+------------
+         64
+(1 row)
+
+SELECT count(*) AS inner_rows FROM fp_inner_fk;   -- 0, inner check caught
+ inner_rows 
+------------
+          0
+(1 row)
+
+DROP TRIGGER fp_reentry_subxact_trg ON fp_outer_fk;
+DROP FUNCTION fp_reentry_subxact();
+DROP TABLE fp_outer_fk, fp_outer_pk, fp_inner_fk, fp_inner_pk;
+-- A nested trigger-firing cycle that checks the same constraint must use a
+-- separate cache entry.  The inner violation is caught by its subtransaction,
+-- while the valid outer row remains buffered and is checked normally.
+CREATE TABLE fp_same_pk (id int PRIMARY KEY);
+INSERT INTO fp_same_pk VALUES (1);
+CREATE TABLE fp_same_fk (a int REFERENCES fp_same_pk (id));
+CREATE FUNCTION fp_reentry_same_constraint() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+    IF NEW.a = 1 THEN
+        BEGIN
+            INSERT INTO fp_same_fk VALUES (999);
+        EXCEPTION WHEN foreign_key_violation THEN
+            NULL;
+        END;
+    END IF;
+    RETURN NEW;
+END$$;
+CREATE TRIGGER fp_reentry_same_constraint_trg AFTER INSERT ON fp_same_fk
+    FOR EACH ROW EXECUTE FUNCTION fp_reentry_same_constraint();
+INSERT INTO fp_same_fk VALUES (1);
+SELECT * FROM fp_same_fk;
+ a 
+---
+ 1
+(1 row)
+
+DROP TRIGGER fp_reentry_same_constraint_trg ON fp_same_fk;
+DROP FUNCTION fp_reentry_same_constraint();
+DROP TABLE fp_same_fk, fp_same_pk;
+-- An AFTER trigger runs a query of its own, and that query inserts into a
+-- second table with a fast-path foreign key.  The entry the nested INSERT
+-- creates belongs to the cursor's portal, which is gone by the time the
+-- entry is torn down at the end of the outer statement.  Every key stored
+-- below is present in its referenced table, so the INSERT must just succeed.
+CREATE TABLE fp_customer (id int PRIMARY KEY);
+INSERT INTO fp_customer VALUES (1);
+CREATE TABLE fp_product (id int PRIMARY KEY);
+INSERT INTO fp_product SELECT generate_series(1, 4);
+CREATE TABLE fp_kit_component (kit_product_id int, component_product_id int);
+INSERT INTO fp_kit_component VALUES (1, 2), (1, 3), (1, 4);
+CREATE TABLE fp_order (id int, customer_id int REFERENCES fp_customer,
+    product_id int);
+CREATE TABLE fp_order_item (order_id int, product_id int
+    REFERENCES fp_product);
+CREATE FUNCTION fp_add_order_item(order_id int, product_id int) RETURNS int
+    LANGUAGE plpgsql AS $$
+BEGIN
+    INSERT INTO fp_order_item VALUES (order_id, product_id);
+    RETURN product_id;
+END$$;
+CREATE FUNCTION fp_expand_kit() RETURNS trigger LANGUAGE plpgsql AS $$
+DECLARE
+    component_id int;
+    ncomponents int := 0;
+BEGIN
+    FOR component_id IN
+        SELECT fp_add_order_item(NEW.id, component_product_id)
+            FROM fp_kit_component WHERE kit_product_id = NEW.product_id
+    LOOP
+        ncomponents := ncomponents + 1;
+    END LOOP;
+    RAISE NOTICE 'order % expanded into % order items', NEW.id, ncomponents;
+    RETURN NULL;
+END$$;
+CREATE TRIGGER fp_expand_kit_trg AFTER INSERT ON fp_order
+    FOR EACH ROW EXECUTE FUNCTION fp_expand_kit();
+INSERT INTO fp_order VALUES (1, 1, 1);
+NOTICE:  order 1 expanded into 3 order items
+SELECT count(*) FROM fp_order_item;
+ count 
+-------
+     3
+(1 row)
+
+DROP TABLE fp_order, fp_order_item, fp_kit_component, fp_product, fp_customer;
+DROP FUNCTION fp_expand_kit(), fp_add_order_item(int, int);
+-- Deferred FK check fires at commit (query depth -1); its batch must still get
+-- a callback registered and flushed.
+CREATE TABLE fp_deferred_pk (id int PRIMARY KEY);
+CREATE TABLE fp_deferred_fk (a int REFERENCES fp_deferred_pk (id)
+    DEFERRABLE INITIALLY DEFERRED);
+BEGIN;
+INSERT INTO fp_deferred_fk VALUES (1);
+INSERT INTO fp_deferred_pk VALUES (1);
+COMMIT;
+SELECT count(*) AS deferred_rows FROM fp_deferred_fk;  -- 1, check passed at commit
+ deferred_rows 
+---------------
+             1
+(1 row)
+
+DROP TABLE fp_deferred_fk, fp_deferred_pk;
diff --git a/src/test/regress/sql/foreign_key.sql b/src/test/regress/sql/foreign_key.sql
index b9b88064ea5..3485012c386 100644
--- a/src/test/regress/sql/foreign_key.sql
+++ b/src/test/regress/sql/foreign_key.sql
@@ -2801,3 +2801,114 @@ 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;
+
+-- Re-entrant fast-path check inside a committing subtransaction.  An AFTER
+-- trigger on one FK table runs FK DML on a second FK table inside a PL/pgSQL
+-- BEGIN ... EXCEPTION block, so the inner check batches in its own
+-- trigger-firing cycle nested in the outer check's.  The inner cycle must
+-- register its own end-of-batch callback and flush -- otherwise its FK check
+-- is skipped (an orphan commits) and its relations leak.
+CREATE TABLE fp_inner_pk (id int PRIMARY KEY);
+INSERT INTO fp_inner_pk VALUES (1);
+CREATE TABLE fp_inner_fk (a int REFERENCES fp_inner_pk (id));
+CREATE TABLE fp_outer_pk (id int PRIMARY KEY);
+INSERT INTO fp_outer_pk SELECT g FROM generate_series(1, 64) g;
+CREATE FUNCTION fp_reentry_subxact() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+    IF NEW.a = 32 THEN
+        BEGIN
+            INSERT INTO fp_inner_fk VALUES (999);  -- violates; must be caught
+        EXCEPTION WHEN foreign_key_violation THEN
+            NULL;
+        END;
+    END IF;
+    RETURN NEW;
+END$$;
+CREATE TABLE fp_outer_fk (a int REFERENCES fp_outer_pk (id));
+CREATE TRIGGER fp_reentry_subxact_trg AFTER INSERT ON fp_outer_fk
+    FOR EACH ROW EXECUTE FUNCTION fp_reentry_subxact();
+INSERT INTO fp_outer_fk SELECT g FROM generate_series(1, 64) g;
+SELECT count(*) AS outer_rows FROM fp_outer_fk;   -- 64, outer batch intact
+SELECT count(*) AS inner_rows FROM fp_inner_fk;   -- 0, inner check caught
+DROP TRIGGER fp_reentry_subxact_trg ON fp_outer_fk;
+DROP FUNCTION fp_reentry_subxact();
+DROP TABLE fp_outer_fk, fp_outer_pk, fp_inner_fk, fp_inner_pk;
+
+-- A nested trigger-firing cycle that checks the same constraint must use a
+-- separate cache entry.  The inner violation is caught by its subtransaction,
+-- while the valid outer row remains buffered and is checked normally.
+CREATE TABLE fp_same_pk (id int PRIMARY KEY);
+INSERT INTO fp_same_pk VALUES (1);
+CREATE TABLE fp_same_fk (a int REFERENCES fp_same_pk (id));
+CREATE FUNCTION fp_reentry_same_constraint() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+    IF NEW.a = 1 THEN
+        BEGIN
+            INSERT INTO fp_same_fk VALUES (999);
+        EXCEPTION WHEN foreign_key_violation THEN
+            NULL;
+        END;
+    END IF;
+    RETURN NEW;
+END$$;
+CREATE TRIGGER fp_reentry_same_constraint_trg AFTER INSERT ON fp_same_fk
+    FOR EACH ROW EXECUTE FUNCTION fp_reentry_same_constraint();
+INSERT INTO fp_same_fk VALUES (1);
+SELECT * FROM fp_same_fk;
+DROP TRIGGER fp_reentry_same_constraint_trg ON fp_same_fk;
+DROP FUNCTION fp_reentry_same_constraint();
+DROP TABLE fp_same_fk, fp_same_pk;
+
+-- An AFTER trigger runs a query of its own, and that query inserts into a
+-- second table with a fast-path foreign key.  The entry the nested INSERT
+-- creates belongs to the cursor's portal, which is gone by the time the
+-- entry is torn down at the end of the outer statement.  Every key stored
+-- below is present in its referenced table, so the INSERT must just succeed.
+CREATE TABLE fp_customer (id int PRIMARY KEY);
+INSERT INTO fp_customer VALUES (1);
+CREATE TABLE fp_product (id int PRIMARY KEY);
+INSERT INTO fp_product SELECT generate_series(1, 4);
+CREATE TABLE fp_kit_component (kit_product_id int, component_product_id int);
+INSERT INTO fp_kit_component VALUES (1, 2), (1, 3), (1, 4);
+CREATE TABLE fp_order (id int, customer_id int REFERENCES fp_customer,
+    product_id int);
+CREATE TABLE fp_order_item (order_id int, product_id int
+    REFERENCES fp_product);
+CREATE FUNCTION fp_add_order_item(order_id int, product_id int) RETURNS int
+    LANGUAGE plpgsql AS $$
+BEGIN
+    INSERT INTO fp_order_item VALUES (order_id, product_id);
+    RETURN product_id;
+END$$;
+CREATE FUNCTION fp_expand_kit() RETURNS trigger LANGUAGE plpgsql AS $$
+DECLARE
+    component_id int;
+    ncomponents int := 0;
+BEGIN
+    FOR component_id IN
+        SELECT fp_add_order_item(NEW.id, component_product_id)
+            FROM fp_kit_component WHERE kit_product_id = NEW.product_id
+    LOOP
+        ncomponents := ncomponents + 1;
+    END LOOP;
+    RAISE NOTICE 'order % expanded into % order items', NEW.id, ncomponents;
+    RETURN NULL;
+END$$;
+CREATE TRIGGER fp_expand_kit_trg AFTER INSERT ON fp_order
+    FOR EACH ROW EXECUTE FUNCTION fp_expand_kit();
+INSERT INTO fp_order VALUES (1, 1, 1);
+SELECT count(*) FROM fp_order_item;
+DROP TABLE fp_order, fp_order_item, fp_kit_component, fp_product, fp_customer;
+DROP FUNCTION fp_expand_kit(), fp_add_order_item(int, int);
+
+-- Deferred FK check fires at commit (query depth -1); its batch must still get
+-- a callback registered and flushed.
+CREATE TABLE fp_deferred_pk (id int PRIMARY KEY);
+CREATE TABLE fp_deferred_fk (a int REFERENCES fp_deferred_pk (id)
+    DEFERRABLE INITIALLY DEFERRED);
+BEGIN;
+INSERT INTO fp_deferred_fk VALUES (1);
+INSERT INTO fp_deferred_pk VALUES (1);
+COMMIT;
+SELECT count(*) AS deferred_rows FROM fp_deferred_fk;  -- 1, check passed at commit
+DROP TABLE fp_deferred_fk, fp_deferred_pk;
-- 
2.47.3

