From b4ba22d2fd9538c0d3202a27994717f2ae79f169 Mon Sep 17 00:00:00 2001
From: Peter Geoghegan <pg@bowt.ie>
Date: Tue, 11 Aug 2026 18:08:15 -0400
Subject: [PATCH] Add test: the RI fast-path FK cache outlives the portal that
 filled it

An INSERT that is valid in every respect fails, and leaks two relations and
two TupleDescs on the way, when an AFTER trigger runs a query of its own that
inserts into a second table with a fast-path foreign key:

  WARNING:  resource was not closed: relation "fp_product"
  WARNING:  resource was not closed: relation "fp_product_pkey"
  WARNING:  resource was not closed: TupleDesc 0x... (34085,-1)
  WARNING:  resource was not closed: TupleDesc 0x... (34068,-1)

The backend then dies on Assert(rel->rd_refcnt > 0) in
RelationDecrementReferenceCount(), reached from AbortCurrentTransaction(),
because the same references are released twice.  Occasionally the ownership
check fires first and the statement ends in "relcache reference ... is not
owned by resource owner Portal" instead.  Without asserts the second release
underflows the reference count silently.

ri_FastPathGetEntry() caches its per-constraint entry in TopTransactionContext,
but acquires everything the entry holds -- table_open on the referenced table
and its index, and two slots built over relcache descriptors -- under whatever
ResourceOwner is current.  In the test that is the portal of the cursor the
trigger's FOR loop runs, and that portal is dropped long before the entry is
torn down at the end of the outer statement.

ri_fastpath_callback_registered is a single static bool, but the batch callback
list is per query level.  The outer INSERT's own FK check registers the
callback, so the nested level registers none and its entry is never torn down
where it was built.  The two foreign keys involved reference different tables,
so nothing about the nested constraint is covered by the outer one.

The expected output is what a correct server prints: the INSERT succeeds and
the trigger's three rows are there.  It was produced by owning the entry's
resources under TopTransactionResourceOwner, which makes the test pass and
changes no other expected output in this file.

The same missing registration also postpones the nested INSERT's FK check past
the end of the statement that inserted the row, so an immediate constraint
stops rejecting a row whose referenced row appears only later in the outer
statement.  This test does not cover that.

19 and master only: the fast path arrived in b7b27eb41, "Optimize fast-path FK
checks with batched index probes", which is on REL_19_STABLE and master and
absent from REL_18_STABLE.
---
 src/test/regress/expected/foreign_key.out | 47 +++++++++++++++++++++++
 src/test/regress/sql/foreign_key.sql      | 42 ++++++++++++++++++++
 2 files changed, 89 insertions(+)

diff --git a/src/test/regress/expected/foreign_key.out b/src/test/regress/expected/foreign_key.out
index 120d33194..d3394fdd1 100644
--- a/src/test/regress/expected/foreign_key.out
+++ b/src/test/regress/expected/foreign_key.out
@@ -3846,3 +3846,50 @@ DETAIL:  Key (a)=(999) is not present in table "fp_subxact_pk".
 DROP TRIGGER fp_subxact_trg ON fp_subxact_fk;
 DROP FUNCTION fp_abort_subxact();
 DROP TABLE fp_subxact_fk, fp_subxact_pk;
+-- An AFTER trigger runs a query of its own, and that query inserts into a
+-- second table with a fast-path foreign key.  The entry the nested INSERT
+-- creates belongs to the cursor's portal, which is gone by the time the
+-- entry is torn down at the end of the outer statement.  Every key stored
+-- below is present in its referenced table, so the INSERT must just succeed.
+CREATE TABLE fp_customer (id int PRIMARY KEY);
+INSERT INTO fp_customer VALUES (1);
+CREATE TABLE fp_product (id int PRIMARY KEY);
+INSERT INTO fp_product SELECT generate_series(1, 4);
+CREATE TABLE fp_kit_component (kit_product_id int, component_product_id int);
+INSERT INTO fp_kit_component VALUES (1, 2), (1, 3), (1, 4);
+CREATE TABLE fp_order (id int, customer_id int REFERENCES fp_customer,
+    product_id int);
+CREATE TABLE fp_order_item (order_id int, product_id int
+    REFERENCES fp_product);
+CREATE FUNCTION fp_add_order_item(order_id int, product_id int) RETURNS int
+    LANGUAGE plpgsql AS $$
+BEGIN
+    INSERT INTO fp_order_item VALUES (order_id, product_id);
+    RETURN product_id;
+END$$;
+CREATE FUNCTION fp_expand_kit() RETURNS trigger LANGUAGE plpgsql AS $$
+DECLARE
+    component_id int;
+    ncomponents int := 0;
+BEGIN
+    FOR component_id IN
+        SELECT fp_add_order_item(NEW.id, component_product_id)
+            FROM fp_kit_component WHERE kit_product_id = NEW.product_id
+    LOOP
+        ncomponents := ncomponents + 1;
+    END LOOP;
+    RAISE NOTICE 'order % expanded into % order items', NEW.id, ncomponents;
+    RETURN NULL;
+END$$;
+CREATE TRIGGER fp_expand_kit_trg AFTER INSERT ON fp_order
+    FOR EACH ROW EXECUTE FUNCTION fp_expand_kit();
+INSERT INTO fp_order VALUES (1, 1, 1);
+NOTICE:  order 1 expanded into 3 order items
+SELECT count(*) FROM fp_order_item;
+ count 
+-------
+     3
+(1 row)
+
+DROP TABLE fp_order, fp_order_item, fp_kit_component, fp_product, fp_customer;
+DROP FUNCTION fp_expand_kit(), fp_add_order_item(int, int);
diff --git a/src/test/regress/sql/foreign_key.sql b/src/test/regress/sql/foreign_key.sql
index b9b88064e..c89528c81 100644
--- a/src/test/regress/sql/foreign_key.sql
+++ b/src/test/regress/sql/foreign_key.sql
@@ -2801,3 +2801,45 @@ INSERT INTO fp_subxact_fk VALUES (999, 'bad'), (0, 'boom'), (1, 'ok');
 DROP TRIGGER fp_subxact_trg ON fp_subxact_fk;
 DROP FUNCTION fp_abort_subxact();
 DROP TABLE fp_subxact_fk, fp_subxact_pk;
+
+-- An AFTER trigger runs a query of its own, and that query inserts into a
+-- second table with a fast-path foreign key.  The entry the nested INSERT
+-- creates belongs to the cursor's portal, which is gone by the time the
+-- entry is torn down at the end of the outer statement.  Every key stored
+-- below is present in its referenced table, so the INSERT must just succeed.
+CREATE TABLE fp_customer (id int PRIMARY KEY);
+INSERT INTO fp_customer VALUES (1);
+CREATE TABLE fp_product (id int PRIMARY KEY);
+INSERT INTO fp_product SELECT generate_series(1, 4);
+CREATE TABLE fp_kit_component (kit_product_id int, component_product_id int);
+INSERT INTO fp_kit_component VALUES (1, 2), (1, 3), (1, 4);
+CREATE TABLE fp_order (id int, customer_id int REFERENCES fp_customer,
+    product_id int);
+CREATE TABLE fp_order_item (order_id int, product_id int
+    REFERENCES fp_product);
+CREATE FUNCTION fp_add_order_item(order_id int, product_id int) RETURNS int
+    LANGUAGE plpgsql AS $$
+BEGIN
+    INSERT INTO fp_order_item VALUES (order_id, product_id);
+    RETURN product_id;
+END$$;
+CREATE FUNCTION fp_expand_kit() RETURNS trigger LANGUAGE plpgsql AS $$
+DECLARE
+    component_id int;
+    ncomponents int := 0;
+BEGIN
+    FOR component_id IN
+        SELECT fp_add_order_item(NEW.id, component_product_id)
+            FROM fp_kit_component WHERE kit_product_id = NEW.product_id
+    LOOP
+        ncomponents := ncomponents + 1;
+    END LOOP;
+    RAISE NOTICE 'order % expanded into % order items', NEW.id, ncomponents;
+    RETURN NULL;
+END$$;
+CREATE TRIGGER fp_expand_kit_trg AFTER INSERT ON fp_order
+    FOR EACH ROW EXECUTE FUNCTION fp_expand_kit();
+INSERT INTO fp_order VALUES (1, 1, 1);
+SELECT count(*) FROM fp_order_item;
+DROP TABLE fp_order, fp_order_item, fp_kit_component, fp_product, fp_customer;
+DROP FUNCTION fp_expand_kit(), fp_add_order_item(int, int);
-- 
2.53.0

