Hi hackers,
We encountered an issue where queries using the index scan reorder queue
(e.g., KNN GiST scans with lossy distance recheck) lose the tuple's
physical ItemPointer (ctid) in the returned slot.
When combined with row locking (e.g., SELECT ... FOR UPDATE), the invalid
TID (4294967295, 0) is passed to heap_lock_tuple(). Because InvalidBlockNumber
equals P_NEW, ReadBuffer() extends the relation on disk before aborting with
"attempted to lock invisible tuple", leaving an orphaned uninitialized block
that fails subsequent sequential scans with "ERROR: invalid page in block N".
This affects all supported versions (PG 14 through master).
=== Cause ===
When tuples are popped from node->iss_ReorderQueue, nodeIndexscan.c calls:
ExecForceStoreHeapTuple(tuple, slot, true);
In src/backend/executor/execTuples.c (TTS_IS_BUFFERTUPLE branch),
ExecClearTuple() resets slot->tts_tid to InvalidItemPointer, but
slot->tts_tid is never updated with tuple->t_self.
=== Reproducer ===
CREATE TABLE t (id int PRIMARY KEY, v circle);
INSERT INTO t SELECT i, circle(point(i, i), 0.5) FROM generate_series(1, 100) i;
CREATE INDEX ON t USING gist (v circle_ops);
-- 1. Emits invalid ctid (4294967295, 0):
SELECT ctid, id FROM t ORDER BY v <-> point(50, 50) LIMIT 1;
-- 2. Fails with "attempted to lock invisible tuple" and extends table on disk:
BEGIN;
SELECT id FROM t ORDER BY v <-> point(50, 50) LIMIT 1 FOR UPDATE;
ROLLBACK;
=== Fix ===
Assign `slot->tts_tid = tuple->t_self;` in the TTS_IS_BUFFERTUPLE branch of
ExecForceStoreHeapTuple(), matching the behavior of
tts_buffer_heap_store_tuple().
Attached is a patch against master including regression test coverage
in gist.sql.
Thanks,
Virender
From a9eb9f17d120060a88a817689ee71f3556bdc258 Mon Sep 17 00:00:00 2001
From: Virender Singla <[email protected]>
Date: Mon, 31 Aug 2026 09:22:00 +0000
Subject: [PATCH] Fix missing tts_tid assignment in ExecForceStoreHeapTuple
When storing a HeapTuple into a BufferHeapTupleTableSlot in
ExecForceStoreHeapTuple(), ExecClearTuple() invalidated slot->tts_tid,
but it was never updated with the tuple's physical ItemPointer (t_self).
For queries using the index scan reorder queue (such as KNN GiST scans
with lossy distance recheck) combined with row locking (e.g. FOR UPDATE),
the invalid TID was passed to heap_lock_tuple(), causing relation file
extension and "attempted to lock invisible tuple" errors.
Fix by assigning slot->tts_tid from tuple->t_self.
Backpatch-through: 14
---
src/backend/executor/execTuples.c | 1 +
src/test/regress/expected/gist.out | 23 +++++++++++++++++++++++
src/test/regress/sql/gist.sql | 16 ++++++++++++++++
3 files changed, 40 insertions(+)
diff --git a/src/backend/executor/execTuples.c b/src/backend/executor/execTuples.c
index b8e8f52c64c..695b87f97e2 100644
--- a/src/backend/executor/execTuples.c
+++ b/src/backend/executor/execTuples.c
@@ -1765,6 +1765,7 @@ ExecForceStoreHeapTuple(HeapTuple tuple,
slot->tts_flags &= ~TTS_FLAG_EMPTY;
oldContext = MemoryContextSwitchTo(slot->tts_mcxt);
bslot->base.tuple = heap_copytuple(tuple);
+ slot->tts_tid = tuple->t_self;
slot->tts_flags |= TTS_FLAG_SHOULDFREE;
MemoryContextSwitchTo(oldContext);
diff --git a/src/test/regress/expected/gist.out b/src/test/regress/expected/gist.out
index ac79f94aa80..280c2a88c88 100644
--- a/src/test/regress/expected/gist.out
+++ b/src/test/regress/expected/gist.out
@@ -463,3 +463,26 @@ create index gist_tbl_box_index on gist_tbl using gist (b);
insert into gist_tbl
select box(point(0.05*i, 0.05*i)) from generate_series(0,10) as i;
drop table gist_tbl;
+-- Test KNN scan with lossy distance recheck, reorder queue, and row locking
+CREATE TABLE gist_reorder_tbl (id int4 PRIMARY KEY, v circle);
+INSERT INTO gist_reorder_tbl (id, v)
+ SELECT i, circle(point(i, i), 0.5) FROM generate_series(1, 100) i;
+CREATE INDEX gist_reorder_idx ON gist_reorder_tbl USING gist (v circle_ops);
+-- Verify that ctid is properly preserved when popped from reorder queue
+SELECT ctid != '(4294967295,0)'::tid AS valid_tid FROM gist_reorder_tbl
+ ORDER BY v <-> point(50, 50) LIMIT 1;
+ valid_tid
+-----------
+ t
+(1 row)
+
+-- Verify that row locking succeeds without attempting to lock invisible tuple
+BEGIN;
+SELECT id FROM gist_reorder_tbl ORDER BY v <-> point(50, 50) LIMIT 1 FOR UPDATE;
+ id
+----
+ 50
+(1 row)
+
+COMMIT;
+DROP TABLE gist_reorder_tbl;
diff --git a/src/test/regress/sql/gist.sql b/src/test/regress/sql/gist.sql
index 57dcc082450..c23912da9cf 100644
--- a/src/test/regress/sql/gist.sql
+++ b/src/test/regress/sql/gist.sql
@@ -236,3 +236,19 @@ create index gist_tbl_box_index on gist_tbl using gist (b);
insert into gist_tbl
select box(point(0.05*i, 0.05*i)) from generate_series(0,10) as i;
drop table gist_tbl;
+
+-- Test KNN scan with lossy distance recheck, reorder queue, and row locking
+CREATE TABLE gist_reorder_tbl (id int4 PRIMARY KEY, v circle);
+INSERT INTO gist_reorder_tbl (id, v)
+ SELECT i, circle(point(i, i), 0.5) FROM generate_series(1, 100) i;
+CREATE INDEX gist_reorder_idx ON gist_reorder_tbl USING gist (v circle_ops);
+
+-- Verify that ctid is properly preserved when popped from reorder queue
+SELECT ctid != '(4294967295,0)'::tid AS valid_tid FROM gist_reorder_tbl
+ ORDER BY v <-> point(50, 50) LIMIT 1;
+
+-- Verify that row locking succeeds without attempting to lock invisible tuple
+BEGIN;
+SELECT id FROM gist_reorder_tbl ORDER BY v <-> point(50, 50) LIMIT 1 FOR UPDATE;
+COMMIT;
+DROP TABLE gist_reorder_tbl;
--
2.55.0.897.gb25b4bd76c-goog