From 49a25e34e227d6a184326f12d35faabe76ebfc6f Mon Sep 17 00:00:00 2001
From: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Date: Thu, 15 Oct 2026 12:18:28 +0000
Subject: [PATCH v1 2/4] Bind BulkInsertState to one relation

BulkInsertState contains relation local state, including a target buffer pin
and relation extension information. Reusing it for another relation requires
resetting all that state correctly. Commits b1ecb9b3fcf and 22655aa2313 fixed
bugs caused by failing to do so.

Make GetBulkInsertState() take a relation and ensure that the resulting state
is used only with that relation.

For partitioned COPY, keep one state per leaf relation and share the caller
owned buffer strategy between them. This removes cross relation reuse while
retaining a single buffer ring for the COPY operation.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/
---
 src/backend/access/heap/heapam.c         | 33 ++++++----
 src/backend/access/heap/heapam_handler.c |  2 +-
 src/backend/commands/copyfrom.c          | 76 ++++++++++++++++++++++--
 src/backend/commands/createas.c          |  2 +-
 src/backend/commands/matview.c           |  2 +-
 src/backend/commands/tablecmds.c         |  2 +-
 src/backend/executor/execMain.c          |  1 +
 src/backend/executor/execPartition.c     |  1 +
 src/include/access/heapam.h              |  2 +-
 src/include/access/hio.h                 |  6 +-
 src/include/access/tableam.h             |  6 +-
 src/include/nodes/execnodes.h            |  2 +
 src/tools/pgindent/typedefs.list         |  1 +
 13 files changed, 110 insertions(+), 26 deletions(-)
  28.9% src/backend/access/heap/
  46.6% src/backend/commands/
  19.6% src/include/access/
   4.7% src/

diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c
index d52468c90f9..6e526793c9d 100644
--- a/src/backend/access/heap/heapam.c
+++ b/src/backend/access/heap/heapam.c
@@ -1934,12 +1934,24 @@ UpdateXmaxHintBits(HeapTupleHeader tuple, Buffer buffer, TransactionId xid)
 
 
 /*
- * GetBulkInsertState - prepare status object for a bulk insert
+ * Verify that bulk insert state is used only with its assigned relation.
+ */
+static inline void
+CheckBulkInsertState(Relation relation, BulkInsertState bistate)
+{
+	if (unlikely(bistate != NULL &&
+				 bistate->relation_id != RelationGetRelid(relation)))
+		elog(ERROR, "bulk insert state for relation %u used for relation %u",
+			 bistate->relation_id, RelationGetRelid(relation));
+}
+
+/*
+ * GetBulkInsertState - prepare bulk insert state for the given relation
  *
- * The caller owns strategy and may share it with other bulk insert states.
+ * The caller owns strategy and may share it with states for other relations.
  */
 BulkInsertState
-GetBulkInsertState(BufferAccessStrategy strategy)
+GetBulkInsertState(Relation relation, BufferAccessStrategy strategy)
 {
 	BulkInsertState bistate;
 
@@ -1947,6 +1959,7 @@ GetBulkInsertState(BufferAccessStrategy strategy)
 
 	bistate = (BulkInsertState) palloc_object(BulkInsertStateData);
 	bistate->strategy = strategy;
+	bistate->relation_id = RelationGetRelid(relation);
 	bistate->current_buf = InvalidBuffer;
 	bistate->next_free = InvalidBlockNumber;
 	bistate->last_free = InvalidBlockNumber;
@@ -1967,6 +1980,8 @@ FreeBulkInsertState(BulkInsertState bistate)
 
 /*
  * ReleaseBulkInsertStatePin - release a buffer currently held in bistate
+ *
+ * Also abandon unused pages from the state's most recent bulk extension.
  */
 void
 ReleaseBulkInsertStatePin(BulkInsertState bistate)
@@ -1975,14 +1990,6 @@ ReleaseBulkInsertStatePin(BulkInsertState bistate)
 		ReleaseBuffer(bistate->current_buf);
 	bistate->current_buf = InvalidBuffer;
 
-	/*
-	 * Despite the name, we also reset bulk relation extension state.
-	 * Otherwise we can end up erroring out due to looking for free space in
-	 * ->next_free of one partition, even though ->next_free was set when
-	 * extending another partition. It could obviously also be bad for
-	 * efficiency to look at existing blocks at offsets from another
-	 * partition, even if we don't error out.
-	 */
 	bistate->next_free = InvalidBlockNumber;
 	bistate->last_free = InvalidBlockNumber;
 }
@@ -2018,6 +2025,8 @@ heap_insert(Relation relation, HeapTuple tup, CommandId cid,
 	bool		clear_all_visible = false;
 	bool		vmbuffer_modified = false;
 
+	CheckBulkInsertState(relation, bistate);
+
 	/* Cheap, simplistic check that the tuple matches the rel's rowtype. */
 	Assert(HeapTupleHeaderGetNatts(tup->t_data) <=
 		   RelationGetNumberOfAttributes(relation));
@@ -2328,6 +2337,8 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples,
 	int			npages = 0;
 	int			npages_used = 0;
 
+	CheckBulkInsertState(relation, bistate);
+
 	/* currently not needed (thus unsupported) for heap_multi_insert() */
 	Assert(!(options & HEAP_INSERT_NO_LOGICAL));
 
diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c
index e33b9a82c74..44e244543ee 100644
--- a/src/backend/access/heap/heapam_handler.c
+++ b/src/backend/access/heap/heapam_handler.c
@@ -641,7 +641,7 @@ heapam_relation_copy_for_cluster(Relation OldHeap, Relation NewHeap,
 	if (concurrent)
 	{
 		strategy = GetAccessStrategy(BAS_BULKWRITE);
-		bistate = GetBulkInsertState(strategy);
+		bistate = GetBulkInsertState(NewHeap, strategy);
 	}
 	else
 	{
diff --git a/src/backend/commands/copyfrom.c b/src/backend/commands/copyfrom.c
index 8729706bdbe..efe334e1523 100644
--- a/src/backend/commands/copyfrom.c
+++ b/src/backend/commands/copyfrom.c
@@ -90,6 +90,13 @@ typedef struct CopyMultiInsertBuffer
 												 * stream */
 } CopyMultiInsertBuffer;
 
+/* Stores single-insert state for one partition. */
+typedef struct CopySingleInsertState
+{
+	ResultRelInfo *resultRelInfo;
+	BulkInsertState bistate;
+} CopySingleInsertState;
+
 /*
  * Stores one or many CopyMultiInsertBuffers and details about the size and
  * number of tuples which are stored in them.  This allows multiple buffers to
@@ -374,7 +381,7 @@ CopyMultiInsertBufferInit(ResultRelInfo *rri)
 	if (rri->ri_FdwRoutine == NULL)
 	{
 		buffer->strategy = GetAccessStrategy(BAS_BULKWRITE);
-		buffer->bistate = GetBulkInsertState(buffer->strategy);
+		buffer->bistate = GetBulkInsertState(rri->ri_RelationDesc, buffer->strategy);
 	}
 	else
 	{
@@ -665,6 +672,44 @@ CopyMultiInsertBufferCleanup(CopyMultiInsertInfo *miinfo,
 	pfree(buffer);
 }
 
+/*
+ * Create state for single inserts into one partition.
+ */
+static CopySingleInsertState *
+CopySingleInsertStateInit(ResultRelInfo *rri, BufferAccessStrategy strategy)
+{
+	CopySingleInsertState *state;
+
+	Assert(rri->ri_CopySingleInsertState == NULL);
+
+	state = palloc_object(CopySingleInsertState);
+	state->resultRelInfo = rri;
+	state->bistate = GetBulkInsertState(rri->ri_RelationDesc, strategy);
+	rri->ri_CopySingleInsertState = state;
+
+	return state;
+}
+
+/*
+ * Release all states used for partitioned single inserts.
+ */
+static void
+CopySingleInsertStatesCleanup(List *states)
+{
+	ListCell   *lc;
+
+	foreach(lc, states)
+	{
+		CopySingleInsertState *state = lfirst(lc);
+
+		state->resultRelInfo->ri_CopySingleInsertState = NULL;
+		FreeBulkInsertState(state->bistate);
+		pfree(state);
+	}
+
+	list_free(states);
+}
+
 /*
  * Write out all stored tuples in all buffers out to the tables.
  *
@@ -810,6 +855,7 @@ CopyFrom(CopyFromState cstate)
 	uint32		ti_options = 0; /* start with default options for insert */
 	BufferAccessStrategy strategy = NULL;
 	BulkInsertState bistate = NULL;
+	List	   *singleInsertStates = NIL;
 	CopyInsertMethod insertMethod;
 	CopyMultiInsertInfo multiInsertInfo = {0};	/* pacify compiler */
 	int64		processed = 0;
@@ -1103,7 +1149,8 @@ CopyFrom(CopyFromState cstate)
 		singleslot = table_slot_create(resultRelInfo->ri_RelationDesc,
 									   &estate->es_tupleTable);
 		strategy = GetAccessStrategy(BAS_BULKWRITE);
-		bistate = GetBulkInsertState(strategy);
+		if (proute == NULL)
+			bistate = GetBulkInsertState(resultRelInfo->ri_RelationDesc, strategy);
 	}
 
 	has_before_insert_row_trig = (resultRelInfo->ri_TrigDesc &&
@@ -1273,8 +1320,25 @@ CopyFrom(CopyFromState cstate)
 											 &processed);
 				}
 
-				if (bistate != NULL)
-					ReleaseBulkInsertStatePin(bistate);
+				if (prevResultRelInfo != NULL &&
+					prevResultRelInfo->ri_CopySingleInsertState != NULL)
+					ReleaseBulkInsertStatePin(prevResultRelInfo->ri_CopySingleInsertState->bistate);
+
+				if (!leafpart_use_multi_insert && resultRelInfo->ri_FdwRoutine == NULL)
+				{
+					CopySingleInsertState *state = resultRelInfo->ri_CopySingleInsertState;
+
+					if (state == NULL)
+					{
+						state = CopySingleInsertStateInit(resultRelInfo, strategy);
+						singleInsertStates = lappend(singleInsertStates, state);
+					}
+
+					bistate = state->bistate;
+				}
+				else
+					bistate = NULL;
+
 				prevResultRelInfo = resultRelInfo;
 			}
 
@@ -1499,7 +1563,9 @@ CopyFrom(CopyFromState cstate)
 								  cstate->num_errors));
 	}
 
-	if (bistate != NULL)
+	if (proute != NULL)
+		CopySingleInsertStatesCleanup(singleInsertStates);
+	else if (bistate != NULL)
 		FreeBulkInsertState(bistate);
 	if (strategy != NULL)
 		FreeAccessStrategy(strategy);
diff --git a/src/backend/commands/createas.c b/src/backend/commands/createas.c
index 8b336e0a3f8..daf9c94f543 100644
--- a/src/backend/commands/createas.c
+++ b/src/backend/commands/createas.c
@@ -569,7 +569,7 @@ intorel_startup(DestReceiver *self, int operation, TupleDesc typeinfo)
 	if (!into->skipData)
 	{
 		myState->strategy = GetAccessStrategy(BAS_BULKWRITE);
-		myState->bistate = GetBulkInsertState(myState->strategy);
+		myState->bistate = GetBulkInsertState(intoRelationDesc, myState->strategy);
 	}
 	else
 	{
diff --git a/src/backend/commands/matview.c b/src/backend/commands/matview.c
index d231004bd80..94fcc6b1197 100644
--- a/src/backend/commands/matview.c
+++ b/src/backend/commands/matview.c
@@ -496,7 +496,7 @@ transientrel_startup(DestReceiver *self, int operation, TupleDesc typeinfo)
 	myState->output_cid = GetCurrentCommandId(true);
 	myState->ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_FROZEN;
 	myState->strategy = GetAccessStrategy(BAS_BULKWRITE);
-	myState->bistate = GetBulkInsertState(myState->strategy);
+	myState->bistate = GetBulkInsertState(transientrel, myState->strategy);
 
 	/*
 	 * Valid smgr_targblock implies something already wrote to the relation.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 7f40a6e1299..d6fe26acc70 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -6237,7 +6237,7 @@ ATRewriteTable(AlteredTableInfo *tab, Oid OIDNewHeap)
 	{
 		mycid = GetCurrentCommandId(true);
 		strategy = GetAccessStrategy(BAS_BULKWRITE);
-		bistate = GetBulkInsertState(strategy);
+		bistate = GetBulkInsertState(newrel, strategy);
 		ti_options = TABLE_INSERT_SKIP_FSM;
 	}
 	else
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index d7a8f36ca1d..5b2cd3b2576 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -1362,6 +1362,7 @@ InitResultRelInfo(ResultRelInfo *resultRelInfo,
 	resultRelInfo->ri_ChildToRootMap = NULL;
 	resultRelInfo->ri_ChildToRootMapValid = false;
 	resultRelInfo->ri_CopyMultiInsertBuffer = NULL;
+	resultRelInfo->ri_CopySingleInsertState = NULL;
 }
 
 /*
diff --git a/src/backend/executor/execPartition.c b/src/backend/executor/execPartition.c
index 4ec69f2bf99..878e7c0a8cd 100644
--- a/src/backend/executor/execPartition.c
+++ b/src/backend/executor/execPartition.c
@@ -1163,6 +1163,7 @@ ExecInitRoutingInfo(ModifyTableState *mtstate,
 	Assert(partRelInfo->ri_BatchSize >= 1);
 
 	partRelInfo->ri_CopyMultiInsertBuffer = NULL;
+	partRelInfo->ri_CopySingleInsertState = NULL;
 
 	/*
 	 * Keep track of it in the PartitionTupleRouting->partitions array.
diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h
index 1d5cdecc74b..25522c1ffa9 100644
--- a/src/include/access/heapam.h
+++ b/src/include/access/heapam.h
@@ -371,7 +371,7 @@ extern bool heap_fetch(Relation relation, Snapshot snapshot,
 
 extern void heap_get_latest_tid(TableScanDesc sscan, ItemPointer tid);
 
-extern BulkInsertState GetBulkInsertState(BufferAccessStrategy strategy);
+extern BulkInsertState GetBulkInsertState(Relation relation, BufferAccessStrategy strategy);
 extern void FreeBulkInsertState(BulkInsertState);
 extern void ReleaseBulkInsertStatePin(BulkInsertState bistate);
 
diff --git a/src/include/access/hio.h b/src/include/access/hio.h
index be17351b2d2..5d02d5426e2 100644
--- a/src/include/access/hio.h
+++ b/src/include/access/hio.h
@@ -19,9 +19,10 @@
 #include "utils/relcache.h"
 
 /*
- * state for bulk inserts --- private to heapam.c and hio.c
+ * state for bulk inserts into one relation --- private to heapam.c and hio.c
  *
- * The caller owns the buffer access strategy. If current_buf isn't
+ * The caller owns the buffer access strategy and may share it with states for
+ * other relations in the same bulk operation. If current_buf isn't
  * InvalidBuffer, then we are holding an extra pin on that buffer.
  *
  * "typedef struct BulkInsertStateData *BulkInsertState" is in heapam.h
@@ -29,6 +30,7 @@
 typedef struct BulkInsertStateData
 {
 	BufferAccessStrategy strategy;	/* our BULKWRITE strategy object */
+	Oid			relation_id;	/* relation this state belongs to */
 	Buffer		current_buf;	/* current insertion target page */
 
 	/*
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index ea3f2a6be99..82d5a8717e2 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -1464,9 +1464,9 @@ table_index_delete_tuples(Relation rel, TM_IndexDeleteOp *delstate)
  * Note that most of these options will be applied when inserting into the
  * heap's TOAST table, too, if the tuple requires any out-of-line data.
  *
- * The BulkInsertState object (if any; bistate can be NULL for default
- * behavior) is also just passed through to RelationGetBufferForTuple. If
- * `bistate` is provided, table_finish_bulk_insert() needs to be called.
+ * A BulkInsertState object (if any; bistate can be NULL for default behavior)
+ * belongs to one relation and allows the AM to optimize a series of inserts.
+ * If `bistate` is provided, table_finish_bulk_insert() needs to be called.
  *
  * On return the slot's tts_tid and tts_tableOid are updated to reflect the
  * insertion. But note that any toasting of fields within the slot is NOT
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index e3cea52e7df..761a47d94ec 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -634,6 +634,8 @@ typedef struct ResultRelInfo
 
 	/* for use by copyfrom.c when performing multi-inserts */
 	struct CopyMultiInsertBuffer *ri_CopyMultiInsertBuffer;
+	/* for use by copyfrom.c when performing partitioned single inserts */
+	struct CopySingleInsertState *ri_CopySingleInsertState;
 
 	/*
 	 * Used when a leaf partition is involved in a cross-partition update of
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index a7a8dbdd23f..ed0924781d8 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -551,6 +551,7 @@ CopyMethod
 CopyMultiInsertBuffer
 CopyMultiInsertInfo
 CopyOnErrorChoice
+CopySingleInsertState
 CopySeqResult
 CopySource
 CopyStmt
-- 
2.34.1

