public inbox for [email protected]  
help / color / mirror / Atom feed
From: =?UTF-8?B?5q615Z2k5LuBKOWIu+mfpyk=?= <[email protected]>
To: pgsql-hackers <[email protected]>
Cc: hlinnaka <[email protected]>
Subject:  Bug in MultiXact replay compat logic for older minor version after crash-recovery
Date: Fri, 20 Mar 2026 02:02:30 +0800
Message-ID: <c4ef1737-8cba-458e-b6fd-4e2d6011e985.duankunren.dkr@alibaba-inc.com> (raw)

Hi hackers,
    
This is related to two recent threads:
    
[0] https://www.postgresql.org/message-id/flat/172e5723-d65f-4eec-b512-14beacb326ce%40yandex.ru
[1] https://www.postgresql.org/message-id/flat/CACV2tSw3VYS7d27ftO_cs%2BaF3M54%2BJwWBbqSGLcKoG9cvyb6EA%4...
    
I think there may be a bug in the compat logic introduced in
RecordNewMultiXact() to fix [0]. The compat logic handles WAL
generated by older  minor versions where ZERO_OFF_PAGE N+1
may appear after CREATE_ID:N in the WAL stream. However, the condition
used to detect whether the next offset page needs initialization
seems to fail after a crash-restart, causing the standby to enter a
FATAL crash loop.
    
We hit this in production: a PG 16.12 standby replaying WAL from a
PG 16.8 primary entered a crash-restart loop with:
    
> FATAL:  could not access status of transaction 1277952
> DETAIL:  Could not read from file "pg_multixact/offsets/0013" at offset
> 131072: read too few bytes.
> CONTEXT:  WAL redo at 5194/5F5F7118 for MultiXact/CREATE_ID: 1277951 
>offset 2605275 nmembers 2: 814605500 (keysh) 814605501 (keysh)
    
== Analysis ==
    
The compat logic condition is:
    
> if (InRecovery &&
>     next_pageno != pageno &&
>     MultiXactOffsetCtl->shared->latest_page_number == pageno)
    
The third condition (latest_page_number == pageno) assumes that if
latest_page_number equals the current multixact's page, then the next
page hasn't been initialized yet. This works during normal (non-crash)
replay because latest_page_number is incrementally maintained by
SimpleLruZeroPage() calls.
    
But after a crash-restart, StartupMultiXact() resets
latest_page_number to page(checkPoint.nextMulti):
    
> void StartupMultiXact(void)
> {
>     MultiXactId multi = MultiXactState->nextMXact;
>     pageno = MultiXactIdToOffsetPage(multi);
>     MultiXactOffsetCtl->shared->latest_page_number = pageno;
> }
    
When the checkpoint captured an already-advanced nextMXact (which
happens when GetNewMultiXactId() incremented nextMXact before the
backend wrote CREATE_ID to WAL), checkPoint.nextMulti >= N+1, so
latest_page_number = page(N+1) = P+1. The compat check becomes:
    
> P+1 == P  ->  FALSE  ->  compat logic skipped
    
The subsequent SimpleLruReadPage(next_pageno=P+1) then fails because
page P+1 doesn't exist on disk (ZERO_OFF_PAGE:P+1 hasn't been
replayed yet -- it's after CREATE_ID:N in the WAL due to the
reordering that the compat logic is supposed to handle).
    
The fix for [1] addressed a related issue where
TRUNCATE replay reset latest_page_number to a very old value
(latest_page_number << pageno). That fix addresses the "too small"
direction but not the "too large" direction described here.
    
== Timeline: the lock-free window ==
    
The root cause is a lock-free window in MultiXactIdCreateFromMembers().GetNewMultiXactId() advances nextMXact under MultiXactGenLock, but
the lock is released BEFORE XLogInsert(CREATE_ID). Any checkpoint
that runs in this window captures the advanced nextMXact without the
corresponding CREATE_ID in WAL.
    
Assume multi N is the last entry on offset page P (entry 2047), so
multi N+1 falls on page P+1.
    
1. [Backend] GetNewMultiXactId():
   - LWLockAcquire(MultiXactGenLock)
   - result = nextMXact (= N)
   - nextMXact++ (now N+1)
   - LWLockRelease(MultiXactGenLock)
   - return N
   <<< lock-free window opens here >>>
   nextMXact=N+1 is globally visible, but CREATE_ID:N not in WAL yet.
2. [Checkpointer] CHECKPOINT runs during the window:
   - reads nextMXact = N+1
   - writes checkpoint record with nextMulti=N+1
3. [Standby] replays the checkpoint, does restartpoint
   (persists nextMulti=N+1 to disk)
4. [Standby] *** CRASH ***
   <<< lock-free window closes >>>
5. [Backend] XLogInsert(CREATE_ID: N)
   -- this WAL record lands AFTER the checkpoint record
6. [Backend] RecordNewMultiXact(N):
   - next_pageno = page(N+1) = P+1
   - SimpleLruReadPage(P+1) -- works fine on primary
    
The resulting WAL stream on disk:
> ... CHECKPOINT(nextMulti=N+1) ... CREATE_ID:N ... ZERO_OFF_PAGE:P+1 ...
>     ^                             ^                ^
>     redo starts here              needs page P+1   page P+1 init
>     after crash-restart
    
After standby crash-restart:
7. [Standby] StartupMultiXact():
   - multi = nextMXact = N+1 (from checkpoint)
   - latest_page_number = page(N+1) = P+1
8. [Standby] Redo CREATE_ID:N -> RecordNewMultiXact(N):
   - pageno = page(N) = P, next_pageno = page(N+1) = P+1
   - Compat logic check:
     InRecovery = true
     next_pageno(P+1) != pageno(P)
     latest_page_number(P+1) == pageno(P) -- FAIL
     --> compat logic SKIPPED
   - SimpleLruReadPage(P+1)
     page P+1 not on disk yet (ZERO_OFF_PAGE:P+1 not replayed)
     --> FATAL: read too few bytes
    
== Reproduction ==
    
I wrote a deterministic reproducer using sleep injection. A sleep
injection patch (sleep-injection-for-multixact-compat-logic-bug-repro.patch)
adds a 60-second sleep in MultiXactIdCreateFromMembers(), after
GetNewMultiXactId() returns but before XLogInsert(CREATE_ID).
The sleep only triggers when the allocated multi is the last entry on
an offset page (entry 2047) AND the file /tmp/mxact_sleep_enabled exists
(so the batch phase that generates multixacts is not affected).
    
The setup:
    
- Primary: PG 16.11 with the sleep injection patch applied.
- Standby: PG 16.13 (contains the compat logic from [0] and[1]).
    
The test script (test_multixact_compat.sh) does the following:
    
1. Initialize primary, create test table
2. Generate multixacts until nextMulti's entry is exactly 2047
   (last entry on page P)
3. pg_basebackup to create standby, start with pg_new (16.13),
   checkpoint_timeout=30s
4. Session A: BEGIN; SELECT * FROM t WHERE id=1 FOR SHARE;
   (dirties heap buffer)
5. CHECKPOINT to flush the dirty buffer (*)
6. Enable sleep injection (touch /tmp/mxact_sleep_enabled)
7. Session B: SELECT * FROM t WHERE id=1 FOR SHARE;
   -> creates multixact N, GetNewMultiXactId advances nextMXact
     to N+1, then sleeps 60s
8. During sleep: CHECKPOINT on primary
   -> completes instantly (buffer is clean)
   -> captures nextMulti=N+1 in checkpoint record
9. Wait for standby to replay checkpoint and do restartpoint
10. Crash standby (pg_ctl -m immediate stop)
11. Wait for sleep to end -> CREATE_ID:N written to WAL
12. Restart standby -> FATAL
    
The CHECKPOINT in step 5 is essential. Without it, the checkpoint
in step 8 blocks because heap_lock_tuple holds the buffer content
lock in EXCLUSIVE mode across the entire MultiXactIdCreateFromMembers
call (including the sleep). Session A's FOR SHARE dirtied the heap
buffer, so BufferSync needs LW_SHARED on the content lock and blocks.
The intermediate CHECKPOINT flushes the dirty buffer before Session B
acquires the content lock.
    
Result:
    
> DEBUG:  next MultiXactId: 114688; next MultiXactOffset: 688233
> FATAL:  could not access status of transaction 114688
> DETAIL:  Could not read from file "pg_multixact/offsets/0001"
>          at offset 196608: read too few bytes.
> CONTEXT:  WAL redo at 0/40426E0 for MultiXact/CREATE_ID: 114687
>           offset 688231 nmembers 2: 116735 (sh) 116736 (sh)
    
== Fix ideas ==
    
I have two ideas for fixing this. Two independent patches are
attached.
    
Idea 1 (0001-Fix-multixact-compat-logic-via-unconditional-zero.patch):
    
Remove the "latest_page_number == pageno" condition entirely. During
recovery, whenever we cross a page boundary in RecordNewMultiXact(),
unconditionally ensure the next page is initialized via
SimpleLruZeroPage().
    
Pro: Minimal change, easy to review, impossible to miss any edge
case since we always initialize. Safe because SimpleLruZeroPage is
idempotent -- at this point the next page only contains zeros
(CREATE_ID records for that page haven't been replayed yet), so
re-zeroing loses nothing.
    
Con: When replaying WAL from a new-version primary (where
ZERO_OFF_PAGE is emitted BEFORE CREATE_ID), the page has already
been initialized by the ZERO_OFF_PAGE record. This patch will
redundantly zero+write it again. The cost is one extra
SimpleLruZeroPage + SimpleLruWritePage per 2048 multixacts during
recovery, which is negligible in practice, but not zero.
    
Idea 2 (0002-Fix-multixact-compat-logic-via-SLRU-buffer-check.patch):
    
Also remove the "latest_page_number == pageno" condition, but add a
two-tier check: first, if latest_page_number == pageno (fast path),
we know the next page needs initialization. Otherwise, scan the SLRU
buffer slots to check if the next page already exists (e.g. from a
prior ZERO_OFF_PAGE replay). Only initialize if the page is not
found in the buffer.
    
Pro: Avoids the redundant zero+write when replaying new-version WAL
where ZERO_OFF_PAGE has already been replayed before CREATE_ID. The
SLRU buffer scan is O(n_buffers) which is cheap (default 8 buffers).
    
Con: More complex than Idea 1. The SLRU buffer check is a heuristic
-- if the page was written out and evicted from the buffer pool
before CREATE_ID replay, we'd zero it again (still safe, just
redundant). Also introduces a dependency on SLRU buffer pool
internals.
    
I'd appreciate it if someone could review whether my analysis of the
bug is correct. If it is, I'd be happy to hear any thoughts on the
patches or better approaches to fix this.
    
Regards,
Duan



Attachments:

  [application/octet-stream] sleep-injection-for-multixact-compat-logic-bug-repro.patch (1.9K, 2-sleep-injection-for-multixact-compat-logic-bug-repro.patch)
  download | inline diff:
From 9caa8fc8a10ad65fe0f1fb5ec9a6c3e42668d9f6 Mon Sep 17 00:00:00 2001
From: "duankunren.dkr" <[email protected]>
Date: Thu, 19 Mar 2026 23:19:19 +0800
Subject: [PATCH] Sleep injection for multixact compat logic bug reproduction

Inject a 60s sleep in MultiXactIdCreateFromMembers() between
GetNewMultiXactId() and XLogInsert(CREATE_ID), triggered when
the allocated multi is the last entry on an offset page (entry 2047).

This creates a deterministic window where CHECKPOINT can capture
nextMulti=N+1 while CREATE_ID:N has not yet been written to WAL,
reproducing the WAL disorder that triggers the compat logic bug
in commit 8ba61bc0638.

Apply to REL_16_11 (or any unpatched minor version) to build the
primary for the reproduction test.
---
 src/backend/access/transam/multixact.c | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

diff --git a/src/backend/access/transam/multixact.c b/src/backend/access/transam/multixact.c
index 3a2d7055c42..cb3f8f207ae 100644
--- a/src/backend/access/transam/multixact.c
+++ b/src/backend/access/transam/multixact.c
@@ -822,6 +822,21 @@ MultiXactIdCreateFromMembers(int nmembers, MultiXactMember *members)
 	 */
 	multi = GetNewMultiXactId(nmembers, &offset);
 
+
+	/*
+	 * BUG REPRODUCTION: Sleep 60s before writing CREATE_ID WAL when
+	 * this multi is the last entry on its offset page (entry 2047).
+	 * Only activates when /tmp/mxact_sleep_enabled exists, so the
+	 * batch phase can advance multixacts without hitting the sleep.
+	 */
+	if (MultiXactIdToOffsetEntry(multi) == MULTIXACT_OFFSETS_PER_PAGE - 1 &&
+		access("/tmp/mxact_sleep_enabled", F_OK) == 0)
+	{
+		elog(LOG, "MXACT_DELAY: multi=%u is last entry on page %d, sleeping 60s",
+			 multi, MultiXactIdToOffsetPage(multi));
+		pg_usleep(60000000L);
+		elog(LOG, "MXACT_DELAY: multi=%u woke up, writing CREATE_ID now", multi);
+	}
 	/* Make an XLOG entry describing the new MXID. */
 	xlrec.mid = multi;
 	xlrec.moff = offset;
-- 
2.32.0.3.g01195cf9f



  [application/octet-stream] test_multixact_compat.sh (9.7K, 3-test_multixact_compat.sh)
  download

  [application/octet-stream] 0001-Fix-multixact-compat-logic-via-unconditional-zero.patch (1.8K, 4-0001-Fix-multixact-compat-logic-via-unconditional-zero.patch)
  download | inline diff:
From 2ea10ae333022b777904e3e64da7f62485a44c90 Mon Sep 17 00:00:00 2001
From: "duankunren.dkr" <[email protected]>
Date: Thu, 19 Mar 2026 22:07:51 +0800
Subject: [PATCH] Fix multixact compat logic via unconditional zero

---
 src/backend/access/transam/multixact.c | 17 ++++++++++++++---
 1 file changed, 14 insertions(+), 3 deletions(-)

diff --git a/src/backend/access/transam/multixact.c b/src/backend/access/transam/multixact.c
index 26b8d4e1230..a9a64d6c9c4 100644
--- a/src/backend/access/transam/multixact.c
+++ b/src/backend/access/transam/multixact.c
@@ -897,10 +897,21 @@ RecordNewMultiXact(MultiXactId multi, MultiXactOffset offset,
 	 * multixid was assigned.  If we're replaying WAL that was generated by
 	 * such a version, the next page might not be initialized yet.  Initialize
 	 * it now.
+	 *
+	 * The previous condition checked latest_page_number == pageno, but that
+	 * fails after a crash-restart: StartupMultiXact() sets
+	 * latest_page_number to page(checkPoint.nextMulti), which can be
+	 * next_pageno or even higher when the checkpoint captured an advanced
+	 * nextMXact.  In that case, the == check doesn't match and we skip
+	 * initialization, causing SimpleLruReadPage(next_pageno) to fail with
+	 * "read too few bytes" because the page doesn't exist on disk.
+	 *
+	 * Use an unconditional check instead: during recovery, whenever we cross
+	 * a page boundary, always ensure the next page is initialized.
+	 * SimpleLruZeroPage is idempotent, and we use pre_initialized_offsets_page
+	 * to skip the subsequent ZERO_OFF_PAGE replay, so this is safe.
 	 */
-	if (InRecovery &&
-		next_pageno != pageno &&
-		MultiXactOffsetCtl->shared->latest_page_number == pageno)
+	if (InRecovery && next_pageno != pageno)
 	{
 		elog(DEBUG1, "next offsets page is not initialized, initializing it now");
 
-- 
2.32.0.3.g01195cf9f



  [application/octet-stream] 0002-Fix-multixact-compat-logic-via-SLRU-buffer-check.patch (3.6K, 5-0002-Fix-multixact-compat-logic-via-SLRU-buffer-check.patch)
  download | inline diff:
From 8025d208a5e7bc8a174445849a730e2a2ff7b172 Mon Sep 17 00:00:00 2001
From: "duankunren.dkr" <[email protected]>
Date: Thu, 19 Mar 2026 22:08:49 +0800
Subject: [PATCH] Fix multixact compat logic via SLRU buffer check

---
 src/backend/access/transam/multixact.c | 73 ++++++++++++++++++++------
 1 file changed, 58 insertions(+), 15 deletions(-)

diff --git a/src/backend/access/transam/multixact.c b/src/backend/access/transam/multixact.c
index 26b8d4e1230..5e18f988e6d 100644
--- a/src/backend/access/transam/multixact.c
+++ b/src/backend/access/transam/multixact.c
@@ -897,25 +897,68 @@ RecordNewMultiXact(MultiXactId multi, MultiXactOffset offset,
 	 * multixid was assigned.  If we're replaying WAL that was generated by
 	 * such a version, the next page might not be initialized yet.  Initialize
 	 * it now.
-	 */
-	if (InRecovery &&
-		next_pageno != pageno &&
-		MultiXactOffsetCtl->shared->latest_page_number == pageno)
+	 *
+	 * The previous condition checked latest_page_number == pageno, but that
+	 * fails after a crash-restart: StartupMultiXact() sets
+	 * latest_page_number to page(checkPoint.nextMulti), which can be
+	 * next_pageno or even higher when the checkpoint captured an advanced
+	 * nextMXact.  In that case, the == check doesn't match and we skip
+	 * initialization, causing SimpleLruReadPage(next_pageno) to fail with
+	 * "read too few bytes" because the page doesn't exist on disk.
+	 *
+	 * When latest_page_number == pageno, we know for sure the next page has
+	 * not been initialized yet.  Otherwise (e.g. after crash-restart),
+	 * latest_page_number is unreliable, so fall back to checking whether the
+	 * next page exists in the SLRU buffer pool.  SimpleLruZeroPage is
+	 * idempotent, and we use pre_initialized_offsets_page to skip the
+	 * subsequent ZERO_OFF_PAGE replay, so this is safe.
+	 */
+	if (InRecovery && next_pageno != pageno)
 	{
-		elog(DEBUG1, "next offsets page is not initialized, initializing it now");
+		bool		need_init;
 
-		/* Create and zero the page */
-		slotno = SimpleLruZeroPage(MultiXactOffsetCtl, next_pageno);
+		if (MultiXactOffsetCtl->shared->latest_page_number == pageno)
+		{
+			/* Fast path: latest_page_number confirms next page not initialized */
+			need_init = true;
+		}
+		else
+		{
+			/*
+			 * latest_page_number != pageno, but we may still need to
+			 * initialize.  Check SLRU buffer pool to decide.
+			 */
+			SlruShared	shared = MultiXactOffsetCtl->shared;
 
-		/* Make sure it's written out */
-		SimpleLruWritePage(MultiXactOffsetCtl, slotno);
-		Assert(!MultiXactOffsetCtl->shared->page_dirty[slotno]);
+			need_init = true;
+			for (slotno = 0; slotno < shared->num_slots; slotno++)
+			{
+				if (shared->page_number[slotno] == next_pageno &&
+					shared->page_status[slotno] != SLRU_PAGE_EMPTY)
+				{
+					need_init = false;
+					break;
+				}
+			}
+		}
 
-		/*
-		 * Remember that we initialized the page, so that we don't zero it
-		 * again at the XLOG_MULTIXACT_ZERO_OFF_PAGE record.
-		 */
-		pre_initialized_offsets_page = next_pageno;
+		if (need_init)
+		{
+			elog(DEBUG1, "next offsets page is not initialized, initializing it now");
+
+			/* Create and zero the page */
+			slotno = SimpleLruZeroPage(MultiXactOffsetCtl, next_pageno);
+
+			/* Make sure it's written out */
+			SimpleLruWritePage(MultiXactOffsetCtl, slotno);
+			Assert(!MultiXactOffsetCtl->shared->page_dirty[slotno]);
+
+			/*
+			 * Remember that we initialized the page, so that we don't zero it
+			 * again at the XLOG_MULTIXACT_ZERO_OFF_PAGE record.
+			 */
+			pre_initialized_offsets_page = next_pageno;
+		}
 	}
 
 	/*
-- 
2.32.0.3.g01195cf9f



view thread (7+ messages)  latest in thread

reply

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Reply to all the recipients using the --to and --cc options:
  reply via email

  To: [email protected]
  Cc: [email protected], [email protected]
  Subject: Re:  Bug in MultiXact replay compat logic for older minor version after crash-recovery
  In-Reply-To: <c4ef1737-8cba-458e-b6fd-4e2d6011e985.duankunren.dkr@alibaba-inc.com>

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox