PG17: log_newpages_copy returns the WAL record start LSN, allowing stale FSM reads
The PG17+ SMGR path returns ProcLastRecPtr from log_newpages_copy(). This is the start of the last WAL record, but the caller uses it as the page LSN and last-written LSN. The resulting read boundary can precede the full-page image it is supposed to cover.
Steps to reproduce
Use a Neon primary with PostgreSQL 17 and run the following in psql, with privileges to install pageinspect / pg_walinspect and execute CHECKPOINT.
This reproducer uses standard PostgreSQL extensions to checks the incorrect LSN directly. The stale-read symptom described below is timing-dependent. If the inspected FSM page is evicted and reconstructed from the pageserver before inspection, its LSN may already be corrected to the record end; this check is not guaranteed to fail on every run.
\set ON_ERROR_STOP on
CREATE EXTENSION IF NOT EXISTS pageinspect;
CREATE EXTENSION IF NOT EXISTS pg_walinspect;
CREATE TABLE fsm_wal_lsn (id int)
WITH (autovacuum_enabled = false);
SELECT pg_current_wal_insert_lsn() AS wal_start \gset
INSERT INTO fsm_wal_lsn SELECT generate_series(1, 10000);
VACUUM fsm_wal_lsn;
-- Exercise the FSM full-page-image write path.
CHECKPOINT;
SELECT pg_current_wal_insert_lsn() AS wal_end \gset
SELECT (page_header(get_raw_page('fsm_wal_lsn', 'fsm', 0))).lsn
AS page_lsn \gset
SELECT start_lsn, end_lsn,
:'page_lsn'::pg_lsn AS page_lsn,
:'page_lsn'::pg_lsn = end_lsn AS uses_record_end
FROM pg_get_wal_block_info(:'wal_start', :'wal_end', false)
WHERE relfilenode = pg_relation_filenode('fsm_wal_lsn')
AND relforknumber = 1
AND relblocknumber = 0
AND block_fpi_length > 0
ORDER BY start_lsn DESC
LIMIT 1;
DROP TABLE fsm_wal_lsn;Expected result
uses_record_end is t: the FSM page LSN should be the end of its latest full-page-image WAL record, ensuring subsequent reads include that image.
This expectation follows PostgreSQL's explicit PageHeaderData.pd_lsn definition: "next byte after last byte of xlog record for last change to this page" (bufpage.h). For Neon, there is also a read-protocol requirement: not_modified_since promises that the page has not changed between that boundary and the requested LSN. A boundary at the start of the FPI record does not cover the page version produced at its end.
Actual result
The page LSN equals the record's start, not its end:
start_lsn | end_lsn | page_lsn | uses_record_end
------------+------------+------------+-----------------
0/D4385E60 | 0/D4387EB0 | 0/D4385E60 | fWe initially found this while investigating intermittent failures of an index-space-reuse regression of pg_textsearch. In an instrumented local run, 35 index pages were recorded as free, but the next 15 allocations reused none of them and extended the index from 46 to 61 pages. At failed allocation, the FSM root reported zero available space while the intermediate and leaf pages reported 255. The WAL full-page image of that root contained 255. In standalone PostgreSQL, the full-page-image logging path correctly sets the page’s LSN to the WAL record’s end LSN (end_lsn), whereas Neon’s PG17 log_newpages_copy() returns the record’s start LSN.
The confirmed impact in this workload is missed space reuse and unnecessary relation growth.
Environment
- Reproduced on a Neon build using PostgreSQL 17.5, Linux x86-64.
- Local primary with one pageserver and one safekeeper;
shared_buffers = 1MB. The small buffer pool makes eviction/re-read timing easier to exercise.
Logs, links
The relevant code path is:
log_newpages_copy() returns ProcLastRecPtr
-> neon_wallog_pagev() sets PageLSN and updates last-written LSN
-> neon_get_request_lsns() uses it as not_modified_since
-> the read boundary can precede the full-page imagePostgreSQL sets ProcLastRecPtr = StartPos and XactLastRecEnd = EndPos after inserting a WAL record. For an image recorded in [S, E), using S as the page's last modification boundary does not ensure that a subsequent read includes the image at E. The pageserver can fix effective_lsn = S when accepting the request. Even if its subsequent WAL wait wakes after reaching E, the read still uses the previously selected S, which precedes the image.
In the stale-FSM run, the root FPI record had start_lsn = 0/CBFAB0E8 and end_lsn = 0/CBFAD138, with root value 255. The compute eviction log used the start:
Page 0 through 1 of relation 1663/75068/98309.1 were force logged, lsn=0/CBFAB0E8
Evicting page 0 of relation 1663/75068/98309.1 with lsn=0/CBFAB0E8Candidate fix tested locally:
--- a/pgxn/neon/pagestore_smgr.c
+++ b/pgxn/neon/pagestore_smgr.c
@@ -159,7 +159,7 @@
page_std);
}
- return ProcLastRecPtr;
+ return XactLastRecEnd;
}
#endif /* PG_MAJORVERSION_NUM >= 17 */
With that change, the SQL reproducer returns:
start_lsn | end_lsn | page_lsn | uses_record_end
------------+------------+------------+-----------------
0/D4295AB8 | 0/D4297B08 | 0/D4297B08 | tSource: neondatabase/neon