On Fri, Oct 9, 2026, Bharath Rupireddy
<[email protected]> wrote:
> How about adjusting EndPos itself to the start of the segment (before
> the page header) using XLogBytePosToEndRecPtr in this case, where the
> switch WAL record falls right on the segment boundary, something like
> the attached?
Agreed, that fixes it where the bad position is produced. Let's go
with yours and drop my XLogBackgroundFlush() change.
0001 is your patch, with you as the author and the code unchanged. I
changed two things in the commit message:
- Your text says the walwriter keeps logging the message. That happens
with my v4, but not on master without any of these patches. There,
if the page at the boundary hasn't been initialized yet, the
walwriter PANICs in XLogWrite():
LOG: request to flush past end of generated WAL; request 0/00900028,
current position 0/00900000
PANIC: xlog write request 0/00900000 is past end of log 0/00000000
and the postmaster restarts the cluster. This happens in non-assert
builds too. If the page has been initialized, it reports write and
flush positions for WAL that was never written, as your text said,
and an assert-enabled build fails Assert(Insert >= Write).
- I replaced "More blah." with a note that pg_switch_wal() now returns
what func-admin.sgml says: "the ending write-ahead log location plus
1 within the just-completed write-ahead log file". The boundary + 40
is in the next file.
0002 is a new test. The v4 test found the window by the overridden
EndPos, which your patch no longer produces, so this one records the
insert position right before pg_switch_wal() and checks that the switch
reports exactly the boundary. If a concurrent record throws off the
padding, it retries. Without 0001 it fails (boundary + 40, or + 36 on
32-bit). With 0001 it passed 30 runs in a row, and the regress,
recovery and test_misc suites pass.
Back branches: the code block is the same on REL_15 through REL_19.
0001 applies cleanly to REL_19, and with git am -3 to REL_15..18
(only the trailing context differs). On every branch the test fails
without 0001 and passes with it. The effect without the fix differs,
though:
- 17 and later: one pg_switch_wal() ending on the boundary kills the
walwriter, by the PANIC above or by the Assert, which came with
logInsertResult in 17.
- 15 and 16: I couldn't make it crash. The walwriter logs the message
once and reports write and flush positions 40 bytes past the end of
generated WAL.
I'd back-patch to all of them, though the case for 15 and 16 is
weaker. The test can keep its number; only the meson.build list needs
adjusting, and REL_15 has no meson.build there.
Side effects of 0001:
- A switch record ending on a page boundary in the middle of a segment
also used to get SizeOfXLogShortPHD added; now it reports the page
boundary. That was harmless before. The new code comment mentions
only the segment boundary, so it might be worth covering this too.
- CheckArchiveTimeout() now gets offset 0 for a switch that ends on the
boundary, which looks like "nothing happened", so its DEBUG1 message
is skipped.
- pg_walfile_name_offset(pg_switch_wal()) gives offset 0 instead of 40.
On REL_15 and REL_16, which use XLByteToPrevSeg there, the file name
also changes from the next segment to the just-completed one. That
is what their documentation describes for a location on a boundary.
I'll update the CF entry's title to match.
Regards,
Paul Kim
From f641116324b9c6ca45f78d328e3c88d5b2400108 Mon Sep 17 00:00:00 2001
From: Bharath Rupireddy <[email protected]>
Date: Fri, 9 Oct 2026 04:16:25 +0000
Subject: [PATCH v6 1/2] Fix end LSN reported for an xlog-switch record ending
on a page boundary
A page header size is added to the end of an xlog-switch record
even when the record ends exactly on a page boundary and crosses
no header. At a segment boundary that puts the end past the end of
reserved WAL. The position then goes into the asynchronous commit
LSN, and from there into the WAL writer, which asks to write and
flush WAL that was never generated. If the page at the boundary
has not been initialized yet, XLogWrite() then PANICs with "xlog
write request ... is past end of log". Otherwise it reports write
and flush positions for WAL that was never written, and in an
assert-enabled build fails an assertion there.
Adjust the end position to the start of the segment (before the
page header) using XLogBytePosToEndRecPtr(), the way it is done
for any other record. The bad position then no longer goes into
the asynchronous commit LSN or the WAL writer.
This also makes pg_switch_wal() return what its documentation says:
the ending location plus 1 within the just-completed WAL file.
---
src/backend/access/transam/xlog.c | 19 ++++++-------------
1 file changed, 6 insertions(+), 13 deletions(-)
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 63d214782da..a9571ad8f61 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -1077,21 +1077,14 @@ XLogInsertRecord(XLogRecData *rdata,
/*
* Even though we reserved the rest of the segment for us, which is
* reflected in EndPos, we return a pointer to just the end of the
- * xlog-switch record.
+ * xlog-switch record. When the record falls right on the segment
+ * boundary, EndPos is adjusted to the start of the segment (before
+ * the page header), so that it doesn't go past the end of reserved
+ * WAL.
*/
if (inserted)
- {
- EndPos = StartPos + SizeOfXLogRecord;
- if (StartPos / XLOG_BLCKSZ != EndPos / XLOG_BLCKSZ)
- {
- uint64 offset = XLogSegmentOffset(EndPos, wal_segment_size);
-
- if (offset == EndPos % XLOG_BLCKSZ)
- EndPos += SizeOfXLogLongPHD;
- else
- EndPos += SizeOfXLogShortPHD;
- }
- }
+ EndPos = XLogBytePosToEndRecPtr(XLogRecPtrToBytePos(StartPos) +
+ MAXALIGN(SizeOfXLogRecord));
}
/* Process any flush progress published while making room for the record. */
--
2.50.1 (Apple Git-155)
From c057afe24a6bae496311501f7eab1dd25d787b6c Mon Sep 17 00:00:00 2001
From: Paul Kim <[email protected]>
Date: Sat, 12 Sep 2026 21:02:26 +0900
Subject: [PATCH v6 2/2] Add a test for the end LSN of an xlog-switch record
An xlog-switch record that starts SizeOfXLogRecord bytes before a
segment boundary ends exactly on it, and pg_switch_wal() must report
the boundary.
The test pads WAL with logical messages to reach that position, which
needs no injection points. Without the fix, pg_switch_wal() reports
the boundary plus SizeOfXLogLongPHD.
---
src/test/modules/test_misc/meson.build | 1 +
.../test_misc/t/016_xlog_switch_end_lsn.pl | 115 ++++++++++++++++++
2 files changed, 116 insertions(+)
create mode 100644 src/test/modules/test_misc/t/016_xlog_switch_end_lsn.pl
diff --git a/src/test/modules/test_misc/meson.build b/src/test/modules/test_misc/meson.build
index 5d81f5b13be..19055431754 100644
--- a/src/test/modules/test_misc/meson.build
+++ b/src/test/modules/test_misc/meson.build
@@ -24,6 +24,7 @@ tests += {
't/013_temp_obj_multisession.pl',
't/014_log_statement_max_length.pl',
't/015_temp_schema_exit_deferrable.pl',
+ 't/016_xlog_switch_end_lsn.pl',
],
# The injection points are cluster-wide, so disable installcheck
'runningcheck': false,
diff --git a/src/test/modules/test_misc/t/016_xlog_switch_end_lsn.pl b/src/test/modules/test_misc/t/016_xlog_switch_end_lsn.pl
new file mode 100644
index 00000000000..bca8ebd67b3
--- /dev/null
+++ b/src/test/modules/test_misc/t/016_xlog_switch_end_lsn.pl
@@ -0,0 +1,115 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test the end LSN reported for an xlog-switch record that ends exactly
+# on a segment boundary.
+#
+# This happens when the switch record starts SizeOfXLogRecord bytes
+# before the boundary. The reported end must be the boundary itself,
+# not the boundary plus a page header that the record never crossed.
+# That position reaches XLogSetAsyncXactLSN() via XactLastRecEnd,
+# because the transaction around pg_switch_wal() has no XID and
+# therefore commits asynchronously, and the walwriter would then try to
+# write and flush WAL that was never generated.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+
+# Small segments keep the padding cheap.
+$node->init(extra => ['--wal-segsize', '1']);
+$node->append_conf(
+ 'postgresql.conf', qq(
+autovacuum = off
+));
+$node->start;
+
+# Pad WAL with logical messages until the insert position is exactly
+# SizeOfXLogRecord (24) bytes before a segment boundary, then switch.
+# The record sizes involved are all MAXALIGNed, so filling the exact
+# gap is possible; a record that crosses a page boundary picks up an
+# extra page header and overshoots, in which case the loop moves on to
+# the next segment boundary and tries again.
+my $pad_and_switch = q{
+DO $$
+DECLARE
+ segsz numeric := (SELECT setting::numeric FROM pg_settings
+ WHERE name = 'wal_segment_size');
+ zero pg_lsn := '0/0';
+ base numeric;
+ cur numeric;
+ target numeric;
+ gap numeric;
+ seg0 numeric;
+BEGIN
+ -- size of a logical message record with an empty payload
+ cur := pg_current_wal_insert_lsn() - zero;
+ PERFORM pg_logical_emit_message(false, 'x', '');
+ base := (pg_current_wal_insert_lsn() - zero) - cur;
+
+ seg0 := floor((pg_current_wal_insert_lsn() - zero) / segsz);
+ LOOP
+ cur := pg_current_wal_insert_lsn() - zero;
+ target := (floor(cur / segsz) + 1) * segsz - 24;
+ gap := target - cur;
+ EXIT WHEN gap = 0;
+ IF floor(cur / segsz) - seg0 > 3 THEN
+ RAISE EXCEPTION 'could not align the insert position';
+ END IF;
+ IF gap >= base + 8192 THEN
+ PERFORM pg_logical_emit_message(false, 'x', repeat('a', 4096));
+ ELSIF gap >= base + 200 THEN
+ -- approach the target in small steps, so that the final
+ -- record below stays under 256 bytes of main data and
+ -- keeps its short data header; a larger record would
+ -- switch to the long data header and its extra bytes
+ -- would spoil the exact fill unless MAXALIGN swallows
+ -- them
+ PERFORM pg_logical_emit_message(false, 'x', repeat('a', 64));
+ ELSIF gap >= base THEN
+ PERFORM pg_logical_emit_message(false, 'x',
+ repeat('a', (gap - base)::int));
+ ELSE
+ -- too close to the boundary, step over it and retry
+ PERFORM pg_logical_emit_message(false, 'x', '');
+ END IF;
+ END LOOP;
+END
+$$;
+SELECT pg_current_wal_insert_lsn() - '0/0'::pg_lsn;
+SELECT pg_switch_wal() - '0/0'::pg_lsn;
+};
+
+my $segsz = 1024 * 1024;
+my ($start, $end);
+
+# A concurrent WAL record (e.g. a bgwriter snapshot) between the
+# padding and the switch can spoil the alignment; the switch record
+# then does not start where we measured, and we simply try again. A
+# hit ends at most SizeOfXLogRecord + SizeOfXLogLongPHD bytes past the
+# measured start, whichever end position the server reports. The
+# padding itself fails if such a record, or a page header, lands
+# between the two reads that measure the size of an empty message;
+# that is a miss too.
+foreach my $attempt (1 .. 10)
+{
+ my $out;
+ next if $node->psql('postgres', $pad_and_switch, stdout => \$out) != 0;
+ ($start, $end) = split /\n/, $out;
+
+ last
+ if $start % $segsz == $segsz - 24
+ && $end - $start <= 24 + 40;
+ undef $start;
+}
+die "could not hit the segment-boundary switch window"
+ unless defined $start;
+
+is($end, $start + 24,
+ 'switch record ending on a segment boundary reports the boundary');
+
+$node->stop;
+done_testing();
--
2.50.1 (Apple Git-155)