From 3f96b87db247c819d7e8094a8f78b2afa664be76 Mon Sep 17 00:00:00 2001 From: Peter Geoghegan 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) --- 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