Skip to content

Commit bfb18f4

Browse files
committed
Restore firing state at subtransaction end
AfterTriggerEndQuery(), AfterTriggerFireDeferred(), and AfterTriggerSetState() bracket their firing loops with firing_depth++/--, and FireAfterTriggerBatchCallbacks() brackets its loop with firing_batch_callbacks. The closing step runs after the loop and is not protected by PG_FINALLY, so if a trigger or batch callback throws and the error is caught by a subtransaction (e.g. a PL/pgSQL EXCEPTION block), firing_depth is left too high and/or firing_batch_callbacks is left set for the rest of the transaction. firing_depth feeds AfterTriggerIsActive(), which the RI fast path uses to decide whether an FK check is running inside trigger firing (and may batch). firing_depth left too high makes AfterTriggerIsActive() wrongly report firing as active afterwards. This is reachable and results in silent data corruption: after a caught FK-check error, an ALTER TABLE ... ADD FOREIGN KEY whose validation runs per-row (RI_Initial_Check() having bailed, e.g. because RLS is enabled on the referenced table) calls RI_FKey_check() with AfterTriggerIsActive() wrongly true, so the check is routed into the batched fast path. A utility command has no AfterTriggerEndQuery() to fire the flush callback, so the batch is never flushed: the violating row is not reported, the constraint is marked validated, and the cached PK relation and index leak. Fix by restoring firing_depth and firing_batch_callbacks in AfterTriggerEndSubXact() to the values saved at subtransaction start, next to the existing query_depth handling. Restoring (rather than zeroing/clearing) is required because a subtransaction can begin and end while an outer query is firing, where firing_depth is legitimately positive and firing_batch_callbacks legitimately set; forcing them to 0/false there breaks the outer firing (FireAfterTriggerBatchCallbacks() asserts firing_depth > 0). Reported-by: Noah Misch <noah@leadboat.com> Discussion: https://postgr.es/m/20260705222115.be.noahmisch@microsoft.com Backpatch-through: 19
1 parent 242899b commit bfb18f4

3 files changed

Lines changed: 106 additions & 2 deletions

File tree

src/backend/commands/trigger.c

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3946,6 +3946,8 @@ struct AfterTriggersTransData
39463946
SetConstraintState state; /* saved S C state, or NULL if not yet saved */
39473947
AfterTriggerEventList events; /* saved list pointer */
39483948
int query_depth; /* saved query_depth */
3949+
int firing_depth; /* saved firing_depth */
3950+
bool firing_batch_callbacks; /* saved firing_batch_callbacks */
39493951
CommandId firing_counter; /* saved firing_counter */
39503952
};
39513953

@@ -5507,6 +5509,9 @@ AfterTriggerBeginSubXact(void)
55075509
afterTriggers.trans_stack[my_level].state = NULL;
55085510
afterTriggers.trans_stack[my_level].events = afterTriggers.events;
55095511
afterTriggers.trans_stack[my_level].query_depth = afterTriggers.query_depth;
5512+
afterTriggers.trans_stack[my_level].firing_depth = afterTriggers.firing_depth;
5513+
afterTriggers.trans_stack[my_level].firing_batch_callbacks =
5514+
afterTriggers.firing_batch_callbacks;
55105515
afterTriggers.trans_stack[my_level].firing_counter = afterTriggers.firing_counter;
55115516
}
55125517

@@ -5607,8 +5612,27 @@ AfterTriggerEndSubXact(bool isCommit)
56075612
}
56085613
}
56095614

5610-
/* Reset in case a callback threw an error while firing. */
5611-
afterTriggers.firing_batch_callbacks = false;
5615+
/*
5616+
* Restore firing_depth and firing_batch_callbacks to their values at
5617+
* subtransaction start. The matching decrement of firing_depth in
5618+
* AfterTriggerEndQuery()/AfterTriggerFireDeferred(), and the clearing of
5619+
* firing_batch_callbacks in FireAfterTriggerBatchCallbacks(), run after
5620+
* their loops and are not protected by PG_FINALLY. A trigger or batch
5621+
* callback error caught by this subtransaction can therefore leave either
5622+
* one set; restoring the saved values unwinds only this subtransaction's
5623+
* firing.
5624+
*
5625+
* Restoring (rather than zeroing/clearing) matters because a subtransaction
5626+
* can begin and end while an outer query's triggers are firing -- for
5627+
* instance a batch callback whose user-supplied cast or equality function
5628+
* runs DML in a BEGIN ... EXCEPTION block. There firing_depth is positive
5629+
* and firing_batch_callbacks is true; forcing them to 0/false would corrupt
5630+
* the outer firing (FireAfterTriggerBatchCallbacks() asserts firing_depth
5631+
* > 0, and clearing the guard would defeat its re-entrancy check).
5632+
*/
5633+
afterTriggers.firing_depth = afterTriggers.trans_stack[my_level].firing_depth;
5634+
afterTriggers.firing_batch_callbacks =
5635+
afterTriggers.trans_stack[my_level].firing_batch_callbacks;
56125636
}
56135637

56145638
/*

src/test/regress/expected/foreign_key.out

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3864,3 +3864,44 @@ SELECT count(*) AS deferred_rows FROM fp_deferred_fk; -- 1, check passed at com
38643864
(1 row)
38653865

38663866
DROP TABLE fp_deferred_fk, fp_deferred_pk;
3867+
-- Stranded firing state must not misroute ALTER TABLE ... ADD FOREIGN KEY
3868+
-- validation into the batched fast path. A caught FK-check error inside a
3869+
-- subtransaction leaves firing_depth set (its decrement is skipped); a
3870+
-- following ALTER whose validation runs per-row (forced here by RLS on the
3871+
-- referenced table, so RI_Initial_Check() bails) would then be wrongly treated
3872+
-- as running inside trigger firing, batched, and never flushed (a utility
3873+
-- command has no AfterTriggerEndQuery), silently validating a violating row.
3874+
CREATE ROLE regress_fpav_role;
3875+
CREATE TABLE fpav_pk (id int PRIMARY KEY);
3876+
INSERT INTO fpav_pk VALUES (1);
3877+
ALTER TABLE fpav_pk ENABLE ROW LEVEL SECURITY;
3878+
CREATE POLICY fpav_pk_all ON fpav_pk FOR ALL USING (true) WITH CHECK (true);
3879+
GRANT REFERENCES, SELECT ON fpav_pk TO regress_fpav_role;
3880+
CREATE TABLE fpav_fk (a int);
3881+
INSERT INTO fpav_fk VALUES (1), (99);
3882+
ALTER TABLE fpav_fk OWNER TO regress_fpav_role;
3883+
CREATE TABLE fpav_cv_pk (id int PRIMARY KEY);
3884+
INSERT INTO fpav_cv_pk VALUES (1);
3885+
CREATE TABLE fpav_cv_fk (a int REFERENCES fpav_cv_pk(id));
3886+
GRANT INSERT ON fpav_cv_fk TO regress_fpav_role;
3887+
GRANT SELECT, INSERT ON fpav_cv_pk TO regress_fpav_role;
3888+
SET ROLE regress_fpav_role;
3889+
BEGIN;
3890+
-- Caught FK violation: leaves firing_depth set if it is not restored.
3891+
DO $$
3892+
BEGIN
3893+
BEGIN
3894+
INSERT INTO fpav_cv_fk VALUES (999);
3895+
EXCEPTION WHEN foreign_key_violation THEN
3896+
NULL;
3897+
END;
3898+
END$$;
3899+
-- Must ERROR on the violating row (99), not silently validate it.
3900+
ALTER TABLE fpav_fk ADD CONSTRAINT fpav_fk_fkey
3901+
FOREIGN KEY (a) REFERENCES fpav_pk (id);
3902+
ERROR: insert or update on table "fpav_fk" violates foreign key constraint "fpav_fk_fkey"
3903+
DETAIL: Key (a)=(99) is not present in table "fpav_pk".
3904+
ROLLBACK;
3905+
RESET ROLE;
3906+
DROP TABLE fpav_fk, fpav_pk, fpav_cv_fk, fpav_cv_pk;
3907+
DROP ROLE regress_fpav_role;

src/test/regress/sql/foreign_key.sql

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2807,3 +2807,42 @@ INSERT INTO fp_deferred_pk VALUES (1);
28072807
COMMIT;
28082808
SELECT count(*) AS deferred_rows FROM fp_deferred_fk; -- 1, check passed at commit
28092809
DROP TABLE fp_deferred_fk, fp_deferred_pk;
2810+
-- Stranded firing state must not misroute ALTER TABLE ... ADD FOREIGN KEY
2811+
-- validation into the batched fast path. A caught FK-check error inside a
2812+
-- subtransaction leaves firing_depth set (its decrement is skipped); a
2813+
-- following ALTER whose validation runs per-row (forced here by RLS on the
2814+
-- referenced table, so RI_Initial_Check() bails) would then be wrongly treated
2815+
-- as running inside trigger firing, batched, and never flushed (a utility
2816+
-- command has no AfterTriggerEndQuery), silently validating a violating row.
2817+
CREATE ROLE regress_fpav_role;
2818+
CREATE TABLE fpav_pk (id int PRIMARY KEY);
2819+
INSERT INTO fpav_pk VALUES (1);
2820+
ALTER TABLE fpav_pk ENABLE ROW LEVEL SECURITY;
2821+
CREATE POLICY fpav_pk_all ON fpav_pk FOR ALL USING (true) WITH CHECK (true);
2822+
GRANT REFERENCES, SELECT ON fpav_pk TO regress_fpav_role;
2823+
CREATE TABLE fpav_fk (a int);
2824+
INSERT INTO fpav_fk VALUES (1), (99);
2825+
ALTER TABLE fpav_fk OWNER TO regress_fpav_role;
2826+
CREATE TABLE fpav_cv_pk (id int PRIMARY KEY);
2827+
INSERT INTO fpav_cv_pk VALUES (1);
2828+
CREATE TABLE fpav_cv_fk (a int REFERENCES fpav_cv_pk(id));
2829+
GRANT INSERT ON fpav_cv_fk TO regress_fpav_role;
2830+
GRANT SELECT, INSERT ON fpav_cv_pk TO regress_fpav_role;
2831+
SET ROLE regress_fpav_role;
2832+
BEGIN;
2833+
-- Caught FK violation: leaves firing_depth set if it is not restored.
2834+
DO $$
2835+
BEGIN
2836+
BEGIN
2837+
INSERT INTO fpav_cv_fk VALUES (999);
2838+
EXCEPTION WHEN foreign_key_violation THEN
2839+
NULL;
2840+
END;
2841+
END$$;
2842+
-- Must ERROR on the violating row (99), not silently validate it.
2843+
ALTER TABLE fpav_fk ADD CONSTRAINT fpav_fk_fkey
2844+
FOREIGN KEY (a) REFERENCES fpav_pk (id);
2845+
ROLLBACK;
2846+
RESET ROLE;
2847+
DROP TABLE fpav_fk, fpav_pk, fpav_cv_fk, fpav_cv_pk;
2848+
DROP ROLE regress_fpav_role;

0 commit comments

Comments
 (0)