From b97fa3c645e4e9b0b74064beb06b326fe98f9160 Mon Sep 17 00:00:00 2001
From: nkey <mihailnikalayeu@gmail.com>
Date: Sat, 1 Aug 2026 22:49:46 +0200
Subject: [PATCH v1 3/3] Re-read conindid under the referenced table's lock in
 the RI fast path

The RI fast path looks up the constraint, takes RowShareLock on the
referenced table, and opens the index conindid names.  Reading conindid
before that lock is not safe.  REINDEX CONCURRENTLY repoints the
constraint at a new index and then drops the old one, and it waits only
for backends holding a lock on the referenced table; a backend that has
read the constraint but not yet taken that lock is not one of them.  It
then opens an index that is already gone, and the write fails with
"could not open relation with OID".  An interrupted rebuild leaves the
old index dead rather than dropped, and opening that one is worse: it is
no longer vacuumed, so its entries can point at line pointers the heap
has handed out again, and the check reports a referenced row that does
not exist.

Re-read the constraint once the table is locked.  Locking it is what
makes the value stable: index_drop() removes the old index only after the
swap that repointed conindid has committed, and only after waiting for
the table's lockers, so afterwards we either see the new index or an old
one that cannot go away until this transaction ends.

Both reproducers added earlier now pass: the write completes and the row
is updated in 015, and in 016 the write is rejected and leaves no foreign
key row without a referenced row.  A fix that skipped the check would
pass the first of those and fail the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---
 src/backend/utils/adt/ri_triggers.c | 19 +++++++++++++++++++
 1 file changed, 19 insertions(+)

diff --git a/src/backend/utils/adt/ri_triggers.c b/src/backend/utils/adt/ri_triggers.c
index 17c230c9d32..4abdcca59df 100644
--- a/src/backend/utils/adt/ri_triggers.c
+++ b/src/backend/utils/adt/ri_triggers.c
@@ -2831,6 +2831,10 @@ ri_FastPathCheck(RI_ConstraintInfo *riinfo,
 	INJECTION_POINT("ri-before-pk-lock", NULL);
 
 	pk_rel = table_open(riinfo->pk_relid, RowShareLock);
+
+	/* Re-read the constraint under that lock; see ri_FastPathGetEntry(). */
+	riinfo = ri_LoadConstraintInfo(riinfo->constraint_id);
+
 	idx_rel = index_open(riinfo->conindid, AccessShareLock);
 
 	slot = table_slot_create(pk_rel, NULL);
@@ -4399,6 +4403,21 @@ ri_FastPathGetEntry(const RI_ConstraintInfo *riinfo, Relation fk_rel)
 		INJECTION_POINT("ri-before-pk-lock", NULL);
 
 		entry->pk_rel = table_open(riinfo->pk_relid, RowShareLock);
+
+		/*
+		 * Re-read the constraint now that the PK table is locked, because
+		 * conindid may have been read before that lock was taken and REINDEX
+		 * CONCURRENTLY moves a constraint to a new index.  Locking the PK
+		 * table is what makes the value we read here stable: index_drop()
+		 * removes the old index only after the swap that repointed conindid
+		 * has committed, and only after waiting for the lockers of the table,
+		 * so we either see the new index or an old one that cannot go away
+		 * until this transaction ends.  Without this we could open an index
+		 * that has already been dropped, or scan one that has been marked
+		 * dead and so no longer receives new rows.
+		 */
+		riinfo = ri_LoadConstraintInfo(riinfo->constraint_id);
+
 		entry->idx_rel = index_open(riinfo->conindid, AccessShareLock);
 		entry->pk_slot = table_slot_create(entry->pk_rel, NULL);
 
-- 
2.54.0.windows.1

