From 0369a9de78a3d6281463ebacea5ed9641dd3cca2 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 v6 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 df5e827d57e..74ca558b58b 100644
--- a/src/test/regress/expected/foreign_key.out
+++ b/src/test/regress/expected/foreign_key.out
@@ -4180,3 +4180,78 @@ ERROR:  insert or update on table "fp_fk_cid" violates foreign key constraint "f
 DETAIL:  Key (a)=(1) is not present in table "fp_pk_cid".
 ROLLBACK TO SAVEPOINT fp_cid;
 ROLLBACK;
+-- 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 ce35c465b81..f2ee4c6a684 100644
--- a/src/test/regress/sql/foreign_key.sql
+++ b/src/test/regress/sql/foreign_key.sql
@@ -3115,3 +3115,58 @@ SAVEPOINT fp_cid;
 INSERT INTO fp_fk_cid VALUES (1);	-- fails, as a violation
 ROLLBACK TO SAVEPOINT fp_cid;
 ROLLBACK;
+
+-- 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();

base-commit: c652cd3f94d57e69965f065c4ed45708a2b2a757
-- 
2.34.1

