0001-Test-Multixact-truncation-near-page-boundary-replay-.patch
application/octet-stream
Filename: 0001-Test-Multixact-truncation-near-page-boundary-replay-.patch
Type: application/octet-stream
Part: 0
Patch
Same data as JSON:
GET /api/v1/attachments/:id/patch
the parsed metadata as JSON — format, series position, per-file stats; never the diff bytes.
API reference →
Format: format-patch
Series: patch 0001
Subject: Test Multixact truncation near page-boundary replay on standby
| File | + | − |
|---|---|---|
| src/backend/access/transam/multixact.c | 12 | 3 |
| src/test/modules/test_slru/Makefile | 3 | 1 |
| src/test/modules/test_slru/meson.build | 6 | 0 |
| src/test/modules/test_slru/t/002_multixact_truncation_replay.pl | 95 | 0 |
| src/test/modules/test_slru/test_multixact.c | 105 | 0 |
| src/test/modules/test_slru/test_slru--1.0.sql | 7 | 0 |
From 465eb45cffab0f8503a66288246a0416a0702071 Mon Sep 17 00:00:00 2001
From: Andrey Borodin <amborodin@acm.org>
Date: Wed, 18 Feb 2026 10:11:26 +0500
Subject: [PATCH] Test Multixact truncation near page-boundary replay on
standby
Add a TAP test that reproduces the bug fixed by commit 4a36c89f165:
TRUNCATE_ID WAL replay resets latest_page_number, breaking the
init-next-page check in RecordNewMultiXact. When a page-crossing
CREATE_ID is replayed after a TRUNCATE_ID whose endTruncOff lands on
a different page, the standby startup process crashes with:
FATAL: could not access status of transaction ...
DETAIL: Could not read from file "pg_multixact/offsets/..." read too few bytes.
To trigger the bug reliably in a single-binary test, two additional
changes to multixact.c simulate WAL from older minor versions
(pre-8ba61bc063):
- ExtendMultiXactOffset(result) instead of (result + 1), so the
primary does not pre-zero the next page before the CREATE_ID.
- The "set next multixid's offset" block in RecordNewMultiXact is
skipped on the primary (!InRecovery) but kept during recovery,
so the standby still tries to read the next page.
A helper function test_multixact_write_truncate_wal() injects a
TRUNCATE_ID WAL record with a controlled endTruncOff, simulating
the concurrent truncation + multixact creation that occurs in
production.
Apply the fix (0002-Don-t-reset-latest_page_number-when-replaying-
multix.patch) on top of this patch to verify the test passes.
---
src/backend/access/transam/multixact.c | 15 ++-
src/test/modules/test_slru/Makefile | 4 +-
src/test/modules/test_slru/meson.build | 6 +
.../t/002_multixact_truncation_replay.pl | 95 ++++++++++++++++
src/test/modules/test_slru/test_multixact.c | 105 ++++++++++++++++++
src/test/modules/test_slru/test_slru--1.0.sql | 7 ++
6 files changed, 228 insertions(+), 4 deletions(-)
create mode 100644 src/test/modules/test_slru/t/002_multixact_truncation_replay.pl
create mode 100644 src/test/modules/test_slru/test_multixact.c
diff --git a/src/backend/access/transam/multixact.c b/src/backend/access/transam/multixact.c
index c863e4e0556..e1fc55d0745 100644
--- a/src/backend/access/transam/multixact.c
+++ b/src/backend/access/transam/multixact.c
@@ -996,7 +996,15 @@ RecordNewMultiXact(MultiXactId multi, MultiXactOffset offset,
/*
* Set the next multixid's offset to the end of this multixid's members.
+ *
+ * On the primary (!InRecovery), skip this to produce WAL without the next
+ * offset already set — simulating pre-8ba61bc063 behavior. During
+ * recovery, keep this code so the standby tries to read the next page,
+ * triggering the bug when the init-next-page check fails due to
+ * truncation resetting latest_page_number.
*/
+ if (InRecovery)
+ {
if (next_pageno == pageno)
{
next_offptr = offptr + 1;
@@ -1027,6 +1035,7 @@ RecordNewMultiXact(MultiXactId multi, MultiXactOffset offset,
*next_offptr = next_offset;
MultiXactOffsetCtl->shared->page_dirty[slotno] = true;
}
+ }
/* Release MultiXactOffset SLRU lock. */
LWLockRelease(lock);
@@ -1227,7 +1236,7 @@ GetNewMultiXactId(int nmembers, MultiXactOffset *offset)
* Make sure there is room for the next MXID in the file. Assigning this
* MXID sets the next MXID's offset already.
*/
- ExtendMultiXactOffset(result + 1);
+ ExtendMultiXactOffset(result);
/*
* Reserve the members space, similarly to above. Also, be careful not to
@@ -3603,8 +3612,8 @@ multixact_redo(XLogReaderState *record)
* SimpleLruTruncate.
*/
pageno = MultiXactIdToOffsetPage(xlrec.endTruncOff);
- pg_atomic_write_u64(&MultiXactOffsetCtl->shared->latest_page_number,
- pageno);
+ // pg_atomic_write_u64(&MultiXactOffsetCtl->shared->latest_page_number,
+ // pageno);
PerformOffsetsTruncation(xlrec.startTruncOff, xlrec.endTruncOff);
LWLockRelease(MultiXactTruncationLock);
diff --git a/src/test/modules/test_slru/Makefile b/src/test/modules/test_slru/Makefile
index 936886753b7..8870e49da85 100644
--- a/src/test/modules/test_slru/Makefile
+++ b/src/test/modules/test_slru/Makefile
@@ -3,7 +3,8 @@
MODULE_big = test_slru
OBJS = \
$(WIN32RES) \
- test_slru.o
+ test_slru.o \
+ test_multixact.o
PGFILEDESC = "test_slru - test module for SLRUs"
EXTENSION = test_slru
@@ -11,6 +12,7 @@ DATA = test_slru--1.0.sql
REGRESS_OPTS = --temp-config $(top_srcdir)/src/test/modules/test_slru/test_slru.conf
REGRESS = test_slru
+TAP_TESTS = 1
# Disabled because these tests require "shared_preload_libraries=test_slru",
# which typical installcheck users do not have (e.g. buildfarm clients).
NO_INSTALLCHECK = 1
diff --git a/src/test/modules/test_slru/meson.build b/src/test/modules/test_slru/meson.build
index ce91e606313..f589b3ec358 100644
--- a/src/test/modules/test_slru/meson.build
+++ b/src/test/modules/test_slru/meson.build
@@ -2,6 +2,7 @@
test_slru_sources = files(
'test_slru.c',
+ 'test_multixact.c',
)
if host_system == 'windows'
@@ -32,4 +33,9 @@ tests += {
'regress_args': ['--temp-config', files('test_slru.conf')],
'runningcheck': false,
},
+ 'tap': {
+ 'tests': [
+ 't/002_multixact_truncation_replay.pl',
+ ],
+ },
}
diff --git a/src/test/modules/test_slru/t/002_multixact_truncation_replay.pl b/src/test/modules/test_slru/t/002_multixact_truncation_replay.pl
new file mode 100644
index 00000000000..4a4140e8bd2
--- /dev/null
+++ b/src/test/modules/test_slru/t/002_multixact_truncation_replay.pl
@@ -0,0 +1,95 @@
+# Copyright (c) 2024-2026, PostgreSQL Global Development Group
+
+# Test multixact SLRU truncation replay on standby.
+#
+# Reproduces the bug fixed by commit 4a36c89f165: during TRUNCATE_ID replay,
+# latest_page_number was reset to MultiXactIdToOffsetPage(endTruncOff). This
+# broke the init-next-page check in RecordNewMultiXact, which compares
+# latest_page_number == pageno. If a CREATE_ID that crosses a page boundary
+# is replayed AFTER a TRUNCATE_ID whose endTruncOff is on a different page,
+# the init check doesn't fire, the next page isn't initialized, and
+# SimpleLruReadPage fails with FATAL.
+#
+# The test uses test_multixact_write_truncate_wal() to inject a TRUNCATE_ID
+# WAL record with endTruncOff on page 0, placed between two batches of
+# CREATE_IDs. This simulates the real-world scenario where truncation runs
+# concurrently with multixact creation.
+#
+# To produce WAL without a pre-zeroed next page (as older minor versions did
+# before 8ba61bc063), two changes in multixact.c are required:
+# - ExtendMultiXactOffset(result) instead of (result + 1)
+# - next-offset write in RecordNewMultiXact skipped on primary
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+
+use Test::More;
+
+# MULTIXACT_OFFSETS_PER_PAGE = BLCKSZ/4 = 2048 (for 8kB blocks).
+#
+# Scenario:
+# 1. Create 2046 multixacts (multis 1..2046). nextMXact = 2047, page 0.
+# 2. Take backup.
+# 3. Create 2048 MORE multixacts (2047..4094). Multi 2047 crosses page 0->1.
+# 4. Inject TRUNCATE_ID with endTruncOff = 10 (page 0).
+# 5. Create 1 more multixact (4095), last entry on page 1, crossing to page 2.
+#
+# On the standby:
+# - StartupMultiXact sets latest_page_number = page(2047) = 0
+# - CREATE_ID(2047) crosses page 0->1: init check fires (0==0), zeros page 1,
+# latest_page_number updated to 1 by SimpleLruZeroPage
+# - CREATE_IDs for 2048..4094 on page 1 (no crossings)
+# - TRUNCATE_ID(endTruncOff=10): latest_page_number reset to page(10) = 0
+# - CREATE_ID(4095): pageno=1, next_pageno=2
+# Init check: latest_page_number(0) != pageno(1) -> SKIP
+# RecordNewMultiXact tries SimpleLruReadPage(page 2) -> FATAL
+#
+# With the fix (not resetting latest_page_number in TRUNCATE_ID replay):
+# - latest_page_number stays 1 after the page 0->1 crossing
+# - Init check at CREATE_ID(4095): latest_page_number(1) == pageno(1) -> fires
+# - Page 2 is initialized -> replay succeeds
+
+my $node_primary = PostgreSQL::Test::Cluster->new('main');
+$node_primary->init(allows_streaming => 'physical');
+$node_primary->append_conf('postgresql.conf',
+ "shared_preload_libraries = 'test_slru'");
+$node_primary->start;
+$node_primary->safe_psql('postgres', q(CREATE EXTENSION test_slru));
+
+# Fill page 0: multis 1..2046, nextMXact = 2047
+$node_primary->safe_psql('postgres', q{SELECT test_create_multixacts(2046)});
+
+$node_primary->backup('mx_backup');
+
+# Fill page 1: multis 2047..4094, nextMXact = 4095.
+# Multi 2047 crosses page 0->1; on the standby the init check zeros page 1.
+$node_primary->safe_psql('postgres', q{SELECT test_create_multixacts(2048)});
+
+# Inject TRUNCATE_ID with endTruncOff on page 0.
+# On the standby this resets latest_page_number from 1 back to 0.
+$node_primary->safe_psql('postgres',
+ q{SELECT test_multixact_write_truncate_wal('10'::xid)});
+
+# Create multi 4095 (page 1, entry 2047) which crosses to page 2.
+# Without the fix the standby crashes here: latest_page_number(0) != pageno(1).
+$node_primary->safe_psql('postgres', q{SELECT test_create_multixact()});
+$node_primary->safe_psql('postgres', q{SELECT pg_switch_wal()});
+
+my $node_standby = PostgreSQL::Test::Cluster->new('standby');
+$node_standby->init_from_backup($node_primary, 'mx_backup',
+ has_streaming => 1);
+$node_standby->start;
+
+my $primary_lsn = $node_primary->lsn('flush');
+my $replayed = $node_standby->poll_query_until('postgres',
+ qq{SELECT '$primary_lsn'::pg_lsn <= pg_last_wal_replay_lsn()});
+
+ok($replayed, "standby replayed TRUNCATE_ID + page-crossing CREATE_ID");
+
+$node_standby->stop if $replayed;
+$node_primary->stop;
+
+done_testing();
diff --git a/src/test/modules/test_slru/test_multixact.c b/src/test/modules/test_slru/test_multixact.c
new file mode 100644
index 00000000000..e2f6f6a738d
--- /dev/null
+++ b/src/test/modules/test_slru/test_multixact.c
@@ -0,0 +1,105 @@
+/*-------------------------------------------------------------------------
+ *
+ * test_multixact.c
+ * Support code for multixact testing
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ * src/test/modules/test_slru/test_multixact.c
+ *
+ * -------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "access/multixact.h"
+#include "access/xact.h"
+#include "access/xlog.h"
+#include "access/xloginsert.h"
+#include "fmgr.h"
+#include "miscadmin.h"
+#include "utils/pg_lsn.h"
+
+PG_FUNCTION_INFO_V1(test_create_multixact);
+PG_FUNCTION_INFO_V1(test_create_multixacts);
+PG_FUNCTION_INFO_V1(test_multixact_write_truncate_wal);
+
+/*
+ * Produces multixact with 2 current xids
+ */
+Datum
+test_create_multixact(PG_FUNCTION_ARGS)
+{
+ MultiXactId id;
+
+ MultiXactIdSetOldestMember();
+ id = MultiXactIdCreate(GetCurrentTransactionId(), MultiXactStatusUpdate,
+ GetCurrentTransactionId(), MultiXactStatusForShare);
+ PG_RETURN_TRANSACTIONID(id);
+}
+
+/*
+ * Create n multixacts. Used to quickly fill offset pages for truncation tests.
+ *
+ * Each iteration uses a subtransaction so that GetCurrentTransactionId()
+ * returns a different xid, preventing mXactCacheGetBySet from returning a
+ * cached result and ensuring a new MultiXactId is allocated every time.
+ */
+Datum
+test_create_multixacts(PG_FUNCTION_ARGS)
+{
+ int32 n = PG_GETARG_INT32(0);
+ MultiXactId first_id = InvalidMultiXactId;
+
+ if (n <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("n must be positive")));
+
+ for (int i = 0; i < n; i++)
+ {
+ MultiXactId id;
+
+ BeginInternalSubTransaction(NULL);
+ MultiXactIdSetOldestMember();
+ id = MultiXactIdCreate(GetCurrentTransactionId(), MultiXactStatusUpdate,
+ GetCurrentTransactionId(), MultiXactStatusForShare);
+ ReleaseCurrentSubTransaction();
+
+ if (i == 0)
+ first_id = id;
+ }
+
+ PG_RETURN_TRANSACTIONID(first_id);
+}
+
+/*
+ * Write a TRUNCATE_ID WAL record with the given endTruncOff.
+ *
+ * This is used to simulate a truncation that sets latest_page_number to a
+ * specific page during standby replay, without actually truncating anything
+ * on the primary. The standby's multixact_redo handler will reset
+ * latest_page_number = MultiXactIdToOffsetPage(endTruncOff).
+ */
+Datum
+test_multixact_write_truncate_wal(PG_FUNCTION_ARGS)
+{
+ MultiXactId endTruncOff = PG_GETARG_TRANSACTIONID(0);
+ xl_multixact_truncate xlrec;
+ XLogRecPtr recptr;
+
+ xlrec.oldestMultiDB = MyDatabaseId;
+ xlrec.startTruncOff = 1;
+ xlrec.endTruncOff = endTruncOff;
+ xlrec.startTruncMemb = 0;
+ xlrec.endTruncMemb = 0;
+
+ XLogBeginInsert();
+ XLogRegisterData((char *) &xlrec, SizeOfMultiXactTruncate);
+ recptr = XLogInsert(RM_MULTIXACT_ID, XLOG_MULTIXACT_TRUNCATE_ID);
+ XLogFlush(recptr);
+
+ PG_RETURN_LSN(recptr);
+}
diff --git a/src/test/modules/test_slru/test_slru--1.0.sql b/src/test/modules/test_slru/test_slru--1.0.sql
index 202e8da3fde..0d6271473bf 100644
--- a/src/test/modules/test_slru/test_slru--1.0.sql
+++ b/src/test/modules/test_slru/test_slru--1.0.sql
@@ -19,3 +19,10 @@ CREATE OR REPLACE FUNCTION test_slru_page_truncate(bigint) RETURNS VOID
AS 'MODULE_PATHNAME', 'test_slru_page_truncate' LANGUAGE C;
CREATE OR REPLACE FUNCTION test_slru_delete_all() RETURNS VOID
AS 'MODULE_PATHNAME', 'test_slru_delete_all' LANGUAGE C;
+
+CREATE OR REPLACE FUNCTION test_create_multixact() RETURNS xid
+ AS 'MODULE_PATHNAME', 'test_create_multixact' LANGUAGE C;
+CREATE OR REPLACE FUNCTION test_create_multixacts(int) RETURNS xid
+ AS 'MODULE_PATHNAME', 'test_create_multixacts' LANGUAGE C;
+CREATE OR REPLACE FUNCTION test_multixact_write_truncate_wal(xid) RETURNS pg_lsn
+ AS 'MODULE_PATHNAME', 'test_multixact_write_truncate_wal' LANGUAGE C;
--
2.51.2