From 461d50718f89e5cc6ffa37ed084ece62c1ebf312 Mon Sep 17 00:00:00 2001
From: luquijeffrey <lucas.jeffrey@anachronics.com>
Date: Thu, 24 Sep 2026 12:56:54 -0300
Subject: [PATCH v4 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 40be9c4f75d..c2ebcd6a44b 100644
--- a/src/test/regress/expected/foreign_key.out
+++ b/src/test/regress/expected/foreign_key.out
@@ -4303,3 +4303,78 @@ SELECT count(*) AS deferred_rows FROM fp_deferred_fk;  -- 1, check passed at com
 (1 row)
 
 DROP TABLE fp_deferred_fk, fp_deferred_pk;
+-- 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 89405dff99e..7b49e5a484b 100644
--- a/src/test/regress/sql/foreign_key.sql
+++ b/src/test/regress/sql/foreign_key.sql
@@ -3211,3 +3211,58 @@ INSERT INTO fp_deferred_pk VALUES (1);
 COMMIT;
 SELECT count(*) AS deferred_rows FROM fp_deferred_fk;  -- 1, check passed at commit
 DROP TABLE fp_deferred_fk, fp_deferred_pk;
+
+-- 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

