1
0
mirror of https://github.com/postgres/postgres.git synced 2026-10-23 13:29:09 +03:00

Fire fast-path FK batches inside the deferred trigger loop

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

A batch callback runs user-supplied cast or equality functions, whose
DML can queue further deferred trigger events.  Because the flush ran
after the loop had exited, such an event was left in
afterTriggers.events with nothing to fire it, since
AfterTriggerFireDeferred() is the last drainer at commit.  The deferred
check was skipped and a row violating the constraint committed.

Move the flush inside the loop, after afterTriggerInvokeEvents(), and
drop the "all fired" break, so afterTriggerMarkEvents() re-checks after
each flush and fires events a flush queued at the correct time.  The
other FireAfterTriggerBatchCallbacks() callers leave any queued event
for the eventual commit-time AfterTriggerFireDeferred(), so only the
commit-time firing, which has no later drainer, lost events.

Reported-by: Noah Misch <noah@leadboat.com>
Reviewed-by: Ayush Tiwari <ayushtiwari.slg01@gmail.com>
Discussion: https://postgr.es/m/20260705210533.ee.noahmisch@microsoft.com
Backpatch-through: 19
This commit is contained in:
Amit Langote
2026-08-07 17:25:42 +09:00
parent 7c1c2f0b8d
commit 291a4bd2ca
3 changed files with 92 additions and 5 deletions

View File

@@ -5380,12 +5380,22 @@ AfterTriggerFireDeferred(void)
{
CommandId firing_id = afterTriggers.firing_counter++;
if (afterTriggerInvokeEvents(events, firing_id, NULL, true))
break; /* all fired */
}
(void) 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--;

View File

@@ -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);

View File

@@ -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);