On Sun, 1 Nov 2026 at 06:35, shihao zhong <[email protected]> wrote:
>
> 2. _hash_alloc_buckets() WAL-logs the new splitpoint before it extends
> the file. If a base backup starts in between, the hash index on the new
> standby or restored server is broken. It looks fine at first. Once the
> index needs an overflow page, inserts fail with "access to noncontiguous
> page" until a REINDEX.
>
> A power loss at the same moment can do the same on the primary.
>
> 0003 sets DELAY_CHKPT_START around the two steps,
> like bulk_write.c. 0004 is the repro.
Thanks for identifying this issue.
I noticed one issue:
After DELAY_CHKPT_START is set, itcan fail with an ordinary ERROR, for
example, due to lack of disk space. If this happens, the flag is left
set. A top-level abort clears it through ProcArrayEndTransaction(),
but AbortSubTransaction() does not. So if the error is caught by a
savepoint or a PL/pgSQL EXCEPTION block, the flag remains set for the
rest of the transaction. Every checkpoint then waits in
CheckpointDelayStart until that transaction ends. Even an
idle-in-transaction session can block checkpoints. The attached test
uses the error action of the new hash-alloc-buckets-after-wal
injection point to reproduce this issue.
Regards,
Vignesh
# Copyright (c) 2026, PostgreSQL Global Development Group
# An error raised between the WAL record and the file extension in
# _hash_alloc_buckets() must not leave a checkpoint waiting.
#
# Expected: the checkpoint completes. This FAILS while the flag stays set.
#
# Not run.
use strict;
use warnings FATAL => 'all';
use PostgreSQL::Test::Cluster;
use PostgreSQL::Test::Utils;
use Test::More;
use Time::HiRes qw(usleep);
if ($ENV{enable_injection_points} ne 'yes')
{
plan skip_all => 'Injection points not supported by this build';
}
my $node = PostgreSQL::Test::Cluster->new('main');
$node->init;
$node->append_conf('postgresql.conf', 'autovacuum = off');
$node->start;
if (!$node->check_extension('injection_points'))
{
plan skip_all => 'Extension injection_points not installed';
}
$node->safe_psql('postgres', 'CREATE EXTENSION injection_points');
$node->safe_psql(
'postgres', q(
CREATE TABLE hash_tab (k int);
CREATE INDEX hash_idx ON hash_tab USING hash (k);
INSERT INTO hash_tab SELECT (random() * 1e9)::int
FROM generate_series(1, 20000);
));
# The session that hits the error. The injection point is attached for this
# backend only.
my $s = $node->background_psql('postgres', on_error_stop => 0);
my $s_pid = $s->query_safe('SELECT pg_backend_pid()');
$s->query_safe(
q(SELECT injection_points_set_local();
SELECT injection_points_attach('hash-alloc-buckets-after-wal', 'error');));
# Insert until the hash index needs a new splitpoint, where _hash_alloc_buckets()
# raises the error. The loop is bounded, so that a failure to reach the point
# shows up as a failed premise and not as a hang.
$s->query_safe('BEGIN; SAVEPOINT s;');
$s->query(q{
DO $$
BEGIN
FOR i IN 1..3000 LOOP
INSERT INTO hash_tab SELECT (random() * 1e9)::int FROM generate_series(1, 1000);
END LOOP;
END $$;
});
like($s->{stderr},
qr/error triggered for injection point hash-alloc-buckets-after-wal/,
'premise: _hash_alloc_buckets() failed between the WAL record and the extension');
# The error is caught by the savepoint, and the transaction goes on.
$s->{stderr} = '';
$s->query_safe('ROLLBACK TO s;');
is( $node->safe_psql(
'postgres', "SELECT state FROM pg_stat_activity WHERE pid = $s_pid"),
'idle in transaction',
'premise: the transaction is still open');
# A checkpoint now. It has no reason to wait: no checkpoint-delaying section is
# in progress.
my $ckpt = $node->background_psql('postgres', on_error_stop => 0);
my $ckpt_pid = $ckpt->query_safe('SELECT pg_backend_pid()');
$ckpt->query_until(
qr/starting_checkpoint/, q(
\echo starting_checkpoint
CHECKPOINT;
));
my ($stuck, $done) = (0, 0);
for (1 .. 150)
{
if ($node->safe_psql(
'postgres',
"SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'checkpointer' AND wait_event = 'CheckpointDelayStart'"
) eq '1')
{
$stuck = 1;
last;
}
if ($node->safe_psql('postgres',
"SELECT state FROM pg_stat_activity WHERE pid = $ckpt_pid") eq 'idle')
{
$done = 1;
last;
}
usleep(100_000);
}
ok(!$stuck,
'the checkpoint does not wait for a transaction that rolled back to a savepoint');
ok($done, 'the checkpoint completed');
# Clean up: the abort of the transaction clears the flag, if it was left set.
$s->query_safe('ROLLBACK;');
$ckpt->query_safe('SELECT 1');
$ckpt->quit;
$s->quit;
$node->stop;
done_testing();