commit bd8d9c9 wrote:
> Commit:     Heikki Linnakangas <[email protected]>
> CommitDate: Tue Dec 9 13:53:03 2025 +0200
> 
>     Widen MultiXactOffset to 64 bits

> --- a/src/bin/pg_upgrade/pg_upgrade.c
> +++ b/src/bin/pg_upgrade/pg_upgrade.c

> +             nxtmulti = old_cluster.controldata.chkpnt_nxtmulti;
> +             if (old_cluster.controldata.cat_ver >= 
> MULTIXACT_FORMATCHANGE_CAT_VER)
> +             {
> +                     /* Versions 9.3 - 18: convert all multixids  */
> +                     oldstMulti = old_cluster.controldata.chkpnt_oldstMulti;

If a cluster's upgrade history includes an upgrade from 9.3 to early 9.4, it
may have a wrong value here.  Specifically, upgrades done before a61daa14
(2014-07 commit) have that hazard.  We still have backend code to detect such
cases and reduce damage:

                ereport(LOG,
                                (errmsg("cannot truncate up to MultiXact %u 
because it does not exist on disk, skipping truncation",
                                                newOldestMulti)));

However, the pg_upgrade side from the v19 commit lacks such protection.  If
heap tuples still reference older multixacts than the faulty control data
suggests, pg_upgrade will copy too small a range, making affected tuples
unreadable.


I also had Opus 4.8 look for defects in this change and write test cases.  It
didn't find the above problem, but it had other findings.  I'm attaching the
full report.  I recommend fixing at least these before release:

> +| 5 | `resetwal-nextmxoff-zero` | `src/bin/pg_resetwal/pg_resetwal.c:703`, 
> and `-O` with no zero check at 297-307 | `pg_resetwal -f` (or `-O 0`) leaves 
> `nextMultiOffset = 0`, the reserved "invalid offset"; first multixact created 
> afterwards is permanently unreadable: `ERROR: MultiXact 1 has invalid offset` 
> | Yes — `src/test/modules/test_slru/t/003_multixact_offset.pl` |

> +| 7 | `members-truncation-apparent-wraparound` | 
> `src/backend/access/transam/multixact.c:2642-2647` | When `nextOffset` lands 
> on a members page boundary, truncation logs `could not truncate directory 
> "pg_multixact/members": apparent wraparound` (impossible for a 64-bit 
> counter) and reclaims nothing | Yes — `003_multixact_offset.pl` |

Others are more optional or already reported.  In particular, two other
findings were already reported and apparently fixed after the Opus run, in
thread "pg_upgrade silently truncates nextMultiOffset to 32 bits":
https://www.postgresql.org/message-id/CAD21AoCvzerscfU8o4ARQ793yAGHpQ72r2x5apeC_W2-k%3DSLCQ%40mail.gmail.com
commit bdff567 (cqla/mxactoffset64-defect-tests)
Author:     Noah Misch <[email protected]>
AuthorDate: Tue Jul 21 17:52:05 2026 +0000
Commit:     Noah Misch <[email protected]>
CommitDate: Tue Jul 21 17:52:05 2026 +0000

    Test user-visible defects left by the 64-bit MultiXactOffset widening
    
    Commit bd8d9c9 widened MultiXactOffset from 32 to 64 bits.  Four of the
    user-visible defects it left behind are still present in master; these
    tests demonstrate all four.  Every test fails on master today and passes
    once the corresponding product-code defect is fixed.
    
    src/test/modules/test_slru/t/003_multixact_offset.pl covers three:
    
    * bd8d9c9 made offset 0 the reserved "invalid offset" marker, changing
      BootStrapXLOG() to start nextMultiOffset at 1 instead of 0, but left
      pg_resetwal's GuessControlValues() assigning 0 and left -O accepting 0
      with no zero check, unlike -m.  A cluster reset that way cannot read
      back the first multixact it creates: "ERROR: MultiXact 1 has invalid
      offset".
    
    * pg_control_checkpoint() still runs the widened field through
      TransactionIdGetDatum(), and pg_proc.dat still declares the
      next_multi_offset output column as xid, so SQL reports the value
      modulo 2^32 while pg_controldata prints it in full.  Observed: control
      file 4294967896, SQL 600.
    
    * PerformMembersTruncation() passes MXOffsetToMemberPage(newOldestOffset)
      to SimpleLruTruncate() with no step-back, unlike 
PerformOffsetsTruncation()
      directly below it, whose comment names exactly this hazard.  When
      nextOffset lands on a members page boundary the cutoff page is one past
      the last page ever written, so SimpleLruTruncate()'s endpoint check
      fires: the server logs 'could not truncate directory
      "pg_multixact/members": apparent wraparound' -- impossible for a
      counter that cannot wrap -- and no obsolete segment is reclaimed.
    
    src/bin/pg_upgrade/t/007_multixact_conversion.pl gains a fourth: while
    chkpnt_nxtmxoff became uint64, get_control_data() still reads
    NextMultiOffset with str2uint(), which returns unsigned int.  Upgrading a
    cluster whose offset has passed 2^32 gives the new cluster an offset
    modulo 2^32, pointing back into members space that is still in use.  No
    SLRU conversion is involved, so this case runs regardless of $oldinstall.
    
    bd8d9c9-untested-defects.md describes four further confirmed defects that
    are not covered here, with the reason each is impractical to test: two
    stale documentation claims in maintenance.sgml, pg_get_multixact_stats()
    reporting lifetime rather than retained members on a hot standby, and a
    server LOG message that reports the wrong subsystem as disabled.  It also
    records three candidates that adversarial verification refuted, so they
    are not re-chased.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01WxWnwpyK9G3DohkEZJxsLU
---
 bd8d9c9-untested-defects.md                        | 529 +++++++++++++++++++++
 src/bin/pg_upgrade/t/007_multixact_conversion.pl   |  68 +++
 src/test/modules/test_slru/meson.build             |   3 +-
 .../modules/test_slru/t/003_multixact_offset.pl    | 254 ++++++++++
 4 files changed, 853 insertions(+), 1 deletion(-)

diff --git a/bd8d9c9-untested-defects.md b/bd8d9c9-untested-defects.md
new file mode 100644
index 0000000..0740d89
--- /dev/null
+++ b/bd8d9c9-untested-defects.md
@@ -0,0 +1,529 @@
+# Defects in bd8d9c9 ("Widen MultiXactOffset to 64 bits") still present in 
master — the ones the companion test does not cover
+
+Target commit: `bd8d9c9bdfa0c2168bb37edca6fa88168cacbbaa`, Heikki Linnakangas, 
2025-12-09
+(author Maxim Orlov), "Widen MultiXactOffset to 64 bits".
+Tree audited: `/home/nm/src/pg/postgresql` at `c90c967` — 2028 commits after 
bd8d9c9.
+Every line quoted below was re-read from that working tree while writing this 
report.
+
+---
+
+## 1. Scope and method
+
+### What was audited
+
+bd8d9c9 as it stands **today in master**, not as it was committed. The commit 
widened
+`MultiXactOffset` from `uint32` to `uint64` (`src/include/c.h:807`), deleted 
the members-space
+wraparound apparatus (`offsetStopLimit`, `MultiXactOffsetWouldWrap`, the 
emergency
+"multixact members limit exceeded" ERROR — `grep -rn 
'offsetStopLimit|MultiXactOffsetWouldWrap' src/`
+now returns nothing), kept the members-driven clamp on vacuum's multixid 
freeze cutoff, and added
+a pg_upgrade path that rewrites v18 32-bit SLRU files into the new format.
+
+Areas covered: `multixact.c` (allocation, startup/trim, truncation, 
freeze-threshold),
+`multixact.h`/`multixact_internal.h` (on-disk and WAL layout), pg_upgrade's 
conversion and control
+data handling, pg_resetwal, pg_controldata (both the frontend program and the 
SQL function),
+vacuum/autovacuum freeze scheduling, `pg_get_multixact_stats()`, and the SGML 
documentation that
+describes any of the above.
+
+### How
+
+Fan-out static analysis: 7 areas x 4 independent reading lenses, each 
producing candidate findings
+with a mandatory verbatim source quote and a concrete trigger. Every candidate 
then went through
+adversarial 3-vote verification: three independent attempts to **refute** it, 
each required to read
+master's source itself and to check `git log bd8d9c9..master -- <path>` for a 
follow-up fix. Anything
+that lost 2 or more votes was dropped. Survivors were then handed to a 
reproduction pass that built
+real clusters from a prebuilt worktree at the same commit 
(`/home/nm/src/pg/pg-master-mxact64`,
+`--enable-cassert`) and tried to observe the symptom end to end.
+
+### Standard applied
+
+A finding counts only if **all** of the following hold:
+
+1. It is present in master's working tree today (not merely in the bd8d9c9 
diff — ~50 follow-up
+   commits, e.g. `ac94ce8`, `d4b7bde`, `ecb553a`, `87a350e`, `6aa26be`, 
`516310e`, `102bdaa`,
+   `ccae90a`, already repaired much of this code).
+2. It is attributable to bd8d9c9: introduced by it, a regression versus 
pre-bd8d9c9 behavior, or a
+   hole in code bd8d9c9 added. Where attribution is arguable, this report says 
so explicitly.
+3. It produces something a user or DBA can **observe**: a wrong number, a 
false message, an ERROR
+   where success is required, data left on disk that should be gone, 
documentation that contradicts
+   the code.
+4. It is reachable without hand-corrupting files into a state the code 
otherwise prevents.
+
+"Risk areas", style, comment quality and unreachable overflow were rejected by 
construction. A prior
+audit of this commit produced 26 "CRITICAL risk areas" that were all vapor; 
the refutation pass here
+killed three more (section 5).
+
+---
+
+## 2. Summary table
+
+| # | id | location | symptom | covered by test? |
+|---|----|----------|---------|------------------|
+| 1 | `maintenance-sgml-32bit-members` | `doc/src/sgml/maintenance.sgml:782` | 
Manual still says the pg_multixact members storage area "uses a 32-bit 
counter"; `MultiXactOffset` is now `uint64` and members cannot wrap | **No** — 
doc-only; nothing to assert at runtime |
+| 2 | `no-launcher-signal-for-member-space` | 
`doc/src/sgml/maintenance.sgml:817-823` (behavior at 
`src/backend/access/transam/multixact.c:1009,2161-2183`) | Docs promise 
member-space-driven aggressive vacuums "even if autovacuum is nominally 
disabled"; since bd8d9c9 no code path starts the launcher on member growth | 
**No** — needs >2e9 member entries (~10 GB) to observe at runtime |
+| 3 | `standby-oldestoffset-zero-stats` | 
`src/backend/access/transam/multixact.c:2555`, 
`src/backend/utils/adt/multixactfuncs.c:128-130` | On a hot standby 
`pg_get_multixact_stats()` reports `num_members`/`members_size` as lifetime 
allocation, not retained members; disagrees with its own primary | **No** — 
needs a primary/standby pair; attribution partly to a later commit (see §3.3) |
+| 4 | `false-truncation-disabled-log` | 
`src/backend/access/transam/multixact.c:2477-2479` | LOG says "MultiXact member 
truncation is disabled …"; truncation is in fact unaffected, and the real 
damage (stale `oldestOffset`) is never mentioned | **No** — needs a hand-staged 
offsets-segment/oldestMulti mismatch |
+| 5 | `resetwal-nextmxoff-zero` | `src/bin/pg_resetwal/pg_resetwal.c:703`, and 
`-O` with no zero check at 297-307 | `pg_resetwal -f` (or `-O 0`) leaves 
`nextMultiOffset = 0`, the reserved "invalid offset"; first multixact created 
afterwards is permanently unreadable: `ERROR: MultiXact 1 has invalid offset` | 
Yes — `src/test/modules/test_slru/t/003_multixact_offset.pl` |
+| 6 | `pg-control-checkpoint-next-multi-offset-xid` | 
`src/backend/utils/misc/pg_controldata.c:133`, 
`src/include/catalog/pg_proc.dat:12374` | 
`pg_control_checkpoint().next_multi_offset` reports the value mod 2^32 (column 
still typed `xid`), disagreeing with `pg_controldata` | Yes — 
`003_multixact_offset.pl` |
+| 7 | `members-truncation-apparent-wraparound` | 
`src/backend/access/transam/multixact.c:2642-2647` | When `nextOffset` lands on 
a members page boundary, truncation logs `could not truncate directory 
"pg_multixact/members": apparent wraparound` (impossible for a 64-bit counter) 
and reclaims nothing | Yes — `003_multixact_offset.pl` |
+| 8 | `pgupgrade-nxtmxoff-str2uint` | `src/bin/pg_upgrade/controldata.c:292` | 
pg_upgrade parses the widened `NextMultiOffset` with 32-bit `str2uint()`; a 
v19+ source past 2^32 yields a new cluster with offset mod 2^32 — either fails 
to start past the "must re-initdb" point, or silently overwrites live members | 
Yes — `src/bin/pg_upgrade/t/007_multixact_conversion.pl` |
+
+Rows 5-8 are documented in the committed test (`003_multixact_offset.pl`, 
`meson.build`, and the
+additions to `007_multixact_conversion.pl`) and are not re-litigated here. 
Rows 1-4 have no test;
+they are detailed below.
+
+---
+
+## 3. Per-defect detail — the untested findings
+
+### 3.1 `maintenance-sgml-32bit-members` — the manual still describes a 32-bit 
members counter
+
+**Location:** `doc/src/sgml/maintenance.sgml:778-786`, section "Multixacts and 
Wraparound".
+
+```
+778         Like transaction IDs, multixact IDs are implemented as a
+779         32-bit counter and corresponding storage, all of which requires
+780         careful aging management, storage cleanup, and wraparound handling.
+781         There is a separate storage area which holds the list of members in
+782         each multixact, which also uses a 32-bit counter and which must 
also
+783         be managed.  The system function
+784         <function>pg_get_multixact_members()</function> described in
+785         <xref linkend="functions-pg-snapshot"/> can be used to examine the
+786         transaction IDs associated with a multixact ID.
+```
+
+**What the code says.** The members storage area is addressed by 
`MultiXactOffset`, and
+`src/include/c.h:807` is now `typedef uint64 MultiXactOffset;`. 
`multixact.c:1106-1112`:
+
+> Offsets are 64-bit integers and will never wrap around.  Firstly, it would 
take an unrealistic
+> amount of time and resources to consume 2^64 offsets.  Secondly, multixid 
creation is WAL-logged,
+> so you would run out of LSNs before reaching offset wraparound.
+
+and `multixact.c:2826-2835` reduces `MultiXactMemberPagePrecedes()` to `return 
page1 < page2;`
+with the comment "members never wrap around".
+
+**What is and is not wrong.** Precisely one clause is false: "which also uses 
a 32-bit counter"
+(line 782). Two neighbouring claims are still correct and must not be reported 
as bugs:
+
+* Line 778-780, about multixact **IDs** being a 32-bit counter needing 
wraparound handling, is still
+  true — `src/include/c.h:805` is `typedef TransactionId MultiXactId;` and 
multixids still wrap.
+* "and which must also be managed" is still true — members space still drives 
SLRU truncation and the
+  2-billion / 4-billion-entry aggressive-vacuum thresholds documented at lines 
814-827.
+
+**Trigger.** Read the v19 manual: "Routine Database Maintenance Tasks" → 
"Preventing Transaction ID
+Wraparound Failures" → "Multixacts and Wraparound", first paragraph.
+
+**User-visible consequence.** A DBA planning capacity concludes there is a 
hard ~2^32-member / ~20 GB
+ceiling on `pg_multixact/members` beyond which the cluster will refuse to 
assign multixids — the
+pre-v19 behavior. There is no such ceiling in v19; the 4-billion figure at 
line 824 is only a vacuum
+aggressiveness threshold. The stated remedy (watch for members wraparound) is 
for a hazard that no
+longer exists.
+
+**Verification evidence.**
+* `git show --stat bd8d9c9 -- doc/` shows the commit touched exactly one SGML 
file,
+  `doc/src/sgml/ref/pg_resetwal.sgml` (updating the `-m` multiplier from 65536 
to 32768 for the now
+  8-byte offsets). `maintenance.sgml` was not touched.
+* `git blame -L 776,786 doc/src/sgml/maintenance.sgml` attributes lines 
781-782 to `53bb309d`
+  ("Teach autovacuum about multixact member wraparound", 2014), where the 
sentence was correct.
+  Nothing in `bd8d9c9..master` has changed it.
+* Interesting corroboration that this is an oversight, not a deliberate 
retention: the *sibling*
+  paragraph was updated after bd8d9c9. `git blame -L 814,827` attributes lines 
817-827 to `97b10177`
+  (2025-12-30). `git show bd8d9c9^:doc/src/sgml/maintenance.sgml` shows the 
pre-commit text there
+  read "The members storage area can grow up to about 20GB before reaching 
wraparound"; that
+  sentence is gone in master. So someone already scrubbed one stale wraparound 
reference from this
+  section and missed line 782.
+
+**Why it is not covered by a test.** It is a documentation defect: there is no 
runtime assertion that
+can fail. PostgreSQL has no doc/code consistency harness that could catch a 
prose claim about an
+integer width. The fix is a one-line SGML edit, not a test.
+
+**Test someone should write:** none. Fix the sentence, e.g. "There is a 
separate storage area which
+holds the list of members in each multixact; it does not wrap around, but its 
disk usage must still
+be managed." A reviewer should also re-read the whole sect3 for other 
pre-64-bit residue while there.
+
+---
+
+### 3.2 `no-launcher-signal-for-member-space` — docs promise member-driven 
vacuums with autovacuum off; the code no longer delivers them
+
+**Location of the false claim:** `doc/src/sgml/maintenance.sgml:814-827`.
+
+```
+814        <para>
+815         As a safety device, an aggressive vacuum scan will
+816         occur for any table whose multixact-age is greater than <xref
+817         linkend="guc-autovacuum-multixact-freeze-max-age"/>. Also, if the 
number
+818         of multixact member entries created exceeds approximately 2 billion
+819         entries (occupying roughly 10GB in the
+820         <literal>pg_multixact/members</literal> directory), aggressive 
vacuum
+821         scans will occur more often for all tables, starting with those 
that
+822         have the oldest multixact-age. Both of these kinds of aggressive
+823         scans will occur even if autovacuum is nominally disabled. At 
approximately
+824         4 billion entries (occupying roughly 20GB in the
+825         <literal>pg_multixact/members</literal> directory), even more 
aggressive
+826         vacuum scans are triggered to reclaim member storage space.
+827        </para>
+```
+
+The word **"Both"** on line 822 is now false for the second kind of scan.
+
+**What the code does.** `grep -rn PMSIGNAL_START_AUTOVAC_LAUNCHER 
src/backend/` returns exactly six
+sites; the three in multixact.c are:
+
+* `multixact.c:1034` and `multixact.c:1059`, both inside
+  `if (!MultiXactIdPrecedes(result, MultiXactState->multiVacLimit))` at line 
1009 — keyed on
+  multixact **age** (`multiVacLimit`, `multiStopLimit`, `result % 65536`);
+* `multixact.c:2183`, `if (MultiXactIdPrecedes(multiVacLimit, curMulti) && 
IsUnderPostmaster)` —
+  also age.
+
+None of them reads `nextOffset` or `oldestOffset`. bd8d9c9 removed the two 
that did: the
+`MULTIXACT_MEMBER_SAFE_THRESHOLD` arm of `GetNewMultiXactId()` and the 
`needs_offset_vacuum` term in
+`SetMultiXactIdLimit()` (verifiable in `git show bd8d9c9 -- 
src/backend/access/transam/multixact.c`).
+
+**This part of the change was deliberate**, and the reasoning is still in the 
tree at
+`multixact.c:2166-2172`:
+
+```
+2166           /*
+2167            * Offsets are 64-bits wide and never wrap around, so we don't 
need to
+2168            * consider them for emergency autovacuum purposes.  But now 
that we're in
+2169            * a consistent state, determine MultiXactState->oldestOffset.  
It will be
+2170            * used to adjust the freezing cutoff, to keep the offsets disk 
usage in
+2171            * check.
+2172            */
+2173           SetOldestOffset();
+```
+
+So the finding is **not** "the trigger was accidentally dropped from 
multixact.c". It is that the
+documentation still asserts the deleted behavior, and — pointedly — the 
paragraph was *rewritten
+after* bd8d9c9 by `97b10177` (2025-12-30), which introduced the new "2 billion 
/ 4 billion entries"
+wording and carried the "even if autovacuum is nominally disabled" clause 
forward unexamined. Before
+bd8d9c9 the sentence was true.
+
+**What still works, so the report is not overstated.** 
`MultiXactMemberFreezeThreshold()`
+(`multixact.c:2589-2636`, using `MULTIXACT_MEMBER_LOW_THRESHOLD` = 2000000000 
and
+`MULTIXACT_MEMBER_HIGH_THRESHOLD` = 4000000000 at `multixact.c:99-100`) is 
alive and consulted by:
+
+* manual `VACUUM` — `src/backend/commands/vacuum.c:1159`;
+* `do_start_worker()` — `src/backend/postmaster/autovacuum.c:1182`
+  (`multiForceLimit = recentMulti - MultiXactMemberFreezeThreshold();`);
+* `relation_needs_vacanalyze()`, with `force_vacuum` bypassing `av_enabled`
+  (`autovacuum.c:3246`, `if (force_vacuum) *dovacuum = true;`).
+
+So with `autovacuum = on`, or for a manual VACUUM, member-pressure clamping 
behaves exactly as
+documented. The regression is confined to `autovacuum = off`: nothing then 
starts the launcher on
+member growth. The launcher's emergency path is `autovacuum.c:593-601`:
+
+```
+593            if (!AutoVacuumingActive())
+594            {
+595                    if (!ShutdownRequestPending)
+596                            do_start_worker();
+597                    proc_exit(0);                   /* done */
+598            }
+```
+
+— reachable only when the postmaster sets `start_autovac_launcher` from
+`PMSIGNAL_START_AUTOVAC_LAUNCHER` (`postmaster.c:3832`). No member-space 
signal, no launcher, no
+clamp applied.
+
+**Trigger.** `autovacuum = off`, `autovacuum_multixact_freeze_max_age` at its 
400,000,000 default.
+Run a workload that creates multixacts with many members each but consumes few 
multixids and few
+XIDs — e.g. transactions that each take `FOR KEY SHARE` on tens of thousands 
of rows that are already
+share-locked — until `nextOffset - oldestOffset` exceeds 2,000,000,000. At 200 
members/multixact that
+is ~10 million multixacts, far under the 400M multixact age that trips the 
surviving age-based
+trigger.
+
+**User-visible consequence.** `pg_multixact/members` grows past the 10 GB and 
20 GB figures the manual
+cites without any autovacuum being launched on that account. It is not 
literally unbounded — growth
+continues until multixact age crosses `autovacuum_multixact_freeze_max_age`, 
or until an
+XID-wraparound emergency launcher fires from `varsup.c:136` — but that ceiling 
is enormously higher
+than what the docs describe: at ~200 members/multixact, roughly 80 billion 
member entries
+(hundreds of GB) versus v18's ~2^31-member relief point. No wrong results, no 
crash; a disk-space
+surprise plus a false promise in the manual.
+
+**Ancillary (comment-only, does not qualify on its own):** the block comment 
bd8d9c9 left at
+`multixact.c:1000-1002` still says "If we're past multiVacLimit **or the safe 
threshold for member
+storage space, or we don't know what the safe threshold for member storage 
is**, start trying to
+force autovacuum cycles" — describing code the same commit deleted.
+
+**Why it is not covered by a test.** Reaching `MULTIXACT_MEMBER_LOW_THRESHOLD` 
requires materialising
+two billion real member entries (~10 GB of `pg_multixact/members`) — hours of 
runtime and 10+ GB of
+disk on a machine that has 2 cores. There is no debug hook to lower the 
threshold: 2000000000 is a
+compile-time `#define`, not a GUC, and no injection point exists in 
`MultiXactMemberFreezeThreshold()`
+or in the launcher path. `pg_resetwal -O` can jump `nextOffset` forward 
cheaply, but that does not
+help: the missing signal is in `GetNewMultiXactId()`/`SetMultiXactIdLimit()`, 
which would have to be
+*reached* with the launcher stopped, and there is nothing left there to 
observe — the assertion would
+be about a signal that is simply never sent.
+
+**Test someone with the resources should write.** Two tiers:
+
+1. *Cheap and honest (recommended):* fix the documentation and add nothing. 
The behavior change is
+   intentional; only the manual is wrong.
+2. *If the behavior is deemed a regression to fix:* add a `test_slru` TAP test 
that (a) sets
+   `autovacuum = off`, (b) uses a debug-build-only GUC or injection point that 
lowers
+   `MULTIXACT_MEMBER_LOW_THRESHOLD` to something like 100000, (c) creates 
enough members via
+   `test_create_multixact()` to cross it, and (d) asserts from the postmaster 
log that an autovacuum
+   worker started, plus that `pg_multixact/members` shrank. Introducing the 
threshold override is the
+   real work; without it the test cannot exist at reasonable cost.
+
+---
+
+### 3.3 `standby-oldestoffset-zero-stats` — `pg_get_multixact_stats()` on a 
hot standby reports lifetime members, not retained members
+
+**Location:** `src/backend/access/transam/multixact.c:2545-2559` and its SQL 
consumer
+`src/backend/utils/adt/multixactfuncs.c:127-130`.
+
+```
+2545   void
+2546   GetMultiXactInfo(uint32 *multixacts, MultiXactOffset *nextOffset,
+2547                                    MultiXactId *oldestMultiXactId, 
MultiXactOffset *oldestOffset)
+2548   {
+2549           MultiXactId nextMultiXactId;
+2550   
+2551           LWLockAcquire(MultiXactGenLock, LW_SHARED);
+2552           *nextOffset = MultiXactState->nextOffset;
+2553           *oldestMultiXactId = MultiXactState->oldestMultiXactId;
+2554           nextMultiXactId = MultiXactState->nextMXact;
+2555           *oldestOffset = MultiXactState->oldestOffset;
+2556           LWLockRelease(MultiXactGenLock);
+```
+
+```
+127            GetMultiXactInfo(&multixacts, &nextOffset, &oldestMultiXactId, 
&oldestOffset);
+128            members = nextOffset - oldestOffset;
+129            membersBytes = MultiXactOffsetStorageSize(nextOffset, 
oldestOffset);
+```
+
+**Mechanism.** `MultiXactState->oldestOffset` is assigned in exactly two 
places:
+`SetOldestOffset()` at `multixact.c:2488`, and `TruncateMultiXact()` at 
`multixact.c:2788`. Neither
+runs in recovery:
+
+* `TruncateMultiXact()` opens with `Assert(!RecoveryInProgress());` 
(`multixact.c:2685`).
+* `SetOldestOffset()` is called only from `SetMultiXactIdLimit()` at 
`multixact.c:2173`, which is
+  *after* the early return at `multixact.c:2161-2162`:
+  `if (!MultiXactState->finishedStartup) return;`. `finishedStartup` is set 
only by
+  `TrimMultiXact()` (`multixact.c:2006`), whose sole caller is `xlog.c:6527`, 
at end of recovery.
+* The redo path (`multixact.c:2985-2990`) calls 
`SetMultiXactIdLimit(xlrec.oldestMulti, …)` — which
+  correctly stores `oldestMultiXactId` at `multixact.c:2140` before the early 
return — and then
+  `PerformMembersTruncation(xlrec.oldestOffset)` / 
`PerformOffsetsTruncation()`, using the WAL
+  record's value directly. It never stores `xlrec.oldestOffset` into shared 
memory.
+
+So on a never-promoted standby, `MultiXactState->oldestOffset` keeps its 
zeroed shmem value for the
+life of the server while `nextOffset` is advanced continuously by redo. 
`num_members` therefore comes
+out as `nextOffset - 0` and `members_size` as `5 * nextOffset` bytes
+(`MultiXactOffsetStorageSize()`, `multixact_internal.h:125-134`: 
`MULTIXACT_MEMBERGROUP_SIZE /
+MULTIXACT_MEMBERS_PER_MEMBERGROUP` = 20/4 = 5). `num_mxids` and 
`oldest_multixact` are correct.
+
+The overreport is exactly the primary's current `oldestOffset`, and since 
bd8d9c9 made offsets
+64-bit and monotonic, that quantity climbs for the life of the installation. 
Meanwhile redo *does*
+physically delete the members segments, so the standby reports gigabytes of 
members it does not have.
+
+**Trigger (reproduced).** Primary with `wal_level = replica`; create a table, 
take `FOR SHARE` from
+concurrent sessions to build multixacts; `pg_basebackup -R` a standby and 
start it; on the primary
+`ALTER DATABASE template0 ALLOW_CONNECTIONS true`, `VACUUM FREEZE` in every 
database, `CHECKPOINT`,
+so `TruncateMultiXact()` advances `oldestOffset`. Observed: primary 
`num_members` 0, `members_size` 0;
+standby `num_members` 3, `members_size` 15, `pg_is_in_recovery()` true. A 
larger run (1000 multixacts
+of 2 members, `nextOffset` 2001) showed the standby reporting 2001/10005 
against a primary reporting
+0/0. Promoting the standby corrects it immediately, confirming the 
`finishedStartup` mechanism.
+
+**User-visible consequence.** `SELECT * FROM pg_get_multixact_stats()` on a 
hot standby contradicts
+the same query on its primary for byte-identical data, and contradicts
+`doc/src/sgml/func/func-info.sgml:3010-3013`, which defines `num_members` as 
"the total number of
+multixact member entries **currently present in the system**" and 
`members_size` as "the storage
+occupied by `num_members` in the `pg_multixact/members` directory". Monitoring 
built on this function
+sees a standby that appears to be holding hundreds of GB of members it does 
not hold.
+
+**Attribution — stated honestly.** This is the weakest attribution in the set, 
and it survived
+2/3 refutation votes rather than 3/3. The dissenting reading is correct on the 
facts and should be
+recorded: the `finishedStartup` gate and the redo path are **identical in 
`bd8d9c9^`**, so the
+standby's stuck `oldestOffset` predates the commit; pre-commit it was harmless 
because the only
+consumer, `MultiXactMemberFreezeThreshold()`, never runs in recovery. What 
bd8d9c9 contributed is
+deleting `MultiXactStateData.oldestOffsetKnown` and changing 
`GetMultiXactInfo()` from returning
+`bool` to `void` — it used to short-circuit with `*members = 0; … return 
false;` so callers could
+distinguish "unknown" from "zero". `97b10177` (2025-12-30) then wired the 
now-unqualified value into
+SQL with no `RecoveryInProgress()` guard and no NULL path. A committer may 
reasonably file this
+against `97b10177`; the missing "unknown" signal is bd8d9c9's.
+
+**Why it is not covered by a test.** Testing it needs a full streaming 
primary/standby pair plus the
+`template0`-connectable `VACUUM FREEZE` dance to move the primary's 
`oldestOffset` off zero — that is
+a `src/test/recovery`-style test, not something that fits in `test_slru`'s 
single-node harness or in
+`007_multixact_conversion.pl`. It also cannot be asserted until the intended 
semantics are decided:
+should the standby return NULL for these two columns, or should redo store 
`xlrec.oldestOffset`?
+Writing a test first would bake in whichever answer the test author guessed.
+
+**Test someone should write** (in `src/test/recovery/t/`, once the semantics 
are settled):
+
+```
+init primary (wal_level=replica), create t, produce N multixacts via 
concurrent FOR SHARE
+pg_basebackup -R -> standby, start, wait for catchup
+on primary: ALTER DATABASE template0 ALLOW_CONNECTIONS true; VACUUM FREEZE in 
all dbs; CHECKPOINT
+wait_for_catchup
+is(standby: SELECT num_members FROM pg_get_multixact_stats(),
+   primary: same)                       # or: expect NULL on the standby
+promote standby; re-run; expect it to match
+```
+
+The middle assertion is the whole test; it fails today with standby = 
`nextOffset`,
+primary = `nextOffset - oldestOffset`.
+
+---
+
+### 3.4 `false-truncation-disabled-log` — a LOG that names the wrong subsystem 
and hides the real degradation
+
+**Location:** `src/backend/access/transam/multixact.c:2469-2479`, in 
`SetOldestOffset()`.
+
+```
+2469           oldestOffsetKnown =
+2470                   find_multixact_start(oldestMultiXactId, &oldestOffset);
+2471   
+2472           if (oldestOffsetKnown)
+2473                   ereport(DEBUG1,
+2474                                   (errmsg_internal("oldest MultiXactId 
member is at offset %" PRIu64,
+2475                                                                    
oldestOffset)));
+2476           else
+2477                   ereport(LOG,
+2478                                   (errmsg("MultiXact member truncation is 
disabled because oldest checkpointed MultiXact %u does not exist on disk",
+2479                                                   oldestMultiXactId)));
+```
+
+**Why the message is false.** `MultiXactState->oldestOffset` — the only thing 
this failure leaves
+unset — is written at `multixact.c:2488` and `multixact.c:2788`, and read only 
through
+`GetMultiXactInfo()` (`multixact.c:2555`). Its consumers are exactly two:
+`MultiXactMemberFreezeThreshold()` (`multixact.c:2603`) and 
`pg_get_multixact_stats()`
+(`multixactfuncs.c:128`). **No truncation path reads it.** 
`TruncateMultiXact()` calls
+`find_multixact_start(newOldestMulti, &newOldestOffset)` for itself 
(`multixact.c:2723`), emits its
+own distinct messages for its own failures (`multixact.c:2725-2730` "cannot 
truncate up to MultiXact
+%u because it does not exist on disk, skipping truncation" and 
`multixact.c:2740-2747` "… because it
+has invalid offset …"), and hands its own local `newOldestOffset` to 
`PerformMembersTruncation()`
+(`multixact.c:2792`). Member truncation continues normally after this LOG 
fires.
+
+The stale comment bd8d9c9 added directly above asserts the same nonexistent 
invariant
+(`multixact.c:2457-2462`): "oldestOffset is initialized to zero at system 
startup, **which prevents
+truncating members until a proper value is calculated**".
+
+**What actually degrades.** A stale-or-zero `oldestOffset`, which:
+
+* makes `MultiXactMemberFreezeThreshold()` compute `members = nextOffset - 0` 
— an *inflated* figure,
+  so vacuum freezes **more** aggressively, not less (clamped to 
`freeze_max_age` 0 once the inflated
+  count passes `MULTIXACT_MEMBER_HIGH_THRESHOLD`). This is the opposite of the 
"members will grow
+  unbounded" reading the message invites;
+* makes `pg_get_multixact_stats()` report inflated 
`num_members`/`members_size`.
+
+Neither is mentioned. bd8d9c9 also deleted the paired "MultiXact member 
wraparound protections are
+now enabled" LOG without a replacement, so nothing announces when the 
condition clears.
+
+**Attribution.** `git show bd8d9c9 -- src/backend/access/transam/multixact.c` 
changes this line from
+"MultiXact member **wraparound protections** are disabled because oldest 
checkpointed MultiXact %u
+does not exist on disk" to the current text, and in the same commit deletes 
`offsetStopLimit` and
+`oldestOffsetKnown` from `MultiXactStateData`. The old wording was accurate 
for `bd8d9c9^`, where
+`oldestOffsetKnown` genuinely gated `offsetStopLimit`; the replacement asserts 
something that has
+never been true post-commit. Only `ef6a95c` (translation catalogs) has touched 
the string since.
+
+**Trigger (reproduced).** Any state where the `pg_multixact/offsets` page 
holding pg_control's
+`oldestMulti` is absent. Reproduced without touching SLRU *content*: a cluster 
driven to truncate
+away its low offsets segments (30000 multixacts via `test_slru`'s 
`test_create_multixact()`, then
+`VACUUM FREEZE` in all three databases, which unlinked `offsets/0001` and
+`members/000000000000000`), then stopped and restarted after
+`pg_resetwal --multixact-ids 95535,65535` — a stale "oldest" value of the kind 
an operator gets by
+following the pg_resetwal recovery recipe with a figure from an out-of-date 
source. On restart
+`TrimMultiXact` → `SetMultiXactIdLimit` → `SetOldestOffset` → 
`find_multixact_start` →
+`SimpleLruDoesPhysicalPageExist` false → the LOG fires. 40000 more multixacts 
plus another
+`VACUUM FREEZE` then **deleted a members segment**, with the "truncation is 
disabled" message still
+in force. `pg_get_multixact_stats()` reported inflated members throughout.
+
+Scope honesty: normal operation does not reach this state. 
`TruncateMultiXact()` updates
+`MultiXactState->oldestMultiXactId` inside the same critical section as the 
unlink, WAL-logs it and
+holds `DELAY_CHKPT_START`; the redo path skips `SetOldestOffset()` entirely. 
Reaching it takes either
+legacy 9.3/9.4-era corruption (which the surrounding comment at 
`multixact.c:2464-2467` still
+anticipates) or an operator-supplied `pg_resetwal -m` whose second value is 
older than the smallest
+surviving segment. Note that following `doc/src/sgml/ref/pg_resetwal.sgml`'s 
documented recipe
+literally yields a value whose page *does* exist, so the documented procedure 
does not hit it. This
+is a wrong-message defect on a recovery path, not an everyday hazard.
+
+**User-visible consequence.** A DBA recovering a damaged cluster reads a 
server LOG stating that
+multixact member truncation has been disabled — implying 
`pg_multixact/members` will grow without
+bound until they intervene — when truncation is working fine, and is given no 
hint about the actual
+consequence (an inflated members count driving over-aggressive freezing and 
wrong
+`pg_get_multixact_stats()` output). The message also never retracts itself.
+
+**Why it is not covered by a test.** The state requires staging a mismatch 
between pg_control's
+`oldestMulti` and the surviving offsets segments — a `pg_resetwal 
--multixact-ids` with a
+deliberately stale value, after a real truncation, on a cluster whose multixid 
counter was
+pre-positioned near a segment boundary. That is ~7 setup steps including a 
`test_slru` module load and
+a pgbench run, and the payload assertion is "a LOG line's text is wrong", 
which the project does not
+normally assert. The genuinely testable half — that truncation still runs 
while the message claims it
+is disabled — requires the same staging plus log scraping. The proportionate 
fix is to reword the
+message and delete the false comment; a test would cost far more than the bug.
+
+**Test someone should write, if one is wanted** 
(`src/test/modules/test_slru/t/`):
+
+```
+initdb; pg_resetwal --multixact-ids 0xFFFF,0xFFFF; hand-place a zeroed 
offsets/0001, rm offsets/0000
+load test_slru; create ~30k multixacts via test_create_multixact()
+make template0 connectable; VACUUM FREEZE all dbs   # truncation unlinks 
offsets/0001
+stop; pg_resetwal --multixact-ids <new nextMulti>,<stale oldest inside the 
unlinked segment>; start
+expect the LOG line
+create more multixacts; VACUUM FREEZE all dbs
+assert a members segment WAS unlinked      # i.e. the LOG's claim is false
+assert pg_get_multixact_stats().num_members is inflated relative to reality
+```
+
+---
+
+## 4. What the companion test does cover
+
+For completeness, `src/test/modules/test_slru/t/003_multixact_offset.pl` (plus 
its `meson.build`
+entry) and the additions to `src/bin/pg_upgrade/t/007_multixact_conversion.pl` 
cover rows 5-8 of the
+summary table:
+
+* **`resetwal-nextmxoff-zero`** — `GuessControlValues()` at 
`pg_resetwal.c:703` assigns
+  `nextMultiOffset = 0`, and `case 'O'` (`pg_resetwal.c:297-307`) accepts 0 
with no zero check,
+  unlike `-o` (`:263-264`) and `-m` (`:290-293`). The resulting cluster cannot 
read back the first
+  multixact it creates: `ERROR: MultiXact 1 has invalid offset`.
+* **`pg-control-checkpoint-next-multi-offset-xid`** — `pg_controldata.c:133` 
(backend) funnels the
+  now-`uint64` field through `TransactionIdGetDatum()`, and 
`pg_proc.dat:12374` still declares the
+  column `xid` (`proallargtypes` has `xid` in that slot; 
`func-info.sgml:3460-3461` documents it as
+  `xid`). Control file holds 4294967896, SQL reports 600.
+* **`members-truncation-apparent-wraparound`** — `PerformMembersTruncation()`
+  (`multixact.c:2642-2647`) passes `MXOffsetToMemberPage(newOldestOffset)` to 
`SimpleLruTruncate()`
+  with no step-back, unlike `PerformOffsetsTruncation()` immediately below it
+  (`multixact.c:2652-2664`), whose comment names exactly this hazard.
+* **`pgupgrade-nxtmxoff-str2uint`** — `controldata.c:292` reads the field with 
`str2uint()`
+  (`util.c:351-355`, returns `unsigned int`) into the `uint64 chkpnt_nxtmxoff`
+  (`pg_upgrade.h:221`). Old 4294967896 → new 600.
+
+---
+
+## 5. Refuted candidates — do not re-chase these
+
+* **"pg_upgrade's multixact conversion `pg_fatal`s on a missing offsets 
segment that both servers
+  tolerate" (`src/bin/pg_upgrade/slru_io.c:112`)** — refuted 2/3. The code 
reading is right, but no
+  normal-operation path produces the state: offsets segments are removed only 
by
+  `TruncateMultiXact()`, which never deletes the segment containing 
`oldestMulti`. Reaching it needs
+  a hand-set pg_control on an already-corrupt cluster, and the claimed 
consequences were overstated.
+
+* **"Widening `moff` to 64 bits opened a 4-byte padding hole that ships 
uninitialized stack in every
+  CREATE_ID WAL record" (`src/include/access/multixact.h:75`)** — refuted 2/3. 
The layout claim is
+  accurate (`mid`@0, `moff`@8, `nmembers`@16, `members`@20 with 
`MultiXactOffset` = `uint64`), but no
+  observable behavior follows: nothing reads the padding, WAL is not compared 
byte-wise across
+  systems, and no user-visible symptom could be stated. Fails the 
"user-visible defect" bar.
+
+* **"`SetOldestOffset()` installs a zero `oldestOffset`, defeating `102bdaa`'s 
guard and clamping
+  vacuum's multixact freeze age cluster-wide" (`multixact.c:2485`)** — refuted 
3/3. The code is
+  really at `multixact.c:2484-2490` and the mechanical chain is right, but the 
value is only
+  installed `if (oldestOffsetKnown)`, and the arithmetic that was claimed to 
clamp
+  `freeze_max_age` bottoms out at `if (fraction >= 1.0) return 0;` only after
+  `members > MULTIXACT_MEMBER_LOW_THRESHOLD`, which the described state does 
not reach. (The related,
+  *real* problem in that function is the false LOG message, §3.4.)
+
+---
+
+## 6. Bottom line
+
+Four confirmed, still-unfixed, user-observable defects attributable to bd8d9c9 
have no test coverage:
+two documentation claims falsified by the commit (§3.1, §3.2), one wrong-value 
report on hot standbys
+(§3.3, with attribution partly to the later `97b10177`), and one server LOG 
message that names the
+wrong subsystem and conceals the actual degradation (§3.4). None of the four 
is testable at
+proportionate cost on ordinary hardware without first adding a threshold 
override
+(§3.2), building a recovery-suite test and settling the intended standby 
semantics (§3.3), or
+accepting a seven-step SLRU staging fixture to assert a message's text (§3.4). 
§3.1 is a one-line
+SGML fix that no test could ever have caught.
diff --git a/src/bin/pg_upgrade/t/007_multixact_conversion.pl 
b/src/bin/pg_upgrade/t/007_multixact_conversion.pl
index 867a062..85742b9 100644
--- a/src/bin/pg_upgrade/t/007_multixact_conversion.pl
+++ b/src/bin/pg_upgrade/t/007_multixact_conversion.pl
@@ -440,4 +440,72 @@ SKIP:
        upgrade_and_compare($tag, $old, $new);
 }
 
+# High-offset scenario: the old cluster's 64-bit NextMultiOffset has
+# passed 2^32.  No SLRU conversion is involved here, so both clusters
+# use the current version regardless of $ENV{oldinstall}: the value is
+# copied verbatim from the old cluster's control file to the new one,
+# which is exactly the code path we want to check.
+#
+# get_control_data() in controldata.c reads "Latest checkpoint's
+# NextMultiOffset" with str2uint(), which returns unsigned int, even
+# though chkpnt_nxtmxoff became uint64 when offsets were widened.  The
+# new cluster therefore gets NextMultiOffset modulo 2^32, i.e. an offset
+# pointing back into members space that is still in use.
+{
+       my $tag = 'highoffset';
+       my $old = PostgreSQL::Test::Cluster->new("${tag}_oldnode");
+       my $new = PostgreSQL::Test::Cluster->new("${tag}_newnode");
+
+       $old->init;
+       $new->init;
+
+       # MULTIXACT_MEMBERS_PER_PAGE, as computed in
+       # reset_mxid_mxoffset_pre_v19() above.
+       my ($out, undef) =
+         run_command([ 'pg_resetwal', '--dry-run', $old->data_dir ]);
+       $out =~ /^Database block size: *(\d+)$/m or die;
+       my $blcksz = $1;
+       my $multixact_members_per_page = int($blcksz / 20) * 4;
+
+       # Smallest members-page-aligned offset at or above 2^32.  Page
+       # alignment matters because pg_upgrade starts the old cluster to
+       # dump its schema: with an aligned nextOffset, TrimMultiXact() does
+       # not read the members page, so the old cluster starts without a
+       # multi-gigabyte members segment on disk.  Alignment also puts
+       # ($target mod 2^32) inside members page 0, which the copied segment
+       # does contain, so pg_upgrade itself still succeeds and the test
+       # fails on a clean value mismatch rather than on command_ok().
+       my $two_to_32 = Math::BigInt->new(2)**32;
+       my $target =
+         ($two_to_32 + $multixact_members_per_page - 1)
+         ->bdiv($multixact_members_per_page)
+         ->bmul($multixact_members_per_page);
+
+       command_ok(
+               [
+                       'pg_resetwal',
+                       '--multixact-offset' => "$target",
+                       $old->data_dir
+               ],
+               'set old cluster NextMultiOffset past 2^32');
+
+       command_ok(
+               [
+                       'pg_upgrade', '--no-sync',
+                       '--old-datadir' => $old->data_dir,
+                       '--new-datadir' => $new->data_dir,
+                       '--old-bindir' => $old->config_data('--bindir'),
+                       '--new-bindir' => $new->config_data('--bindir'),
+                       '--socketdir' => $new->host,
+                       '--old-port' => $old->port,
+                       '--new-port' => $new->port,
+               ],
+               'run of pg_upgrade with a NextMultiOffset above 2^32');
+
+       my (undef, undef, $old_mxoff) = read_multixid_fields($old);
+       my (undef, undef, $new_mxoff) = read_multixid_fields($new);
+       is($new_mxoff, $old_mxoff,
+               'NextMultiOffset is preserved across pg_upgrade');
+}
+
 done_testing();
diff --git a/src/test/modules/test_slru/meson.build 
b/src/test/modules/test_slru/meson.build
index 00f3ee3..f4536ea 100644
--- a/src/test/modules/test_slru/meson.build
+++ b/src/test/modules/test_slru/meson.build
@@ -39,7 +39,8 @@ tests += {
     },
     'tests': [
       't/001_multixact.pl',
-      't/002_multixact_wraparound.pl'
+      't/002_multixact_wraparound.pl',
+      't/003_multixact_offset.pl'
     ],
   },
 }
diff --git a/src/test/modules/test_slru/t/003_multixact_offset.pl 
b/src/test/modules/test_slru/t/003_multixact_offset.pl
new file mode 100644
index 0000000..c6771dd
--- /dev/null
+++ b/src/test/modules/test_slru/t/003_multixact_offset.pl
@@ -0,0 +1,254 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Tests for the 64-bit MultiXactOffset counter (nextMultiOffset), which
+# commit bd8d9c9bdf widened from 32 bits.  Three independent scenarios,
+# each with its own cluster:
+#
+# 1. pg_resetwal must never leave nextMultiOffset at 0.  Now that
+#    offsets are 64-bit and no longer wrap, 0 is reserved as the
+#    "invalid offset" marker: GetMultiXactIdMembers() in
+#    access/transam/multixact.c errors out with "MultiXact %u has
+#    invalid offset" when it reads a zero offset.  But
+#    GuessControlValues() in bin/pg_resetwal/pg_resetwal.c still assigns
+#    nextMultiOffset = 0, and the -O option still accepts 0, unlike -m
+#    which rejects it.  A cluster left in that state cannot read back
+#    the first multixact it creates.
+#
+# 2. pg_control_checkpoint() must report the full 64-bit
+#    NextMultiOffset.  pg_control_checkpoint() in
+#    backend/utils/misc/pg_controldata.c still runs the field through
+#    TransactionIdGetDatum(), and pg_proc.dat still declares the
+#    next_multi_offset output column as xid, so the value is silently
+#    reported modulo 2^32.
+#
+# 3. Members truncation must not report a bogus "apparent wraparound"
+#    and must actually reclaim obsolete segments.
+#    PerformMembersTruncation() in access/transam/multixact.c passes
+#    MXOffsetToMemberPage(newOldestOffset) to SimpleLruTruncate()
+#    without stepping back one member, unlike PerformOffsetsTruncation()
+#    directly below it.  When nextOffset happens to sit exactly on a
+#    members page boundary, the cutoff page is one past the last page
+#    ever written, so SimpleLruTruncate()'s endpoint-page safety check
+#    fires and nothing is removed.  Before the widening,
+#    PerformMembersTruncation() unlinked the segments itself and had no
+#    such check.
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+
+use Test::More;
+
+# Read "Latest checkpoint's NextMultiOffset" out of pg_controldata.
+sub next_multi_offset
+{
+       my $node = shift;
+
+       my $out = (run_command([ 'pg_controldata', $node->data_dir ]))[0];
+       $out =~ /^Latest checkpoint's NextMultiOffset: *(\d+)$/m
+         or die "could not find NextMultiOffset in: $out";
+       return $1;
+}
+
+# Extract the SLRU geometry from pg_resetwal --dry-run output, like
+# 002_multixact_wraparound.pl does.
+sub slru_geometry
+{
+       my $node = shift;
+
+       my $out =
+         (run_command([ 'pg_resetwal', '--dry-run', $node->data_dir ]))[0];
+       $out =~ /^Database block size: *(\d+)$/m or die;
+       my $blcksz = $1;
+       $out =~ /^Pages per SLRU segment: *(\d+)$/m or die;
+       my $pages_per_seg = $1;
+
+       # MULTIXACT_MEMBERS_PER_PAGE: a member group is
+       # 4 * sizeof(TransactionId) + 4 == 20 bytes and holds 4 members.
+       # See MULTIXACT_MEMBERGROUP_SIZE in access/multixact_internal.h.
+       my $members_per_page = int($blcksz / 20) * 4;
+
+       return ($blcksz, $pages_per_seg, $members_per_page);
+}
+
+#
+# 1. pg_resetwal must not produce nextMultiOffset == 0.
+#
+{
+       my $node = PostgreSQL::Test::Cluster->new('mxoff_zero');
+       $node->init;
+       $node->append_conf('postgresql.conf',
+               "shared_preload_libraries = 'test_slru'\n" . "autovacuum = 
off\n");
+
+       # Use run_command(), not command_ok(): a fix for this may well make
+       # pg_resetwal reject -O 0 outright, and this test should pass either
+       # way.  All that matters is what ends up in the control file.
+       run_command(
+               [ 'pg_resetwal', '--multixact-offset' => 0, $node->data_dir ]);
+
+       cmp_ok(next_multi_offset($node), '>=', 1,
+               'pg_resetwal -O 0 does not store the reserved offset 0');
+
+       # Whatever the control file says, the cluster must be able to create
+       # and read back multixacts.
+       $node->start;
+       $node->safe_psql('postgres', 'CREATE EXTENSION test_slru');
+
+       my $mx1 = $node->safe_psql('postgres', 'SELECT 
test_create_multixact()');
+       my ($rc, undef, $err) =
+         $node->psql('postgres', "SELECT test_read_multixact('$mx1')");
+       is($rc, 0, 'the first multixact created after pg_resetwal is readable')
+         or diag("multixact $mx1: $err");
+
+       # Negative control: a later multixact must be fine regardless, so a
+       # failure above is really about the first one and not about a
+       # generally broken cluster.
+       my $mx2 = $node->safe_psql('postgres', 'SELECT 
test_create_multixact()');
+       ($rc, undef, $err) =
+         $node->psql('postgres', "SELECT test_read_multixact('$mx2')");
+       is($rc, 0, 'a subsequent multixact is readable')
+         or diag("multixact $mx2: $err");
+
+       $node->stop;
+
+       # The producer of a zero offset that needs no bad user input is
+       # GuessControlValues(), used when pg_control cannot be read at all.
+       # That is the documented "pg_resetwal -f" last-resort recovery.  No
+       # server start here: a guessed control file also rewinds nextXid, so
+       # the cluster is not usable without more hand-picked values.
+       my $pg_control = $node->data_dir . '/global/pg_control';
+       my $size = -s $pg_control;
+       open my $fh, '>', $pg_control or die "could not open pg_control: $!";
+       binmode $fh;
+       print $fh pack("x[$size]");
+       close $fh;
+
+       command_ok([ 'pg_resetwal', '--force', $node->data_dir ],
+               'pg_resetwal -f on an unreadable pg_control');
+       cmp_ok(next_multi_offset($node), '>=', 1,
+               'pg_resetwal -f does not guess the reserved offset 0');
+}
+
+#
+# 2. pg_control_checkpoint() must not truncate NextMultiOffset to 32 bits.
+#
+{
+       my $node = PostgreSQL::Test::Cluster->new('mxoff_64bit');
+       $node->init;
+       $node->append_conf('postgresql.conf', "autovacuum = off\n");
+
+       my (undef, undef, $members_per_page) = slru_geometry($node);
+
+       # Smallest members-page-aligned offset at or above 2^32.  Page
+       # alignment matters: TrimMultiXact() then skips reading the members
+       # page, so the cluster starts without a huge segment on disk.
+       my $target =
+         int((4294967296 + $members_per_page - 1) / $members_per_page) *
+         $members_per_page;
+
+       command_ok(
+               [ 'pg_resetwal', '--multixact-offset' => $target, 
$node->data_dir ],
+               'set NextMultiOffset past 2^32');
+
+       # pg_resetwal's own -O parser is 64-bit; check that the control file
+       # really holds the large value before blaming SQL for the mismatch.
+       my $from_controldata = next_multi_offset($node);
+       is($from_controldata, "$target", 'pg_controldata reports the full 
value');
+
+       $node->start;
+
+       # Compare against the string pg_controldata printed, and cast to
+       # text: a complete fix needs Int64GetDatum() *and* a change of the
+       # column's declared type from xid to int8, and this comparison stays
+       # valid across that change.
+       is( $node->safe_psql(
+                       'postgres',
+                       'SELECT next_multi_offset::text FROM 
pg_control_checkpoint()'),
+               $from_controldata,
+               'pg_control_checkpoint() reports the full 64-bit 
NextMultiOffset');
+
+       $node->stop;
+}
+
+#
+# 3. Members truncation must not claim "apparent wraparound".
+#
+{
+       my $node = PostgreSQL::Test::Cluster->new('mxoff_members');
+       $node->init;
+       $node->append_conf('postgresql.conf',
+               "shared_preload_libraries = 'test_slru'\n" . "autovacuum = 
off\n");
+
+       my ($blcksz, $pages_per_seg, $members_per_page) = slru_geometry($node);
+       my $members_per_seg = $members_per_page * $pages_per_seg;
+
+       # Aim to land nextOffset exactly on a members segment boundary, two
+       # segments in.  test_create_multixact() creates exactly one multixact
+       # with exactly 2 members, so start 2 short of the boundary.
+       my $target = 2 * $members_per_seg;
+
+       command_ok(
+               [
+                       'pg_resetwal',
+                       '--multixact-offset' => $target - 2,
+                       $node->data_dir
+               ],
+               'set NextMultiOffset just below a members segment boundary');
+
+       # pg_resetwal only updates the control file, so create the members
+       # segments that the pre-seeded offset lives in, filled with zeros, as
+       # 002_multixact_wraparound.pl does for the offsets SLRU.  (In the
+       # field the server would have written them itself.)  Segment 0 must
+       # exist too, or there would be nothing for the truncation to reclaim.
+       # The members SLRU uses long segment names.
+       my $bytes_per_seg = $pages_per_seg * $blcksz;
+       foreach my $segno (0, 1)
+       {
+               my $path =
+                 sprintf('%s/pg_multixact/members/%015X', $node->data_dir, 
$segno);
+               open my $fh, '>', $path or die "could not open \"$path\": $!";
+               binmode $fh;
+               syswrite($fh, "\0" x $bytes_per_seg) == $bytes_per_seg
+                 or die "could not write to \"$path\": $!";
+               close $fh;
+       }
+
+       $node->start;
+       $node->safe_psql('postgres', 'CREATE EXTENSION test_slru');
+
+       # One multixact of 2 members: nextOffset becomes exactly $target,
+       # i.e. exactly on a members segment (hence page) boundary.
+       $node->safe_psql('postgres', 'SELECT test_create_multixact()');
+
+       # Do not restart from here on.  StartupMultiXact()/TrimMultiXact()
+       # would re-derive the members SLRU's latest_page_number from
+       # nextOffset and mask the problem.
+
+       my $log_offset = -s $node->logfile;
+
+       # Drive every datminmxid up to nextMulti, so that TruncateMultiXact()
+       # takes the "there are NO MultiXacts" branch and uses nextOffset as
+       # the members cutoff.
+       $node->safe_psql('postgres',
+               'ALTER DATABASE template0 ALLOW_CONNECTIONS true');
+       foreach my $db ('postgres', 'template1', 'template0')
+       {
+               $node->safe_psql($db, 'VACUUM FREEZE');
+       }
+
+       ok( !$node->log_contains(
+                       qr{pg_multixact/members": apparent wraparound}, 
$log_offset),
+               'members truncation does not report a bogus wraparound');
+
+       my $seg0 = $node->data_dir . '/pg_multixact/members/000000000000000';
+       my @segs = map { (split m{/})[-1] }
+         glob($node->data_dir . '/pg_multixact/members/*');
+       ok(!-f $seg0, 'obsolete members segment is reclaimed')
+         or diag('members segments left behind: ' . join(' ', sort @segs));
+
+       $node->stop;
+}
+
+done_testing();

Reply via email to