Hi hackers,

We found a bug where executing a DELETE on a self-referential table that
fires triggers can cause a segmentation fault. This is due to a
*use-after-free* of a Postgres plan generated by the referential integrity
module (ri_triggers.c, RI_FKey_cascade_del). The crash occurs if the
Postgres plancache is invalidated (ResetPlanCache) during the execution of
a reentrant RI trigger.

A reentrant RI_FKey_cascade_del can occur if a table is self-referential
(i.e., it has a foreign key referencing its own primary key) and has BEFORE
DELETE triggers that delete rows from that same table.

   -

   *The first patch* adds a test case that reproduces the segmentation
   fault. The crash itself happens in _SPI_execute_plan, but the root cause
   is that the plan being executed was prematurely freed by the RI module.
   -

   *The second patch* fixes ri_triggers.c by introducing reentrancy guards,
   which maintain a reference count of plans in execution to prevent them from
   being freed while active.

Feedback and reviews are welcome.

Best regards,

Lucas Jeffrey
From b93e8f45f6b67083f610c53624e19320cb80f6e9 Mon Sep 17 00:00:00 2001
From: luquijeffrey <[email protected]>
Date: Tue, 19 May 2026 17:42:11 -0300
Subject: [PATCH 2/2] Fix crash in RI triggers by refcounting active plans

---
 src/backend/utils/adt/ri_triggers.c | 86 ++++++++++++++++++++++++++++-
 1 file changed, 85 insertions(+), 1 deletion(-)

diff --git a/src/backend/utils/adt/ri_triggers.c b/src/backend/utils/adt/ri_triggers.c
index dc89c686394..10019dc8ec5 100644
--- a/src/backend/utils/adt/ri_triggers.c
+++ b/src/backend/utils/adt/ri_triggers.c
@@ -251,12 +251,24 @@ typedef struct RI_FastPathEntry
 	int			batch_count;
 } RI_FastPathEntry;
 
+/*
+ * RI_QueryPlanCacheExecutingRefCountEntry
+ *
+ * Entry to track the number of times a prepared plan is being executed.
+ */
+typedef struct RI_QueryPlanCacheExecutingRefCountEntry
+{
+	SPIPlanPtr  plan;
+	uint32      refcount; /* number of times this plan is being executed (can be more than 1 if reentrant) */
+} RI_QueryPlanCacheExecutingRefCountEntry;
+
 /*
  * Local data
  */
 static HTAB *ri_constraint_cache = NULL;
 static HTAB *ri_query_cache = NULL;
 static HTAB *ri_compare_cache = NULL;
+static HTAB *ri_query_plan_cache_executing_refcount = NULL;
 static dclist_head ri_constraint_cache_valid_list;
 
 static HTAB *ri_fastpath_cache = NULL;
@@ -295,6 +307,11 @@ static SPIPlanPtr ri_FetchPreparedPlan(RI_QueryKey *key);
 static void ri_HashPreparedPlan(RI_QueryKey *key, SPIPlanPtr plan);
 static RI_CompareHashEntry *ri_HashCompareOp(Oid eq_opr, Oid typeid);
 
+/* Reentrancy protection: prevent segfault on deleting a plan in execution if invalidated during reentrant RI check. */
+static void ri_PreparedPlanExecutionStarted(SPIPlanPtr plan);
+static void ri_PreparedPlanExecutionFinished(SPIPlanPtr plan);
+static bool ri_PreparedPlanCanRelease(SPIPlanPtr plan);
+
 static void ri_CheckTrigger(FunctionCallInfo fcinfo, const char *funcname,
 							int tgkind);
 static RI_ConstraintInfo *ri_FetchConstraintInfo(Trigger *trigger,
@@ -2724,6 +2741,9 @@ ri_PerformCheck(const RI_ConstraintInfo *riinfo,
 						   save_sec_context | SECURITY_LOCAL_USERID_CHANGE |
 						   SECURITY_NOFORCE_RLS);
 
+	/* Increase plan use count for reentrancy protection. */
+	ri_PreparedPlanExecutionStarted(qplan);
+
 	/*
 	 * Finally we can run the query.
 	 *
@@ -2735,6 +2755,9 @@ ri_PerformCheck(const RI_ConstraintInfo *riinfo,
 									  vals, nulls,
 									  test_snapshot, crosscheck_snapshot,
 									  false, false, limit);
+	
+	/* Decrease plan use count. this call can free the plan if it was invalidated and no longer in use. */
+	ri_PreparedPlanExecutionFinished(qplan);
 
 	/* Restore UID and security context */
 	SetUserIdAndSecContext(save_userid, save_sec_context);
@@ -3762,6 +3785,12 @@ ri_InitHashTables(void)
 	ri_compare_cache = hash_create("RI compare cache",
 								   RI_INIT_QUERYHASHSIZE,
 								   &ctl, HASH_ELEM | HASH_BLOBS);
+
+	ctl.keysize = sizeof(SPIPlanPtr);
+	ctl.entrysize = sizeof(RI_QueryPlanCacheExecutingRefCountEntry);
+	ri_query_plan_cache_executing_refcount = hash_create("RI plan cache execution refcount",
+								   RI_INIT_QUERYHASHSIZE,
+								   &ctl, HASH_ELEM | HASH_BLOBS);
 }
 
 
@@ -3811,7 +3840,7 @@ ri_FetchPreparedPlan(RI_QueryKey *key)
 	 * memory space before we make a new one.
 	 */
 	entry->plan = NULL;
-	if (plan)
+	if (plan && ri_PreparedPlanCanRelease(plan))
 		SPI_freeplan(plan);
 
 	return NULL;
@@ -3847,6 +3876,61 @@ ri_HashPreparedPlan(RI_QueryKey *key, SPIPlanPtr plan)
 }
 
 
+static void
+ri_PreparedPlanExecutionStarted(SPIPlanPtr plan)
+{	
+	RI_QueryPlanCacheExecutingRefCountEntry* entry;
+	bool found;
+
+	if (!ri_query_plan_cache_executing_refcount)
+		ri_InitHashTables();
+	
+	entry = (RI_QueryPlanCacheExecutingRefCountEntry*) hash_search(ri_query_plan_cache_executing_refcount, &plan, HASH_ENTER, &found);
+	if (found)
+		entry->refcount++;
+	else
+		entry->refcount = 1;
+}
+
+static void
+ri_PreparedPlanExecutionFinished(SPIPlanPtr plan)
+{	
+	RI_QueryPlanCacheExecutingRefCountEntry* entry;
+	bool found;
+
+	if (!ri_query_plan_cache_executing_refcount)
+		return;
+	
+	entry = (RI_QueryPlanCacheExecutingRefCountEntry*) hash_search(ri_query_plan_cache_executing_refcount, &plan, HASH_FIND, &found);
+	if (!entry)
+		return;
+	
+	entry->refcount--;
+	if (entry->refcount == 0 && !SPI_plan_is_valid(plan))
+	{
+		// Remove the entry
+		hash_search(ri_query_plan_cache_executing_refcount, &plan, HASH_REMOVE, NULL);
+		SPI_freeplan(plan);
+	}
+}
+
+static bool
+ri_PreparedPlanCanRelease(SPIPlanPtr plan)
+{
+	RI_QueryPlanCacheExecutingRefCountEntry* entry;
+	bool found;
+
+	if (!ri_query_plan_cache_executing_refcount)
+		return true;
+	
+	entry = (RI_QueryPlanCacheExecutingRefCountEntry*) hash_search(ri_query_plan_cache_executing_refcount, &plan, HASH_FIND, &found);
+	if (!entry)
+		return true;
+	
+	return entry->refcount == 0;
+}
+
+
 /*
  * ri_KeysEqual -
  *
-- 
2.34.1

From ae9ed37ccfa88c6874ba9114953f5032cc85d086 Mon Sep 17 00:00:00 2001
From: luquijeffrey <[email protected]>
Date: Tue, 19 May 2026 17:42:11 -0300
Subject: [PATCH 1/2] Add isolation test case for RI plan invalidation crash

---
 .../isolation/expected/ri-cascade-del.out     | 28 ++++++
 src/test/isolation/isolation_schedule         |  1 +
 src/test/isolation/specs/ri-cascade-del.spec  | 85 +++++++++++++++++++
 3 files changed, 114 insertions(+)
 create mode 100644 src/test/isolation/expected/ri-cascade-del.out
 create mode 100644 src/test/isolation/specs/ri-cascade-del.spec

diff --git a/src/test/isolation/expected/ri-cascade-del.out b/src/test/isolation/expected/ri-cascade-del.out
new file mode 100644
index 00000000000..051083b17f8
--- /dev/null
+++ b/src/test/isolation/expected/ri-cascade-del.out
@@ -0,0 +1,28 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s2_lock s1_delete s2_inval s2_unlock s1_commit
+step s2_lock: SELECT pg_advisory_lock(1);
+pg_advisory_lock
+----------------
+                
+(1 row)
+
+step s1_delete: DELETE FROM crash_reentrancia_tabla_autorederencial WHERE id = 1; <waiting ...>
+step s2_inval: 
+  DO $$                                    
+  BEGIN
+    FOR i IN 1..1000 LOOP
+      EXECUTE 'CREATE TEMPORARY TABLE t_temp_inval_(id INTEGER PRIMARY KEY)';
+      EXECUTE 'DROP TABLE t_temp_inval_';
+    END LOOP;
+  END;
+  $$;
+
+step s2_unlock: SELECT pg_advisory_unlock(1);
+pg_advisory_unlock
+------------------
+t                 
+(1 row)
+
+step s1_delete: <... completed>
+step s1_commit: COMMIT;
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index 1578ba191c8..39a0a1ee792 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -12,6 +12,7 @@ test: project-manager
 test: classroom-scheduling
 test: total-cash
 test: referential-integrity
+test: ri-cascade-del
 test: ri-trigger
 test: partial-index
 test: two-ids
diff --git a/src/test/isolation/specs/ri-cascade-del.spec b/src/test/isolation/specs/ri-cascade-del.spec
new file mode 100644
index 00000000000..aa8e090c7ad
--- /dev/null
+++ b/src/test/isolation/specs/ri-cascade-del.spec
@@ -0,0 +1,85 @@
+# Setup for referential integrity crash test
+setup
+{
+    CREATE TABLE crash_reentrancia_tabla_autorederencial (
+        id int PRIMARY KEY,
+        nombre text,
+        padre_id int REFERENCES crash_reentrancia_tabla_autorederencial(id) ON DELETE CASCADE
+    );
+
+    CREATE TABLE crash_reentrancia_segunda_tabla (
+        id    int PRIMARY KEY,
+        valor text
+    );
+
+    CREATE OR REPLACE FUNCTION crash_reentrancia_before_delete()
+    RETURNS trigger AS $$
+    DECLARE
+        v_valor text;
+    BEGIN
+        IF OLD.id % 2 = 1 THEN 
+            RETURN OLD;
+        END IF;
+
+        -- Wait for S2 to finish flooding the invalidation message queue
+        IF OLD.id = 2 THEN
+            PERFORM pg_advisory_lock(1);
+            PERFORM pg_advisory_unlock(1);
+        END IF;
+
+        IF OLD.id > 4 THEN
+            -- This opens the table and forces processing of pending inval messages
+            SELECT valor INTO v_valor FROM crash_reentrancia_segunda_tabla WHERE id = OLD.id;
+        END IF;
+        
+        DELETE FROM crash_reentrancia_tabla_autorederencial WHERE padre_id = OLD.id;
+        RETURN OLD;
+    END;
+    $$ LANGUAGE plpgsql;
+
+    CREATE TRIGGER trg_crash_reentrancia_before_delete
+        BEFORE DELETE ON crash_reentrancia_tabla_autorederencial
+        FOR EACH ROW EXECUTE FUNCTION crash_reentrancia_before_delete();
+
+    INSERT INTO crash_reentrancia_tabla_autorederencial VALUES (1, 'A', NULL);
+    INSERT INTO crash_reentrancia_tabla_autorederencial VALUES (2, 'B', 1);
+    INSERT INTO crash_reentrancia_tabla_autorederencial VALUES (3, 'C', 2);
+    INSERT INTO crash_reentrancia_tabla_autorederencial VALUES (4, 'D', 3);
+    INSERT INTO crash_reentrancia_tabla_autorederencial VALUES (5, 'E', 4);
+    INSERT INTO crash_reentrancia_tabla_autorederencial VALUES (6, 'F', 5);
+
+    INSERT INTO crash_reentrancia_segunda_tabla VALUES 
+        (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd'), (5, 'e'), (6, 'f');
+}
+
+teardown
+{
+    DROP TRIGGER trg_crash_reentrancia_before_delete ON crash_reentrancia_tabla_autorederencial;
+    DROP FUNCTION crash_reentrancia_before_delete CASCADE;
+    DROP TABLE crash_reentrancia_tabla_autorederencial CASCADE;
+    DROP TABLE crash_reentrancia_segunda_tabla CASCADE;
+}
+
+session s1
+setup { BEGIN; }
+step s1_delete { DELETE FROM crash_reentrancia_tabla_autorederencial WHERE id = 1; }
+step s1_commit { COMMIT; }
+
+session s2
+step s2_lock { SELECT pg_advisory_lock(1); }
+step s2_inval {
+  DO $$                                    
+  BEGIN
+    FOR i IN 1..1000 LOOP
+      EXECUTE 'CREATE TEMPORARY TABLE t_temp_inval_(id INTEGER PRIMARY KEY)';
+      EXECUTE 'DROP TABLE t_temp_inval_';
+    END LOOP;
+  END;
+  $$;
+}
+step s2_unlock { SELECT pg_advisory_unlock(1); }
+
+# Execution permutation
+# S2 locks -> S1 blocks on S2 -> S2 forces inval queue overflow -> S2 unlocks
+# S1 awakens -> S1 forces table_open -> invalidation processed -> segfault!
+permutation s2_lock s1_delete s2_inval s2_unlock s1_commit
-- 
2.34.1

Reply via email to