From 0f83990fafb1940f5248e61920321542ea79565f Mon Sep 17 00:00:00 2001
From: Amit Langote <amitlan@postgresql.org>
Date: Thu, 16 Jul 2026 18:16:57 +0900
Subject: [PATCH v1 2/6] Fire fast-path FK batches inside the deferred trigger
 loop

AfterTriggerFireDeferred() drains queued after-trigger events in a loop,
then called FireAfterTriggerBatchCallbacks() once after the loop to
flush any RI fast-path FK-check batches the fired triggers accumulated.

A batch callback runs user-supplied cast or equality functions during
the flush, and their DML can queue further deferred trigger events --
for example a cast that inserts into another table with its own deferred
foreign key.  Because the flush ran after the drain loop had already
exited, such an event was left in afterTriggers.events with nothing left
to fire it: AfterTriggerFireDeferred() is the last drainer at commit, so
the event was silently dropped.  The queued deferred FK check was
skipped and a row violating the constraint committed.

Move the flush inside the drain loop, after afterTriggerInvokeEvents(),
and drop the "all fired" break.  The loop condition,
afterTriggerMarkEvents(), now re-checks after each flush, so events
queued by a flush are fired on the next iteration, at the correct time
(transaction end), and the loop terminates only when nothing remains.

The other FireAfterTriggerBatchCallbacks() callers do not have this
problem: AfterTriggerEndQuery() fires immediate triggers and any
deferred event a flush queues there is drained later by
AfterTriggerFireDeferred(); AfterTriggerSetState() (SET CONSTRAINTS)
likewise leaves a queued event for the eventual commit-time
AfterTriggerFireDeferred().  Only the commit-time deferred firing has no
later drainer, so only it lost events.

The fast-path FK check and its batch flush were added by commits
2da86c1ef9 and b7b27eb41a5.  Add a regression test to foreign_key.sql
for the dropped commit-time check.

Reported-by: Noah Misch <noah@leadboat.com>
Discussion: https://postgr.es/m/20260705210533.ee.noahmisch@microsoft.com
Backpatch-through: 19
---
 src/backend/commands/trigger.c            | 20 +++++++++---
 src/test/regress/expected/foreign_key.out | 39 +++++++++++++++++++++++
 src/test/regress/sql/foreign_key.sql      | 38 ++++++++++++++++++++++
 3 files changed, 92 insertions(+), 5 deletions(-)

diff --git a/src/backend/commands/trigger.c b/src/backend/commands/trigger.c
index 401baddbfc6..c41157ce50c 100644
--- a/src/backend/commands/trigger.c
+++ b/src/backend/commands/trigger.c
@@ -5380,12 +5380,22 @@ AfterTriggerFireDeferred(void)
 	{
 		CommandId	firing_id = afterTriggers.firing_counter++;
 
-		if (afterTriggerInvokeEvents(events, firing_id, NULL, true))
-			break;				/* all fired */
-	}
+		afterTriggerInvokeEvents(events, firing_id, NULL, true);
 
-	/* Flush any fast-path batches accumulated by the triggers just fired. */
-	FireAfterTriggerBatchCallbacks(afterTriggers.batch_callbacks);
+		/*
+		 * Flush any fast-path FK-check batches accumulated by the triggers
+		 * just fired.  A batch callback runs user-supplied cast or equality
+		 * functions, whose DML can queue further deferred trigger events.
+		 * Flush inside the loop so afterTriggerMarkEvents() sees any such
+		 * events on the next iteration and fires them; flushing after the
+		 * loop would leave them unfired, silently skipping e.g. a deferred FK
+		 * check and letting a violating row commit.  (The former "all fired"
+		 * break is therefore gone: the loop now terminates only when
+		 * afterTriggerMarkEvents() finds nothing left, including events queued
+		 * by the flush.)
+		 */
+		FireAfterTriggerBatchCallbacks(afterTriggers.batch_callbacks);
+	}
 
 	afterTriggers.firing_depth--;
 
diff --git a/src/test/regress/expected/foreign_key.out b/src/test/regress/expected/foreign_key.out
index c334cce752c..120d3319451 100644
--- a/src/test/regress/expected/foreign_key.out
+++ b/src/test/regress/expected/foreign_key.out
@@ -3604,6 +3604,45 @@ ERROR:  insert or update on table "fp_fk_defer" violates foreign key constraint
 DETAIL:  Key (a)=(3) is not present in table "fp_pk_defer".
 COMMIT;
 DROP TABLE fp_pk_defer, fp_fk_defer;
+-- A deferred FK check queued while firing deferred triggers at commit must
+-- not be lost.  The RI fast-path flush runs during the deferred firing loop
+-- and can run user cast/equality code whose DML queues a further deferred
+-- check; that check must still fire.  fk_defer_main's deferred fast-path check
+-- runs a cast whose function inserts a violating row into fk_defer_t2,
+-- queueing fk_defer_t2's own deferred check, which must still be reported.
+CREATE TABLE fk_defer_t2_pk (id int PRIMARY KEY);
+CREATE TABLE fk_defer_t2 (a int REFERENCES fk_defer_t2_pk(id)
+    DEFERRABLE INITIALLY DEFERRED);
+CREATE TYPE fk_defer_vch AS (v int);
+CREATE FUNCTION fk_defer_cast(fk_defer_vch) RETURNS int
+    LANGUAGE plpgsql AS $$
+BEGIN
+    INSERT INTO fk_defer_t2 VALUES (999);   -- 999 absent, queues deferred check
+    RETURN $1.v;
+END$$;
+CREATE CAST (fk_defer_vch AS int) WITH FUNCTION fk_defer_cast(fk_defer_vch)
+    AS IMPLICIT;
+CREATE TABLE fk_defer_main_pk (id int PRIMARY KEY);
+INSERT INTO fk_defer_main_pk VALUES (1);
+CREATE TABLE fk_defer_main (a fk_defer_vch REFERENCES fk_defer_main_pk(id)
+    DEFERRABLE INITIALLY DEFERRED);
+-- At COMMIT the queued fk_defer_t2 check must fire and report the violation,
+-- rather than being dropped (which would let the dangling row commit).
+BEGIN;
+INSERT INTO fk_defer_main VALUES (row(1)::fk_defer_vch);
+COMMIT;   -- expected: ERROR on fk_defer_t2_a_fkey
+ERROR:  insert or update on table "fk_defer_t2" violates foreign key constraint "fk_defer_t2_a_fkey"
+DETAIL:  Key (a)=(999) is not present in table "fk_defer_t2_pk".
+-- The queued check must fire at transaction end, not early: making the
+-- referenced row present in the same transaction lets the commit succeed.
+INSERT INTO fk_defer_t2_pk VALUES (999);
+BEGIN;
+INSERT INTO fk_defer_main VALUES (row(1)::fk_defer_vch);
+COMMIT;   -- expected: success
+DROP TABLE fk_defer_main, fk_defer_main_pk, fk_defer_t2, fk_defer_t2_pk;
+DROP CAST (fk_defer_vch AS int);
+DROP FUNCTION fk_defer_cast(fk_defer_vch);
+DROP TYPE fk_defer_vch;
 -- Subtransaction abort: cached state must be invalidated on ROLLBACK TO
 CREATE TABLE fp_pk_subxact (a int PRIMARY KEY);
 CREATE TABLE fp_fk_subxact (a int REFERENCES fp_pk_subxact);
diff --git a/src/test/regress/sql/foreign_key.sql b/src/test/regress/sql/foreign_key.sql
index 17eadc4bb5a..b9b88064ea5 100644
--- a/src/test/regress/sql/foreign_key.sql
+++ b/src/test/regress/sql/foreign_key.sql
@@ -2580,6 +2580,44 @@ INSERT INTO fp_fk_defer VALUES (3);  -- should fail, also tests that cache was c
 COMMIT;
 DROP TABLE fp_pk_defer, fp_fk_defer;
 
+-- A deferred FK check queued while firing deferred triggers at commit must
+-- not be lost.  The RI fast-path flush runs during the deferred firing loop
+-- and can run user cast/equality code whose DML queues a further deferred
+-- check; that check must still fire.  fk_defer_main's deferred fast-path check
+-- runs a cast whose function inserts a violating row into fk_defer_t2,
+-- queueing fk_defer_t2's own deferred check, which must still be reported.
+CREATE TABLE fk_defer_t2_pk (id int PRIMARY KEY);
+CREATE TABLE fk_defer_t2 (a int REFERENCES fk_defer_t2_pk(id)
+    DEFERRABLE INITIALLY DEFERRED);
+CREATE TYPE fk_defer_vch AS (v int);
+CREATE FUNCTION fk_defer_cast(fk_defer_vch) RETURNS int
+    LANGUAGE plpgsql AS $$
+BEGIN
+    INSERT INTO fk_defer_t2 VALUES (999);   -- 999 absent, queues deferred check
+    RETURN $1.v;
+END$$;
+CREATE CAST (fk_defer_vch AS int) WITH FUNCTION fk_defer_cast(fk_defer_vch)
+    AS IMPLICIT;
+CREATE TABLE fk_defer_main_pk (id int PRIMARY KEY);
+INSERT INTO fk_defer_main_pk VALUES (1);
+CREATE TABLE fk_defer_main (a fk_defer_vch REFERENCES fk_defer_main_pk(id)
+    DEFERRABLE INITIALLY DEFERRED);
+-- At COMMIT the queued fk_defer_t2 check must fire and report the violation,
+-- rather than being dropped (which would let the dangling row commit).
+BEGIN;
+INSERT INTO fk_defer_main VALUES (row(1)::fk_defer_vch);
+COMMIT;   -- expected: ERROR on fk_defer_t2_a_fkey
+-- The queued check must fire at transaction end, not early: making the
+-- referenced row present in the same transaction lets the commit succeed.
+INSERT INTO fk_defer_t2_pk VALUES (999);
+BEGIN;
+INSERT INTO fk_defer_main VALUES (row(1)::fk_defer_vch);
+COMMIT;   -- expected: success
+DROP TABLE fk_defer_main, fk_defer_main_pk, fk_defer_t2, fk_defer_t2_pk;
+DROP CAST (fk_defer_vch AS int);
+DROP FUNCTION fk_defer_cast(fk_defer_vch);
+DROP TYPE fk_defer_vch;
+
 -- Subtransaction abort: cached state must be invalidated on ROLLBACK TO
 CREATE TABLE fp_pk_subxact (a int PRIMARY KEY);
 CREATE TABLE fp_fk_subxact (a int REFERENCES fp_pk_subxact);
-- 
2.47.3

