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

