[Patch] Fix pg_get_multixact_stats() over-reporting members on a hot standby

From: Naga Appani <nagnrik(at)gmail(dot)com>
To: pgsql-hackers(at)lists(dot)postgresql(dot)org
Cc: Heikki Linnakangas <hlinnaka(at)iki(dot)fi>, Noah Misch <noah(at)leadboat(dot)com>, Michael Paquier <michael(at)paquier(dot)xyz>
Subject: [Patch] Fix pg_get_multixact_stats() over-reporting members on a hot standby
Date: 2026-09-21 04:43:42
Message-ID: CA+QeY+DcnsZRL4GZD8JyJu5HD-d6p8_9M5kT4jgF3gq3Rh8n4w@mail.gmail.com
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

Hi,

Splitting this out into its own thread for CommitFest tracking. It was
first discussed in [0], where Heikki pointed out that
pg_get_multixact_stats() reports wrong member counts on a hot standby.

MultiXactState->oldestOffset is not maintained during recovery, so on a hot
standby it stays at 0 for the life of the server. pg_get_multixact_stats()
then computes num_members as nextOffset instead of nextOffset -
oldestOffset, and over-reports members and members_size. num_mxids and
oldest_multixact are correct.

I reproduced it on REL_19_BETA3 by building up multixacts on a primary,
setting up a standby, then advancing the primary's oldestOffset (VACUUM
FREEZE across all databases and a CHECKPOINT) and comparing
pg_get_multixact_stats() on both:

=== PRIMARY ===
num_mxids | num_members | members_size | oldest_multixact
-----------+-------------+--------------+------------------
0 | 0 | 0 | 409396

=== STANDBY (in recovery) ===
pg_is_in_recovery | num_mxids | num_members | members_size |
oldest_multixact
-------------------+-----------+-------------+--------------+------------------
t | 0 | 17563829 | 87819145 |
409396

The standby shows about 17.5M members / ~84 MB while the primary correctly
shows zero for the same data. Promoting the standby fixes it right away,
since the correct oldestOffset is only computed at end of recovery.

The attached patch updates MultiXactState->oldestOffset while replaying
XLOG_MULTIXACT_TRUNCATE_ID, as Heikki suggested, so the value stays current
during recovery. TrimMultiXact() still computes a fresh value at end of
recovery. With the patch, the same workload gives matching results on the
standby:

=== PRIMARY ===
num_mxids | num_members | members_size | oldest_multixact
-----------+-------------+--------------+------------------
0 | 0 | 0 | 397660

=== STANDBY (in recovery) ===
pg_is_in_recovery | num_mxids | num_members | members_size |
oldest_multixact
-------------------+-----------+-------------+--------------+------------------
t | 0 | 0 | 0 |
397660

I did not add the start-of-recovery initialization from the control file
Heikki mentioned [0]. With only the redo-path update there is still a short
window before the first truncation record is replayed where oldestOffset
reads 0. Handling that at startup needs the value without the
find_multixact_start() lookup, which asserts finishedStartup, so I left it
out for now.

I would love to help see this through. I may not be able to stay very
hands-on over the next while though, so if it needs follow-up and I am slow
to respond, please do not wait on me. I am happy for someone to build on it
or take a different approach, and glad to review and help where I can.

Patch attached.

[0] https://www.postgresql.org/message-id/05d064e8-219d-40bd-9fdf-652b648ba317%40iki.fi

Best regards,
Naga Appani

Attachment Content-Type Size
v1-0001-Maintain-MultiXact-oldestOffset-during-recovery.patch application/octet-stream 1.8 KB

Responses

Browse pgsql-hackers by date

  From Date Subject
Next Message Manuel Reyes Bravo 2026-09-21 04:45:03 Re: Dropping a composite attribute causes data integrity violations
Previous Message Nikolay Samokhvalov 2026-09-21 04:24:39 Re: [PATCH] Corruption Issue: Fix missing tts_tid in ExecForceStoreHeapTuple