From fa185bf0123e44392d96a460534891b8ff379897 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 13:00:10 +0000 Subject: [PATCH] Add tests for fake LSN assignment on unlogged hash and GiST indexes Commit cea74f4 fixed a missed fake-LSN assignment in the hash AM's log_split_page() for unlogged relations (an oversight in the commit that introduced fake LSN use in the hash AM). The fix was purely defensive: nothing in the tree visibly fails without it, which also means that no existing test would notice if a fake-LSN assignment were lost again. The regression suites exercise almost exclusively logged relations, so the XLogGetFakeLSN() paths in the index AMs -- including the parent-LSN/child-NSN split-detection interlock that unlogged GiST indexes rely on -- had essentially no coverage. Add two layers of coverage: * A pageinspect regression test, unlogged_lsn, deterministically drives an unlogged hash index through a bucket split that migrates a multi-page chain of duplicate tuples, and an unlogged GiST index through many page splits, then asserts that the affected index pages carry nonzero, strictly advancing (fake) LSNs and NSNs. The hash check is written so that precisely the omission fixed by cea74f4 is detected: without log_split_page()'s fake LSN, the last overflow page of the migrated chain keeps the LSN assigned when the page was allocated, which equals the preceding page's LSN, so the test's strictly-advancing assertion fails (verified by temporarily reverting cea74f4). All output is reduced to booleans, keeping it independent of the shared fake-LSN counter's actual values. * A TAP test in src/test/modules/index, 001_unlogged_lsn_stress.pl, runs index scans concurrently with split-heavy insert workloads on unlogged hash and GiST indexes, cross-checking every scan result against WAL-logged twin tables holding identical data. The critical interleaving (a scan paused inside a bucket/page while it is split) cannot be expressed with the isolation tester without adding injection points to the hash/GiST split paths, so this is a bounded stress test with a fixed number of scan iterations; pass/fail is still deterministic, as it fails only if a scan returns incorrect results or a backend fails. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PtxA6B4d47ietk5dGsseuP --- contrib/pageinspect/Makefile | 1 + contrib/pageinspect/expected/unlogged_lsn.out | 178 +++++++++++++++++ contrib/pageinspect/meson.build | 1 + contrib/pageinspect/sql/unlogged_lsn.sql | 166 ++++++++++++++++ src/test/modules/index/Makefile | 2 + src/test/modules/index/meson.build | 5 + .../index/t/001_unlogged_lsn_stress.pl | 181 ++++++++++++++++++ 7 files changed, 534 insertions(+) create mode 100644 contrib/pageinspect/expected/unlogged_lsn.out create mode 100644 contrib/pageinspect/sql/unlogged_lsn.sql create mode 100644 src/test/modules/index/t/001_unlogged_lsn_stress.pl diff --git a/contrib/pageinspect/Makefile b/contrib/pageinspect/Makefile index eae989569d013..95b99096bb7b3 100644 --- a/contrib/pageinspect/Makefile +++ b/contrib/pageinspect/Makefile @@ -32,6 +32,7 @@ REGRESS = \ gin \ gist \ hash \ + unlogged_lsn \ oldextversions ifdef USE_PGXS diff --git a/contrib/pageinspect/expected/unlogged_lsn.out b/contrib/pageinspect/expected/unlogged_lsn.out new file mode 100644 index 0000000000000..2f72ccb25b009 --- /dev/null +++ b/contrib/pageinspect/expected/unlogged_lsn.out @@ -0,0 +1,178 @@ +-- Fake LSN assignment on unlogged indexes (hash and GiST). +-- +-- Index AMs that use page LSNs for concurrent-modification interlocks +-- (e.g. the GiST parent-LSN/child-NSN split detection) must assign fake +-- LSNs (XLogGetFakeLSN) to pages of relations that are not WAL-logged. +-- The regression suites otherwise exercise almost exclusively logged +-- relations, so a missed fake-LSN assignment -- such as the one fixed in +-- hash's log_split_page() for unlogged relations -- produces no failure +-- anywhere. Verify via pageinspect that unlogged hash and GiST index +-- pages carry nonzero, advancing LSNs after operations that must assign +-- them. +-- +-- Fake LSNs handed out by XLogGetFakeLSN() for unlogged relations start +-- at FirstNormalUnloggedLSN (1000, i.e. 0/3E8) and increase strictly, so +-- "lsn >= 0/3E8" means "a fake LSN was assigned at some point". All +-- output is reduced to booleans so that it is independent of the actual +-- fake-LSN counter values, which are shared cluster-wide. +-- =================================================================== +-- Unlogged hash: bucket split must assign fake LSNs (log_split_page) +-- =================================================================== +CREATE UNLOGGED TABLE test_unlogged_hash (k int4); +-- Low fillfactor makes bucket splits happen frequently. +CREATE INDEX test_unlogged_hash_idx ON test_unlogged_hash + USING hash (k) WITH (fillfactor = 10); +-- The interesting case is a bucket split that moves a multi-page chain of +-- tuples into the new bucket: the pages of the new bucket's overflow chain +-- are then written by _hash_splitbucket()/log_split_page(), and the last +-- page of the chain retains log_split_page()'s LSN afterwards (earlier +-- chain pages are overwritten again when the next overflow page is +-- allocated, and the primary bucket page when the split is marked +-- complete). +-- +-- Drive this deterministically: pick a key value whose hash code has its +-- low 8 bits all set, so the tuple chain migrates to the freshly created +-- bucket every time its current bucket is split (for any bucket mask up +-- to 255). Insert that value one row at a time; each insertion is +-- followed by a split attempt once the fill factor is exceeded, and the +-- insert that triggers the split of the chain's bucket contributes its +-- tuple before the split runs. Stop as soon as the chain has moved while +-- containing enough tuples to span at least two overflow pages: at that +-- point the chain has just been rewritten by the split, with no later +-- modifications. +CREATE TABLE test_unlogged_hash_state (bucket int8, ndups int); +DO $$ +DECLARE + v int; + h int8; + n int := 0; + prevb int8 := -1; + curb int8; + mx int8; + hi int8; + lo int8; +BEGIN + SELECT i INTO STRICT v FROM generate_series(1, 100000) i + WHERE hashint4(i) & 255 = 255 LIMIT 1; + h := hashint4(v)::int8 & 4294967295; + LOOP + INSERT INTO test_unlogged_hash VALUES (v); + n := n + 1; + -- locate the bucket now holding the duplicates, from the metapage + SELECT maxbucket, highmask, lowmask INTO mx, hi, lo + FROM hash_metapage_info(get_raw_page('test_unlogged_hash_idx', 0)); + curb := h & hi; + IF curb > mx THEN + curb := curb & lo; + END IF; + -- 1200 duplicates are enough for two overflow pages at any MAXALIGN + IF n >= 1200 AND prevb >= 0 AND curb <> prevb THEN + EXIT; + END IF; + prevb := curb; + IF n >= 20000 THEN + RAISE EXCEPTION 'duplicate chain did not migrate after % inserts', n; + END IF; + END LOOP; + INSERT INTO test_unlogged_hash_state VALUES (curb, n); +END +$$; +-- Walk the new bucket's page chain in order and check its LSNs: +-- * every page must carry a fake LSN (>= 0/3E8, in particular nonzero); +-- * the overflow-page LSNs must strictly advance along the chain; +-- without the log_split_page() fake LSN the last overflow page would +-- still carry the LSN assigned when it was allocated, which equals the +-- preceding page's LSN; +-- * the primary bucket page must carry the newest LSN of the chain, +-- assigned when the split was marked complete. +WITH RECURSIVE chain AS ( + SELECT s.blkno, 0 AS pos + FROM generate_series(1, pg_relation_size('test_unlogged_hash_idx') / current_setting('block_size')::int - 1) s (blkno) + WHERE hash_page_type(get_raw_page('test_unlogged_hash_idx', s.blkno)) = 'bucket' + AND (hash_page_stats(get_raw_page('test_unlogged_hash_idx', s.blkno))).hasho_bucket = + (SELECT bucket FROM test_unlogged_hash_state) + UNION ALL + SELECT (hash_page_stats(get_raw_page('test_unlogged_hash_idx', c.blkno))).hasho_nextblkno::int8, + c.pos + 1 + FROM chain c + WHERE (hash_page_stats(get_raw_page('test_unlogged_hash_idx', c.blkno))).hasho_nextblkno::int8 + <> 4294967295 -- InvalidBlockNumber +), +info AS ( + SELECT c.pos, + (page_header(get_raw_page('test_unlogged_hash_idx', c.blkno))).lsn + FROM chain c +) +SELECT count(*) >= 3 AS chain_has_two_overflow_pages, + bool_and(lsn >= '0/3E8') AS all_chain_pages_have_fake_lsns, + (SELECT bool_and(lsn > prev_lsn) + FROM (SELECT lsn, lag(lsn) OVER (ORDER BY pos) AS prev_lsn + FROM info WHERE pos > 0) ov + WHERE prev_lsn IS NOT NULL) AS overflow_lsns_strictly_advance, + (max(lsn) FILTER (WHERE pos = 0)) = max(lsn) AS primary_page_has_newest_lsn +FROM info; + chain_has_two_overflow_pages | all_chain_pages_have_fake_lsns | overflow_lsns_strictly_advance | primary_page_has_newest_lsn +------------------------------+--------------------------------+--------------------------------+----------------------------- + t | t | t | t +(1 row) + +-- The unlogged index's metapage must carry a fake LSN as well, and it +-- must advance with every atomic action, e.g. a plain insert. +SELECT (page_header(get_raw_page('test_unlogged_hash_idx', 0))).lsn AS hash_metap_lsn +\gset +SELECT :'hash_metap_lsn'::pg_lsn >= '0/3E8' AS hash_metapage_has_fake_lsn; + hash_metapage_has_fake_lsn +---------------------------- + t +(1 row) + +INSERT INTO test_unlogged_hash + SELECT i FROM generate_series(1, 1000) i WHERE hashint4(i) & 255 = 0 LIMIT 1; +SELECT (page_header(get_raw_page('test_unlogged_hash_idx', 0))).lsn + > :'hash_metap_lsn'::pg_lsn AS hash_metapage_lsn_advanced; + hash_metapage_lsn_advanced +---------------------------- + t +(1 row) + +DROP TABLE test_unlogged_hash, test_unlogged_hash_state; +-- =================================================================== +-- Unlogged GiST: page splits must assign fake LSNs and NSNs +-- =================================================================== +CREATE UNLOGGED TABLE test_unlogged_gist (p point); +CREATE INDEX test_unlogged_gist_idx ON test_unlogged_gist USING gist (p); +-- Enough inserts to force plenty of page splits. +INSERT INTO test_unlogged_gist + SELECT point(i % 100, i / 100) FROM generate_series(1, 2000) i; +-- Every page of the index must carry a fake LSN (the index was built +-- empty, so all pages have since been written through the insert/split +-- paths), and pages created by splits must carry an NSN that is itself a +-- fake LSN: a zero or stale NSN would break the parent-LSN/child-NSN +-- split-detection interlock used by concurrent GiST scans. +SELECT count(*) > 5 AS gist_has_multiple_pages, + bool_and(o.lsn >= '0/3E8') AS all_gist_pages_have_fake_lsns, + count(*) FILTER (WHERE o.nsn <> '0/0') > 0 AS some_gist_pages_have_nsns, + bool_and(o.nsn = '0/0' OR o.nsn >= '0/3E8') AS all_nsns_are_fake_lsns +FROM generate_series(0, pg_relation_size('test_unlogged_gist_idx') / current_setting('block_size')::int - 1) s (blkno), + LATERAL gist_page_opaque_info(get_raw_page('test_unlogged_gist_idx', s.blkno)) o; + gist_has_multiple_pages | all_gist_pages_have_fake_lsns | some_gist_pages_have_nsns | all_nsns_are_fake_lsns +-------------------------+-------------------------------+---------------------------+------------------------ + t | t | t | t +(1 row) + +-- More splits must advance the highest page LSN in the index. +SELECT max(o.lsn) AS gist_max_lsn +FROM generate_series(0, pg_relation_size('test_unlogged_gist_idx') / current_setting('block_size')::int - 1) s (blkno), + LATERAL gist_page_opaque_info(get_raw_page('test_unlogged_gist_idx', s.blkno)) o +\gset +INSERT INTO test_unlogged_gist + SELECT point(i % 100, i / 100) FROM generate_series(2001, 4000) i; +SELECT max(o.lsn) > :'gist_max_lsn'::pg_lsn AS gist_max_lsn_advanced +FROM generate_series(0, pg_relation_size('test_unlogged_gist_idx') / current_setting('block_size')::int - 1) s (blkno), + LATERAL gist_page_opaque_info(get_raw_page('test_unlogged_gist_idx', s.blkno)) o; + gist_max_lsn_advanced +----------------------- + t +(1 row) + +DROP TABLE test_unlogged_gist; diff --git a/contrib/pageinspect/meson.build b/contrib/pageinspect/meson.build index c43ea400a4d7b..f1495de6a0f7e 100644 --- a/contrib/pageinspect/meson.build +++ b/contrib/pageinspect/meson.build @@ -54,6 +54,7 @@ tests += { 'gin', 'gist', 'hash', + 'unlogged_lsn', 'checksum', 'oldextversions', ], diff --git a/contrib/pageinspect/sql/unlogged_lsn.sql b/contrib/pageinspect/sql/unlogged_lsn.sql new file mode 100644 index 0000000000000..65407dcee9f3e --- /dev/null +++ b/contrib/pageinspect/sql/unlogged_lsn.sql @@ -0,0 +1,166 @@ +-- Fake LSN assignment on unlogged indexes (hash and GiST). +-- +-- Index AMs that use page LSNs for concurrent-modification interlocks +-- (e.g. the GiST parent-LSN/child-NSN split detection) must assign fake +-- LSNs (XLogGetFakeLSN) to pages of relations that are not WAL-logged. +-- The regression suites otherwise exercise almost exclusively logged +-- relations, so a missed fake-LSN assignment -- such as the one fixed in +-- hash's log_split_page() for unlogged relations -- produces no failure +-- anywhere. Verify via pageinspect that unlogged hash and GiST index +-- pages carry nonzero, advancing LSNs after operations that must assign +-- them. +-- +-- Fake LSNs handed out by XLogGetFakeLSN() for unlogged relations start +-- at FirstNormalUnloggedLSN (1000, i.e. 0/3E8) and increase strictly, so +-- "lsn >= 0/3E8" means "a fake LSN was assigned at some point". All +-- output is reduced to booleans so that it is independent of the actual +-- fake-LSN counter values, which are shared cluster-wide. + +-- =================================================================== +-- Unlogged hash: bucket split must assign fake LSNs (log_split_page) +-- =================================================================== + +CREATE UNLOGGED TABLE test_unlogged_hash (k int4); +-- Low fillfactor makes bucket splits happen frequently. +CREATE INDEX test_unlogged_hash_idx ON test_unlogged_hash + USING hash (k) WITH (fillfactor = 10); + +-- The interesting case is a bucket split that moves a multi-page chain of +-- tuples into the new bucket: the pages of the new bucket's overflow chain +-- are then written by _hash_splitbucket()/log_split_page(), and the last +-- page of the chain retains log_split_page()'s LSN afterwards (earlier +-- chain pages are overwritten again when the next overflow page is +-- allocated, and the primary bucket page when the split is marked +-- complete). +-- +-- Drive this deterministically: pick a key value whose hash code has its +-- low 8 bits all set, so the tuple chain migrates to the freshly created +-- bucket every time its current bucket is split (for any bucket mask up +-- to 255). Insert that value one row at a time; each insertion is +-- followed by a split attempt once the fill factor is exceeded, and the +-- insert that triggers the split of the chain's bucket contributes its +-- tuple before the split runs. Stop as soon as the chain has moved while +-- containing enough tuples to span at least two overflow pages: at that +-- point the chain has just been rewritten by the split, with no later +-- modifications. +CREATE TABLE test_unlogged_hash_state (bucket int8, ndups int); + +DO $$ +DECLARE + v int; + h int8; + n int := 0; + prevb int8 := -1; + curb int8; + mx int8; + hi int8; + lo int8; +BEGIN + SELECT i INTO STRICT v FROM generate_series(1, 100000) i + WHERE hashint4(i) & 255 = 255 LIMIT 1; + h := hashint4(v)::int8 & 4294967295; + LOOP + INSERT INTO test_unlogged_hash VALUES (v); + n := n + 1; + -- locate the bucket now holding the duplicates, from the metapage + SELECT maxbucket, highmask, lowmask INTO mx, hi, lo + FROM hash_metapage_info(get_raw_page('test_unlogged_hash_idx', 0)); + curb := h & hi; + IF curb > mx THEN + curb := curb & lo; + END IF; + -- 1200 duplicates are enough for two overflow pages at any MAXALIGN + IF n >= 1200 AND prevb >= 0 AND curb <> prevb THEN + EXIT; + END IF; + prevb := curb; + IF n >= 20000 THEN + RAISE EXCEPTION 'duplicate chain did not migrate after % inserts', n; + END IF; + END LOOP; + INSERT INTO test_unlogged_hash_state VALUES (curb, n); +END +$$; + +-- Walk the new bucket's page chain in order and check its LSNs: +-- * every page must carry a fake LSN (>= 0/3E8, in particular nonzero); +-- * the overflow-page LSNs must strictly advance along the chain; +-- without the log_split_page() fake LSN the last overflow page would +-- still carry the LSN assigned when it was allocated, which equals the +-- preceding page's LSN; +-- * the primary bucket page must carry the newest LSN of the chain, +-- assigned when the split was marked complete. +WITH RECURSIVE chain AS ( + SELECT s.blkno, 0 AS pos + FROM generate_series(1, pg_relation_size('test_unlogged_hash_idx') / current_setting('block_size')::int - 1) s (blkno) + WHERE hash_page_type(get_raw_page('test_unlogged_hash_idx', s.blkno)) = 'bucket' + AND (hash_page_stats(get_raw_page('test_unlogged_hash_idx', s.blkno))).hasho_bucket = + (SELECT bucket FROM test_unlogged_hash_state) + UNION ALL + SELECT (hash_page_stats(get_raw_page('test_unlogged_hash_idx', c.blkno))).hasho_nextblkno::int8, + c.pos + 1 + FROM chain c + WHERE (hash_page_stats(get_raw_page('test_unlogged_hash_idx', c.blkno))).hasho_nextblkno::int8 + <> 4294967295 -- InvalidBlockNumber +), +info AS ( + SELECT c.pos, + (page_header(get_raw_page('test_unlogged_hash_idx', c.blkno))).lsn + FROM chain c +) +SELECT count(*) >= 3 AS chain_has_two_overflow_pages, + bool_and(lsn >= '0/3E8') AS all_chain_pages_have_fake_lsns, + (SELECT bool_and(lsn > prev_lsn) + FROM (SELECT lsn, lag(lsn) OVER (ORDER BY pos) AS prev_lsn + FROM info WHERE pos > 0) ov + WHERE prev_lsn IS NOT NULL) AS overflow_lsns_strictly_advance, + (max(lsn) FILTER (WHERE pos = 0)) = max(lsn) AS primary_page_has_newest_lsn +FROM info; + +-- The unlogged index's metapage must carry a fake LSN as well, and it +-- must advance with every atomic action, e.g. a plain insert. +SELECT (page_header(get_raw_page('test_unlogged_hash_idx', 0))).lsn AS hash_metap_lsn +\gset +SELECT :'hash_metap_lsn'::pg_lsn >= '0/3E8' AS hash_metapage_has_fake_lsn; +INSERT INTO test_unlogged_hash + SELECT i FROM generate_series(1, 1000) i WHERE hashint4(i) & 255 = 0 LIMIT 1; +SELECT (page_header(get_raw_page('test_unlogged_hash_idx', 0))).lsn + > :'hash_metap_lsn'::pg_lsn AS hash_metapage_lsn_advanced; + +DROP TABLE test_unlogged_hash, test_unlogged_hash_state; + +-- =================================================================== +-- Unlogged GiST: page splits must assign fake LSNs and NSNs +-- =================================================================== + +CREATE UNLOGGED TABLE test_unlogged_gist (p point); +CREATE INDEX test_unlogged_gist_idx ON test_unlogged_gist USING gist (p); + +-- Enough inserts to force plenty of page splits. +INSERT INTO test_unlogged_gist + SELECT point(i % 100, i / 100) FROM generate_series(1, 2000) i; + +-- Every page of the index must carry a fake LSN (the index was built +-- empty, so all pages have since been written through the insert/split +-- paths), and pages created by splits must carry an NSN that is itself a +-- fake LSN: a zero or stale NSN would break the parent-LSN/child-NSN +-- split-detection interlock used by concurrent GiST scans. +SELECT count(*) > 5 AS gist_has_multiple_pages, + bool_and(o.lsn >= '0/3E8') AS all_gist_pages_have_fake_lsns, + count(*) FILTER (WHERE o.nsn <> '0/0') > 0 AS some_gist_pages_have_nsns, + bool_and(o.nsn = '0/0' OR o.nsn >= '0/3E8') AS all_nsns_are_fake_lsns +FROM generate_series(0, pg_relation_size('test_unlogged_gist_idx') / current_setting('block_size')::int - 1) s (blkno), + LATERAL gist_page_opaque_info(get_raw_page('test_unlogged_gist_idx', s.blkno)) o; + +-- More splits must advance the highest page LSN in the index. +SELECT max(o.lsn) AS gist_max_lsn +FROM generate_series(0, pg_relation_size('test_unlogged_gist_idx') / current_setting('block_size')::int - 1) s (blkno), + LATERAL gist_page_opaque_info(get_raw_page('test_unlogged_gist_idx', s.blkno)) o +\gset +INSERT INTO test_unlogged_gist + SELECT point(i % 100, i / 100) FROM generate_series(2001, 4000) i; +SELECT max(o.lsn) > :'gist_max_lsn'::pg_lsn AS gist_max_lsn_advanced +FROM generate_series(0, pg_relation_size('test_unlogged_gist_idx') / current_setting('block_size')::int - 1) s (blkno), + LATERAL gist_page_opaque_info(get_raw_page('test_unlogged_gist_idx', s.blkno)) o; + +DROP TABLE test_unlogged_gist; diff --git a/src/test/modules/index/Makefile b/src/test/modules/index/Makefile index 29047044ede3d..7d51c675a26d8 100644 --- a/src/test/modules/index/Makefile +++ b/src/test/modules/index/Makefile @@ -4,6 +4,8 @@ EXTRA_INSTALL = contrib/btree_gin contrib/btree_gist ISOLATION = killtuples +TAP_TESTS = 1 + ifdef USE_PGXS PG_CONFIG = pg_config PGXS := $(shell $(PG_CONFIG) --pgxs) diff --git a/src/test/modules/index/meson.build b/src/test/modules/index/meson.build index 834ce081f099f..04a6f85a8f3f7 100644 --- a/src/test/modules/index/meson.build +++ b/src/test/modules/index/meson.build @@ -9,4 +9,9 @@ tests += { 'killtuples', ], }, + 'tap': { + 'tests': [ + 't/001_unlogged_lsn_stress.pl', + ], + }, } diff --git a/src/test/modules/index/t/001_unlogged_lsn_stress.pl b/src/test/modules/index/t/001_unlogged_lsn_stress.pl new file mode 100644 index 0000000000000..29de336a202f8 --- /dev/null +++ b/src/test/modules/index/t/001_unlogged_lsn_stress.pl @@ -0,0 +1,181 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Concurrency stress for unlogged hash and GiST indexes: scans running +# concurrently with split-heavy insert workloads must return correct +# results, cross-checked against WAL-logged twin tables holding identical +# data. +# +# Unlogged indexes rely on fake LSNs (XLogGetFakeLSN) for the LSN-based +# split-detection interlocks that logged relations get from real WAL +# insertions: GiST scans compare the LSN they saw on a parent page with a +# child page's NSN to detect concurrent page splits, and the hash AM +# stamps every atomic action's pages with an LSN likewise. A missed or +# stale fake-LSN assignment (such as the one fixed in hash's +# log_split_page() for unlogged relations) cannot be noticed by purely +# serial tests, so exercise scans while inserts drive bucket/page splits +# in another session. +# +# The interleaving of the two sessions is not controlled (there are no +# injection points in the hash/GiST split paths through which the +# isolation tester could pin down a mid-split scan), so this is a bounded +# stress test: a fixed number of scan iterations runs while the writer's +# bulk inserts execute. Pass/fail is nonetheless deterministic: the test +# fails only if a scan returns incorrect results or a backend fails. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $scan_iterations = 100; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->start; + +# Reader session, kept across a whole phase so that planner GUCs stick. +# Force index scans; both the plain and bitmap scan paths go through the +# AM's split-detection logic, but plain index scans exercise the +# pin-and-resume machinery harder. +my $reader = $node->background_psql('postgres'); +$reader->query_safe( + q(SET enable_seqscan = off; + SET enable_bitmapscan = off;)); + +# Run one phase of the stress test: with the given setup already done, +# start $writer_sql in a background session and, while it runs, execute +# $probe_sql (which must return expected|expected) a fixed number of +# times in the reader session. +sub run_phase +{ + my ($phase, $writer_sql, $probe_sql, $expected) = @_; + + my $writer = $node->background_psql('postgres'); + + # The \echo lets query_until return as soon as the session is up, + # while the inserts keep running in the background. + $writer->query_until(qr/writer_running/, + "\\echo writer_running\n" . $writer_sql); + + my $mismatches = 0; + foreach my $i (1 .. $scan_iterations) + { + my $result = $reader->query_safe($probe_sql); + if ($result ne $expected) + { + $mismatches++; + diag("$phase scan $i returned '$result', expected '$expected'"); + } + } + is($mismatches, 0, "$phase: all concurrent scans returned correct results"); + + # Wait for the writer to complete; a failed insert makes psql exit + # with a nonzero status due to ON_ERROR_STOP. + ok($writer->quit, "$phase: writer completed"); + + # The scans must still be correct once the dust has settled. + is($reader->query_safe($probe_sql), + $expected, "$phase: scans correct after writer completed"); + return; +} + +# ================================================================ +# Phase 1: hash — concurrent bucket splits vs. equality scans +# ================================================================ + +# Identical preloaded data (keys 1..200, 20 copies each) in an unlogged +# table and a logged twin; those rows must always be found. The writer +# then inserts a large batch of disjoint keys (> 1000), driving a long +# series of bucket splits that move the preloaded index tuples between +# buckets while the reader probes them. +$node->safe_psql( + 'postgres', q( + CREATE UNLOGGED TABLE hash_unlogged (k int4); + CREATE TABLE hash_logged (k int4); + CREATE INDEX hash_unlogged_idx ON hash_unlogged USING hash (k); + CREATE INDEX hash_logged_idx ON hash_logged USING hash (k); + INSERT INTO hash_unlogged SELECT (i % 200) + 1 FROM generate_series(0, 3999) i; + INSERT INTO hash_logged SELECT (i % 200) + 1 FROM generate_series(0, 3999) i; +)); + +my $hash_writer_sql = q( +INSERT INTO hash_unlogged SELECT (i % 20000) + 1001 FROM generate_series(1, 200000) i; +INSERT INTO hash_logged SELECT (i % 20000) + 1001 FROM generate_series(1, 200000) i; +); + +# 200 index probes per table and iteration; 20 matches per key. +my $hash_probe_sql = q( +SELECT (SELECT sum(c)::text FROM generate_series(1, 200) g, + LATERAL (SELECT count(*) AS c FROM hash_unlogged WHERE k = g) p) + || '|' || + (SELECT sum(c)::text FROM generate_series(1, 200) g, + LATERAL (SELECT count(*) AS c FROM hash_logged WHERE k = g) p); +); + +run_phase('hash', $hash_writer_sql, $hash_probe_sql, '4000|4000'); + +# Finally, both tables must contain exactly the same rows. +is( $node->safe_psql( + 'postgres', q( + SELECT count(*) + FROM ((TABLE hash_unlogged EXCEPT ALL TABLE hash_logged) + UNION ALL + (TABLE hash_logged EXCEPT ALL TABLE hash_unlogged)) diff;)), + '0', + 'hash: unlogged table and logged twin contain identical data'); + +# ================================================================ +# Phase 2: GiST — concurrent page splits vs. bounding-box scans +# ================================================================ + +# Preloaded points on a 50x40 grid (ids 0..1999) in an unlogged table and +# a logged twin. The writer inserts many more points into the very same +# region (ids >= 100000), splitting the leaf pages that hold the +# preloaded points, while the reader runs a bounding-box query that must +# always find exactly the 2000 preloaded points. This is the +# parent-LSN/child-NSN interlock: with a zero or stale NSN, a scan could +# miss tuples moved right by a concurrent page split. +$node->safe_psql( + 'postgres', q( + CREATE UNLOGGED TABLE gist_unlogged (id int4, p point); + CREATE TABLE gist_logged (id int4, p point); + CREATE INDEX gist_unlogged_idx ON gist_unlogged USING gist (p); + CREATE INDEX gist_logged_idx ON gist_logged USING gist (p); + INSERT INTO gist_unlogged SELECT i, point(i % 50, i / 50) FROM generate_series(0, 1999) i; + INSERT INTO gist_logged SELECT i, point(i % 50, i / 50) FROM generate_series(0, 1999) i; +)); + +my $gist_writer_sql = q( +INSERT INTO gist_unlogged SELECT 100000 + i, point((i % 1000) / 20.0, (i % 800) / 20.0) FROM generate_series(1, 50000) i; +INSERT INTO gist_logged SELECT 100000 + i, point((i % 1000) / 20.0, (i % 800) / 20.0) FROM generate_series(1, 50000) i; +); + +my $gist_probe_sql = q( +SELECT (SELECT count(*)::text FROM gist_unlogged + WHERE p <@ '(49,39),(0,0)'::box AND id < 100000) + || '|' || + (SELECT count(*)::text FROM gist_logged + WHERE p <@ '(49,39),(0,0)'::box AND id < 100000); +); + +run_phase('gist', $gist_writer_sql, $gist_probe_sql, '2000|2000'); + +# (point has no equality operator, so compare the coordinates) +is( $node->safe_psql( + 'postgres', q( + SELECT count(*) + FROM ((SELECT id, p[0], p[1] FROM gist_unlogged + EXCEPT ALL + SELECT id, p[0], p[1] FROM gist_logged) + UNION ALL + (SELECT id, p[0], p[1] FROM gist_logged + EXCEPT ALL + SELECT id, p[0], p[1] FROM gist_unlogged)) diff;)), + '0', + 'gist: unlogged table and logged twin contain identical data'); + +$reader->quit; +$node->stop; + +done_testing();