From 7757e38a0f020fd3d93b380aa9242beb666c875c Mon Sep 17 00:00:00 2001 From: Vignesh C Date: Wed, 28 Oct 2026 20:12:17 +0530 Subject: [PATCH] Test for hash index corruption issue. Test for hash index corruption issue. --- src/backend/access/transam/xloginsert.c | 23 +++ src/test/recovery/Makefile | 1 + src/test/recovery/meson.build | 1 + .../recovery/t/061_hash_vacuum_insert_crc.pl | 160 ++++++++++++++++++ 4 files changed, 185 insertions(+) create mode 100644 src/test/recovery/t/061_hash_vacuum_insert_crc.pl diff --git a/src/backend/access/transam/xloginsert.c b/src/backend/access/transam/xloginsert.c index c3c95f89276..0c5bac6523c 100644 --- a/src/backend/access/transam/xloginsert.c +++ b/src/backend/access/transam/xloginsert.c @@ -39,6 +39,7 @@ #include "replication/origin.h" #include "storage/bufmgr.h" #include "storage/proc.h" +#include "utils/injection_point.h" #include "utils/memutils.h" #include "utils/pgstat_internal.h" #include "utils/rel.h" @@ -630,6 +631,7 @@ XLogRecordAssemble(RmgrId rmid, uint8 info, XLogRecData *rdt; uint64 total_len = 0; int block_id; + int nochange_bufs = 0; pg_crc32c rdata_crc; registered_buffer *prev_regbuf = NULL; XLogRecData *rdt_datas_last; @@ -737,6 +739,9 @@ XLogRecordAssemble(RmgrId rmid, uint8 info, else include_image = needs_backup || (info & XLR_CHECK_CONSISTENCY) != 0; + if ((regbuf->flags & REGBUF_NO_CHANGE) != 0) + nochange_bufs++; + if (include_image) { const PageData *page = regbuf->page; @@ -999,6 +1004,24 @@ XLogRecordAssemble(RmgrId rmid, uint8 info, for (rdt = hdr_rdt.next; rdt != NULL; rdt = rdt->next) COMP_CRC32C(rdata_crc, rdt->data, rdt->len); + /* + * Test hook: lets a test change a buffer after the CRC was computed but + * before the record is copied into the WAL buffers. The argument names + * the record as "rmid/info/nochange_bufs" so that a test can pick one + * kind of record without waiting on every record. This runs in critical + * sections, so it must not allocate; it only fires in a backend that + * loaded the point beforehand. + */ +#ifdef USE_INJECTION_POINTS + { + char pointarg[24]; + + snprintf(pointarg, sizeof(pointarg), "%d/%02X/%d", + rmid, info, nochange_bufs); + INJECTION_POINT_CACHED("xlog-assemble-after-crc", pointarg); + } +#endif + /* * Ensure that the XLogRecord is not too large. * diff --git a/src/test/recovery/Makefile b/src/test/recovery/Makefile index 9c4102b6b2c..456c211ea62 100644 --- a/src/test/recovery/Makefile +++ b/src/test/recovery/Makefile @@ -10,6 +10,7 @@ #------------------------------------------------------------------------- EXTRA_INSTALL=contrib/pg_prewarm \ + contrib/pg_visibility \ contrib/pg_stat_statements \ contrib/test_decoding \ src/test/modules/injection_points \ diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index b6ac9dd07a7..5051031df56 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -69,6 +69,7 @@ tests += { 't/058_shutdown_crash_restart.pl', 't/059_remote_apply_status_interval.pl', 't/060_multixact_crashed_updater.pl', + 't/061_hash_vacuum_insert_crc.pl', ], }, } diff --git a/src/test/recovery/t/061_hash_vacuum_insert_crc.pl b/src/test/recovery/t/061_hash_vacuum_insert_crc.pl new file mode 100644 index 00000000000..d1450130d6c --- /dev/null +++ b/src/test/recovery/t/061_hash_vacuum_insert_crc.pl @@ -0,0 +1,160 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Test that the WAL record written by hash index VACUUM for an overflow page is +# intact when a concurrent INSERT changes the primary bucket page. +# +# hashbucketcleanup() keeps only a pin on the primary bucket page while it +# walks the overflow chain, and registers that page (REGBUF_STANDARD | +# REGBUF_NO_IMAGE | REGBUF_NO_CHANGE) with the XLOG_HASH_DELETE record it +# writes for each overflow page. With wal_consistency_checking enabled for +# the hash resource manager, XLogRecordAssemble() includes a full image of +# every registered block, so it reads the unlocked primary bucket page. If an +# INSERT adds a tuple to that page after the record's CRC was computed but +# before the page is copied into the WAL buffers, the record is written with a +# CRC that does not match its contents. Replay then stops at that record with +# "incorrect resource manager data checksum". +# +# The window is a few microseconds wide, so the test widens it with an +# injection point in XLogRecordAssemble(), placed right after the CRC is +# computed. It is attached to the VACUUM session only, and only matches the +# record in question, identified by "rmgr id/xl_info/number of REGBUF_NO_CHANGE +# buffers": a hash DELETE with the consistency-check flag (0x92) that registers +# one such buffer (the unlocked primary bucket page). The point still fires +# after the bug is fixed, so the test exercises the same interleaving. +# +# The assertions state the correct behavior (all WAL is valid and replays), so +# the test fails while the bug exists. + +use strict; +use warnings FATAL => 'all'; + +use IPC::Run; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq{ +wal_consistency_checking = 'hash' +autovacuum = off +fsync = off +checkpoint_timeout = 1h +}); +$node->start; + +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} +$node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); + +my $hash_rmid = $node->safe_psql('postgres', + q{SELECT rm_id FROM pg_get_wal_resource_managers() WHERE rm_name = 'Hash'}); + +# All the rows get the same key, so they land in one bucket: 600 entries fill +# the primary page (about 400 fit) and one overflow page. Deleting them leaves +# dead entries on both pages for VACUUM to remove. +# +# hashbuild() sizes the index from estimate_rel_size() on the heap, which for a +# table that has never been vacuumed applies a floor of 10 pages and gives a +# block-size-dependent number of initial buckets. Vacuuming first sets +# reltuples to 0, so the estimate is believed and the index starts with the +# minimum of 2 buckets. +$node->safe_psql( + 'postgres', q{ +CREATE TABLE hash_t (k int) WITH (autovacuum_enabled = false); +VACUUM hash_t; +CREATE INDEX hash_i ON hash_t USING hash (k); +INSERT INTO hash_t SELECT 1 FROM generate_series(1, 600); +DELETE FROM hash_t; +}); + +# Premise: metapage, two bucket pages, a bitmap page and one overflow page. +is( $node->safe_psql( + 'postgres', + q{SELECT pg_relation_size('hash_i') / current_setting('block_size')::int} + ), + '5', + 'premise: the index has a primary bucket page and one overflow page'); + +# Start from a checkpoint, so crash recovery replays everything below and +# pg_waldump has a record boundary to start from. +$node->safe_psql('postgres', 'CHECKPOINT'); +my $start_lsn = $node->safe_psql('postgres', 'SELECT pg_current_wal_lsn()'); + +# The VACUUM session. The point is attached to this session only and must be +# loaded before the critical section in which it runs. +my $vacuum = $node->background_psql('postgres'); +$vacuum->query_safe(q{SELECT injection_points_set_local()}); +$vacuum->query_safe( + qq{SELECT injection_points_attach('xlog-assemble-after-crc', 'wait', '$hash_rmid/92/1')} +); +$vacuum->query_safe(q{SELECT injection_points_load('xlog-assemble-after-crc')}); +$vacuum->query_until( + qr/starting_vacuum/, + q(\echo starting_vacuum +VACUUM (INDEX_CLEANUP on) hash_t; +\q +)); + +# VACUUM has removed the dead entries from the primary page, released its lock +# (keeping the pin), and is now logging the removal from the overflow page: the +# CRC of that record is computed and the unlocked primary page is not yet +# copied. +$node->wait_for_event('client backend', 'xlog-assemble-after-crc'); +note('VACUUM is between computing the CRC and copying the record'); + +# Now there is room in the primary page, so this INSERT changes it. +$node->safe_psql('postgres', q{INSERT INTO hash_t VALUES (1)}); + +$node->safe_psql( + 'postgres', q{ +SELECT injection_points_wakeup('xlog-assemble-after-crc'); +SELECT injection_points_detach('xlog-assemble-after-crc'); +}); +$vacuum->quit; + +my $end_lsn = $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); +$node->poll_query_until('postgres', + "SELECT pg_current_wal_flush_lsn() >= '$end_lsn'") + or die "timed out waiting for WAL flush"; + +# Crash, so that start-up has to replay (and CRC-check) the workload. +$node->stop('immediate'); + +# 1. Every record in the range must have a valid CRC. pg_waldump verifies it +# for every record it reads, whatever the --rmgr filter. +my $stderr; +my $ok = IPC::Run::run( + [ + 'pg_waldump', + '--path' => $node->data_dir . '/pg_wal', + '--start' => $start_lsn, + '--end' => $end_lsn, + '--rmgr' => 'Hash', + ], + '>' => '/dev/null', + '2>' => \$stderr); + +ok($ok, 'pg_waldump reads the whole workload without errors'); +unlike($stderr, qr/incorrect resource manager data checksum/, + 'no WAL record has a mismatching CRC'); + +# 2. Crash recovery must replay it without complaint. +my $log_offset = -s $node->logfile; +$node->start; +my $log = slurp_file($node->logfile, $log_offset); +unlike( + $log, + qr/incorrect resource manager data checksum|inconsistent page found/, + 'crash recovery replays the workload without CRC or consistency errors'); + +$node->stop; +done_testing(); -- 2.55.0