ExecForceStoreHeapTuple() loses tts_tid, so ORDER BY-op index scans project an invalid ctid
Hackorum builds and tests every patch posted to the lists, not only commitfest submissions. This is Hackorum's own CI rather than the PostgreSQL project's, and it is still under testing - please report anything that looks wrong.
You can run a PostgreSQL built from this patch straight from Docker, with no checkout and no build:
docker run --rm -p 5432:5432 ghcr.io/hackorum-dev/postgres-patch:t253715psql -h localhost -U postgresBuilt from patchset v1 (message #1), September 19, 2026 at 05:47 PM.
Every patchset is also pushed to a branch of our PostgreSQL fork, so you can check out the same tree CI built. Without a PostgreSQL checkout:
git clone --branch t253715_1 https://github.com/hackorum-dev/postgres.gitIn a checkout you already have, add the fork once:
git remote add hackorum https://github.com/hackorum-dev/postgres.gitthen, for this patchset and every later one:
git fetch hackorum t253715_1 && git checkout t253715_1Patchset v1 (message #1) is on t253715_1
Hi Hackers,
The fix is simple, one line, but IMO does need to be backpatched to v13.
ExecForceStoreHeapTuple() does not set slot->tts_tid when the target
slot is a TTS_IS_BUFFERTUPLE slot. Any plan that re-stores a heap tuple
through it and then projects ctid therefore gets (4294967295,0) instead
of the row's real heap TID.
The affected branch src/backend/executor/execTuples.c:
else if (TTS_IS_BUFFERTUPLE(slot))
{
MemoryContext oldContext;
BufferHeapTupleTableSlot *bslot = (BufferHeapTupleTableSlot *) slot;
ExecClearTuple(slot); /* invalidates tts_tid */
slot->tts_flags &= ~TTS_FLAG_EMPTY;
oldContext = MemoryContextSwitchTo(slot->tts_mcxt);
bslot->base.tuple = heap_copytuple(tuple);
slot->tts_flags |= TTS_FLAG_SHOULDFREE;
MemoryContextSwitchTo(oldContext);
/* BUG: the tts_tid is never restored from tuple->t_self */
if (shouldFree)
pfree(tuple);
}
ExecClearTuple() reaches tts_buffer_heap_clear(), which does
ItemPointerSetInvalid(&slot->tts_tid). The tuple is then copied in, but
tts_tid is left invalid. The sibling path, ExecStoreHeapTuple() ->
tts_heap_store_tuple() — *does* slot->tts_tid = tuple->t_self, so this
reads as a plain asymmetry rather than an intentional choice.
It is user-visible because slot_getsysattr() answers
SelfItemPointerAttributeNumber directly out of slot->tts_tid
(src/include/executor/tuptable.h).
nodeIndexscan.c reaches it on a normal code path:
reorderqueue_pop() hands its palloc'd copy to
ExecForceStoreHeapTuple(). So for any index AM that sets
xs_recheckorderby = true, every tuple routed through the reorder queue
projects the invalid-TID sentinel — even though the AM set xs_heaptid
correctly, which is why the row data is right and only ctid is wrong.
Reproducer: core GiST only, no extensions
Thin diagonal triangles, so the bounding-box distance strictly
under-estimates the true polygon distance: gist_poly_consistent sets
recheck, was_exact comes out false, and the tuples are pushed to the
reorder queue.
CREATE TABLE tri (id int, p polygon);
INSERT INTO tri
SELECT i, ('((' || i*10 || ',0),(' || (i*10+9) || ',9),('
|| (i*10+9) || ',0))')::polygon
FROM generate_series(1,3000) i;
CREATE INDEX tri_idx ON tri USING gist (p);
ANALYZE tri;
SET enable_seqscan = off;
SELECT ctid, id FROM tri ORDER BY p <-> point(15000,4) LIMIT 5;
On 18.4:
ctid | id
----------------+------
(23,4) | 1499 <- returned directly, ctid correct
(4294967295,0) | 1500 <- came off the reorder queue
(4294967295,0) | 1501
(4294967295,0) | 1498
(4294967295,0) | 1502
The one row IndexNextWithReorder() returned without queueing keeps its
real ctid, which pins the fault to the requeue path.
Consequences:
-- ctid self-join: finds 1 row, not 5
WITH k AS (SELECT ctid AS c FROM tri ORDER BY p <-> point(15000,4) LIMIT 5)
SELECT count(*) FROM tri t JOIN k ON t.ctid = k.c;
-- and this quietly updates ONE row instead of five, with no error
WITH k AS (SELECT ctid AS c FROM tri ORDER BY p <-> point(15000,4) LIMIT 5)
UPDATE tri SET ... WHERE ctid IN (SELECT c FROM k);
The UPDATE is the case I would highlight: it does not fail, it just
affects the wrong number of rows.
Verification:
Built both ways on one machine and ran one script, stock 18.4 versus an
18.3 tree with only the attached hunk applied:
unpatched patched
ctid self-join, expect 5 1 5
UPDATE ... WHERE ctid, expect 5 1 5
sentinel ctids at LIMIT 50 49/50 0/50
I also checked that there is no query-level workaround: WITH ... AS
MATERIALIZED, casting to text inside a subquery, and extra subquery
nesting all still return the sentinel, since it is already in the slot
before any of them run. Forcing a seqscan returns correct ctids but
abandons the index.
49 of 50 rather than 50 is the was_exact fast path again: a tuple whose
index-returned ORDER BY value compares equal to the recomputed one is
returned without queueing. An AM that cannot usefully bound its ORDER BY
value and advertises -inf has 100% of its tuples queued.
Patch:
One line plus a comment, restoring tts_tid in that branch, mirroring
what tts_heap_store_tuple() already does:
slot->tts_tid = tuple->t_self;
ExecForceStoreHeapTuple()'s implementation hasn't changed since REL_13,
so this fix applies to all of them. I'd lean toward backpatching all.
Backstory:
The bug was introduced by b8d71745eac during the v12 work by Andres on
the slot rewrite. It was made observable one commit later by ff11e7f4b9a.
Before ff11e7f4b9a, ExecClearTuple() left tts_tid alone, so the slot happened
to retain a stale-but-often-right tid; after it, the buffer branch reliably
leaves InvalidBlockNumber.
The GiST/ctid symptom only appeared at b8b94ea129f ("Fix slot type issue for
fuzzy distance index scan over out-of-core table AM"), which switched
nodeIndexscan.c's reorderqueue_pop() from ExecStoreHeapTuple() into
ExecForceStoreHeapTuple() and deleted the dedicated iss_ReorderQueueSlot (a
TTSOpsHeapTuple slot, which took the correct branch).
I found this via an out-of-tree index AM (pg_turbovec) that I'm working
on, it sets xs_recheckorderby = true to re-rank approximate distances
exactly, but as shown above this is an issue in core that just happened
to surface during that other work.
Patch v1 with test attached.
best.
-greg
On Tue, Sep 08, 2026 at 01:28:22PM -0400, Greg Burd wrote:
ExecForceStoreHeapTuple() does not set slot->tts_tid when the target
slot is a TTS_IS_BUFFERTUPLE slot. Any plan that re-stores a heap tuple
through it and then projects ctid therefore gets (4294967295,0) instead
of the row's real heap TID.
Oops.
My question would be what kind of testing you have done to spot that..
ExecClearTuple() reaches tts_buffer_heap_clear(), which does
ItemPointerSetInvalid(&slot->tts_tid). The tuple is then copied in, but
tts_tid is left invalid. The sibling path, ExecStoreHeapTuple() ->
tts_heap_store_tuple() — *does* slot->tts_tid = tuple->t_self, so this
reads as a plain asymmetry rather than an intentional choice.
That's strange. Once thing that I can see why scanning this file is
the same code pattern in tts_buffer_heap_copyslot(), where a slot is
similarly cleared in a copy-paste fashion.
Andres?
--
Michael
On Tue, Sep 8, 2026, at 8:03 PM, Michael Paquier wrote:
On Tue, Sep 08, 2026 at 01:28:22PM -0400, Greg Burd wrote:
ExecForceStoreHeapTuple() does not set slot->tts_tid when the target
slot is a TTS_IS_BUFFERTUPLE slot. Any plan that re-stores a heap tuple
through it and then projects ctid therefore gets (4294967295,0) instead
of the row's real heap TID.Oops.
Yeah, oops indeed. :)
My question would be what kind of testing you have done to spot that..
Honestly, not by a test. I am working on a new index AM for vector
similarity search [1]https://codeberg.org/gregburd/pg_turbovec https://github.com/gburd/pg_turbovec adding a 1-bit encoding with rerank. To do that
rerank it sets xs_recheckorderby = true, because its distances are
quantised and only the executor's exact re-check can order the top-k
correctly. That routes tuples through nodeIndexscan.c's reorder queue.
One of our documented recipes harvests ctid from a scan and chains it
downstream; that quietly started matching nothing, with the ctids
coming back as (4294967295,0).
My first assumption was that I'd broken something, so I went looking for
the boundary:
- xs_heaptid was correct and every real column was right; only the
projected ctid was wrong, which pointed at the slot, not the AM.
- ExecForceStoreHeapTuple's TTS_IS_BUFFERTUPLE branch calls
ExecClearTuple (hence ItemPointerSetInvalid on tts_tid) and never
restores it, while the sibling tts_heap_store_tuple does; and
slot_getsysattr answers SelfItemPointerAttributeNumber straight out of
tts_tid.
- Then I reproduced it with core GiST and nothing else loaded, which is
the version in the patch.
To confirm the fix I built stock 18.4 with only that hunk applied and
ran a test:
unpatched patched
ctid self-join, expect 5 1 5
UPDATE ... WHERE ctid, expect 5 1 5
sentinel ctids at LIMIT 50 49/50 0/50
The UPDATE line is the one that bothers me most. No error, it just
affects the wrong number of rows.
As for why the tree doesn't catch it, AFAICT nothing in core projects
ctid from an ORDER BY-op index scan. The GiST kNN tests check ordering
and results, which are fine here the row data is never wrong. That's
what v1 adds a regress case for, and I checked it fails
(ctid_matches = 1) without the hunk and passes (5) with it, so it gates
the fix rather than just recording current output.
One detail that probably explains the longevity, IndexNextWithReorder
only queues a tuple when the AM's advertised ORDER BY value doesn't
compare equal to the recomputed one. GiST's bounding-box distance is
sometimes exact, so some rows keep their real ctid. Note the 49 of 50
above rather than 50. My index AM can't usefully bound its distance and
advertises -inf, so every tuple goes through the queue and every one
shows the sentinel. Partial in core, total out here.
ExecClearTuple() reaches tts_buffer_heap_clear(), which does
ItemPointerSetInvalid(&slot->tts_tid). The tuple is then copied in, but
tts_tid is left invalid. The sibling path, ExecStoreHeapTuple() ->
tts_heap_store_tuple() — *does* slot->tts_tid = tuple->t_self, so this
reads as a plain asymmetry rather than an intentional choice.That's strange. Once thing that I can see why scanning this file is
the same code pattern in tts_buffer_heap_copyslot(), where a slot is
similarly cleared in a copy-paste fashion.
Agreed.
Andres?
--
Michael
best, thanks for looking,
-greg
[1]: https://codeberg.org/gregburd/pg_turbovec https://github.com/gburd/pg_turbovec
On Sep 9, 2026, at 8:18 AM, Greg Burd <greg@burd.me> wrote:
On Tue, Sep 8, 2026, at 8:03 PM, Michael Paquier wrote:
On Tue, Sep 08, 2026 at 01:28:22PM -0400, Greg Burd wrote:
ExecForceStoreHeapTuple() does not set slot->tts_tid when the target
slot is a TTS_IS_BUFFERTUPLE slot. Any plan that re-stores a heap tuple
through it and then projects ctid therefore gets (4294967295,0) instead
of the row's real heap TID.Oops.
Yeah, oops indeed. :)
My question would be what kind of testing you have done to spot that..
Honestly, not by a test. I am working on a new index AM for vector
similarity search [1] adding a 1-bit encoding with rerank. To do that
rerank it sets xs_recheckorderby = true, because its distances are
quantised and only the executor's exact re-check can order the top-k
correctly. That routes tuples through nodeIndexscan.c's reorder queue.
One of our documented recipes harvests ctid from a scan and chains it
downstream; that quietly started matching nothing, with the ctids
coming back as (4294967295,0).My first assumption was that I'd broken something, so I went looking for
the boundary:- xs_heaptid was correct and every real column was right; only the
projected ctid was wrong, which pointed at the slot, not the AM.
- ExecForceStoreHeapTuple's TTS_IS_BUFFERTUPLE branch calls
ExecClearTuple (hence ItemPointerSetInvalid on tts_tid) and never
restores it, while the sibling tts_heap_store_tuple does; and
slot_getsysattr answers SelfItemPointerAttributeNumber straight out of
tts_tid.
- Then I reproduced it with core GiST and nothing else loaded, which is
the version in the patch.To confirm the fix I built stock 18.4 with only that hunk applied and
ran a test:
unpatched patched
ctid self-join, expect 5 1 5
UPDATE ... WHERE ctid, expect 5 1 5
sentinel ctids at LIMIT 50 49/50 0/50The UPDATE line is the one that bothers me most. No error, it just
affects the wrong number of rows.As for why the tree doesn't catch it, AFAICT nothing in core projects
ctid from an ORDER BY-op index scan. The GiST kNN tests check ordering
and results, which are fine here the row data is never wrong. That's
what v1 adds a regress case for, and I checked it fails
(ctid_matches = 1) without the hunk and passes (5) with it, so it gates
the fix rather than just recording current output.One detail that probably explains the longevity, IndexNextWithReorder
only queues a tuple when the AM's advertised ORDER BY value doesn't
compare equal to the recomputed one. GiST's bounding-box distance is
sometimes exact, so some rows keep their real ctid. Note the 49 of 50
above rather than 50. My index AM can't usefully bound its distance and
advertises -inf, so every tuple goes through the queue and every one
shows the sentinel. Partial in core, total out here.ExecClearTuple() reaches tts_buffer_heap_clear(), which does
ItemPointerSetInvalid(&slot->tts_tid). The tuple is then copied in, but
tts_tid is left invalid. The sibling path, ExecStoreHeapTuple() ->
tts_heap_store_tuple() — *does* slot->tts_tid = tuple->t_self, so this
reads as a plain asymmetry rather than an intentional choice.That's strange. Once thing that I can see why scanning this file is
the same code pattern in tts_buffer_heap_copyslot(), where a slot is
similarly cleared in a copy-paste fashion.Agreed.
Andres?
--
Michaelbest, thanks for looking,
-greg
[1] https://codeberg.org/gregburd/pg_turbovec https://github.com/gburd/pg_turbovec
As can happen it turns out I'm not the first to report this or propose a patch for
it. [1]/messages/by-id/CAM6Zo8wZOLnCWRO_tuuXVX9J4N4JN6GsEnk8WJtT0=_0zy-1dw@mail.gmail.com So I suggest we continue on that thread.
Also, the other report found that the FOR UPDATE can extend the relation and
leave a block that later breaks seqscans so essentially this bug can cause
on-disk damage.
best.
-greg
[1]: /messages/by-id/CAM6Zo8wZOLnCWRO_tuuXVX9J4N4JN6GsEnk8WJtT0=_0zy-1dw@mail.gmail.com
Hi,
On 2026-09-09 09:03:46 +0900, Michael Paquier wrote:
On Tue, Sep 08, 2026 at 01:28:22PM -0400, Greg Burd wrote:
ExecClearTuple() reaches tts_buffer_heap_clear(), which does
ItemPointerSetInvalid(&slot->tts_tid). The tuple is then copied in, but
tts_tid is left invalid. The sibling path, ExecStoreHeapTuple() ->
tts_heap_store_tuple() — *does* slot->tts_tid = tuple->t_self, so this
reads as a plain asymmetry rather than an intentional choice.That's strange. Once thing that I can see why scanning this file is
the same code pattern in tts_buffer_heap_copyslot(), where a slot is
similarly cleared in a copy-paste fashion.
Andres?
The assymmetry does suggest we should fix this. I'm somewhat sceptical that
it's sane to expect uses of ExecForceStoreHeapTuple() to actually have valid
tids, but ...
For a bit I was wondering whether the tuple's tid is actually the right one,
due to stuff like walking a HOT chain. But it seems we set both to the same
value (there's some subtleties around this nearby that I think I was confusing
this with, with the tid for a HOT updated needing to point to the root tuple
in some cases). I wonder if we ought to have an assertion for the two tids
being the same that, perhaps only on master?
Greetings,
Andres Freund
On Mon, Sep 14, 2026, at 10:56 AM, Andres Freund wrote:
Hi,
On 2026-09-09 09:03:46 +0900, Michael Paquier wrote:
On Tue, Sep 08, 2026 at 01:28:22PM -0400, Greg Burd wrote:
ExecClearTuple() reaches tts_buffer_heap_clear(), which does
ItemPointerSetInvalid(&slot->tts_tid). The tuple is then copied in, but
tts_tid is left invalid. The sibling path, ExecStoreHeapTuple() ->
tts_heap_store_tuple() — *does* slot->tts_tid = tuple->t_self, so this
reads as a plain asymmetry rather than an intentional choice.That's strange. Once thing that I can see why scanning this file is
the same code pattern in tts_buffer_heap_copyslot(), where a slot is
similarly cleared in a copy-paste fashion.Andres?
Hey Andres, thanks for taking time to review this. The thread is moving to
the one started by the first person to report this issue [1]/messages/by-id/CAM6Zo8wZOLnCWRO_tuuXVX9J4N4JN6GsEnk8WJtT0=_0zy-1dw@mail.gmail.com.
The assymmetry does suggest we should fix this. I'm somewhat sceptical that
it's sane to expect uses of ExecForceStoreHeapTuple() to actually have valid
tids, but ...For a bit I was wondering whether the tuple's tid is actually the right one,
due to stuff like walking a HOT chain. But it seems we set both to the same
value (there's some subtleties around this nearby that I think I was confusing
this with, with the tid for a HOT updated needing to point to the root tuple
in some cases). I wonder if we ought to have an assertion for the two tids
being the same that, perhaps only on master?
So, that I'm sure I understand the suggestion, you'd like to ensure that for any
heap or buffer-heap slot holding a tuple, slot->tts_tid == slot's stored
tuple->t_self. Correct?
I'll update the proposed patch set on the other thread [1]/messages/by-id/CAM6Zo8wZOLnCWRO_tuuXVX9J4N4JN6GsEnk8WJtT0=_0zy-1dw@mail.gmail.com. Hope to see you
on that one so we can wrap this up and backpatch it.
Greetings,
Andres Freund
best.
-greg
[1]: /messages/by-id/CAM6Zo8wZOLnCWRO_tuuXVX9J4N4JN6GsEnk8WJtT0=_0zy-1dw@mail.gmail.com
Hi,
On 2026-09-14 11:08:48 -0400, Greg Burd wrote:
On Mon, Sep 14, 2026, at 10:56 AM, Andres Freund wrote:
On 2026-09-09 09:03:46 +0900, Michael Paquier wrote:
On Tue, Sep 08, 2026 at 01:28:22PM -0400, Greg Burd wrote:
ExecClearTuple() reaches tts_buffer_heap_clear(), which does
ItemPointerSetInvalid(&slot->tts_tid). The tuple is then copied in, but
tts_tid is left invalid. The sibling path, ExecStoreHeapTuple() ->
tts_heap_store_tuple() — *does* slot->tts_tid = tuple->t_self, so this
reads as a plain asymmetry rather than an intentional choice.That's strange. Once thing that I can see why scanning this file is
the same code pattern in tts_buffer_heap_copyslot(), where a slot is
similarly cleared in a copy-paste fashion.Andres?
Hey Andres, thanks for taking time to review this. The thread is moving to
the one started by the first person to report this issue [1].
Just had replied there...
The assymmetry does suggest we should fix this. I'm somewhat sceptical that
it's sane to expect uses of ExecForceStoreHeapTuple() to actually have valid
tids, but ...For a bit I was wondering whether the tuple's tid is actually the right one,
due to stuff like walking a HOT chain. But it seems we set both to the same
value (there's some subtleties around this nearby that I think I was confusing
this with, with the tid for a HOT updated needing to point to the root tuple
in some cases). I wonder if we ought to have an assertion for the two tids
being the same that, perhaps only on master?So, that I'm sure I understand the suggestion, you'd like to ensure that for any
heap or buffer-heap slot holding a tuple, slot->tts_tid == slot's stored
tuple->t_self. Correct?
I don't think we can do that in general, there are legitimate cases of those
differing due to HOT IIRC. But in the reorder case I don't think that
difference exists, and it'd lead to different query results, so I think we
should just assert it there.
Greetings,
Andres Freund