Received: from malur.postgresql.org ([217.196.149.56]) by arkaria.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wgUga-006DkJ-0n for pgsql-hackers@arkaria.postgresql.org; Sun, 05 Jul 2026 21:47:36 +0000 Received: from localhost ([127.0.0.1] helo=malur.postgresql.org) by malur.postgresql.org with esmtp (Exim 4.96) (envelope-from ) id 1wgUgX-00DoZi-2b for pgsql-hackers@arkaria.postgresql.org; Sun, 05 Jul 2026 21:47:33 +0000 Received: from magus.postgresql.org ([2a02:c0:301:0:ffff::29]) by malur.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wgUgX-00DoZY-1Z for pgsql-hackers@lists.postgresql.org; Sun, 05 Jul 2026 21:47:33 +0000 Received: from mail-pf1-x430.google.com ([2607:f8b0:4864:20::430]) by magus.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (Exim 4.98.2) (envelope-from ) id 1wgUgP-00000001wJS-2Op1 for pgsql-hackers@postgresql.org; Sun, 05 Jul 2026 21:47:27 +0000 Received: by mail-pf1-x430.google.com with SMTP id d2e1a72fcca58-845ea8924fdso2576004b3a.0 for ; Sun, 05 Jul 2026 14:47:25 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=leadboat.com; s=google; t=1783288043; x=1783892843; darn=postgresql.org; h=user-agent:in-reply-to:content-disposition:mime-version:references :message-id:subject:cc:to:from:date:from:to:cc:subject:date :message-id:reply-to; bh=kwhOKxPutEYQIKkFua+RTPcp0qW5QgAgBQ4baUvvulc=; b=FEkEkIr7Qiel91ZDsCePXJR4FByzgfaIyKU29iCx8lijaEU+RrRbvAjrVMKpBTUfay G1wL6pP3y8Qd8AING7z36BY5Hy/zRvHgc09GbGhaVwGKzDCG1WWDaV6NZgs59FXgWSNd iHsktQdht/CUsRnoCMiymx/aK4JcY7bIokVSE= X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1783288043; x=1783892843; h=user-agent:in-reply-to:content-disposition:mime-version:references :message-id:subject:cc:to:from:date:x-gm-gg:x-gm-message-state:from :to:cc:subject:date:message-id:reply-to; bh=kwhOKxPutEYQIKkFua+RTPcp0qW5QgAgBQ4baUvvulc=; b=qcqb5p/csQnTlseTb2kjLu5s4vX7h8FP0ffLaK+rjEnpoMd6jM4PcK+4kL+TK6CN6O AcQjhq0mO1jabz4GsDDe04BF2z357tVDJvgxFTi8JV5fngvGc0Ao7uZiRIpwnA22rB5Z qdfPcQbDRQEIciDCBHxv44kSwis+9PKnL8hiuVui/oB4+tUn/6LaLL/IKcprz/+ZjI3i aR+ZkTWQ3s3JDK2kYs59YUA0qu0wFEFvg/ATZidXrduQq3ug+bUOJBcg+rzFtoo+9aBu q/dvTHdLxnbbfwSrT2EG2WtYnXyg+kXXhzSOo13DDRyxa3m6CG0TWuEy+cPyXQynjEd7 EISQ== X-Gm-Message-State: AOJu0YzcRp7rncU3uAfeuAL4BSyyPNT2eKazIq+fp2r4fhSsDbiatO9q A2nxi+b3df6J8pVxDKIPzMdc8xbghnx80JBB3PiNSnOjN9vUwKj3OwewXo1Hp9jz4g== X-Gm-Gg: AfdE7cmKd7sJoaOVM43W0G2e1Ad0JXGrcjGvjh8AUczir4it1tE6NAuSN6NUrU2f5np 0hNqiYISNO8u9sU9+ozKzaHcU2/y0y8T9KDCWOSYiX5rSdk9mW/JcUVAaYtdInbOSvdSsT6z9RT LPN6Tz2OAz9nbpTFkPB0hc1w9Godl6/z6lLhLH5DqLpgJEuA+TtKHl/vxJl7g86UuDlxfICsb5m TBtytGOtFmyGI0Em3pIZgyOTahSSsJwdl8Bwq3TxjmEiV2jXfTBoQtVvyfEaZjgFoNmlHQB5XYM /geBbKPiGhtRlwcn3dusfD4Am1/7eJuU7dpXNLAhDrabnNd24Hx8DSaTjpBh18tWweNJKpL6WEU qn6dmkBwwGZnAyi3a673jr+RCzp0WIRizqkRRHxJhaRnWZM++pOexVfN1dpvd1NoCVhFCZX9gr7 J62Zl3+ELxuAWzLSO+GbkP86ebvOuQAHUJWYGNkKoE4Sxu2xU0BboL2F70uN0= X-Received: by 2002:a05:6a21:3e09:b0:3c0:4515:564e with SMTP id adf61e73a8af0-3c04515a0b9mr5943719637.36.1783288043205; Sun, 05 Jul 2026 14:47:23 -0700 (PDT) Received: from rfd.leadboat.com (c-73-15-160-255.hsd1.ca.comcast.net. [73.15.160.255]) by smtp.gmail.com with ESMTPSA id 5a478bee46e88-30f1595e912sm50583346eec.31.2026.07.05.14.47.21 (version=TLS1_2 cipher=ECDHE-ECDSA-AES128-GCM-SHA256 bits=128/128); Sun, 05 Jul 2026 14:47:22 -0700 (PDT) Date: Sun, 5 Jul 2026 14:47:20 -0700 From: Noah Misch To: amitlangote09@gmail.com, zhjwpku@gmail.com Cc: pgsql-hackers@postgresql.org Subject: Re: ri_Fast* crash w/ nullable UNIQUE constraint Message-ID: <20260705214720.33@rfd.leadboat.com> References: <20260705210533.ee.noahmisch@microsoft.com> MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="MhVYHFhmcPooip95" Content-Disposition: inline In-Reply-To: <20260705210533.ee.noahmisch@microsoft.com> User-Agent: Mutt/2.3.0 (2026-01-25) List-Id: List-Help: List-Subscribe: List-Post: List-Owner: List-Archive: Archived-At: Precedence: bulk --MhVYHFhmcPooip95 Content-Type: text/plain; charset=us-ascii Content-Disposition: inline On Sun, Jul 05, 2026 at 02:05:33PM -0700, Noah Misch wrote: > attached demo patch shows a crash from this. I had Opus 4.8 write the demo, > and it included a fix that I've not vetted. But I've vetted that reverting > the src/backend changes and running "make -C src/test/isolation check" does > see the crash: > > TRAP: failed Assert("!isnull[i]"), File: "ri_triggers.c", Line: 3431, PID: 297407 Here's the attachment I forgot. --MhVYHFhmcPooip95 Content-Type: text/plain; charset=us-ascii Content-Disposition: attachment; filename=test-fk-unique-nullable-v0.patch commit 31376ce (HEAD -> fk-fastpath-null-key) Author: Noah Misch AuthorDate: Wed Jul 1 18:56:36 2026 +0000 Commit: Noah Misch CommitDate: Wed Jul 1 18:56:36 2026 +0000 Fix RI fast-path crash when referenced key is concurrently set to NULL A foreign key may reference a nullable UNIQUE column, not only a NOT NULL primary key. The RI fast-path check, however, assumed that a referenced key column could never be NULL ("A PK column can never be set to NULL"). Under READ COMMITTED, ri_LockPKTuple() locks with TUPLE_LOCK_FLAG_FIND_LAST_VERSION, so it can chase an update chain to a newer version of the referenced row. If a concurrent transaction set the referenced key to NULL and committed, the fast path follows the chain to that NULL version. recheck_matched_pk_tuple() (per-row loop flush) and ri_FastPathFlushArray() (SK_SEARCHARRAY flush) then read a NULL key where they asserted one could not occur, tripping an assertion in cassert builds and, in production builds, dereferencing a NULL datum for by-reference key types or reporting a spurious violation for by-value types. Treat a NULL referenced key as "not a match", which is what the SPI path does implicitly: its requalifying "pkatt = $n" evaluates to NULL and the row is not returned, yielding an ordinary foreign-key violation. Add an isolation test covering both the per-row and array flush paths. The fast path and the NULL assumption were introduced by commit 2da86c1ef9; the array-path variant came with the batching in b7b27eb41a5. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Uoxu6AaEfa1135tvCA2dHJ --- src/backend/utils/adt/ri_triggers.c | 25 +++++++++++--- .../isolation/expected/fk-fastpath-null-key.out | 17 +++++++++ src/test/isolation/isolation_schedule | 1 + src/test/isolation/specs/fk-fastpath-null-key.spec | 40 ++++++++++++++++++++++ 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/adt/ri_triggers.c b/src/backend/utils/adt/ri_triggers.c index 627a9fb..d2b969e 100644 --- a/src/backend/utils/adt/ri_triggers.c +++ b/src/backend/utils/adt/ri_triggers.c @@ -3184,9 +3184,19 @@ ri_FastPathFlushArray(RI_FastPathEntry *fpentry, TupleTableSlot *fk_slot, if (!ri_LockPKTuple(pk_rel, pk_slot, snapshot, &concurrently_updated)) continue; - /* Extract the PK value from the matched and locked tuple */ + /* + * Extract the PK value from the matched and locked tuple. + * + * A foreign key may reference a nullable unique column, not just a NOT + * NULL primary key. If ri_LockPKTuple() chased an update chain to a + * version whose referenced key is now NULL, that version cannot equal + * any buffered (non-null) FK value, so skip it. This mirrors the SPI + * path, where the requalifying "pkatt = $n" yields NULL and the row is + * not returned. + */ found_val = slot_getattr(pk_slot, riinfo->pk_attnums[0], &found_null); - Assert(!found_null); + if (found_null) + continue; if (concurrently_updated) { @@ -3427,9 +3437,14 @@ recheck_matched_pk_tuple(Relation idxrel, ScanKeyData *skeys, int nkeys, { ScanKeyData *skey = &skeys[i]; - /* A PK column can never be set to NULL. */ - Assert(!isnull[i]); - if (!DatumGetBool(FunctionCall2Coll(&skey->sk_func, + /* + * A foreign key may reference a nullable unique column, so the version + * we chased the update chain to may have a NULL in a key column. A + * NULL never equals the value we searched for, so treat it as no + * match, as the SPI path's requalification would. + */ + if (isnull[i] || + !DatumGetBool(FunctionCall2Coll(&skey->sk_func, skey->sk_collation, values[i], skey->sk_argument))) diff --git a/src/test/isolation/expected/fk-fastpath-null-key.out b/src/test/isolation/expected/fk-fastpath-null-key.out new file mode 100644 index 0000000..3efe1e7 --- /dev/null +++ b/src/test/isolation/expected/fk-fastpath-null-key.out @@ -0,0 +1,17 @@ +Parsed test spec with 2 sessions + +starting permutation: s1b s1upd_null s2ins s1c +step s1b: BEGIN; +step s1upd_null: UPDATE pktable SET u = NULL WHERE u = 5; +step s2ins: INSERT INTO fktable VALUES (5); +step s1c: COMMIT; +step s2ins: <... completed> +ERROR: insert or update on table "fktable" violates foreign key constraint "fktable_a_fkey" + +starting permutation: s1b s1upd_null s2ins_arr s1c +step s1b: BEGIN; +step s1upd_null: UPDATE pktable SET u = NULL WHERE u = 5; +step s2ins_arr: INSERT INTO fktable VALUES (5), (6); +step s1c: COMMIT; +step s2ins_arr: <... completed> +ERROR: insert or update on table "fktable" violates foreign key constraint "fktable_a_fkey" diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92..cbc40f2 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -38,6 +38,7 @@ test: fk-snapshot test: fk-snapshot-2 test: fk-snapshot-3 test: fk-concurrent-pk-upd +test: fk-fastpath-null-key test: subxid-overflow test: eval-plan-qual test: eval-plan-qual-trigger diff --git a/src/test/isolation/specs/fk-fastpath-null-key.spec b/src/test/isolation/specs/fk-fastpath-null-key.spec new file mode 100644 index 0000000..51e11b1 --- /dev/null +++ b/src/test/isolation/specs/fk-fastpath-null-key.spec @@ -0,0 +1,40 @@ +# A foreign key may reference a nullable UNIQUE column, not only a NOT NULL +# primary key. Test that the RI fast-path check copes when a concurrent +# transaction sets the referenced key to NULL. +# +# s2's INSERT probes the PK index, finds the (u=5) row, and blocks on s1's +# in-progress key-changing UPDATE. After s1 commits, the tuple lock follows +# the update chain (table_tuple_lock with TUPLE_LOCK_FLAG_FIND_LAST_VERSION) +# to the now-NULL version. The check must treat that as "referenced row not +# found" and raise an ordinary foreign-key violation -- not assume that a +# referenced key can never be NULL. +# +# Two permutations exercise the two fast-path flush routines: a single-row +# INSERT goes through ri_FastPathFlushLoop()/recheck_matched_pk_tuple(), while +# a multi-row single-column INSERT goes through ri_FastPathFlushArray(). + +setup +{ + CREATE TABLE pktable (u int UNIQUE, c int); + CREATE TABLE fktable (a int REFERENCES pktable (u)); + INSERT INTO pktable VALUES (5, 1), (6, 2); +} + +teardown +{ + DROP TABLE fktable, pktable; +} + +session s1 +step s1b { BEGIN; } +step s1upd_null { UPDATE pktable SET u = NULL WHERE u = 5; } +step s1c { COMMIT; } + +session s2 +# single-row batch -> per-row loop flush path +step s2ins { INSERT INTO fktable VALUES (5); } +# multi-row single-column batch -> SK_SEARCHARRAY flush path +step s2ins_arr { INSERT INTO fktable VALUES (5), (6); } + +permutation s1b s1upd_null s2ins s1c +permutation s1b s1upd_null s2ins_arr s1c --MhVYHFhmcPooip95--