Skip to content

Commit 75ff0b6

Browse files
petergeogheganclaude
authored andcommitted
Add tests for hash index scans concurrent with bucket splits.
Test the bug fixed by the preceding commit at two levels, using two new injection points in _hash_splitbucket: an error-mode point placed before tuple relocation begins (where existing code can fail anyway), and a wait-mode point placed after tuple relocation, where the splitter holds no buffer content locks. A regress test uses the error-mode point to interrupt a split partway through, then verifies that forward, backward, and bitmap scans of the affected bucket pair all agree with a seqscan. The bug isn't specific to interrupted splits: interrupting a split is just the most convenient way to hold the bucket pair in the same state that scans observe while any split is in progress, making the test deterministic without a second session. An isolation test proves that claim directly: it pauses a splitter on the wait-mode point and runs the same scans from a second session while the split remains genuinely in progress, then lets the split finish and scans once more. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 76d4724 commit 75ff0b6

7 files changed

Lines changed: 394 additions & 1 deletion

File tree

src/backend/access/hash/hashpage.c

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
#include "port/pg_bitutils.h"
3636
#include "storage/predicate.h"
3737
#include "storage/smgr.h"
38+
#include "utils/injection_point.h"
3839
#include "utils/rel.h"
3940

4041
static bool _hash_alloc_buckets(Relation rel, BlockNumber firstblock,
@@ -1104,6 +1105,8 @@ _hash_splitbucket(Relation rel,
11041105
npage = BufferGetPage(nbuf);
11051106
nopaque = HashPageGetOpaque(npage);
11061107

1108+
INJECTION_POINT("hash-split-before-relocation", NULL);
1109+
11071110
/* Copy the predicate locks from old bucket to new bucket. */
11081111
PredicateLockPageSplit(rel,
11091112
BufferGetBlockNumber(bucket_obuf),
@@ -1252,6 +1255,8 @@ _hash_splitbucket(Relation rel,
12521255
/* be tidy */
12531256
for (i = 0; i < nitups; i++)
12541257
pfree(itups[i]);
1258+
1259+
INJECTION_POINT("hash-split-after-relocation", NULL);
12551260
break;
12561261
}
12571262

src/test/modules/injection_points/Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ EXTENSION = injection_points
99
DATA = injection_points--1.0.sql
1010
PGFILEDESC = "injection_points - facility for injection points"
1111

12-
REGRESS = injection_points hashagg reindex_conc vacuum
12+
REGRESS = injection_points hashagg reindex_conc vacuum hash_split
1313
REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
1414

1515
ISOLATION = basic \
16+
hash-split \
1617
inplace \
1718
reindex_concurrently_deferred \
1819
repack \
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
Parsed test spec with 2 sessions
2+
3+
starting permutation: s1_split s2_insert s2_scan s2_back s2_detach s2_wakeup s1_noop s2_scan
4+
injection_points_attach
5+
-----------------------
6+
7+
(1 row)
8+
9+
step s1_split:
10+
INSERT INTO hash_split_test
11+
SELECT 1000000 + g FROM hash_split_geom, generate_series(1, fillrows) g;
12+
<waiting ...>
13+
step s2_insert:
14+
INSERT INTO hash_split_test SELECT k FROM hash_split_key;
15+
16+
step s2_scan:
17+
SELECT count(*) FROM hash_split_test
18+
WHERE v = (SELECT k FROM hash_split_key);
19+
20+
count
21+
-----
22+
11
23+
(1 row)
24+
25+
step s2_back:
26+
BEGIN;
27+
DECLARE c SCROLL CURSOR FOR
28+
SELECT v = (SELECT k FROM hash_split_key) FROM hash_split_test
29+
WHERE v = (SELECT k FROM hash_split_key);
30+
MOVE FORWARD ALL IN c;
31+
FETCH BACKWARD ALL FROM c;
32+
COMMIT;
33+
34+
?column?
35+
--------
36+
t
37+
t
38+
t
39+
t
40+
t
41+
t
42+
t
43+
t
44+
t
45+
t
46+
t
47+
(11 rows)
48+
49+
step s2_detach: SELECT injection_points_detach('hash-split-after-relocation');
50+
injection_points_detach
51+
-----------------------
52+
53+
(1 row)
54+
55+
step s2_wakeup: SELECT injection_points_wakeup('hash-split-after-relocation');
56+
injection_points_wakeup
57+
-----------------------
58+
59+
(1 row)
60+
61+
step s1_split: <... completed>
62+
step s1_noop:
63+
step s2_scan:
64+
SELECT count(*) FROM hash_split_test
65+
WHERE v = (SELECT k FROM hash_split_key);
66+
67+
count
68+
-----
69+
11
70+
(1 row)
71+
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
-- Test hash index scans of a bucket pair whose split is incomplete
2+
--
3+
-- The behavior tested here is not specific to incomplete splits: it
4+
-- applies to any scan that runs while the bucket pair's split-in-progress
5+
-- flags are set, which is the case for the duration of every split.
6+
-- Interrupting a split is merely the most convenient way to hold the
7+
-- bucket pair in that state: it makes the tests below deterministic,
8+
-- without any need for a second session. The hash-split isolation test
9+
-- provides equivalent coverage for scans that run during a live split.
10+
CREATE EXTENSION injection_points;
11+
SELECT injection_points_set_local();
12+
injection_points_set_local
13+
----------------------------
14+
15+
(1 row)
16+
17+
CREATE TABLE hash_split_test (v int4) WITH (autovacuum_enabled = false);
18+
CREATE INDEX hash_split_index ON hash_split_test USING hash (v);
19+
-- Determine the layout of the (never-split) index: one metapage, nbuckets
20+
-- bucket pages, and one bitmap page. The index's first bucket split will
21+
-- split bucket 0, creating bucket nbuckets.
22+
CREATE TEMP TABLE hash_split_geom AS
23+
SELECT nbuckets,
24+
(current_setting('block_size')::bigint / 16) * nbuckets AS fillrows
25+
FROM (SELECT pg_relation_size('hash_split_index') /
26+
current_setting('block_size')::bigint - 2 AS nbuckets) g;
27+
-- sanity: initial bucket counts are always powers of two
28+
SELECT nbuckets > 0 AND (nbuckets & (nbuckets - 1)) = 0 AS nbuckets_ok
29+
FROM hash_split_geom;
30+
nbuckets_ok
31+
-------------
32+
t
33+
(1 row)
34+
35+
-- Choose a value that maps to bucket 0 before the split and to the new
36+
-- bucket afterwards
37+
CREATE TEMP TABLE hash_split_key AS
38+
SELECT min(v)::int4 AS k
39+
FROM generate_series(1, 10000) v, hash_split_geom
40+
WHERE (hashint4(v::int4) & (2 * nbuckets - 1)) = nbuckets;
41+
INSERT INTO hash_split_test
42+
SELECT k FROM hash_split_key, generate_series(1, 10);
43+
-- Error out during the first bucket split, leaving it incomplete.
44+
-- fillrows is guaranteed to cross the split threshold of ffactor * nbuckets
45+
-- tuples: a hash index entry occupies at least 16 bytes (line pointer
46+
-- included), and the default fillfactor targets 75% page fullness, so
47+
-- ffactor can never exceed block_size / 16 tuples per bucket.
48+
SELECT injection_points_attach('hash-split-before-relocation', 'error');
49+
injection_points_attach
50+
-------------------------
51+
52+
(1 row)
53+
54+
INSERT INTO hash_split_test
55+
SELECT 1000000 + g FROM hash_split_geom, generate_series(1, fillrows) g;
56+
ERROR: error triggered for injection point hash-split-before-relocation
57+
SELECT injection_points_detach('hash-split-before-relocation');
58+
injection_points_detach
59+
-------------------------
60+
61+
(1 row)
62+
63+
-- This insertion goes to the new bucket, which is still flagged as being
64+
-- populated by the incomplete split
65+
INSERT INTO hash_split_test SELECT k FROM hash_split_key;
66+
-- Scans of that bucket must visit both buckets of the incomplete split:
67+
-- the fresh row from the new bucket, plus the 10 older rows that remain
68+
-- in bucket 0
69+
SET enable_seqscan = off;
70+
SET enable_bitmapscan = off;
71+
EXPLAIN (COSTS OFF)
72+
SELECT count(*) FROM hash_split_test
73+
WHERE v = (SELECT k FROM hash_split_key);
74+
QUERY PLAN
75+
------------------------------------------------------------
76+
Aggregate
77+
InitPlan expr_1
78+
-> Seq Scan on hash_split_key
79+
Disabled: true
80+
-> Index Scan using hash_split_index on hash_split_test
81+
Index Cond: (v = (InitPlan expr_1).col1)
82+
(6 rows)
83+
84+
SELECT count(*) FROM hash_split_test
85+
WHERE v = (SELECT k FROM hash_split_key);
86+
count
87+
-------
88+
11
89+
(1 row)
90+
91+
-- Backward scans must visit both buckets too, in the opposite order
92+
BEGIN;
93+
DECLARE c SCROLL CURSOR FOR
94+
SELECT v = (SELECT k FROM hash_split_key) FROM hash_split_test
95+
WHERE v = (SELECT k FROM hash_split_key);
96+
MOVE FORWARD ALL IN c;
97+
FETCH BACKWARD ALL FROM c;
98+
?column?
99+
----------
100+
t
101+
t
102+
t
103+
t
104+
t
105+
t
106+
t
107+
t
108+
t
109+
t
110+
t
111+
(11 rows)
112+
113+
COMMIT;
114+
-- Same count via a bitmap scan, which shares the underlying scan code
115+
SET enable_indexscan = off;
116+
SET enable_bitmapscan = on;
117+
SELECT count(*) FROM hash_split_test
118+
WHERE v = (SELECT k FROM hash_split_key);
119+
count
120+
-------
121+
11
122+
(1 row)
123+
124+
-- And the ground truth, via the heap
125+
SET enable_bitmapscan = off;
126+
RESET enable_seqscan;
127+
SELECT count(*) FROM hash_split_test
128+
WHERE v = (SELECT k FROM hash_split_key);
129+
count
130+
-------
131+
11
132+
(1 row)
133+
134+
RESET enable_indexscan;
135+
RESET enable_bitmapscan;
136+
DROP TABLE hash_split_test;
137+
DROP EXTENSION injection_points;

src/test/modules/injection_points/meson.build

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ tests += {
3636
'hashagg',
3737
'reindex_conc',
3838
'vacuum',
39+
'hash_split',
3940
],
4041
'regress_args': ['--dlpath', meson.project_build_root() / 'src/test/regress'],
4142
# The injection points are cluster-wide, so disable installcheck
@@ -44,6 +45,7 @@ tests += {
4445
'isolation': {
4546
'specs': [
4647
'basic',
48+
'hash-split',
4749
'inplace',
4850
'reindex_concurrently_deferred',
4951
'repack',
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Test hash index scans that run while a bucket split is in progress.
2+
#
3+
# The interesting window opens once the split has relocated tuples to the
4+
# new bucket, before the buckets' split-in-progress flags are cleared. A
5+
# concurrent scan whose value belongs to the new bucket must visit both
6+
# buckets of the pair: it skips the moved-by-split tuples in the new
7+
# bucket, so it has to read their authoritative copies from the old
8+
# bucket, plus any tuples inserted into the new bucket after the split
9+
# began.
10+
#
11+
# s1 performs the split, pausing inside that window on a wait injection
12+
# point (placed where the splitter holds no buffer content locks). s2
13+
# then inserts a matching row (which goes to the new bucket) and scans,
14+
# both forwards and backwards.
15+
16+
setup
17+
{
18+
CREATE EXTENSION injection_points;
19+
CREATE TABLE hash_split_test (v int4) WITH (autovacuum_enabled = false);
20+
CREATE INDEX hash_split_index ON hash_split_test USING hash (v);
21+
CREATE TABLE hash_split_geom AS
22+
SELECT nbuckets,
23+
(current_setting('block_size')::bigint / 16) * nbuckets AS fillrows
24+
FROM (SELECT pg_relation_size('hash_split_index') /
25+
current_setting('block_size')::bigint - 2 AS nbuckets) g;
26+
CREATE TABLE hash_split_key AS
27+
SELECT min(v)::int4 AS k
28+
FROM generate_series(1, 10000) v, hash_split_geom
29+
WHERE (hashint4(v::int4) & (2 * nbuckets - 1)) = nbuckets;
30+
INSERT INTO hash_split_test
31+
SELECT k FROM hash_split_key, generate_series(1, 10);
32+
}
33+
34+
teardown
35+
{
36+
DROP TABLE hash_split_test, hash_split_geom, hash_split_key;
37+
DROP EXTENSION injection_points;
38+
}
39+
40+
session s1
41+
setup
42+
{
43+
SELECT injection_points_set_local();
44+
SELECT injection_points_attach('hash-split-after-relocation', 'wait');
45+
}
46+
# fillrows is guaranteed to cross the split threshold of ffactor * nbuckets
47+
# tuples: a hash index entry occupies at least 16 bytes (line pointer
48+
# included), and the default fillfactor targets 75% page fullness, so
49+
# ffactor can never exceed block_size / 16 tuples per bucket
50+
step s1_split
51+
{
52+
INSERT INTO hash_split_test
53+
SELECT 1000000 + g FROM hash_split_geom, generate_series(1, fillrows) g;
54+
}
55+
step s1_noop { }
56+
57+
session s2
58+
setup
59+
{
60+
SET enable_seqscan = off;
61+
SET enable_bitmapscan = off;
62+
}
63+
step s2_insert
64+
{
65+
INSERT INTO hash_split_test SELECT k FROM hash_split_key;
66+
}
67+
step s2_scan
68+
{
69+
SELECT count(*) FROM hash_split_test
70+
WHERE v = (SELECT k FROM hash_split_key);
71+
}
72+
step s2_back
73+
{
74+
BEGIN;
75+
DECLARE c SCROLL CURSOR FOR
76+
SELECT v = (SELECT k FROM hash_split_key) FROM hash_split_test
77+
WHERE v = (SELECT k FROM hash_split_key);
78+
MOVE FORWARD ALL IN c;
79+
FETCH BACKWARD ALL FROM c;
80+
COMMIT;
81+
}
82+
step s2_detach { SELECT injection_points_detach('hash-split-after-relocation'); }
83+
step s2_wakeup { SELECT injection_points_wakeup('hash-split-after-relocation'); }
84+
85+
# Scan during the paused split, then let the split finish and scan again.
86+
# The detach must happen before the wakeup: the same INSERT can trigger
87+
# another split, which must not hit the injection point again. The no-op
88+
# step in the splitter's session forces the isolation tester to wait for
89+
# s1_split to finish, keeping the position of its completion report stable
90+
# regardless of how long the remaining inserts take (see basic.spec).
91+
permutation s1_split s2_insert s2_scan s2_back s2_detach s2_wakeup s1_noop s2_scan

0 commit comments

Comments
 (0)