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();