From 3f96b87db247c819d7e8094a8f78b2afa664be76 Mon Sep 17 00:00:00 2001
From: Peter Geoghegan <pg@bowt.ie>
Date: Sat, 15 Aug 2026 17:30:22 -0400
Subject: [PATCH] Add a test for GiST killtuples state left over by a rescan

gistrescan() leaves GISTScanOpaque->killedItems, numKilled, curBlkno and
curPageLSN alone.  A scan that stops before it has drained the current
page therefore hands its pending killed-item offsets to the next scan,
which applies them to the first page it reads.  Since 9c9ddf109 that page
is the root, and curPageLSN is refreshed along with curBlkno, so the LSN
guard in gistkillitems() no longer rejects the write.

The new test drives that from SQL: a LATERAL subquery with LIMIT 1 stops
each inner scan early, and 59 of every 60 rows are dead, so each rescan
has killed items pending.  Without the fix in the next commit, the second
query returns 0 rows on a release build, and an assert-enabled build
trips Assert(GistPageIsLeaf(page)) in gistkillitems() instead.

The test needs the deleted rows to be dead to everyone, so it disables
autovacuum for the table rather than racing it, and it counts s.k rather
than * so that the planner does not pick an index-only scan, which does
not reach the rescan path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---
 src/test/modules/index/Makefile               |  1 +
 .../modules/index/expected/gistrescan.out     | 33 +++++++++++++++++++
 src/test/modules/index/meson.build            |  5 +++
 src/test/modules/index/sql/gistrescan.sql     | 27 +++++++++++++++
 4 files changed, 66 insertions(+)
 create mode 100644 src/test/modules/index/expected/gistrescan.out
 create mode 100644 src/test/modules/index/sql/gistrescan.sql

diff --git a/src/test/modules/index/Makefile b/src/test/modules/index/Makefile
index 29047044e..0a02b3dcd 100644
--- a/src/test/modules/index/Makefile
+++ b/src/test/modules/index/Makefile
@@ -2,6 +2,7 @@
 
 EXTRA_INSTALL = contrib/btree_gin contrib/btree_gist
 
+REGRESS = gistrescan
 ISOLATION = killtuples
 
 ifdef USE_PGXS
diff --git a/src/test/modules/index/expected/gistrescan.out b/src/test/modules/index/expected/gistrescan.out
new file mode 100644
index 000000000..be3215b69
--- /dev/null
+++ b/src/test/modules/index/expected/gistrescan.out
@@ -0,0 +1,33 @@
+-- Rescanning a GiST index scan must not carry pending killed-item offsets
+-- over into the next scan.  The offsets in GISTScanOpaque->killedItems are
+-- only meaningful for the page in ->curBlkno, and the next scan starts by
+-- reading the root page.
+CREATE TABLE gist_rescan(k int, p point) WITH (autovacuum_enabled = false);
+INSERT INTO gist_rescan
+    SELECT g, point(g, i)
+    FROM generate_series(1, 200) g, generate_series(1, 60) i;
+CREATE INDEX ON gist_rescan USING gist (p);
+-- leave one live row per group, behind 59 dead ones for killtuples to find
+DELETE FROM gist_rescan WHERE (p)[1] < 60;
+SET enable_seqscan = false;
+SET enable_bitmapscan = false;
+-- Stops each inner scan early, so every rescan has killed items pending.
+-- count(s.k) rather than count(*), so that this is not an index-only scan.
+SELECT count(s.k) FROM generate_series(1, 200) o(g),
+    LATERAL (SELECT k FROM gist_rescan
+             WHERE p <@ box(point(o.g - 0.5, -1000), point(o.g + 0.5, 1000))
+             LIMIT 1) s;
+ count 
+-------
+   200
+(1 row)
+
+-- every row must still be reachable through the index
+SELECT count(*) FROM gist_rescan
+WHERE p <@ box(point(0, 0), point(1000, 1000));
+ count 
+-------
+   200
+(1 row)
+
+DROP TABLE gist_rescan;
diff --git a/src/test/modules/index/meson.build b/src/test/modules/index/meson.build
index 834ce081f..8c6029e62 100644
--- a/src/test/modules/index/meson.build
+++ b/src/test/modules/index/meson.build
@@ -4,6 +4,11 @@ tests += {
   'name': 'index',
   'sd': meson.current_source_dir(),
   'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'gistrescan',
+    ],
+  },
   'isolation': {
     'specs': [
       'killtuples',
diff --git a/src/test/modules/index/sql/gistrescan.sql b/src/test/modules/index/sql/gistrescan.sql
new file mode 100644
index 000000000..454f91d46
--- /dev/null
+++ b/src/test/modules/index/sql/gistrescan.sql
@@ -0,0 +1,27 @@
+-- Rescanning a GiST index scan must not carry pending killed-item offsets
+-- over into the next scan.  The offsets in GISTScanOpaque->killedItems are
+-- only meaningful for the page in ->curBlkno, and the next scan starts by
+-- reading the root page.
+CREATE TABLE gist_rescan(k int, p point) WITH (autovacuum_enabled = false);
+INSERT INTO gist_rescan
+    SELECT g, point(g, i)
+    FROM generate_series(1, 200) g, generate_series(1, 60) i;
+CREATE INDEX ON gist_rescan USING gist (p);
+-- leave one live row per group, behind 59 dead ones for killtuples to find
+DELETE FROM gist_rescan WHERE (p)[1] < 60;
+
+SET enable_seqscan = false;
+SET enable_bitmapscan = false;
+
+-- Stops each inner scan early, so every rescan has killed items pending.
+-- count(s.k) rather than count(*), so that this is not an index-only scan.
+SELECT count(s.k) FROM generate_series(1, 200) o(g),
+    LATERAL (SELECT k FROM gist_rescan
+             WHERE p <@ box(point(o.g - 0.5, -1000), point(o.g + 0.5, 1000))
+             LIMIT 1) s;
+
+-- every row must still be reachable through the index
+SELECT count(*) FROM gist_rescan
+WHERE p <@ box(point(0, 0), point(1000, 1000));
+
+DROP TABLE gist_rescan;
-- 
2.53.0

