v1-0005-executor-adopt-buffered-insert-API-for-restricted.patch

application/octet-stream

Filename: v1-0005-executor-adopt-buffered-insert-API-for-restricted.patch
Type: application/octet-stream
Part: 1
Message: Re: Introduce new multi insert Table AM and improve performance of various SQL commands with it for Heap AM

Patch

Same data as JSON: GET /api/v1/attachments/:id/patch the parsed metadata as JSON — format, series position, per-file stats; never the diff bytes. API reference →
Format: format-patch
Series: patch v1-0005
Subject: executor: adopt buffered-insert API for restricted INSERT ... SELECT
File+
src/backend/executor/nodeModifyTable.c 164 0
src/include/nodes/execnodes.h 5 0
src/test/regress/expected/insert_buffered.out 271 0
src/test/regress/parallel_schedule 1 1
src/test/regress/sql/insert_buffered.sql 209 0
From f2a38d918a0cb3f4279c75f399c82eedb37c909d Mon Sep 17 00:00:00 2001
From: Haibo Yan <haibo.yan@apple.com>
Date: Sun, 26 Apr 2026 22:24:20 -0700
Subject: [PATCH v1 5/5] executor: adopt buffered-insert API for restricted
 INSERT ... SELECT

Add a restricted first-step buffered-insert adoption for
`INSERT INTO ... SELECT` in the executor.

This patch integrates the existing buffered-insert lifecycle API into the
plain non-partitioned heap `CMD_INSERT` path in `ExecInsert()` /
`ExecModifyTable()`, using a local flush callback for post-insert work.

The buffered path is intentionally narrow. It falls back to the existing
single-row path for cases outside the restricted scope, including:
- ON CONFLICT
- RETURNING
- partitioned targets
- BEFORE ROW / INSTEAD OF triggers
- FDW targets
- MERGE / cross-partition UPDATE insert side
- volatile target-side default expressions

The flush callback performs the minimal post-insert executor work needed
for this first step:
- index maintenance via `ExecInsertIndexTuples()`
- AFTER ROW trigger firing via `ExecARInsertTriggers()`
- `es_processed` accounting

Add focused regression coverage for:
- basic bulk INSERT ... SELECT
- indexed targets
- AFTER ROW trigger behavior
- index + trigger combination
- fallback cases (ON CONFLICT, RETURNING, BEFORE ROW trigger,
  partitioned target, volatile target defaults)
- zero-row insert
---
 src/backend/executor/nodeModifyTable.c        | 164 +++++++++++
 src/include/nodes/execnodes.h                 |   5 +
 src/test/regress/expected/insert_buffered.out | 271 ++++++++++++++++++
 src/test/regress/parallel_schedule            |   2 +-
 src/test/regress/sql/insert_buffered.sql      | 209 ++++++++++++++
 5 files changed, 650 insertions(+), 1 deletion(-)
 create mode 100644 src/test/regress/expected/insert_buffered.out
 create mode 100644 src/test/regress/sql/insert_buffered.sql

diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 4cb057ca4f9..06af0637407 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -83,6 +83,85 @@ typedef struct MTTargetRelLookup
 	int			relationIndex;	/* rel's index in resultRelInfo[] array */
 } MTTargetRelLookup;
 
+/*
+ * Flush callback context for buffered INSERT INTO ... SELECT.
+ *
+ * These are the parameters required by the three callback operations
+ * (ExecInsertIndexTuples, ExecARInsertTriggers, es_processed++):
+ *
+ * - estate, resultRelInfo: required by ExecInsertIndexTuples/ExecARInsertTriggers.
+ * - transition_capture: 5th parameter of ExecARInsertTriggers; the non-buffered
+ *   path passes mtstate->mt_transition_capture for CMD_INSERT.  NULL would
+ *   silently break statement-level triggers with REFERENCING NEW TABLE.
+ * - canSetTag: the non-buffered path gates es_processed++ on this after the
+ *   insert; the buffered return-NULL bypasses that gate, so the callback
+ *   replicates it.
+ */
+typedef struct ExecBufferedInsertFlushState
+{
+	EState	   *estate;
+	ResultRelInfo *resultRelInfo;
+	TransitionCaptureState *transition_capture;
+	bool		canSetTag;
+} ExecBufferedInsertFlushState;
+
+/*
+ * Flush callback for buffered INSERT INTO ... SELECT.
+ *
+ * Called once per flushed tuple after heap_multi_insert() completes a batch.
+ * Performs index maintenance, AFTER ROW trigger firing, and tuple counting.
+ */
+static void
+ExecBufferedInsertFlushCb(void *context, TupleTableSlot *slot)
+{
+	ExecBufferedInsertFlushState *ctx = (ExecBufferedInsertFlushState *) context;
+	ResultRelInfo *resultRelInfo = ctx->resultRelInfo;
+	EState	   *estate = ctx->estate;
+	List	   *recheckIndexes = NIL;
+
+	if (resultRelInfo->ri_NumIndices > 0)
+		recheckIndexes = ExecInsertIndexTuples(resultRelInfo, estate, 0,
+											   slot, NIL, NULL);
+
+	ExecARInsertTriggers(estate, resultRelInfo, slot, recheckIndexes,
+						 ctx->transition_capture);
+
+	list_free(recheckIndexes);
+
+	if (ctx->canSetTag)
+		(estate->es_processed)++;
+}
+
+/*
+ * Check whether a relation has volatile default expressions.
+ *
+ * Conservative target-side restriction: if any column default contains a
+ * volatile function (excluding nextval), the buffered-insert path is not used.
+ * This mirrors COPY FROM's volatile_defexprs check.
+ */
+static bool
+ExecRelHasVolatileDefaults(Relation rel)
+{
+	TupleConstr *constr = RelationGetDescr(rel)->constr;
+
+	if (constr == NULL || constr->num_defval == 0)
+		return false;
+
+	for (int i = 0; i < constr->num_defval; i++)
+	{
+		Node	   *expr;
+
+		if (constr->defval[i].adbin == NULL)
+			continue;
+
+		expr = stringToNode(constr->defval[i].adbin);
+
+		if (contain_volatile_functions_not_nextval(expr))
+			return true;
+	}
+	return false;
+}
+
 /*
  * Context struct for a ModifyTable operation, containing basic execution
  * state and some output variables populated by ExecUpdateAct() and
@@ -1269,6 +1348,49 @@ ExecInsert(ModifyTableContext *context,
 		}
 		else
 		{
+			/*
+			 * Buffered-insert path: lazily open the session on first call,
+			 * then submit tuples via put() instead of single-row insert.
+			 * Post-insert work (indexes, triggers) fires in the flush callback.
+			 */
+			if (mtstate->mt_buffered_insert_eligible &&
+				mtstate->mt_bi_state == NULL)
+			{
+				ExecBufferedInsertFlushState *flush_ctx;
+
+				flush_ctx = palloc(sizeof(ExecBufferedInsertFlushState));
+				flush_ctx->estate = estate;
+				flush_ctx->resultRelInfo = resultRelInfo;
+				flush_ctx->transition_capture = mtstate->mt_transition_capture;
+				flush_ctx->canSetTag = canSetTag;
+				mtstate->mt_bi_flush_ctx = flush_ctx;
+
+				mtstate->mt_bi_state =
+					table_buffered_insert_begin(resultRelationDesc,
+												estate->es_output_cid,
+												TABLE_INSERT_BAS_BULKWRITE,
+												ExecBufferedInsertFlushCb,
+												flush_ctx);
+				if (mtstate->mt_bi_state == NULL)
+					mtstate->mt_buffered_insert_eligible = false;
+			}
+
+			if (mtstate->mt_bi_state != NULL)
+			{
+				/*
+				 * Pre-insert validation already ran in this else-branch
+				 * above the ON CONFLICT test — specifically:
+				 *   tts_tableOid init, ExecComputeStoredGenerated,
+				 *   ExecWithCheckOptions (RLS), ExecConstraints,
+				 *   ExecPartitionCheck.
+				 * This inner else-branch (no ON CONFLICT) is reached only
+				 * after all of those.  Submit the validated tuple to the
+				 * AM buffer; post-insert work fires in the flush callback.
+				 */
+				table_buffered_insert_put(mtstate->mt_bi_state, slot);
+				return NULL;
+			}
+
 			/* insert the tuple normally */
 			table_tuple_insert(resultRelationDesc, slot,
 							   estate->es_output_cid,
@@ -5027,6 +5149,15 @@ ExecModifyTable(PlanState *pstate)
 	if (estate->es_insert_pending_result_relations != NIL)
 		ExecPendingInserts(estate);
 
+	/*
+	 * Flush and clean up buffered-insert session if active.
+	 */
+	if (node->mt_bi_state != NULL)
+	{
+		table_buffered_insert_end(node->mt_bi_state);
+		node->mt_bi_state = NULL;
+	}
+
 	/*
 	 * We're done, but fire AFTER STATEMENT triggers before exiting.
 	 */
@@ -5773,6 +5904,30 @@ ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags)
 			resultRelInfo->ri_BatchSize = 1;
 	}
 
+	/*
+	 * For CMD_INSERT without ON CONFLICT/RETURNING/partitioning/BEFORE ROW
+	 * triggers, determine if the restricted buffered-insert path is eligible.
+	 * AM support is resolved lazily at first ExecInsert() call.
+	 */
+	if (operation == CMD_INSERT)
+	{
+		ModifyTable *mtnode = (ModifyTable *) mtstate->ps.plan;
+
+		resultRelInfo = mtstate->resultRelInfo;
+		mtstate->mt_buffered_insert_eligible =
+			(mtnode->onConflictAction == ONCONFLICT_NONE &&
+			 resultRelInfo->ri_projectReturning == NULL &&
+			 resultRelInfo->ri_RelationDesc->rd_rel->relkind !=
+			 RELKIND_PARTITIONED_TABLE &&
+			 !(resultRelInfo->ri_TrigDesc &&
+			   resultRelInfo->ri_TrigDesc->trig_insert_before_row) &&
+			 !(resultRelInfo->ri_TrigDesc &&
+			   resultRelInfo->ri_TrigDesc->trig_insert_instead_row) &&
+			 resultRelInfo->ri_FdwRoutine == NULL &&
+			 mtstate->operation == CMD_INSERT &&
+			 !ExecRelHasVolatileDefaults(resultRelInfo->ri_RelationDesc));
+	}
+
 	/*
 	 * Lastly, if this is not the primary (canSetTag) ModifyTable node, add it
 	 * to estate->es_auxmodifytables so that it will be run to completion by
@@ -5802,6 +5957,15 @@ ExecEndModifyTable(ModifyTableState *node)
 {
 	int			i;
 
+	/*
+	 * Defensive: clean up buffered-insert if end() was not reached above.
+	 */
+	if (node->mt_bi_state != NULL)
+	{
+		table_buffered_insert_end(node->mt_bi_state);
+		node->mt_bi_state = NULL;
+	}
+
 	/*
 	 * Allow any FDWs to shut down
 	 */
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 13359180d25..7790bb0ba38 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -1508,6 +1508,11 @@ typedef struct ModifyTableState
 	List	   *mt_updateColnosLists;
 	List	   *mt_mergeActionLists;
 	List	   *mt_mergeJoinConditions;
+
+	/* Buffered-insert state for restricted INSERT INTO ... SELECT */
+	bool		mt_buffered_insert_eligible;
+	struct TableBufferedInsertStateData *mt_bi_state;
+	void	   *mt_bi_flush_ctx;	/* private to nodeModifyTable.c */
 } ModifyTableState;
 
 /* ----------------
diff --git a/src/test/regress/expected/insert_buffered.out b/src/test/regress/expected/insert_buffered.out
new file mode 100644
index 00000000000..3ee43ae8b04
--- /dev/null
+++ b/src/test/regress/expected/insert_buffered.out
@@ -0,0 +1,271 @@
+--
+-- Tests for buffered-insert adoption in INSERT INTO ... SELECT (Patch 0005).
+-- Restricted first step: non-partitioned heap target, no ON CONFLICT,
+-- no RETURNING, no BEFORE ROW triggers.
+--
+-- ============================================================
+-- T1: Basic bulk insert (exercises multiple auto-flush cycles)
+-- ============================================================
+CREATE TABLE bi_target_basic (id int, val text);
+INSERT INTO bi_target_basic
+SELECT g, 'row-' || g FROM generate_series(1, 2000) g;
+SELECT count(*) FROM bi_target_basic;
+ count 
+-------
+  2000
+(1 row)
+
+SELECT min(id), max(id) FROM bi_target_basic;
+ min | max  
+-----+------
+   1 | 2000
+(1 row)
+
+DROP TABLE bi_target_basic;
+-- ============================================================
+-- T2: Indexed target
+-- ============================================================
+CREATE TABLE bi_target_idx (id int, val text);
+CREATE INDEX bi_target_idx_id ON bi_target_idx (id);
+INSERT INTO bi_target_idx
+SELECT g, 'row-' || g FROM generate_series(1, 500) g;
+SELECT count(*) FROM bi_target_idx;
+ count 
+-------
+   500
+(1 row)
+
+-- Verify index is usable and correct
+SET enable_seqscan = off;
+SELECT count(*) FROM bi_target_idx WHERE id BETWEEN 1 AND 500;
+ count 
+-------
+   500
+(1 row)
+
+RESET enable_seqscan;
+DROP TABLE bi_target_idx;
+-- ============================================================
+-- T3: AFTER ROW trigger
+-- ============================================================
+CREATE TABLE bi_target_trig (id int, val text);
+CREATE TABLE bi_audit (id int, val text, logged_at timestamp DEFAULT now());
+CREATE FUNCTION bi_audit_fn() RETURNS trigger
+LANGUAGE plpgsql AS $$
+BEGIN
+    INSERT INTO bi_audit (id, val) VALUES (NEW.id, NEW.val);
+    RETURN NEW;
+END;
+$$;
+CREATE TRIGGER bi_target_trig_after
+    AFTER INSERT ON bi_target_trig
+    FOR EACH ROW EXECUTE FUNCTION bi_audit_fn();
+INSERT INTO bi_target_trig
+SELECT g, 'row-' || g FROM generate_series(1, 50) g;
+SELECT count(*) FROM bi_target_trig;
+ count 
+-------
+    50
+(1 row)
+
+SELECT count(*) FROM bi_audit;
+ count 
+-------
+    50
+(1 row)
+
+-- Verify insertion order is preserved
+SELECT bool_and(t.id = a.id) AS order_preserved
+FROM (SELECT id, row_number() OVER (ORDER BY ctid) AS rn FROM bi_target_trig) t
+JOIN (SELECT id, row_number() OVER (ORDER BY ctid) AS rn FROM bi_audit) a
+ON t.rn = a.rn;
+ order_preserved 
+-----------------
+ t
+(1 row)
+
+DROP TABLE bi_target_trig CASCADE;
+DROP TABLE bi_audit;
+DROP FUNCTION bi_audit_fn;
+-- ============================================================
+-- T4: Index + AFTER ROW trigger combined
+-- ============================================================
+CREATE TABLE bi_target_combo (id int, val text);
+CREATE INDEX bi_target_combo_id ON bi_target_combo (id);
+CREATE TABLE bi_audit_combo (id int, val text);
+CREATE FUNCTION bi_audit_combo_fn() RETURNS trigger
+LANGUAGE plpgsql AS $$
+BEGIN
+    INSERT INTO bi_audit_combo (id, val) VALUES (NEW.id, NEW.val);
+    RETURN NEW;
+END;
+$$;
+CREATE TRIGGER bi_target_combo_after
+    AFTER INSERT ON bi_target_combo
+    FOR EACH ROW EXECUTE FUNCTION bi_audit_combo_fn();
+INSERT INTO bi_target_combo
+SELECT g, 'row-' || g FROM generate_series(1, 100) g;
+SELECT count(*) FROM bi_target_combo;
+ count 
+-------
+   100
+(1 row)
+
+SELECT count(*) FROM bi_audit_combo;
+ count 
+-------
+   100
+(1 row)
+
+-- Verify index correctness
+SET enable_seqscan = off;
+SELECT count(*) FROM bi_target_combo WHERE id BETWEEN 1 AND 100;
+ count 
+-------
+   100
+(1 row)
+
+RESET enable_seqscan;
+DROP TABLE bi_target_combo CASCADE;
+DROP TABLE bi_audit_combo;
+DROP FUNCTION bi_audit_combo_fn;
+-- ============================================================
+-- T5: ON CONFLICT fallback (uses non-buffered path)
+-- ============================================================
+CREATE TABLE bi_target_conflict (id int PRIMARY KEY, val text);
+INSERT INTO bi_target_conflict VALUES (1, 'existing');
+INSERT INTO bi_target_conflict
+SELECT g, 'row-' || g FROM generate_series(1, 10) g
+ON CONFLICT (id) DO NOTHING;
+SELECT count(*) FROM bi_target_conflict;
+ count 
+-------
+    10
+(1 row)
+
+SELECT val FROM bi_target_conflict WHERE id = 1;
+   val    
+----------
+ existing
+(1 row)
+
+DROP TABLE bi_target_conflict;
+-- ============================================================
+-- T6: RETURNING fallback (uses non-buffered path)
+-- ============================================================
+CREATE TABLE bi_target_ret (id int, val text);
+INSERT INTO bi_target_ret
+SELECT g, 'row-' || g FROM generate_series(1, 3) g
+RETURNING id, val;
+ id |  val  
+----+-------
+  1 | row-1
+  2 | row-2
+  3 | row-3
+(3 rows)
+
+SELECT count(*) FROM bi_target_ret;
+ count 
+-------
+     3
+(1 row)
+
+DROP TABLE bi_target_ret;
+-- ============================================================
+-- T7: BEFORE ROW trigger fallback (uses non-buffered path)
+-- ============================================================
+CREATE TABLE bi_target_br (id int, val text);
+CREATE FUNCTION bi_br_fn() RETURNS trigger
+LANGUAGE plpgsql AS $$
+BEGIN
+    NEW.val := NEW.val || '-modified';
+    RETURN NEW;
+END;
+$$;
+CREATE TRIGGER bi_target_br_before
+    BEFORE INSERT ON bi_target_br
+    FOR EACH ROW EXECUTE FUNCTION bi_br_fn();
+INSERT INTO bi_target_br
+SELECT g, 'row-' || g FROM generate_series(1, 5) g;
+SELECT count(*) FROM bi_target_br;
+ count 
+-------
+     5
+(1 row)
+
+SELECT val FROM bi_target_br WHERE id = 1;
+      val       
+----------------
+ row-1-modified
+(1 row)
+
+DROP TABLE bi_target_br;
+DROP FUNCTION bi_br_fn;
+-- ============================================================
+-- T8: Partitioned target fallback (uses non-buffered path)
+-- ============================================================
+CREATE TABLE bi_target_part (id int, val text) PARTITION BY RANGE (id);
+CREATE TABLE bi_target_part_1 PARTITION OF bi_target_part FOR VALUES FROM (1) TO (501);
+CREATE TABLE bi_target_part_2 PARTITION OF bi_target_part FOR VALUES FROM (501) TO (1001);
+INSERT INTO bi_target_part
+SELECT g, 'row-' || g FROM generate_series(1, 1000) g;
+SELECT count(*) FROM bi_target_part;
+ count 
+-------
+  1000
+(1 row)
+
+SELECT count(*) FROM bi_target_part_1;
+ count 
+-------
+   500
+(1 row)
+
+SELECT count(*) FROM bi_target_part_2;
+ count 
+-------
+   500
+(1 row)
+
+DROP TABLE bi_target_part;
+-- ============================================================
+-- T9: Volatile target-default fallback
+-- Expected to fall back to non-buffered path under E8.
+-- Test validates correctness; path selection is not observable
+-- from SQL output.
+-- ============================================================
+CREATE TABLE bi_target_volatile (
+    id int,
+    val text,
+    rand_val double precision DEFAULT random()
+);
+INSERT INTO bi_target_volatile (id, val)
+SELECT g, 'row-' || g FROM generate_series(1, 5) g;
+SELECT count(*) FROM bi_target_volatile;
+ count 
+-------
+     5
+(1 row)
+
+-- Verify the volatile default was evaluated (all values should be distinct)
+SELECT count(DISTINCT rand_val) = count(*) AS all_distinct
+FROM bi_target_volatile;
+ all_distinct 
+--------------
+ t
+(1 row)
+
+DROP TABLE bi_target_volatile;
+-- ============================================================
+-- T10: Zero-row insert
+-- ============================================================
+CREATE TABLE bi_target_zero (id int, val text);
+INSERT INTO bi_target_zero
+SELECT g, 'row-' || g FROM generate_series(1, 100) g WHERE false;
+SELECT count(*) FROM bi_target_zero;
+ count 
+-------
+     0
+(1 row)
+
+DROP TABLE bi_target_zero;
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 5d4f910155e..ae63c3dd73c 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -36,7 +36,7 @@ test: geometry horology tstypes regex type_sanity opr_sanity misc_sanity comment
 # execute two copy tests in parallel, to check that copy itself
 # is concurrent safe.
 # ----------
-test: copy copyselect copydml copyencoding insert insert_conflict
+test: copy copyselect copydml copyencoding insert insert_conflict insert_buffered
 
 # ----------
 # More groups of parallel tests
diff --git a/src/test/regress/sql/insert_buffered.sql b/src/test/regress/sql/insert_buffered.sql
new file mode 100644
index 00000000000..2ca6f3525f4
--- /dev/null
+++ b/src/test/regress/sql/insert_buffered.sql
@@ -0,0 +1,209 @@
+--
+-- Tests for buffered-insert adoption in INSERT INTO ... SELECT (Patch 0005).
+-- Restricted first step: non-partitioned heap target, no ON CONFLICT,
+-- no RETURNING, no BEFORE ROW triggers.
+--
+
+-- ============================================================
+-- T1: Basic bulk insert (exercises multiple auto-flush cycles)
+-- ============================================================
+CREATE TABLE bi_target_basic (id int, val text);
+
+INSERT INTO bi_target_basic
+SELECT g, 'row-' || g FROM generate_series(1, 2000) g;
+
+SELECT count(*) FROM bi_target_basic;
+SELECT min(id), max(id) FROM bi_target_basic;
+
+DROP TABLE bi_target_basic;
+
+-- ============================================================
+-- T2: Indexed target
+-- ============================================================
+CREATE TABLE bi_target_idx (id int, val text);
+CREATE INDEX bi_target_idx_id ON bi_target_idx (id);
+
+INSERT INTO bi_target_idx
+SELECT g, 'row-' || g FROM generate_series(1, 500) g;
+
+SELECT count(*) FROM bi_target_idx;
+
+-- Verify index is usable and correct
+SET enable_seqscan = off;
+SELECT count(*) FROM bi_target_idx WHERE id BETWEEN 1 AND 500;
+RESET enable_seqscan;
+
+DROP TABLE bi_target_idx;
+
+-- ============================================================
+-- T3: AFTER ROW trigger
+-- ============================================================
+CREATE TABLE bi_target_trig (id int, val text);
+CREATE TABLE bi_audit (id int, val text, logged_at timestamp DEFAULT now());
+
+CREATE FUNCTION bi_audit_fn() RETURNS trigger
+LANGUAGE plpgsql AS $$
+BEGIN
+    INSERT INTO bi_audit (id, val) VALUES (NEW.id, NEW.val);
+    RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER bi_target_trig_after
+    AFTER INSERT ON bi_target_trig
+    FOR EACH ROW EXECUTE FUNCTION bi_audit_fn();
+
+INSERT INTO bi_target_trig
+SELECT g, 'row-' || g FROM generate_series(1, 50) g;
+
+SELECT count(*) FROM bi_target_trig;
+SELECT count(*) FROM bi_audit;
+
+-- Verify insertion order is preserved
+SELECT bool_and(t.id = a.id) AS order_preserved
+FROM (SELECT id, row_number() OVER (ORDER BY ctid) AS rn FROM bi_target_trig) t
+JOIN (SELECT id, row_number() OVER (ORDER BY ctid) AS rn FROM bi_audit) a
+ON t.rn = a.rn;
+
+DROP TABLE bi_target_trig CASCADE;
+DROP TABLE bi_audit;
+DROP FUNCTION bi_audit_fn;
+
+-- ============================================================
+-- T4: Index + AFTER ROW trigger combined
+-- ============================================================
+CREATE TABLE bi_target_combo (id int, val text);
+CREATE INDEX bi_target_combo_id ON bi_target_combo (id);
+CREATE TABLE bi_audit_combo (id int, val text);
+
+CREATE FUNCTION bi_audit_combo_fn() RETURNS trigger
+LANGUAGE plpgsql AS $$
+BEGIN
+    INSERT INTO bi_audit_combo (id, val) VALUES (NEW.id, NEW.val);
+    RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER bi_target_combo_after
+    AFTER INSERT ON bi_target_combo
+    FOR EACH ROW EXECUTE FUNCTION bi_audit_combo_fn();
+
+INSERT INTO bi_target_combo
+SELECT g, 'row-' || g FROM generate_series(1, 100) g;
+
+SELECT count(*) FROM bi_target_combo;
+SELECT count(*) FROM bi_audit_combo;
+
+-- Verify index correctness
+SET enable_seqscan = off;
+SELECT count(*) FROM bi_target_combo WHERE id BETWEEN 1 AND 100;
+RESET enable_seqscan;
+
+DROP TABLE bi_target_combo CASCADE;
+DROP TABLE bi_audit_combo;
+DROP FUNCTION bi_audit_combo_fn;
+
+-- ============================================================
+-- T5: ON CONFLICT fallback (uses non-buffered path)
+-- ============================================================
+CREATE TABLE bi_target_conflict (id int PRIMARY KEY, val text);
+
+INSERT INTO bi_target_conflict VALUES (1, 'existing');
+
+INSERT INTO bi_target_conflict
+SELECT g, 'row-' || g FROM generate_series(1, 10) g
+ON CONFLICT (id) DO NOTHING;
+
+SELECT count(*) FROM bi_target_conflict;
+SELECT val FROM bi_target_conflict WHERE id = 1;
+
+DROP TABLE bi_target_conflict;
+
+-- ============================================================
+-- T6: RETURNING fallback (uses non-buffered path)
+-- ============================================================
+CREATE TABLE bi_target_ret (id int, val text);
+
+INSERT INTO bi_target_ret
+SELECT g, 'row-' || g FROM generate_series(1, 3) g
+RETURNING id, val;
+
+SELECT count(*) FROM bi_target_ret;
+
+DROP TABLE bi_target_ret;
+
+-- ============================================================
+-- T7: BEFORE ROW trigger fallback (uses non-buffered path)
+-- ============================================================
+CREATE TABLE bi_target_br (id int, val text);
+
+CREATE FUNCTION bi_br_fn() RETURNS trigger
+LANGUAGE plpgsql AS $$
+BEGIN
+    NEW.val := NEW.val || '-modified';
+    RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER bi_target_br_before
+    BEFORE INSERT ON bi_target_br
+    FOR EACH ROW EXECUTE FUNCTION bi_br_fn();
+
+INSERT INTO bi_target_br
+SELECT g, 'row-' || g FROM generate_series(1, 5) g;
+
+SELECT count(*) FROM bi_target_br;
+SELECT val FROM bi_target_br WHERE id = 1;
+
+DROP TABLE bi_target_br;
+DROP FUNCTION bi_br_fn;
+
+-- ============================================================
+-- T8: Partitioned target fallback (uses non-buffered path)
+-- ============================================================
+CREATE TABLE bi_target_part (id int, val text) PARTITION BY RANGE (id);
+CREATE TABLE bi_target_part_1 PARTITION OF bi_target_part FOR VALUES FROM (1) TO (501);
+CREATE TABLE bi_target_part_2 PARTITION OF bi_target_part FOR VALUES FROM (501) TO (1001);
+
+INSERT INTO bi_target_part
+SELECT g, 'row-' || g FROM generate_series(1, 1000) g;
+
+SELECT count(*) FROM bi_target_part;
+SELECT count(*) FROM bi_target_part_1;
+SELECT count(*) FROM bi_target_part_2;
+
+DROP TABLE bi_target_part;
+
+-- ============================================================
+-- T9: Volatile target-default fallback
+-- Expected to fall back to non-buffered path under E8.
+-- Test validates correctness; path selection is not observable
+-- from SQL output.
+-- ============================================================
+CREATE TABLE bi_target_volatile (
+    id int,
+    val text,
+    rand_val double precision DEFAULT random()
+);
+
+INSERT INTO bi_target_volatile (id, val)
+SELECT g, 'row-' || g FROM generate_series(1, 5) g;
+
+SELECT count(*) FROM bi_target_volatile;
+-- Verify the volatile default was evaluated (all values should be distinct)
+SELECT count(DISTINCT rand_val) = count(*) AS all_distinct
+FROM bi_target_volatile;
+
+DROP TABLE bi_target_volatile;
+
+-- ============================================================
+-- T10: Zero-row insert
+-- ============================================================
+CREATE TABLE bi_target_zero (id int, val text);
+
+INSERT INTO bi_target_zero
+SELECT g, 'row-' || g FROM generate_series(1, 100) g WHERE false;
+
+SELECT count(*) FROM bi_target_zero;
+
+DROP TABLE bi_target_zero;
-- 
2.52.0