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
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