commit bdff567 (cqla/mxactoffset64-defect-tests) Author: Noah Misch AuthorDate: Tue Jul 21 17:52:05 2026 +0000 Commit: Noah Misch 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) 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 -- ` 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 pg_get_multixact_members() described in +785 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 +815 As a safety device, an aggressive vacuum scan will +816 occur for any table whose multixact-age is greater than . Also, if the number +818 of multixact member entries created exceeds approximately 2 billion +819 entries (occupying roughly 10GB in the +820 pg_multixact/members 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 pg_multixact/members directory), even more aggressive +826 vacuum scans are triggered to reclaim member storage space. +827 +``` + +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 ,; 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();