From c62ba1c3ef280221dec9d53796cf8d652841748a Mon Sep 17 00:00:00 2001 From: Amit Langote Date: Fri, 23 Oct 2026 18:31:06 +0900 Subject: [PATCH v10-REL_14_STABLE] Don't free a cached RI plan that may be in use Currently, ri_FetchPreparedPlan() immediately frees a cached SPI plan if SPI_plan_is_valid() returns false, without tracking whether the plan might be in use. That can cause _SPI_execute_plan() to read freed memory. This happens, for example, when an RI check's or action's query runs some user-defined code such as a cast function or BEFORE ROW trigger function which may perform its own DML using its own query that is not restricted from firing its own triggers, some of which may be RI triggers for the same constraint as the outer RI check and hence use the same cached plan. If an invalidation arrives that marks the cached plans referenced by the outer check's SPI plan invalid between the outer check having called _SPI_execute_plan() and the inner check's ri_FetchPreparedPlan(), the latter will free the SPI plan as mentioned above. Lucas Jeffrey reported an instance of this bug involving a self-referencing ON DELETE CASCADE foreign key. The cascade's DELETE fires a BEFORE DELETE trigger on the rows it deletes, which runs while the cascade's RI plan is executing. The trigger invalidates that plan with GRANT and REVOKE on the table and then deletes more rows itself. That DELETE's cascade fires when it finishes, still inside the trigger and so inside the outer cascade's execution, and its ri_FetchPreparedPlan() finds the shared plan invalid and frees it. The outer cascade crashes when its execution resumes. Separately, as Tom Lane reported, buildfarm member trilobite showed that a nested RI check for the same constraint running due to a cast function running as part of the query for an outer RI check would free the SPI plan used by the latter because CLOBBER_CACHE_ALWAYS causes it to be predictably invalidated. This trilobite failure came after 6aecabcd5ed made the RI checks that require cast functions to be used go through the SPI path, because the fast path didn't have the same issue as it didn't use SPI plans to begin with. Fix by keeping a stack of the plans that every ri_PerformCheck() call in progress is executing, pushed before SPI_execute_snapshot() and popped whether it returns or throws an error, the latter using PG_FINALLY(). ri_FetchPreparedPlan() now frees an invalid plan on the spot when it is not on that stack, as before, since nothing can be executing it. Otherwise it only detaches the plan from its hash entry and queues it on a list. Whenever a check finishes, the queued plans that are no longer on the stack are freed. So a plan is held only while a check is actually executing it, however many invalidations happen meanwhile. A new AtEOXact_RI() frees whatever is left. Add the test from Lucas's original patch, which reproduces the crash on an ordinary cassert build, and a test that raises errors from inside a check's query, some of them inside subtransactions that are then rolled back, to check that the stack stays exact. Author: Lucas Jeffrey Co-authored-by: Trakshan Mishra Co-authored-by: Amit Langote Reported-by: Lucas Jeffrey Reported-by: Tom Lane Reviewed-by: Tomas Vondra Discussion: https://postgr.es/m/3043944.1792163844@sss.pgh.pa.us Discussion: https://postgr.es/m/CAGHzy7S0XfXdK3amd5c4QhpS1o9dySVzPr+-d9ohaE4iV_vNog@mail.gmail.com Backpatch-through: 14 --- src/backend/access/transam/xact.c | 3 + src/backend/utils/adt/ri_triggers.c | 205 +++++++++++++++++++++- src/include/commands/trigger.h | 1 + src/test/regress/expected/foreign_key.out | 156 ++++++++++++++++ src/test/regress/sql/foreign_key.sql | 125 +++++++++++++ 5 files changed, 482 insertions(+), 8 deletions(-) diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c index ef0fe7a5361..35ac4e65438 100644 --- a/src/backend/access/transam/xact.c +++ b/src/backend/access/transam/xact.c @@ -2301,6 +2301,7 @@ CommitTransaction(void) AtEOXact_Files(true); AtEOXact_ComboCid(); AtEOXact_HashTables(true); + AtEOXact_RI(true); AtEOXact_PgStat(true, is_parallel_worker); AtEOXact_Snapshot(true, false); AtEOXact_ApplyLauncher(true); @@ -2589,6 +2590,7 @@ PrepareTransaction(void) AtEOXact_Files(true); AtEOXact_ComboCid(); AtEOXact_HashTables(true); + AtEOXact_RI(true); /* don't call AtEOXact_PgStat here; we fixed pgstat state above */ AtEOXact_Snapshot(true, true); pgstat_report_xact_timestamp(0); @@ -2802,6 +2804,7 @@ AbortTransaction(void) AtEOXact_Files(false); AtEOXact_ComboCid(); AtEOXact_HashTables(false); + AtEOXact_RI(false); AtEOXact_PgStat(false, is_parallel_worker); AtEOXact_ApplyLauncher(false); pgstat_report_xact_timestamp(0); diff --git a/src/backend/utils/adt/ri_triggers.c b/src/backend/utils/adt/ri_triggers.c index 96269fc2adb..75c77aaf881 100644 --- a/src/backend/utils/adt/ri_triggers.c +++ b/src/backend/utils/adt/ri_triggers.c @@ -162,6 +162,26 @@ typedef struct RI_CompareHashEntry FmgrInfo cast_func_finfo; /* in case we must coerce input */ } RI_CompareHashEntry; +/* + * Stack of the plans being executed by ri_PerformCheck() calls currently + * running, i.e. RI queries whose SPI_execute_snapshot() has not returned. + * Innermost of those appears last in the array. A plan that + * ri_FetchPreparedPlan() finds invalid cannot be freed while it is on this + * stack. It instead goes on ri_plan_dead_list and is freed by + * ri_FreeDeadPlans() once it is no longer on the stack, which is checked + * whenever a check finishes normally, or by AtEOXact_RI() at the latest. + * Both live in TopMemoryContext. + * + * ri_PerformCheck() pops its plan whether the query returns or an error + * is thrown, by restoring the depth it saw at push time, so the stack is + * exact at all times. A plan on the stack is never freed until the stack + * is cut back past it, so no entry can dangle. + */ +static SPIPlanPtr *ri_executing_plans = NULL; +static int ri_executing_depth = 0; +static int ri_executing_maxdepth = 0; +static List *ri_plan_dead_list = NIL; + /* * Local data @@ -203,6 +223,9 @@ static void ri_InitHashTables(void); static void InvalidateConstraintCacheCallBack(Datum arg, int cacheid, uint32 hashvalue); static SPIPlanPtr ri_FetchPreparedPlan(RI_QueryKey *key); static void ri_HashPreparedPlan(RI_QueryKey *key, SPIPlanPtr plan); +static int ri_PushExecutingPlan(SPIPlanPtr plan); +static bool ri_PlanIsExecuting(SPIPlanPtr plan); +static void ri_FreeDeadPlans(void); static RI_CompareHashEntry *ri_HashCompareOp(Oid eq_opr, Oid typeid); static void ri_CheckTrigger(FunctionCallInfo fcinfo, const char *funcname, @@ -2251,6 +2274,7 @@ ri_PerformCheck(const RI_ConstraintInfo *riinfo, Snapshot crosscheck_snapshot; int limit; int spi_result; + int exec_depth; Oid save_userid; int save_sec_context; Datum vals[RI_MAX_NUMKEYS * 2]; @@ -2336,15 +2360,44 @@ ri_PerformCheck(const RI_ConstraintInfo *riinfo, save_sec_context | SECURITY_LOCAL_USERID_CHANGE | SECURITY_NOFORCE_RLS); - /* Finally we can run the query. */ - spi_result = SPI_execute_snapshot(qplan, - vals, nulls, - test_snapshot, crosscheck_snapshot, - false, false, limit); + /* + * Finally we can run the query. + * + * The plan is on the stack of executing plans while the query runs, so + * that a nested check finding it invalid does not free it; see + * ri_FetchPreparedPlan(). Pop it even if the query throws an error, so + * that, whatever catches the error, the stack holds only the plans still + * executing. We don't free anything in the finally block, so if the query + * throws an error, whatever was queued while it ran stays queued. The + * next check that finishes normally frees it, once no check is executing + * it, or at the latest AtEOXact_RI() does. + */ + exec_depth = ri_PushExecutingPlan(qplan); + PG_TRY(); + { + spi_result = SPI_execute_snapshot(qplan, + vals, nulls, + test_snapshot, crosscheck_snapshot, + false, false, limit); + } + PG_FINALLY(); + { + /* Pop qplan, whether the query returned normally or threw an error. */ + ri_executing_depth = exec_depth; + } + PG_END_TRY(); /* Restore UID and security context */ SetUserIdAndSecContext(save_userid, save_sec_context); + /* + * Free the queued plans that no check is executing any more, which may + * include qplan itself now that it is popped. Checks further up the + * stack may still be executing others, which stay queued. + */ + if (ri_plan_dead_list != NIL) + ri_FreeDeadPlans(); + /* Check result */ if (spi_result < 0) elog(ERROR, "SPI_execute_snapshot returned %s", SPI_result_code_string(spi_result)); @@ -2670,12 +2723,32 @@ ri_FetchPreparedPlan(RI_QueryKey *key) return plan; /* - * Otherwise we might as well flush the cached plan now, to free a little - * memory space before we make a new one. + * Otherwise, detach the stale plan from the entry so that the caller's + * replacement can be hashed under the same key, and free it if we can. + * + * We can't if an RI check further up the stack is executing this very + * plan. The check's query can run user-defined code (a cast function + * applied to the FK values, or a trigger fired by a cascaded update or + * delete, say) whose DML fires another RI check for the same constraint, + * and hence the same query key, before the outer SPI_execute_snapshot() + * returns. If the plan has been invalidated in between, freeing it here + * would pull the SPIPlan out from under the outer _SPI_execute_plan(). So + * in that case queue it on ri_plan_dead_list, to be freed once no check + * is in progress. */ entry->plan = NULL; if (plan) - SPI_freeplan(plan); + { + if (!ri_PlanIsExecuting(plan)) + SPI_freeplan(plan); + else + { + MemoryContext oldcxt = MemoryContextSwitchTo(TopMemoryContext); + + ri_plan_dead_list = lappend(ri_plan_dead_list, plan); + MemoryContextSwitchTo(oldcxt); + } + } return NULL; } @@ -2709,6 +2782,90 @@ ri_HashPreparedPlan(RI_QueryKey *key, SPIPlanPtr plan) entry->plan = plan; } +/* + * ri_PushExecutingPlan - + * + * Record that ri_PerformCheck() is about to execute plan. Returns the depth + * of the stack before the push; the caller pops by setting + * ri_executing_depth back to it when SPI_execute_snapshot() returns or + * throws an error. + */ +static int +ri_PushExecutingPlan(SPIPlanPtr plan) +{ + int depth = ri_executing_depth; + + if (ri_executing_depth >= ri_executing_maxdepth) + { + int newmax = Max(8, ri_executing_maxdepth * 2); + + if (ri_executing_plans == NULL) + ri_executing_plans = (SPIPlanPtr *) + MemoryContextAlloc(TopMemoryContext, + newmax * sizeof(SPIPlanPtr)); + else + ri_executing_plans = (SPIPlanPtr *) + repalloc(ri_executing_plans, newmax * sizeof(SPIPlanPtr)); + ri_executing_maxdepth = newmax; + } + ri_executing_plans[ri_executing_depth++] = plan; + + return depth; +} + +/* + * ri_PlanIsExecuting - + * + * Is plan being executed by an ri_PerformCheck() call in progress? The + * stack is as deep as RI checks nest, so a linear scan is fine. + */ +static bool +ri_PlanIsExecuting(SPIPlanPtr plan) +{ + for (int i = 0; i < ri_executing_depth; i++) + { + if (ri_executing_plans[i] == plan) + return true; + } + return false; +} + +/* + * ri_FreeDeadPlans - + * + * Free the plans queued on ri_plan_dead_list that no RI check is executing + * any more, keeping the others queued. With the stack empty, that is all of + * them. + */ +static void +ri_FreeDeadPlans(void) +{ + List *plans = ri_plan_dead_list; + ListCell *lc; + + /* + * Detach the list from the static before freeing, so that an error + * while freeing a plan does not leave a dangling pointer to a half-freed + * plan in it. Not expected, but we may be called from the abort path, + * where no further cleanup pass would come to fix that up. + */ + ri_plan_dead_list = NIL; + + foreach(lc, plans) + { + SPIPlanPtr plan = (SPIPlanPtr) lfirst(lc); + + if (ri_PlanIsExecuting(plan)) + continue; + SPI_freeplan(plan); + plans = foreach_delete_current(plans, lc); + } + + /* Put back whatever is still executing, if any. */ + Assert(plans == NIL || ri_executing_depth > 0); + ri_plan_dead_list = plans; +} + /* * ri_KeysEqual - @@ -2950,3 +3107,35 @@ RI_FKey_trigger_type(Oid tgfoid) return RI_TRIGGER_NONE; } + +/* + * AtEOXact_RI + * End-of-transaction cleanup for referential integrity. + * + * Release the SPI plans that ri_FetchPreparedPlan() found invalid while a + * check was executing them and queued on ri_plan_dead_list. These are + * normally freed as soon as the check executing them finishes; we only see + * them here if an error interrupted that check and no later check finished + * normally in the transaction. Here no RI check can be in progress, so they + * can be released. isCommit is accepted for consistency with the other + * AtEOXact_* routines but is not used: the release is the same on the commit + * and the abort path. + * + * There is no AtEOSubXact_RI() counterpart. Nothing here is scoped to a + * subtransaction: a detached plan stays reachable from the dead list + * whichever subtransaction detached it, and a check executing it may be + * running at an outer level, so releasing at subtransaction end would be + * unsafe as well as unnecessary. + */ +void +AtEOXact_RI(bool isCommit) +{ + /* + * No RI check can be in progress here, so clear the stack. Normally + * ri_PerformCheck() has already popped every entry, but a FATAL error + * skips those pops. + */ + ri_executing_depth = 0; + if (ri_plan_dead_list != NIL) + ri_FreeDeadPlans(); +} diff --git a/src/include/commands/trigger.h b/src/include/commands/trigger.h index a97a2ef805f..d99e214ce9c 100644 --- a/src/include/commands/trigger.h +++ b/src/include/commands/trigger.h @@ -273,5 +273,6 @@ extern void RI_PartitionRemove_Check(Trigger *trigger, Relation fk_rel, #define RI_TRIGGER_NONE 0 /* is not an RI trigger function */ extern int RI_FKey_trigger_type(Oid tgfoid); +extern void AtEOXact_RI(bool isCommit); #endif /* TRIGGER_H */ diff --git a/src/test/regress/expected/foreign_key.out b/src/test/regress/expected/foreign_key.out index 44be1c565dd..30ee9173c54 100644 --- a/src/test/regress/expected/foreign_key.out +++ b/src/test/regress/expected/foreign_key.out @@ -2800,3 +2800,159 @@ SET client_min_messages TO warning; DROP SCHEMA fkpart12 CASCADE; RESET client_min_messages; RESET search_path; +-- Re-entrant ON DELETE CASCADE whose trigger invalidates the cached RI plan. +-- The cascade for id = 2 runs a BEFORE DELETE trigger that does GRANT/REVOKE +-- on the table, invalidating the cached plan the cascade is executing, and +-- then deletes more rows, whose own cascade fetches that plan again. The +-- inner fetch must not free the plan out from under the outer cascade. +-- Unlike the case above, this reproduces the crash without cache clobbering. +CREATE TABLE fk_self_ref ( + id int PRIMARY KEY, + parent_id int REFERENCES fk_self_ref (id) ON DELETE CASCADE +); +CREATE FUNCTION fk_self_ref_before_del() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF OLD.id = 2 THEN + GRANT SELECT ON fk_self_ref TO PUBLIC; + REVOKE SELECT ON fk_self_ref FROM PUBLIC; + DELETE FROM fk_self_ref WHERE parent_id = OLD.id; + ELSIF OLD.id = 4 AND current_setting('fk_self_ref.fail', true) = 'on' THEN + RAISE EXCEPTION 'fk_self_ref: deleting %', OLD.id; + END IF; + RETURN OLD; +END$$; +CREATE TRIGGER fk_self_ref_before_del BEFORE DELETE ON fk_self_ref + FOR EACH ROW EXECUTE PROCEDURE fk_self_ref_before_del(); +INSERT INTO fk_self_ref VALUES + (1, NULL), (2, 1), (3, 2), (4, 3), (5, 4), (10, NULL), (11, 10); +DELETE FROM fk_self_ref WHERE id = 1; +SELECT * FROM fk_self_ref ORDER BY id; + id | parent_id +----+----------- + 10 | + 11 | 10 +(2 rows) + +-- The same with the nested cascade raising an error while the plans are in +-- use, repeatedly, then a normal delete after another invalidation. +INSERT INTO fk_self_ref VALUES (1, NULL), (2, 1), (3, 2), (4, 3), (5, 4); +SET fk_self_ref.fail = on; +DO $$ +DECLARE + failures int := 0; +BEGIN + FOR i IN 1..10 LOOP + BEGIN + DELETE FROM fk_self_ref WHERE id = 1; + EXCEPTION WHEN raise_exception THEN + failures := failures + 1; + END; + END LOOP; + RAISE NOTICE 'failures: %', failures; +END$$; +NOTICE: failures: 10 +SELECT * FROM fk_self_ref ORDER BY id; + id | parent_id +----+----------- + 1 | + 2 | 1 + 3 | 2 + 4 | 3 + 5 | 4 + 10 | + 11 | 10 +(7 rows) + +RESET fk_self_ref.fail; +GRANT SELECT ON fk_self_ref TO PUBLIC; +REVOKE SELECT ON fk_self_ref FROM PUBLIC; +DELETE FROM fk_self_ref WHERE id = 1; +SELECT * FROM fk_self_ref ORDER BY id; + id | parent_id +----+----------- + 10 | + 11 | 10 +(2 rows) + +DROP TABLE fk_self_ref; +DROP FUNCTION fk_self_ref_before_del(); +-- Errors raised from inside an RI check's query, as opposed to by the check +-- itself afterwards, must leave the stack of executing RI plans exact (an +-- assertion at end of transaction checks that), and a subtransaction abort +-- in the middle of all that must not disturb the plans queued to be freed. +-- The cast raises for v = 97, so a check on row(97) errors out while its +-- plan is being executed. For v = 1 it first invalidates the plan of the +-- check calling it, so that the nested check on row(97) finds that plan +-- invalid while it is executing and queues it, and then errors out, which +-- the cast catches: a subtransaction abort inside the outer check, with the +-- outer check's plan queued. The same error is then caught above every +-- check, by a PL/pgSQL exception block and by ROLLBACK TO SAVEPOINT; more +-- checks and a commit follow each. +CREATE ROLE regress_fk_err_role; +CREATE TABLE fk_err_pk (id int PRIMARY KEY); +INSERT INTO fk_err_pk VALUES (1), (2); +CREATE TYPE fk_err_vch AS (v int); +CREATE FUNCTION fk_err_vcast(fk_err_vch) RETURNS int LANGUAGE plpgsql AS $$ +BEGIN + IF $1.v = 97 THEN + RAISE EXCEPTION 'fk_err_vcast: %', $1.v; + END IF; + IF $1.v = 1 THEN + IF current_setting('fk_err.flip', true) = 'on' THEN + GRANT SELECT ON fk_err_pk TO regress_fk_err_role; + PERFORM set_config('fk_err.flip', 'off', true); + ELSE + REVOKE SELECT ON fk_err_pk FROM regress_fk_err_role; + PERFORM set_config('fk_err.flip', 'on', true); + END IF; + BEGIN + INSERT INTO fk_err_fk VALUES (row(97)::fk_err_vch); + EXCEPTION WHEN raise_exception THEN NULL; + END; + END IF; + RETURN $1.v; +END$$; +CREATE CAST (fk_err_vch AS int) WITH FUNCTION fk_err_vcast(fk_err_vch) AS IMPLICIT; +CREATE TABLE fk_err_fk (a fk_err_vch REFERENCES fk_err_pk (id)); +-- caught inside the outer check +INSERT INTO fk_err_fk SELECT row(1)::fk_err_vch FROM generate_series(1, 3); +-- caught above every check, then more checks in the same transaction +BEGIN; +DO $$ +BEGIN + FOR i IN 1..3 LOOP + BEGIN + INSERT INTO fk_err_fk VALUES (row(97)::fk_err_vch); + EXCEPTION WHEN raise_exception THEN NULL; + END; + END LOOP; +END$$; +INSERT INTO fk_err_fk SELECT row(2)::fk_err_vch FROM generate_series(1, 3); +COMMIT; +-- the same with savepoints, including one rolled back after the check whose +-- cast caught the error had already succeeded +BEGIN; +SAVEPOINT s1; +INSERT INTO fk_err_fk VALUES (row(97)::fk_err_vch); +ERROR: fk_err_vcast: 97 +CONTEXT: PL/pgSQL function fk_err_vcast(fk_err_vch) line 4 at RAISE +SQL statement "SELECT 1 FROM ONLY "public"."fk_err_pk" x WHERE "id" OPERATOR(pg_catalog.=) $1::pg_catalog.int4 FOR KEY SHARE OF x" +ROLLBACK TO SAVEPOINT s1; +INSERT INTO fk_err_fk SELECT row(2)::fk_err_vch FROM generate_series(1, 3); +SAVEPOINT s2; +INSERT INTO fk_err_fk SELECT row(1)::fk_err_vch FROM generate_series(1, 3); +ROLLBACK TO SAVEPOINT s2; +INSERT INTO fk_err_fk SELECT row(1)::fk_err_vch FROM generate_series(1, 3); +COMMIT; +SELECT a, count(*) FROM fk_err_fk GROUP BY a ORDER BY a; + a | count +-----+------- + (1) | 6 + (2) | 6 +(2 rows) + +DROP TABLE fk_err_fk, fk_err_pk; +DROP CAST (fk_err_vch AS int); +DROP FUNCTION fk_err_vcast(fk_err_vch); +DROP TYPE fk_err_vch; +DROP ROLE regress_fk_err_role; diff --git a/src/test/regress/sql/foreign_key.sql b/src/test/regress/sql/foreign_key.sql index 2590df3c5e1..27a81ef9300 100644 --- a/src/test/regress/sql/foreign_key.sql +++ b/src/test/regress/sql/foreign_key.sql @@ -1986,3 +1986,128 @@ SET client_min_messages TO warning; DROP SCHEMA fkpart12 CASCADE; RESET client_min_messages; RESET search_path; + +-- Re-entrant ON DELETE CASCADE whose trigger invalidates the cached RI plan. +-- The cascade for id = 2 runs a BEFORE DELETE trigger that does GRANT/REVOKE +-- on the table, invalidating the cached plan the cascade is executing, and +-- then deletes more rows, whose own cascade fetches that plan again. The +-- inner fetch must not free the plan out from under the outer cascade. +-- Unlike the case above, this reproduces the crash without cache clobbering. +CREATE TABLE fk_self_ref ( + id int PRIMARY KEY, + parent_id int REFERENCES fk_self_ref (id) ON DELETE CASCADE +); +CREATE FUNCTION fk_self_ref_before_del() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF OLD.id = 2 THEN + GRANT SELECT ON fk_self_ref TO PUBLIC; + REVOKE SELECT ON fk_self_ref FROM PUBLIC; + DELETE FROM fk_self_ref WHERE parent_id = OLD.id; + ELSIF OLD.id = 4 AND current_setting('fk_self_ref.fail', true) = 'on' THEN + RAISE EXCEPTION 'fk_self_ref: deleting %', OLD.id; + END IF; + RETURN OLD; +END$$; +CREATE TRIGGER fk_self_ref_before_del BEFORE DELETE ON fk_self_ref + FOR EACH ROW EXECUTE PROCEDURE fk_self_ref_before_del(); +INSERT INTO fk_self_ref VALUES + (1, NULL), (2, 1), (3, 2), (4, 3), (5, 4), (10, NULL), (11, 10); +DELETE FROM fk_self_ref WHERE id = 1; +SELECT * FROM fk_self_ref ORDER BY id; +-- The same with the nested cascade raising an error while the plans are in +-- use, repeatedly, then a normal delete after another invalidation. +INSERT INTO fk_self_ref VALUES (1, NULL), (2, 1), (3, 2), (4, 3), (5, 4); +SET fk_self_ref.fail = on; +DO $$ +DECLARE + failures int := 0; +BEGIN + FOR i IN 1..10 LOOP + BEGIN + DELETE FROM fk_self_ref WHERE id = 1; + EXCEPTION WHEN raise_exception THEN + failures := failures + 1; + END; + END LOOP; + RAISE NOTICE 'failures: %', failures; +END$$; +SELECT * FROM fk_self_ref ORDER BY id; +RESET fk_self_ref.fail; +GRANT SELECT ON fk_self_ref TO PUBLIC; +REVOKE SELECT ON fk_self_ref FROM PUBLIC; +DELETE FROM fk_self_ref WHERE id = 1; +SELECT * FROM fk_self_ref ORDER BY id; +DROP TABLE fk_self_ref; +DROP FUNCTION fk_self_ref_before_del(); + +-- Errors raised from inside an RI check's query, as opposed to by the check +-- itself afterwards, must leave the stack of executing RI plans exact (an +-- assertion at end of transaction checks that), and a subtransaction abort +-- in the middle of all that must not disturb the plans queued to be freed. +-- The cast raises for v = 97, so a check on row(97) errors out while its +-- plan is being executed. For v = 1 it first invalidates the plan of the +-- check calling it, so that the nested check on row(97) finds that plan +-- invalid while it is executing and queues it, and then errors out, which +-- the cast catches: a subtransaction abort inside the outer check, with the +-- outer check's plan queued. The same error is then caught above every +-- check, by a PL/pgSQL exception block and by ROLLBACK TO SAVEPOINT; more +-- checks and a commit follow each. +CREATE ROLE regress_fk_err_role; +CREATE TABLE fk_err_pk (id int PRIMARY KEY); +INSERT INTO fk_err_pk VALUES (1), (2); +CREATE TYPE fk_err_vch AS (v int); +CREATE FUNCTION fk_err_vcast(fk_err_vch) RETURNS int LANGUAGE plpgsql AS $$ +BEGIN + IF $1.v = 97 THEN + RAISE EXCEPTION 'fk_err_vcast: %', $1.v; + END IF; + IF $1.v = 1 THEN + IF current_setting('fk_err.flip', true) = 'on' THEN + GRANT SELECT ON fk_err_pk TO regress_fk_err_role; + PERFORM set_config('fk_err.flip', 'off', true); + ELSE + REVOKE SELECT ON fk_err_pk FROM regress_fk_err_role; + PERFORM set_config('fk_err.flip', 'on', true); + END IF; + BEGIN + INSERT INTO fk_err_fk VALUES (row(97)::fk_err_vch); + EXCEPTION WHEN raise_exception THEN NULL; + END; + END IF; + RETURN $1.v; +END$$; +CREATE CAST (fk_err_vch AS int) WITH FUNCTION fk_err_vcast(fk_err_vch) AS IMPLICIT; +CREATE TABLE fk_err_fk (a fk_err_vch REFERENCES fk_err_pk (id)); +-- caught inside the outer check +INSERT INTO fk_err_fk SELECT row(1)::fk_err_vch FROM generate_series(1, 3); +-- caught above every check, then more checks in the same transaction +BEGIN; +DO $$ +BEGIN + FOR i IN 1..3 LOOP + BEGIN + INSERT INTO fk_err_fk VALUES (row(97)::fk_err_vch); + EXCEPTION WHEN raise_exception THEN NULL; + END; + END LOOP; +END$$; +INSERT INTO fk_err_fk SELECT row(2)::fk_err_vch FROM generate_series(1, 3); +COMMIT; +-- the same with savepoints, including one rolled back after the check whose +-- cast caught the error had already succeeded +BEGIN; +SAVEPOINT s1; +INSERT INTO fk_err_fk VALUES (row(97)::fk_err_vch); +ROLLBACK TO SAVEPOINT s1; +INSERT INTO fk_err_fk SELECT row(2)::fk_err_vch FROM generate_series(1, 3); +SAVEPOINT s2; +INSERT INTO fk_err_fk SELECT row(1)::fk_err_vch FROM generate_series(1, 3); +ROLLBACK TO SAVEPOINT s2; +INSERT INTO fk_err_fk SELECT row(1)::fk_err_vch FROM generate_series(1, 3); +COMMIT; +SELECT a, count(*) FROM fk_err_fk GROUP BY a ORDER BY a; +DROP TABLE fk_err_fk, fk_err_pk; +DROP CAST (fk_err_vch AS int); +DROP FUNCTION fk_err_vcast(fk_err_vch); +DROP TYPE fk_err_vch; +DROP ROLE regress_fk_err_role; -- 2.47.3