pg.ddx.io  pgsql-bugs@postgresql.org mailing list archive  
help / color / mirror / Atom feed
TID Range Scans can return wrong results after scanning backwards
3+ messages / 3 participants
[nested] [flat]

Commit outlook (model estimate bayes-2.0, computed 2026-11-01; about the patch, not a person):
  The activity on this thread indicates a low probability (1%) of a commit from this thread within 60 days.
  Seek out Zsolt Parragi or Rafia Sabih or Steven Niu or Cary Huang or Junwang Zhao: they have history with this code.
  What the estimate rests on: thread commits 2.079 (lowers it), author thread rate 0.32 (raises it), last msg days 3.68 (lowers it), msgs 30d 0.0 (lowers it)
Commits from this thread:
  2026-09-23 374522aa63 ad230d5888 39a0db1011 25ebf0097f 6ceac2e125 cd7d3e129b c84dd31bd9  Fix incorrect block accounting in TID Range Scans
* TID Range Scans can return wrong results after scanning backwards
@ 2026-09-16 20:07  Jeff Davis <pgsql@j-davis.com>
  0 siblings, 1 reply; 3+ messages in thread

From: Jeff Davis @ 2026-09-16 20:07 UTC (permalink / raw)
  To: pgsql-bugs

AI reported a bug (appended to this email) where TID Range Scans can
return the wrong results after changing the direction of the scan.

Regards,
	Jeff Davis



SQL repro
---------

    CREATE TABLE t (id int, data text) WITH (fillfactor = 10);
    -- 5 rows per page, 20 pages: ctids (0,1) .. (19,5)
    INSERT INTO t SELECT i, repeat('x', 100)
      FROM generate_series(1, 100) i;
    SET enable_seqscan = off;
    BEGIN;
    DECLARE c SCROLL CURSOR FOR
      SELECT ctid FROM t WHERE ctid >= '(2,1)' AND ctid <= '(11,5)';
    MOVE FORWARD 35 c;      -- cursor is now on (8,5)
    FETCH BACKWARD 2 c;     -- (8,4), (8,3): correct
    FETCH BACKWARD ALL c;   -- should return 32 rows, returns 12
    COMMIT;


Diagnosis
---------

heap_setscanlimits() stores the number of blocks to scan in
rs_numblocks. heapgettup_advance_block() then treats that field as a
remaining budget: it decrements it once for every block it hands out,
in either direction, and ends the scan when it reaches zero.  That
model only holds for a scan that moves in one direction and consumes
exactly the blocks it requests.  A change in direction breaks that.









^ permalink  raw  reply  [nested|flat] 3+ messages in thread

* Re: TID Range Scans can return wrong results after scanning backwards
@ 2026-09-17 07:13  Ewan Young <kdbase.hack@gmail.com>
  parent: Jeff Davis <pgsql@j-davis.com>
  0 siblings, 1 reply; 3+ messages in thread

From: Ewan Young @ 2026-09-17 07:13 UTC (permalink / raw)
  To: Jeff Davis <pgsql@j-davis.com>; +Cc: pgsql-bugs

Hi,

Thanks for the report!
I can reproduce this on master and on REL_19_STABLE (12 rows instead
of 32, as in your example), and the diagnosis matches what I see:
heapgettup_advance_block() spends rs_numblocks as a budget in both
directions, so a scan that reverses mid-way runs out early.  It goes
back to when TID Range Scans made bidirectional limited scans possible
(v14); the budget logic itself is older, from the BRIN work, and the
2021 fix for the backward *start* block (16dfe253e31) didn't consider
direction changes.

The attached patch treats the limit as a fixed window of rs_numblocks
blocks starting at rs_startblock instead of a budget: the forward
direction ends when it reaches the block just past the window, and the
backward direction already ends at rs_startblock, since
heapgettup_initial_block() starts a backward scan at the window's last
block.  rs_numblocks is no longer modified during the scan.  TID Range
Scans are the only caller that scans a limited range in both
directions, so other heap_setscanlimits() users see no change.

Besides your example, I ran a randomized comparison of SCROLL cursor
sequences (random MOVE/FETCH FORWARD/BACKWARD/ALL/ABSOLUTE/RELATIVE
over random ctid ranges) between the TID Range Scan and a seqscan of
the same query: 135 of 1500 sequences differ on unpatched master, none
with the patch.

On Thu, Sep 17, 2026 at 4:07 AM Jeff Davis <pgsql@j-davis.com> wrote:
>
> AI reported a bug (appended to this email) where TID Range Scans can
> return the wrong results after changing the direction of the scan.
>
> Regards,
>         Jeff Davis
>
>
>
> SQL repro
> ---------
>
>     CREATE TABLE t (id int, data text) WITH (fillfactor = 10);
>     -- 5 rows per page, 20 pages: ctids (0,1) .. (19,5)
>     INSERT INTO t SELECT i, repeat('x', 100)
>       FROM generate_series(1, 100) i;
>     SET enable_seqscan = off;
>     BEGIN;
>     DECLARE c SCROLL CURSOR FOR
>       SELECT ctid FROM t WHERE ctid >= '(2,1)' AND ctid <= '(11,5)';
>     MOVE FORWARD 35 c;      -- cursor is now on (8,5)
>     FETCH BACKWARD 2 c;     -- (8,4), (8,3): correct
>     FETCH BACKWARD ALL c;   -- should return 32 rows, returns 12
>     COMMIT;
>
>
> Diagnosis
> ---------
>
> heap_setscanlimits() stores the number of blocks to scan in
> rs_numblocks. heapgettup_advance_block() then treats that field as a
> remaining budget: it decrements it once for every block it hands out,
> in either direction, and ends the scan when it reaches zero.  That
> model only holds for a scan that moves in one direction and consumes
> exactly the blocks it requests.  A change in direction breaks that.
>
>
>
>
>


-- 
Regards,
Ewan Young

Attachments:

v1-0001-Fix-TID-range-scans-that-change-direction-mid-scan.patch (application/octet-stream, 5.3K) download From 032a5b44d2ceb931ad3303e3be3967ab2212d56c Mon Sep 17 00:00:00 2001 From: Ewan Young <kdbase.hack@gmail.com> Date: Thu, 17 Sep 2026 22:58:12 +0800 Subject: [PATCH] Fix TID range scans that change direction mid-scan heap_setscanlimits() stores the number of blocks to scan in rs_numblocks, and heapgettup_advance_block() treated that as a remaining budget, decrementing it once per block handed out in either direction and ending the scan when it hit zero. That only works for a scan that runs in one direction. A scrollable cursor over a TID Range Scan that moves forward and then backward kept spending the same budget, so the backward leg stopped early and rows in range were never returned; a later forward leg was cut short as well. Treat the limit as a fixed window of rs_numblocks blocks starting at rs_startblock instead: a forward scan ends when it reaches the block just past the window, and a backward scan ends at rs_startblock, which the existing check already handled since heapgettup_initial_block() starts a backward scan at the window's last block. rs_numblocks is no longer modified during the scan. TID Range Scans are the only caller that can scan a limited range in both directions, so this only affects them. Reported-by: Jeff Davis <pgsql@j-davis.com> Discussion: https://postgr.es/m/6b50233fcc0f7d6f4845f083777fa9702d9612e4.camel@j-davis.com --- src/backend/access/heap/heapam.c | 28 +++++--- src/test/regress/expected/tidrangescan.out | 75 ++++++++++++++++++++++ src/test/regress/sql/tidrangescan.sql | 10 +++ 3 files changed, 103 insertions(+), 10 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 9ebb1b35d37..e35057e6cc0 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -908,10 +908,20 @@ heapgettup_advance_block(HeapScanDesc scan, BlockNumber block, ScanDirection dir if (block == scan->rs_startblock) return InvalidBlockNumber; - /* check if the limit imposed by heap_setscanlimits() is met */ + /* + * Check if the limit imposed by heap_setscanlimits() is met. The + * limit is a fixed window of rs_numblocks blocks starting at + * rs_startblock, so compare against its end rather than counting + * down a budget; a scan that changes direction (a SCROLL cursor) + * would otherwise exhaust the budget and stop early. + */ if (scan->rs_numblocks != InvalidBlockNumber) { - if (--scan->rs_numblocks == 0) + BlockNumber endblock; + + endblock = (scan->rs_startblock + scan->rs_numblocks) % + scan->rs_nblocks; + if (block == endblock) return InvalidBlockNumber; } @@ -919,17 +929,15 @@ heapgettup_advance_block(HeapScanDesc scan, BlockNumber block, ScanDirection dir } else { - /* we're done if the last block is the start position */ + /* + * We're done if the last block is the start position. This also + * covers the limit imposed by heap_setscanlimits(), since the window + * begins at rs_startblock and heapgettup_initial_block() started us + * at its last block. + */ if (block == scan->rs_startblock) return InvalidBlockNumber; - /* check if the limit imposed by heap_setscanlimits() is met */ - if (scan->rs_numblocks != InvalidBlockNumber) - { - if (--scan->rs_numblocks == 0) - return InvalidBlockNumber; - } - /* wrap to the end of the heap when the last page was page 0 */ if (block == 0) block = scan->rs_nblocks; diff --git a/src/test/regress/expected/tidrangescan.out b/src/test/regress/expected/tidrangescan.out index 89930f7bc7c..e567edfbff8 100644 --- a/src/test/regress/expected/tidrangescan.out +++ b/src/test/regress/expected/tidrangescan.out @@ -295,6 +295,81 @@ FETCH LAST c; (0,10) (1 row) +COMMIT; +-- a scrollable cursor that changes direction mid-scan must still stay within +-- the block range computed by heap_setscanlimits() +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT ctid FROM tidrangescan WHERE ctid >= '(0,1)' AND ctid <= '(2,10)'; +MOVE FORWARD 25 c; +FETCH BACKWARD 2 c; + ctid +------- + (2,4) + (2,3) +(2 rows) + +FETCH BACKWARD ALL c; + ctid +-------- + (2,2) + (2,1) + (1,10) + (1,9) + (1,8) + (1,7) + (1,6) + (1,5) + (1,4) + (1,3) + (1,2) + (1,1) + (0,10) + (0,9) + (0,8) + (0,7) + (0,6) + (0,5) + (0,4) + (0,3) + (0,2) + (0,1) +(22 rows) + +FETCH FORWARD ALL c; + ctid +-------- + (0,1) + (0,2) + (0,3) + (0,4) + (0,5) + (0,6) + (0,7) + (0,8) + (0,9) + (0,10) + (1,1) + (1,2) + (1,3) + (1,4) + (1,5) + (1,6) + (1,7) + (1,8) + (1,9) + (1,10) + (2,1) + (2,2) + (2,3) + (2,4) + (2,5) + (2,6) + (2,7) + (2,8) + (2,9) + (2,10) +(30 rows) + COMMIT; DROP TABLE tidrangescan; -- Tests for parallel TID Range Scans diff --git a/src/test/regress/sql/tidrangescan.sql b/src/test/regress/sql/tidrangescan.sql index 1ac3995e71c..55e266c431c 100644 --- a/src/test/regress/sql/tidrangescan.sql +++ b/src/test/regress/sql/tidrangescan.sql @@ -96,6 +96,16 @@ FETCH FIRST c; FETCH LAST c; COMMIT; +-- a scrollable cursor that changes direction mid-scan must still stay within +-- the block range computed by heap_setscanlimits() +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT ctid FROM tidrangescan WHERE ctid >= '(0,1)' AND ctid <= '(2,10)'; +MOVE FORWARD 25 c; +FETCH BACKWARD 2 c; +FETCH BACKWARD ALL c; +FETCH FORWARD ALL c; +COMMIT; + DROP TABLE tidrangescan; -- Tests for parallel TID Range Scans -- 2.47.3
^ permalink raw reply [nested|flat] 3+ messages in thread

* Re: TID Range Scans can return wrong results after scanning backwards
@ 2026-09-23 13:34  David Rowley <dgrowleyml@gmail.com>
  parent: Ewan Young <kdbase.hack@gmail.com>
  0 siblings, 0 replies; 3+ messages in thread

From: David Rowley @ 2026-09-23 13:34 UTC (permalink / raw)
  To: Ewan Young <kdbase.hack@gmail.com>; +Cc: Jeff Davis <pgsql@j-davis.com>; pgsql-bugs

On Thu, 17 Sept 2026 at 19:14, Ewan Young <kdbase.hack@gmail.com> wrote:
> The attached patch treats the limit as a fixed window of rs_numblocks
> blocks starting at rs_startblock instead of a budget: the forward
> direction ends when it reaches the block just past the window, and the
> backward direction already ends at rs_startblock, since
> heapgettup_initial_block() starts a backward scan at the window's last
> block.  rs_numblocks is no longer modified during the scan.  TID Range
> Scans are the only caller that scans a limited range in both
> directions, so other heap_setscanlimits() users see no change.

I adjusted this and wrote the version for the backbranches (block
advancing is different pre-v16) and pushed it.

For the record, you really need to sense-check your LLM-generated
comments in the future. If patch authors don't check this sort of
stuff, then I really fail to see the point of posting these simple bug
fix patches. I really was unable to parse the "started us at its last
block" in the following:

+ * We're done if the last block is the start position.  This also
+ * covers the limit imposed by heap_setscanlimits(), since the window
+ * begins at rs_startblock and heapgettup_initial_block() started us
+ * at its last block.

Anyway, I rewrote that into something that I could understand.

For the tests, I think they were far too verbose. I added some extra
MOVEs to make it so the expected results weren't as large.

David






^ permalink  raw  reply  [nested|flat] 3+ messages in thread


end of thread, other threads:[~2026-09-23 13:34 UTC | newest]

Thread overview: 3+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2026-09-16 20:07 TID Range Scans can return wrong results after scanning backwards Jeff Davis <pgsql@j-davis.com>
2026-09-17 07:13 ` Ewan Young <kdbase.hack@gmail.com>
2026-09-23 13:34   ` David Rowley <dgrowleyml@gmail.com>

This inbox is served by DDX for PostgreSQL; see mirroring instructions
for how to clone and mirror all data and code used for this inbox