From 6b47105b8cb6d6da21884ef747b0c1ed03f209fb Mon Sep 17 00:00:00 2001 From: Amit Langote Date: Fri, 23 Oct 2026 18:16:36 +0900 Subject: [PATCH v10] 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. AtEOXact_RI() frees whatever is left, the same treatment the fast-path metadata already gets since e73e0196. Re-add the test case that f52d4051 removed because it turned trilobite red, now that the bug is fixed, along with a comment noting that it takes CLOBBER_CACHE_ALWAYS to hit the bug with it. Also, 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/utils/adt/ri_triggers.c | 209 ++++++++++++++++++++-- src/test/regress/expected/foreign_key.out | 186 +++++++++++++++++++ src/test/regress/sql/foreign_key.sql | 150 ++++++++++++++++ 3 files changed, 527 insertions(+), 18 deletions(-) diff --git a/src/backend/utils/adt/ri_triggers.c b/src/backend/utils/adt/ri_triggers.c index 7143a900fe0..acda6faac21 100644 --- a/src/backend/utils/adt/ri_triggers.c +++ b/src/backend/utils/adt/ri_triggers.c @@ -231,6 +231,26 @@ static dclist_head ri_constraint_cache_valid_list; */ static FastPathMeta *ri_fpmeta_dead_list = NULL; +/* + * 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 either 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 function prototypes */ @@ -262,6 +282,9 @@ static void InvalidateConstraintCacheCallBack(Datum arg, SysCacheIdentifier cach 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, @@ -2628,6 +2651,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]; @@ -2719,15 +2743,42 @@ ri_PerformCheck(const RI_ConstraintInfo *riinfo, * Set fire_triggers to false to ensure that AFTER triggers are queued in * the outer query's after-trigger context and fire after all RI updates * on the same row are complete, rather than immediately. + * + * 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. */ - spi_result = SPI_execute_snapshot(qplan, - vals, nulls, - test_snapshot, crosscheck_snapshot, - false, false, limit); + 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)); @@ -3689,12 +3740,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; } @@ -3729,6 +3800,91 @@ ri_HashPreparedPlan(RI_QueryKey *key, SPIPlanPtr 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 - * @@ -3996,20 +4152,28 @@ RI_FKey_trigger_type(Oid tgfoid) * AtEOXact_RI * End-of-transaction cleanup for referential integrity. * - * Currently this only releases fast-path metadata detached during the - * transaction. InvalidateConstraintCacheCallBack() cannot free a - * FastPathMeta when it detaches one, because an RI check further up the - * stack may still hold a pointer into it. It queues them on - * ri_fpmeta_dead_list instead, and we release them here, where no such - * reference can exist. isCommit is accepted for consistency with the - * other AtEOXact_* routines but is not used: the release is the same on + * This releases objects that were detached from the RI caches during the + * transaction but could not be freed on the spot, because an RI check + * further up the stack might still have been using them: + * + * - fast-path metadata detached by InvalidateConstraintCacheCallBack(), + * queued on ri_fpmeta_dead_list; + * + * - SPI plans found invalid by ri_FetchPreparedPlan() while a check was + * executing them, queued on ri_plan_dead_list. These are normally freed + * as soon as the outermost check finishes; we only see them here if an + * error interrupted that check and no later check ran in the transaction. + * + * Here no RI check can be in progress, so both 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 FastPathMeta stays reachable from the dead - * list whichever subtransaction detached it, and a check holding a pointer - * into one may be running at an outer level, so releasing at subtransaction - * end would be unsafe as well as unnecessary. + * subtransaction: a detached object stays reachable from its dead list + * whichever subtransaction detached it, and a check using one may be + * running at an outer level, so releasing at subtransaction end would be + * unsafe as well as unnecessary. */ void AtEOXact_RI(bool isCommit) @@ -4021,4 +4185,13 @@ AtEOXact_RI(bool isCommit) ri_fpmeta_dead_list = dead->next_dead; pfree(dead); } + + /* + * 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/test/regress/expected/foreign_key.out b/src/test/regress/expected/foreign_key.out index a2f37c6d153..dd4e3fd5c92 100644 --- a/src/test/regress/expected/foreign_key.out +++ b/src/test/regress/expected/foreign_key.out @@ -4021,6 +4021,192 @@ DETAIL: Key (a)=(999) is not present in table "fp_pk_dom". INSERT INTO fp_fk_dom VALUES (NULL); DROP TABLE fp_fk_dom, fp_pk_dom; DROP DOMAIN fp_int8dom; +-- Re-entrant FK check: DML on the same FK table from a cast function +CREATE TABLE fp_reentry_pk (id int PRIMARY KEY); +INSERT INTO fp_reentry_pk VALUES (1), (2); +CREATE TYPE fp_vch AS (v int); +CREATE FUNCTION fp_vcast(fp_vch) RETURNS int LANGUAGE plpgsql AS $$ +BEGIN + IF $1.v = 1 THEN + INSERT INTO fp_reentry_fk VALUES (row(2)::fp_vch); + END IF; + RETURN $1.v; +END$$; +CREATE CAST (fp_vch AS int) WITH FUNCTION fp_vcast(fp_vch) AS IMPLICIT; +CREATE TABLE fp_reentry_fk (a fp_vch + REFERENCES fp_reentry_pk (id)); +-- The cast re-enters with DML on the same FK, so the inner check looks up +-- the same cached SPI plan while the outer check is still executing it. +-- If the plan has been invalidated meanwhile (as CLOBBER_CACHE_ALWAYS makes +-- certain), the inner check must not free it out from under the outer one. +INSERT INTO fp_reentry_fk SELECT row(1)::fp_vch FROM generate_series(1, 2); +SELECT a, count(*) FROM fp_reentry_fk GROUP BY a ORDER BY a; + a | count +-----+------- + (1) | 2 + (2) | 2 +(2 rows) + +DROP TABLE fp_reentry_fk, fp_reentry_pk; +DROP CAST (fp_vch AS int); +DROP FUNCTION fp_vcast(fp_vch); +DROP TYPE fp_vch; +-- 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; -- -- Cache invalidation arriving during a fast-path check. -- diff --git a/src/test/regress/sql/foreign_key.sql b/src/test/regress/sql/foreign_key.sql index 5525c0f90c8..1ed32978041 100644 --- a/src/test/regress/sql/foreign_key.sql +++ b/src/test/regress/sql/foreign_key.sql @@ -2979,6 +2979,156 @@ INSERT INTO fp_fk_dom VALUES (NULL); DROP TABLE fp_fk_dom, fp_pk_dom; DROP DOMAIN fp_int8dom; +-- Re-entrant FK check: DML on the same FK table from a cast function +CREATE TABLE fp_reentry_pk (id int PRIMARY KEY); +INSERT INTO fp_reentry_pk VALUES (1), (2); +CREATE TYPE fp_vch AS (v int); +CREATE FUNCTION fp_vcast(fp_vch) RETURNS int LANGUAGE plpgsql AS $$ +BEGIN + IF $1.v = 1 THEN + INSERT INTO fp_reentry_fk VALUES (row(2)::fp_vch); + END IF; + RETURN $1.v; +END$$; +CREATE CAST (fp_vch AS int) WITH FUNCTION fp_vcast(fp_vch) AS IMPLICIT; +CREATE TABLE fp_reentry_fk (a fp_vch + REFERENCES fp_reentry_pk (id)); +-- The cast re-enters with DML on the same FK, so the inner check looks up +-- the same cached SPI plan while the outer check is still executing it. +-- If the plan has been invalidated meanwhile (as CLOBBER_CACHE_ALWAYS makes +-- certain), the inner check must not free it out from under the outer one. +INSERT INTO fp_reentry_fk SELECT row(1)::fp_vch FROM generate_series(1, 2); +SELECT a, count(*) FROM fp_reentry_fk GROUP BY a ORDER BY a; +DROP TABLE fp_reentry_fk, fp_reentry_pk; +DROP CAST (fp_vch AS int); +DROP FUNCTION fp_vcast(fp_vch); +DROP TYPE fp_vch; + +-- 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; + -- -- Cache invalidation arriving during a fast-path check. -- -- 2.47.3