A streaming replica on PostgreSQL 14.23 stops replaying WAL coming from a primary on 14.17. The startup process self-deadlocks acquiring MultiXactOffsetSLRULock inside RecordNewMultiXact → SimpleLruWriteAll. The bug is in the back-patched compatibility code introduced by commit 789d65364c (“Set next multixid’s offset when creating a new multixid”) and partially addressed by c60a58204435 in 14.22. This post documents the symptoms, diagnosis, and a workaround.
Environment
- Primary: PostgreSQL 14.17 (Ubuntu 14.17-1.pgdg20.04+1), Ubuntu 20.04 (focal), aarch64
- Standby: PostgreSQL 14.23 (Ubuntu 14.23-1.pgdg22.04+1), Ubuntu 22.04 (jammy), amd64
- Streaming replication, archived WAL via pgBackRest 2.55.1
- TimescaleDB loaded via
shared_preload_libraries, present as extension 2.17.2 in one database
Symptoms
pg_stat_replication on the primary, with sent_lsn/write_lsn/flush_lsn current but replay_lsn frozen:
1 | client_addr | state | sent_lsn | write_lsn | flush_lsn | replay_lsn | write_lag | replay_lag |
On the standby, the receive LSN advances normally while replay LSN does not:
1 | pg_last_wal_receive_lsn | pg_last_wal_replay_lsn | lag |
Approximately 600 MB of WAL received but unreplayed. Replication lag grows 1:1 with wall-clock time.
The startup process is alive but uses no CPU:
1 | $ ps -p $(pgrep -f 'startup recovering') -o pid,etime,time,cmd |
No errors are emitted to the log after the initial recovery setup. pg_stat_database_conflicts reports zero conflicts. max_standby_streaming_delay was the default 30s. No long-running queries were holding back replay; in fact the same hang occurs before consistent recovery state is reached, with no client backends connected.
The startup process is sleeping on a futex with no timeout:
1 | $ sudo timeout 5 strace -p 2088872 2>&1 | tail |
Stuck WAL record
The replay LSN identifies the record being processed at the freeze. Using pgbackrest archive-get to fetch the segment from the archive and pg_waldump to inspect it:
1 | sudo -u postgres pgbackrest --stanza=<STANZA> archive-get \ |
1 | rmgr: MultiXact len (rec/tot): 54/54, tx: 228676118, |
Multixact ID 68812799 is the last entry in offsets file 0419:
- 65536 entries per offsets segment file
68812799 / 65536 = 1049.99…68812799 = 1050 × 65536 - 1- The next multixact (
68812800) would belong to a new segment file (041A)
The first hang (at LSN 54/B6F3A5B8, mxid 68763647) was on the same kind of record — a MultiXact/CREATE_ID near the boundary into a new SLRU offsets segment. Both freezes were deterministic; the standby reproduced the hang at the same LSN across repeated restarts.
Stack trace
After installing postgresql-14-dbgsym and attaching gdb to the stuck startup process:
1 | #7 LWLockAcquire (lock=0x7f1450553180, mode=LW_EXCLUSIVE) at lwlock.c:1325 |
Relevant locals on frame #9:
1 | pageno = 33599 |
(pageno = 33599 is the last page of segment file 0419; entryno = 2047 is the last entry on that page.)
The checkpointer was blocked on the same lock via a different path:
1 | #7 LWLockAcquire (lock=0x7f1450553180, mode=LW_EXCLUSIVE) |
LWLock state
Reading the lock structure directly:
1 | $ sudo gdb -p <startup-pid> -batch \ |
Decoding state.value = 0x61000000:
| Bit | Flag | Set? |
|---|---|---|
0x40000000 |
LW_FLAG_HAS_WAITERS |
yes |
0x20000000 |
LW_FLAG_RELEASE_OK |
yes |
0x01000000 |
LW_VAL_EXCLUSIVE |
yes |
0x00FFFFFF |
shared-holder count | 0 |
The lock is held exclusive, with two PGPROCs queued behind it. No third PostgreSQL process is in any SLRU code path — only the startup process and the checkpointer exist (plus postmaster and background writer, which are in their idle loops). The exclusive holder is therefore the startup process itself: RecordNewMultiXact acquires MultiXactOffsetSLRULock exclusively at the start of the function, reaches the segment-extension branch, and calls SimpleLruWriteAll, whose first action is to acquire the same lock exclusively. PostgreSQL LWLocks are not recursive. The process queues itself on the wait list for the lock it already holds.
Related upstream patches
This appears to be related to but not fully resolved by the following work:
- 2025-06-25: Thread “IPC/MultixactCreation on the Standby server” opened by Dmitry @ Yandex on pgsql-hackers (message-id 172e5723-d65f-4eec-b512-14beacb326ce@yandex.ru). The reproduction targeted PG17/18 client backends hanging on
IPC/MultixactCreation; the reporter notes the issue is not reproducible on PG16.9. - 2025-12-03: Commit
789d65364c“Set next multixid’s offset when creating a new multixid” by Andrey Borodin / committer Heikki Linnakangas. Back-patched to all supported versions. - 2025-12-05: Commit
4d689a17693“Fix setting next multixid’s offset at offset wraparound”. Back-patched. - 2026-02-12: Released in 14.21, 15.16, 16.12, 17.8, 18.2.
- 2026-02-16: Commit
c60a58204435(Michael Paquier) “Fix failure to replay WAL generated on older minor versions”. Back-patched 14–18. - 2026-02-26: Out-of-cycle release of 14.22, 15.17, 16.13, 17.9, 18.3 to address the above regression. 14.22 release notes:
“Fix failure after replaying a multixid truncation record from WAL that was generated by an older minor version (Heikki Linnakangas). Erroneous logic for coping with the way that previous versions handled multixid wraparound led to replay failure, with messages like ‘could not access status of transaction’. A typical scenario in which this could occur is a standby server of the latest minor version consuming WAL from a primary server of an older version.”
The case documented here is a different failure mode (a self-deadlock during replay, not a could not access status of transaction error) in the same cross-minor-version scenario that 14.22 was supposed to fully cover. The standby was on 14.23, which contains both patches; the primary was on 14.17, which generates WAL in the pre-fix format. The hang occurs deterministically at the WAL record listed above.
Reproduction conditions
From available evidence:
- A primary running a minor version older than the fix (14.17 here; presumably any 14.x ≤ 14.20).
- A standby running a minor version that contains the back-patched compat code (14.21+).
- The standby replaying a
MULTIXACT/CREATE_IDWAL record where the multixact ID is the last entry of the current offsets segment file (i.e., a record that triggers the SLRU segment-extension branch inRecordNewMultiXact). - The checkpointer running a restartpoint concurrently is not required for the deadlock (the startup process is by itself sufficient because of the self-recursive lock acquisition), but it amplifies it.
The standby has been observed to deadlock at this code path on at least two distinct multixact IDs (68763647 and 68812799) on different occasions, each at a segment-boundary record.
Workaround
Align minor versions on both sides. With both standby and primary on the same minor version, the cross-version compat code path is not exercised. Either:
- Upgrade primary to 14.21+ to match the standby. This was not viable in this case because Ubuntu 20.04 was EOL’d by Ubuntu in April 2025, and PostgreSQL global development group removed focal from
apt.postgresql.orgin July 2025. The archive atapt-archive.postgresql.orgonly holds focal builds up to 14.18. Upgrading the OS is the prerequisite to upgrading PostgreSQL on that host. - Downgrade standby to match the primary. This is what was actually done.
For (2), the standby was on PGDG packages for jammy. The PGDG main repo retains only the most recent few minor versions; older versions are at apt-archive.postgresql.org:
1 | echo "deb https://apt-archive.postgresql.org/pub/repos/apt jammy-pgdg-archive main" \ |
Stop the cluster and install the older version explicitly:
1 | sudo systemctl stop postgresql@14-main |
Holding the packages prevents an unattended upgrade from silently moving the standby back into the buggy version range. If TimescaleDB is installed, hold those packages too:
1 | sudo apt-mark hold \ |
Same-major-version downgrade reads the existing data directory; PostgreSQL guarantees on-disk and WAL format compatibility within a major version.
After downgrade, the standby replayed past the previously-stuck LSN without further intervention and resumed streaming from the primary.
Diagnostic command reference
1 | # Receive vs replay progression on standby |