From ff4d60923e62066fa9b66b948cb7f159d707ea63 Mon Sep 17 00:00:00 2001 From: Alexander Korotkov Date: Sat, 14 Nov 2026 00:16:08 +0200 Subject: [PATCH v1] Don't make WAIT FOR LSN depend on WAL written after a page boundary When the last WAL record ends exactly at a page boundary, pg_current_wal_insert_lsn() reports the position after the next page's header, 24 bytes past the boundary, or 40 bytes at a segment start. The flush, write, and replay positions stay at the boundary until more WAL is written. So WAIT FOR LSN on the value recommended in the documentation could hang until some unrelated record, such as bgwriter's RUNNING_XACTS, or until its timeout expired. This was first seen as an unexplained delay in 049_wait_for_lsn.pl and then hit about once in 2000 waits by an application doing read-your-writes. No WAL record can end inside a page header, so a target pointing into or just past the header denotes the same point in the WAL as the start of the page. Map such targets to the page start in WAIT itself, so that existing callers of pg_current_wal_insert_lsn() don't need a workaround. Do this in ExecWaitStmt() rather than in WaitForLSN(), whose internal callers may want to wait for any WAL past a given record end. Similar off-by-header problem was fixed for gistGetFakeLSN() in b1f14c9672 by introducing GetXLogInsertEndRecPtr(). Reported-by: Jobin Augustine Author: Alexander Korotkov Co-authored-by: Xuneng Zhou Discussion: https://postgr.es/m/CABPTF7WZ1yuYz8V=xsbghg8e7qaAm5MpyNw6BthWcbN7+P6biw@mail.gmail.com Backpatch-through: 19 --- src/backend/commands/wait.c | 37 ++++++++++++- src/test/recovery/t/049_wait_for_lsn.pl | 72 +++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/backend/commands/wait.c b/src/backend/commands/wait.c index a609f6375dd..b148d902e6e 100644 --- a/src/backend/commands/wait.c +++ b/src/backend/commands/wait.c @@ -15,6 +15,7 @@ #include "access/xact.h" #include "access/xlog.h" +#include "access/xlog_internal.h" #include "access/xlogrecovery.h" #include "access/xlogwait.h" #include "catalog/pg_type_d.h" @@ -31,11 +32,42 @@ #include "utils/snapmgr.h" +/* + * Map a target LSN that points inside or just past a WAL page header to the + * start of that page. + * + * No WAL record can end inside a page header, so both LSNs denote the same + * point in the WAL. But when the last record ends exactly at a page + * boundary, pg_current_wal_insert_lsn() reports the position after the next + * page's header, while the replay, write, and flush positions stay at the + * boundary until more WAL is written. Without this mapping, waiting for + * such a target would depend on unrelated WAL activity. + * + * This is done here rather than in WaitForLSN(), whose internal callers + * may want to wait for any WAL past a given record end. + */ +static XLogRecPtr +WaitTargetLSN(XLogRecPtr lsn) +{ + uint32 page_offset = lsn % XLOG_BLCKSZ; + uint32 header_size; + + if (XLogSegmentOffset(lsn, wal_segment_size) < XLOG_BLCKSZ) + header_size = SizeOfXLogLongPHD; + else + header_size = SizeOfXLogShortPHD; + + if (page_offset > 0 && page_offset <= header_size) + return lsn - page_offset; + return lsn; +} + void ExecWaitStmt(ParseState *pstate, WaitStmt *stmt, bool isTopLevel, DestReceiver *dest) { XLogRecPtr lsn; + XLogRecPtr target_lsn; int timeout = 0; WaitLSNResult waitLSNResult; WaitLSNType lsnType = WAIT_LSN_TYPE_STANDBY_REPLAY; /* default */ @@ -61,6 +93,7 @@ ExecWaitStmt(ParseState *pstate, WaitStmt *stmt, bool isTopLevel, /* Parse and validate the mandatory LSN */ lsn = DatumGetLSN(DirectFunctionCall1(pg_lsn_in, CStringGetDatum(stmt->lsn_literal))); + target_lsn = WaitTargetLSN(lsn); foreach_node(DefElem, defel, stmt->options) { @@ -213,7 +246,7 @@ ExecWaitStmt(ParseState *pstate, WaitStmt *stmt, bool isTopLevel, lsnType == WAIT_LSN_TYPE_STANDBY_WRITE || lsnType == WAIT_LSN_TYPE_STANDBY_FLUSH) && RecoveryInProgress() && - lsn > GetCurrentLSNForWaitType(lsnType)) + target_lsn > GetCurrentLSNForWaitType(lsnType)) { LOCKTAG locktag; @@ -234,7 +267,7 @@ ExecWaitStmt(ParseState *pstate, WaitStmt *stmt, bool isTopLevel, } /* Now wait for the LSN */ - waitLSNResult = WaitForLSN(lsnType, lsn, timeout); + waitLSNResult = WaitForLSN(lsnType, target_lsn, timeout); /* * Process the result of WaitForLSN(). Throw appropriate error if needed. diff --git a/src/test/recovery/t/049_wait_for_lsn.pl b/src/test/recovery/t/049_wait_for_lsn.pl index 0fe5b9c6f67..2d2fcecf078 100644 --- a/src/test/recovery/t/049_wait_for_lsn.pl +++ b/src/test/recovery/t/049_wait_for_lsn.pl @@ -1214,6 +1214,78 @@ SKIP: $stale_session->quit; } +# 11e. When the last record ends exactly at a WAL page boundary, +# pg_current_wal_insert_lsn() points past the next page's header, while the +# flush, write, and replay positions stay at the boundary. WAIT must treat +# both positions as the same point instead of waiting for unrelated WAL. +# +# Fill the current page with a flushed nontransactional logical message. Its +# record takes 55 bytes besides the payload: XLogRecord (24), long data header +# (5), xl_logical_message (24) and the "p" prefix with its terminator (2). +# Retry if other WAL gets in the way. +my $page_size = $rcv_primary->safe_psql('postgres', "SHOW wal_block_size"); +my $past_header_lsn; +for (my $attempt = 0; $attempt < 10 && !defined($past_header_lsn); $attempt++) +{ + my $room = $rcv_primary->safe_psql('postgres', + "SELECT $page_size - (pg_current_wal_insert_lsn() - '0/0'::pg_lsn) % $page_size" + ); + + # Leave enough room for the long data header; otherwise move to the + # next page first. + if ($room < 512) + { + $rcv_primary->safe_psql('postgres', + "SELECT pg_logical_emit_message(false, 'p', repeat('x', 1024), true)" + ); + next; + } + + my $payload = $room - 55; + $rcv_primary->safe_psql('postgres', + "SELECT pg_logical_emit_message(false, 'p', repeat('x', $payload), true)" + ); + $past_header_lsn = $rcv_primary->safe_psql( + 'postgres', qq[ + SELECT lsn FROM (SELECT pg_current_wal_insert_lsn() AS lsn) s + WHERE (lsn - '0/0'::pg_lsn) % $page_size = + CASE WHEN (lsn - '0/0'::pg_lsn) % + pg_size_bytes(current_setting('wal_segment_size')) < $page_size + THEN 40 ELSE 24 END + ]); + $past_header_lsn = undef if $past_header_lsn eq ''; +} +ok(defined($past_header_lsn), + "insert LSN points past a WAL page header after the last record") + or die "could not make the last WAL record end at a page boundary"; + +my $page_start_lsn = $rcv_primary->safe_psql('postgres', + "SELECT '$past_header_lsn'::pg_lsn - ('$past_header_lsn'::pg_lsn - '0/0'::pg_lsn) % $page_size" +); +$rcv_standby->poll_query_until('postgres', + "SELECT pg_last_wal_replay_lsn() >= '$page_start_lsn'::pg_lsn") + or die "rcv_standby did not replay up to the page boundary"; + +# The timeout only bounds the failure case; the targets are reached already. +is( $rcv_primary->safe_psql( + 'postgres', qq[ + WAIT FOR LSN '$past_header_lsn' + WITH (MODE 'primary_flush', timeout '3s', no_throw); + ]), + 'success', + "primary_flush: target past a page header is reached at the boundary"); + +foreach my $mode ('standby_replay', 'standby_write', 'standby_flush') +{ + is( $rcv_standby->safe_psql( + 'postgres', qq[ + WAIT FOR LSN '$past_header_lsn' + WITH (MODE '$mode', timeout '3s', no_throw); + ]), + 'success', + "$mode: target past a page header is reached at the boundary"); +} + $rcv_standby->stop; $rcv_primary->stop; -- 2.55.0