From f74e5dd795470979ccc557009402f5634e498964 Mon Sep 17 00:00:00 2001
From: Amit Langote <amitlan@postgresql.org>
Date: Wed, 25 Feb 2026 21:25:14 +0900
Subject: [PATCH v5 3/6] Buffer FK rows for batched fast-path probing

Instead of probing the PK index immediately on each trigger
invocation, buffer FK rows in the per-constraint cache entry
(RI_FastPathEntry) and flush them in a batch.  When the buffer
fills (64 rows) or the trigger-firing cycle ends, ri_FastPathBatchFlush()
probes the index for all buffered rows in a tight loop, sharing a
single CommandCounterIncrement, security context switch, and
permissions check across the batch.

FK tuples are materialized via ExecCopySlotHeapTuple() into
TopTransactionContext so they survive across trigger invocations.
Violations are reported immediately during the flush via
ri_ReportViolation(), which does not return.

ri_FastPathCleanup() flushes any partial batch before tearing down
cached resources.  Since the FK relation may already be closed by
flush time (e.g. for deferred constraints at COMMIT), the entry
stashes fk_relid and reopens it if needed.

The non-cached path (ALTER TABLE validation) bypasses batching and
continues to call ri_FastPathCheck() directly per row.

On its own, this patch does not improve performance over 0002 because
the per-row index descent still dominates.  It provides the buffering
infrastructure for the next patch, which replaces the tight loop with
a single SK_SEARCHARRAY index probe.
---
 src/backend/utils/adt/ri_triggers.c       | 217 +++++++++++++++++++++-
 src/test/regress/expected/foreign_key.out |  23 +++
 src/test/regress/sql/foreign_key.sql      |  21 +++
 3 files changed, 253 insertions(+), 8 deletions(-)

diff --git a/src/backend/utils/adt/ri_triggers.c b/src/backend/utils/adt/ri_triggers.c
index 9611b23e1ce..7929d6f8c85 100644
--- a/src/backend/utils/adt/ri_triggers.c
+++ b/src/backend/utils/adt/ri_triggers.c
@@ -196,6 +196,8 @@ typedef struct RI_CompareHashEntry
 	FmgrInfo	cast_func_finfo;	/* in case we must coerce input */
 } RI_CompareHashEntry;
 
+#define RI_FASTPATH_BATCH_SIZE	64
+
 /*
  * RI_FastPathEntry
  *		Per-constraint cache of resources needed by ri_FastPathCheck().
@@ -216,6 +218,12 @@ typedef struct RI_FastPathEntry
 	/* For when IsolationUsesXactSnapshot() is true */
 	Snapshot	xact_snap;
 	IndexScanDesc xact_scan;
+
+	HeapTuple	batch[RI_FASTPATH_BATCH_SIZE];
+	int			batch_count;
+
+	/* For ri_FastPathEndBatch() */
+	const RI_ConstraintInfo *riinfo;
 } RI_FastPathEntry;
 
 /*
@@ -303,7 +311,12 @@ pg_noreturn static void ri_ReportViolation(const RI_ConstraintInfo *riinfo,
 										   TupleTableSlot *violatorslot, TupleDesc tupdesc,
 										   int queryno, bool is_restrict, bool partgone);
 static RI_FastPathEntry *ri_FastPathGetEntry(const RI_ConstraintInfo *riinfo);
-static void ri_FastPathCleanup(void *arg);
+static void ri_FastPathEndBatch(void *arg);
+static void ri_FastPathTeardown(void);
+static void ri_FastPathBatchAdd(const RI_ConstraintInfo *riinfo,
+								Relation fk_rel, TupleTableSlot *newslot);
+static void ri_FastPathBatchFlush(RI_FastPathEntry *fpentry,
+								  Relation fk_rel);
 
 
 /*
@@ -416,8 +429,18 @@ RI_FKey_check(TriggerData *trigdata)
 	 */
 	if (ri_fastpath_is_applicable(riinfo))
 	{
-		ri_FastPathCheck(riinfo, fk_rel, newslot);
-		return PointerGetDatum(NULL);
+		if (AfterTriggerBatchIsActive())
+		{
+			/* Batched path: buffer and probe in groups */
+			ri_FastPathBatchAdd(riinfo, fk_rel, newslot);
+			return PointerGetDatum(NULL);
+		}
+		else
+		{
+			/* ALTER TABLE validation: per-row, no cache */
+			ri_FastPathCheck(riinfo, fk_rel, newslot);
+			return PointerGetDatum(NULL);
+		}
 	}
 
 	SPI_connect();
@@ -3782,13 +3805,50 @@ RI_FKey_trigger_type(Oid tgfoid)
 }
 
 /*
- * ri_FastPathCleanup
- *		Tear down all cached fast-path state.
+ * ri_FastPathEndBatch
+ *		Flush remaining rows and tear down cached state.
  *
- * Called as an AfterTriggerBatchCallback at end of batch.
+ * Registered as an AfterTriggerBatchCallback.  Note: the flush can
+ * do real work (CCI, security context switch, index probes) and can
+ * throw ERROR on a constraint violation.  If that happens,
+ * ri_FastPathTeardown never runs; ResourceOwner + XactCallback
+ * handle resource cleanup on the abort path.
  */
 static void
-ri_FastPathCleanup(void *arg)
+ri_FastPathEndBatch(void *arg)
+{
+	HASH_SEQ_STATUS status;
+	RI_FastPathEntry *entry;
+
+	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)
+	{
+		if (entry->batch_count > 0)
+		{
+			Relation fk_rel = table_open(entry->riinfo->fk_relid,
+										 AccessShareLock);
+
+			ri_FastPathBatchFlush(entry, fk_rel);
+			table_close(fk_rel, NoLock);
+		}
+	}
+
+	/* Orderly teardown */
+	ri_FastPathTeardown();
+}
+
+/*
+ * ri_FastPathTeardown
+ *		Release all cached resources (scans, relations, snapshots).
+ *
+ * Pure resource cleanup -- no user-visible side effects, no errors.
+ */
+static void
+ri_FastPathTeardown(void)
 {
 	HASH_SEQ_STATUS status;
 	RI_FastPathEntry *entry;
@@ -3951,7 +4011,7 @@ ri_FastPathGetEntry(const RI_ConstraintInfo *riinfo)
 		/* Ensure cleanup at end of this trigger-firing batch */
 		if (!ri_fastpath_callback_registered)
 		{
-			RegisterAfterTriggerBatchCallback(ri_FastPathCleanup, NULL);
+			RegisterAfterTriggerBatchCallback(ri_FastPathEndBatch, NULL);
 			ri_fastpath_callback_registered = true;
 		}
 
@@ -3962,7 +4022,148 @@ ri_FastPathGetEntry(const RI_ConstraintInfo *riinfo)
 							   SECURITY_NOFORCE_RLS);
 		ri_CheckPermissions(entry->pk_rel);
 		SetUserIdAndSecContext(saved_userid, saved_sec_context);
+
+		/* For ri_FastPathEndBatch() */
+		entry->riinfo = riinfo;
 	}
 
 	return entry;
 }
+
+static void
+ri_FastPathBatchFlush(RI_FastPathEntry *fpentry, Relation fk_rel)
+{
+	const RI_ConstraintInfo *riinfo = fpentry->riinfo;
+	Relation	pk_rel = fpentry->pk_rel;
+	Relation	idx_rel = fpentry->idx_rel;
+	IndexScanDesc scandesc = fpentry->scandesc;
+	TupleTableSlot *slot = fpentry->slot;
+	Snapshot	snapshot = fpentry->snapshot;
+	TupleTableSlot *fk_slot;
+	Datum		pk_vals[INDEX_MAX_KEYS];
+	char		pk_nulls[INDEX_MAX_KEYS];
+	ScanKeyData skey[INDEX_MAX_KEYS];
+	Oid			saved_userid;
+	int			saved_sec_context;
+	MemoryContext oldcxt;
+
+	if (fpentry->batch_count == 0)
+		return;
+
+	if (riinfo->fpmeta == NULL)
+		ri_populate_fastpath_metadata((RI_ConstraintInfo *) riinfo,
+									  fk_rel, idx_rel);
+	Assert(riinfo->fpmeta);
+
+	CommandCounterIncrement();
+	snapshot->curcid = GetCurrentCommandId(false);
+
+	GetUserIdAndSecContext(&saved_userid, &saved_sec_context);
+	SetUserIdAndSecContext(RelationGetForm(pk_rel)->relowner,
+						   saved_sec_context |
+						   SECURITY_LOCAL_USERID_CHANGE |
+						   SECURITY_NOFORCE_RLS);
+
+	fk_slot = MakeSingleTupleTableSlot(RelationGetDescr(fk_rel),
+									   &TTSOpsHeapTuple);
+
+	oldcxt = MemoryContextSwitchTo(TopTransactionContext);
+	for (int i = 0; i < fpentry->batch_count; i++)
+	{
+		HeapTuple fktuple = fpentry->batch[i];
+		bool	found = false;
+
+		ExecStoreHeapTuple(fktuple, fk_slot, false);
+
+		ri_ExtractValues(fk_rel, fk_slot, riinfo, false, pk_vals, pk_nulls);
+		build_index_scankeys(riinfo, idx_rel, pk_vals, pk_nulls, skey);
+
+		index_rescan(scandesc, skey, riinfo->nkeys, NULL, 0);
+
+		if (index_getnext_slot(scandesc, ForwardScanDirection, slot))
+		{
+			bool	concurrently_updated;
+
+			if (ri_LockPKTuple(pk_rel, slot, snapshot,
+							   &concurrently_updated))
+			{
+				if (concurrently_updated)
+					found = recheck_matched_pk_tuple(idx_rel, skey, slot);
+				else
+					found = true;
+			}
+		}
+
+		if (found && IsolationUsesXactSnapshot())
+		{
+			IndexScanDesc xact_scan;
+			TupleTableSlot *xact_slot;
+			Snapshot	xact_snap = GetTransactionSnapshot();
+
+			xact_slot = table_slot_create(pk_rel, NULL);
+			xact_scan = index_beginscan(pk_rel, idx_rel,
+										xact_snap, NULL,
+										riinfo->nkeys, 0);
+			index_rescan(xact_scan, skey, riinfo->nkeys, NULL, 0);
+
+			if (!index_getnext_slot(xact_scan, ForwardScanDirection,
+									xact_slot))
+				found = false;
+
+			index_endscan(xact_scan);
+			ExecDropSingleTupleTableSlot(xact_slot);
+		}
+
+		/*
+		 * Report immediately.  ri_ReportViolation calls ereport(ERROR)
+		 * which doesn't return, so remaining batch items and cleanup
+		 * are handled by the error path (ResourceOwner + XactCallback).
+		 */
+		if (!found)
+			ri_ReportViolation(riinfo, pk_rel, fk_rel,
+							   fk_slot, NULL,
+							   RI_PLAN_CHECK_LOOKUPPK, false, false);
+	}
+
+	MemoryContextSwitchTo(oldcxt);
+	SetUserIdAndSecContext(saved_userid, saved_sec_context);
+
+	/* Free materialized tuples and reset */
+	for (int i = 0; i < fpentry->batch_count; i++)
+		heap_freetuple(fpentry->batch[i]);
+
+	fpentry->batch_count = 0;
+
+	ExecDropSingleTupleTableSlot(fk_slot);
+}
+
+/*
+ * ri_FastPathBatchAdd
+ *		Buffer a FK row for batched probing.
+ *
+ * Adds the row to the batch buffer.  When the buffer is full, flushes
+ * all buffered rows by probing the PK index.  Any violation is reported
+ * immediately during the flush via ri_ReportViolation (which does not
+ * return).
+ *
+ * The batch is also flushed at end of trigger-firing cycle via
+ * ri_FastPathTeardown.
+ */
+static void
+ri_FastPathBatchAdd(const RI_ConstraintInfo *riinfo,
+					Relation fk_rel, TupleTableSlot *newslot)
+{
+	RI_FastPathEntry *fpentry;
+	MemoryContext oldcxt;
+
+	fpentry = ri_FastPathGetEntry(riinfo);
+
+	oldcxt = MemoryContextSwitchTo(TopTransactionContext);
+	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);
+}
diff --git a/src/test/regress/expected/foreign_key.out b/src/test/regress/expected/foreign_key.out
index 808f2e632e7..16bb6370a97 100644
--- a/src/test/regress/expected/foreign_key.out
+++ b/src/test/regress/expected/foreign_key.out
@@ -3570,3 +3570,26 @@ SELECT * FROM fp_fk_subxact;
 (2 rows)
 
 DROP TABLE fp_fk_subxact, fp_pk_subxact;
+-- Multi-column FK: exercises batched per-row probing with composite keys
+CREATE TABLE fp_pk_multi (a int, b int, PRIMARY KEY (a, b));
+INSERT INTO fp_pk_multi SELECT i, i FROM generate_series(1, 100) i;
+CREATE TABLE fp_fk_multi (x int, a int, b int,
+    FOREIGN KEY (a, b) REFERENCES fp_pk_multi);
+INSERT INTO fp_fk_multi SELECT i, i, i FROM generate_series(1, 100) i;
+INSERT INTO fp_fk_multi VALUES (1, 999, 999);
+ERROR:  insert or update on table "fp_fk_multi" violates foreign key constraint "fp_fk_multi_a_b_fkey"
+DETAIL:  Key (a, b)=(999, 999) is not present in table "fp_pk_multi".
+DROP TABLE fp_fk_multi, fp_pk_multi;
+-- Deferred constraint: batch flushed at COMMIT, not at statement end
+CREATE TABLE fp_pk_commit (a int PRIMARY KEY);
+CREATE TABLE fp_fk_commit (a int REFERENCES fp_pk_commit
+    DEFERRABLE INITIALLY DEFERRED);
+INSERT INTO fp_pk_commit VALUES (1);
+BEGIN;
+INSERT INTO fp_fk_commit VALUES (1);
+INSERT INTO fp_fk_commit VALUES (1);
+INSERT INTO fp_fk_commit VALUES (999);
+COMMIT;
+ERROR:  insert or update on table "fp_fk_commit" violates foreign key constraint "fp_fk_commit_a_fkey"
+DETAIL:  Key (a)=(999) is not present in table "fp_pk_commit".
+DROP TABLE fp_fk_commit, fp_pk_commit;
diff --git a/src/test/regress/sql/foreign_key.sql b/src/test/regress/sql/foreign_key.sql
index ef6a3381e08..bc24272df20 100644
--- a/src/test/regress/sql/foreign_key.sql
+++ b/src/test/regress/sql/foreign_key.sql
@@ -2556,3 +2556,24 @@ INSERT INTO fp_fk_subxact VALUES (1);
 COMMIT;
 SELECT * FROM fp_fk_subxact;
 DROP TABLE fp_fk_subxact, fp_pk_subxact;
+
+-- Multi-column FK: exercises batched per-row probing with composite keys
+CREATE TABLE fp_pk_multi (a int, b int, PRIMARY KEY (a, b));
+INSERT INTO fp_pk_multi SELECT i, i FROM generate_series(1, 100) i;
+CREATE TABLE fp_fk_multi (x int, a int, b int,
+    FOREIGN KEY (a, b) REFERENCES fp_pk_multi);
+INSERT INTO fp_fk_multi SELECT i, i, i FROM generate_series(1, 100) i;
+INSERT INTO fp_fk_multi VALUES (1, 999, 999);
+DROP TABLE fp_fk_multi, fp_pk_multi;
+
+-- Deferred constraint: batch flushed at COMMIT, not at statement end
+CREATE TABLE fp_pk_commit (a int PRIMARY KEY);
+CREATE TABLE fp_fk_commit (a int REFERENCES fp_pk_commit
+    DEFERRABLE INITIALLY DEFERRED);
+INSERT INTO fp_pk_commit VALUES (1);
+BEGIN;
+INSERT INTO fp_fk_commit VALUES (1);
+INSERT INTO fp_fk_commit VALUES (1);
+INSERT INTO fp_fk_commit VALUES (999);
+COMMIT;
+DROP TABLE fp_fk_commit, fp_pk_commit;
-- 
2.41.0

