From 6587a490533ebfbd3a4b5e5b980bd3662690c288 Mon Sep 17 00:00:00 2001
From: Lucas Jeffrey <lucas.jeffrey@anachronics.com>
Date: Thu, 24 Sep 2026 12:56:54 -0300
Subject: [PATCH v5 1/2] Add test for reentrant ON DELETE CASCADE on a
 self-referencing table

Add a regression test for a crash in RI_FKey_cascade_del(): with an ON
DELETE CASCADE foreign key on a self-referencing table and a BEFORE
DELETE trigger that deletes from that same table, the RI cascade query
can be reentered while it's executing.  If the cascade's plan gets
invalidated meanwhile, the nested call finds it invalid in
ri_FetchPreparedPlan() and frees it, while the outer call is still
executing it, leading to a use-after-free in _SPI_execute_plan().

The test also checks the rows that survive the cascade, and covers the
error path: the nested cascade raises an error, caught by a PL/pgSQL
exception block, several times in a row, while the invalidated plans
are still being executed; a plain DELETE after a further invalidation
must still work (and not trip any assertion).

Signed-off-by: Lucas Jeffrey <lucas.jeffrey@anachronics.com>
---
 src/test/regress/expected/foreign_key.out | 75 +++++++++++++++++++++++
 src/test/regress/sql/foreign_key.sql      | 55 +++++++++++++++++
 2 files changed, 130 insertions(+)

diff --git a/src/test/regress/expected/foreign_key.out b/src/test/regress/expected/foreign_key.out
index abafaff785a..eb1a9828146 100644
--- a/src/test/regress/expected/foreign_key.out
+++ b/src/test/regress/expected/foreign_key.out
@@ -4119,3 +4119,78 @@ DROP TYPE fkint CASCADE;
 NOTICE:  drop cascades to 2 other objects
 DETAIL:  drop cascades to function fkint_in(cstring)
 drop cascades to function fkint_out(fkint)
+-- ON DELETE CASCADE on a self-referencing table, with a BEFORE DELETE trigger
+-- that deletes from the same table.  The cascade's plan is invalidated (by
+-- the GRANT) while the cascade is executing it, and the nested DELETE reaches
+-- the same RI query again; the outer execution must still be able to finish.
+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)
+
+-- Same, but with the nested cascade failing, which aborts the executions
+-- that are using the plans.  Their pins must be released on error, so that
+-- the plans can be replaced and freed afterwards.
+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();
diff --git a/src/test/regress/sql/foreign_key.sql b/src/test/regress/sql/foreign_key.sql
index da62f601b46..fa3564c907e 100644
--- a/src/test/regress/sql/foreign_key.sql
+++ b/src/test/regress/sql/foreign_key.sql
@@ -3065,3 +3065,58 @@ DROP TABLE pktable_inval;
 DROP CAST (fkint AS int4);
 DROP FUNCTION fkint_to_int4(fkint);
 DROP TYPE fkint CASCADE;
+
+-- ON DELETE CASCADE on a self-referencing table, with a BEFORE DELETE trigger
+-- that deletes from the same table.  The cascade's plan is invalidated (by
+-- the GRANT) while the cascade is executing it, and the nested DELETE reaches
+-- the same RI query again; the outer execution must still be able to finish.
+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;
+
+-- Same, but with the nested cascade failing, which aborts the executions
+-- that are using the plans.  Their pins must be released on error, so that
+-- the plans can be replaced and freed afterwards.
+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();
-- 
2.34.1

