From b893c9e09daabae60312b68edb250acde8999d64 Mon Sep 17 00:00:00 2001 From: Ayush Tiwari Date: Sun, 2 Aug 2026 15:01:22 +0530 Subject: [PATCH] Batch fsyncs when recycling WAL segments, guarded by a timeline-qualified durability frontier The checkpointer recycles old WAL segments with durable_rename(), which fsyncs the renamed file and pg_wal once per segment, so a checkpoint that recycles many segments pays one filesystem journal flush each. Batch it instead: do all of a pass's renames and removals first, then fsync the recycled files and fsync pg_wal once. Deferring the fsync is a crash hazard, since a recycled segment can be written by the WAL path before its rename is durable and issue_xlog_fsync() never fsyncs pg_wal. XLogCtl keeps a durability frontier (InstalledDurableTLI, InstalledDurableSeg), advanced by the checkpointer after the batched fsync; XLogFileInit() consults it before handing out a segment and fsyncs the segment and pg_wal itself if it is not yet covered. The frontier is timeline-qualified so a stale value cannot mask an undurable rename after promotion. Archive-status files are dropped only after the batch is durable. An injection point and 056_wal_recycle_durability.pl exercise the crash window. --- src/backend/access/transam/xlog.c | 266 ++++++++++++++++-- .../recovery/t/056_wal_recycle_durability.pl | 120 ++++++++ 2 files changed, 363 insertions(+), 23 deletions(-) create mode 100644 src/test/recovery/t/056_wal_recycle_durability.pl diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c index f8b939853e9..2c568d1e103 100644 --- a/src/backend/access/transam/xlog.c +++ b/src/backend/access/transam/xlog.c @@ -466,6 +466,16 @@ typedef struct XLogCtlData XLogSegNo lastRemovedSegNo; /* latest removed/recycled XLOG segment */ + /* + * Directory-durability frontier: the highest WAL segment whose pg_wal + * entry is known durable, on timeline InstalledDurableTLI. Lets batched + * recycling defer fsyncs without the write path entering a segment whose + * rename is not yet flushed. Timeline-qualified so a stale value cannot + * carry across a promotion. Protected by info_lck. + */ + TimeLineID InstalledDurableTLI; + XLogSegNo InstalledDurableSeg; + /* Fake LSN counter, for unlogged relations. */ pg_atomic_uint64 unloggedLSN; @@ -708,15 +718,18 @@ static void AdvanceXLInsertBuffer(XLogRecPtr upto, TimeLineID tli, static void XLogWrite(XLogwrtRqst WriteRqst, TimeLineID tli, bool flexible); static bool InstallXLogFileSegment(XLogSegNo *segno, char *tmppath, bool find_free, XLogSegNo max_segno, - TimeLineID tli); + TimeLineID tli, bool batch); static void XLogFileClose(void); +static void AdvanceInstalledDurableSeg(TimeLineID tli, XLogSegNo segno); +static void EnsureXLogSegDirDurable(int fd, XLogSegNo segno, TimeLineID tli); static void PreallocXlogFiles(XLogRecPtr endptr, TimeLineID tli); static void RemoveTempXlogFiles(void); static void RemoveOldXlogFiles(XLogSegNo segno, XLogRecPtr lastredoptr, XLogRecPtr endptr, TimeLineID insertTLI); static void RemoveXlogFile(const struct dirent *segment_de, XLogSegNo recycleSegNo, XLogSegNo *endlogSegNo, - TimeLineID insertTLI); + TimeLineID insertTLI, bool batch, + List **recycled_paths, List **cleanup_names); static void UpdateLastRemovedPtr(char *filename); static void ValidateXLOGDirectoryStructure(void); static void CleanupBackupHistory(void); @@ -3395,7 +3408,7 @@ XLogFileInitInternal(XLogSegNo logsegno, TimeLineID logtli, */ max_segno = logsegno + CheckPointSegments; if (InstallXLogFileSegment(&installed_segno, tmppath, true, max_segno, - logtli)) + logtli, false)) { *added = true; elog(DEBUG2, "done creating and filling new WAL file"); @@ -3436,16 +3449,24 @@ XLogFileInit(XLogSegNo logsegno, TimeLineID logtli) Assert(logtli != 0); fd = XLogFileInitInternal(logsegno, logtli, &ignore_added, path); - if (fd >= 0) - return fd; - - /* Now open original target segment (might not be file I just made) */ - fd = BasicOpenFile(path, O_RDWR | PG_BINARY | O_CLOEXEC | - get_sync_bit(wal_sync_method)); if (fd < 0) - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not open file \"%s\": %m", path))); + { + /* Now open original target segment (might not be file I just made) */ + fd = BasicOpenFile(path, O_RDWR | PG_BINARY | O_CLOEXEC | + get_sync_bit(wal_sync_method)); + if (fd < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not open file \"%s\": %m", path))); + } + + /* + * About to write WAL here. If the checkpointer recycled this segment with + * a deferred fsync, make its rename durable first. Covers both the reuse + * and reopen paths above; PreallocXlogFiles() only pre-creates and calls + * XLogFileInitInternal() directly, so it does not reach here. + */ + EnsureXLogSegDirDurable(fd, logsegno, logtli); return fd; } @@ -3579,7 +3600,7 @@ XLogFileCopy(TimeLineID destTLI, XLogSegNo destsegno, /* * Now move the segment into place with its final name. */ - if (!InstallXLogFileSegment(&destsegno, tmppath, false, 0, destTLI)) + if (!InstallXLogFileSegment(&destsegno, tmppath, false, 0, destTLI, false)) elog(ERROR, "InstallXLogFileSegment should not have failed"); } @@ -3611,7 +3632,8 @@ XLogFileCopy(TimeLineID destTLI, XLogSegNo destsegno, */ static bool InstallXLogFileSegment(XLogSegNo *segno, char *tmppath, - bool find_free, XLogSegNo max_segno, TimeLineID tli) + bool find_free, XLogSegNo max_segno, TimeLineID tli, + bool batch) { char path[MAXPGPATH]; struct stat stat_buf; @@ -3649,7 +3671,26 @@ InstallXLogFileSegment(XLogSegNo *segno, char *tmppath, } Assert(access(path, F_OK) != 0 && errno == ENOENT); - if (durable_rename(tmppath, path, LOG) != 0) + + if (batch) + { + /* + * Plain rename; the caller fsyncs the renamed files and pg_wal once the + * whole pass is done, so the filesystem can coalesce the flushes. The + * pre-rename source fsync durable_rename() does is skipped: a WAL + * segment is already durable by the time it is recycled. + */ + if (rename(tmppath, path) < 0) + { + LWLockRelease(ControlFileLock); + ereport(LOG, + (errcode_for_file_access(), + errmsg("could not rename file \"%s\" to \"%s\": %m", + tmppath, path))); + return false; + } + } + else if (durable_rename(tmppath, path, LOG) != 0) { LWLockRelease(ControlFileLock); /* durable_rename already emitted log message */ @@ -3658,6 +3699,14 @@ InstallXLogFileSegment(XLogSegNo *segno, char *tmppath, LWLockRelease(ControlFileLock); + /* + * The non-batched path used durable_rename(), so the entry is durable now; + * advance the frontier. In batched mode the caller advances it after its + * bulk pg_wal fsync. + */ + if (!batch) + AdvanceInstalledDurableSeg(tli, *segno); + return true; } @@ -3872,6 +3921,103 @@ UpdateLastRemovedPtr(char *filename) SpinLockRelease(&XLogCtl->info_lck); } +/* + * Advance the directory-durability frontier to at least (tli, segno). + * Called once a segment's rename into pg_wal has been made durable (by + * durable_rename(), or by the checkpointer's batched fsync of pg_wal). + * + * The frontier is timeline-qualified: a higher timeline always supersedes the + * previous one (its segments are different files needing their own fsync), and + * within a timeline the highest segment number wins. + */ +static void +AdvanceInstalledDurableSeg(TimeLineID tli, XLogSegNo segno) +{ + SpinLockAcquire(&XLogCtl->info_lck); + if (tli > XLogCtl->InstalledDurableTLI) + { + XLogCtl->InstalledDurableTLI = tli; + XLogCtl->InstalledDurableSeg = segno; + } + else if (tli == XLogCtl->InstalledDurableTLI && + segno > XLogCtl->InstalledDurableSeg) + XLogCtl->InstalledDurableSeg = segno; + SpinLockRelease(&XLogCtl->info_lck); +} + +/* + * Ensure segment (segno, tli)'s pg_wal entry is durable before it is written. + * + * Batched recycling renames segments but defers the pg_wal fsync, so if the + * write path reaches a just-renamed segment first, its rename is not yet + * durable (issue_xlog_fsync() fsyncs only the file, never pg_wal). If the + * frontier does not already cover it, fsync the file and pg_wal here; the + * timeline-qualified check keeps a stale frontier from carrying across a + * promotion. Normally the checkpointer stays ahead and this is just a + * spinlock-protected compare. + */ +static void +EnsureXLogSegDirDurable(int fd, XLogSegNo segno, TimeLineID tli) +{ + bool durable; + + SpinLockAcquire(&XLogCtl->info_lck); + durable = (tli == XLogCtl->InstalledDurableTLI && + segno <= XLogCtl->InstalledDurableSeg); + SpinLockRelease(&XLogCtl->info_lck); + + if (durable) + return; + + /* + * fsync the file (carries the rename where we cannot fsync a directory, + * e.g. Windows) and fsync pg_wal (carries it on most Unix filesystems). + */ + if (pg_fsync(fd) != 0) + { + char path[MAXPGPATH]; + + XLogFilePath(path, tli, segno, wal_segment_size); + ereport(data_sync_elevel(ERROR), + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\": %m", path))); + } + fsync_fname(XLOGDIR, true); + + AdvanceInstalledDurableSeg(tli, segno); +} + +/* + * fsync a recycled segment file by name, tolerating concurrent removal. + * Unlike fsync_fname(), ENOENT is not an error: if another process removed + * the segment after we renamed it, its durability is no longer our concern. + */ +static void +fsync_fname_recycled(const char *fname) +{ + int fd; + + fd = BasicOpenFile(fname, O_RDWR | PG_BINARY | O_CLOEXEC); + if (fd < 0) + { + if (errno == ENOENT) + return; + ereport(data_sync_elevel(ERROR), + (errcode_for_file_access(), + errmsg("could not open file \"%s\": %m", fname))); + } + + if (pg_fsync(fd) != 0) + ereport(data_sync_elevel(ERROR), + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\": %m", fname))); + + if (close(fd) != 0) + ereport(data_sync_elevel(ERROR), + (errcode_for_file_access(), + errmsg("could not close file \"%s\": %m", fname))); +} + /* * Remove all temporary log files in pg_wal * @@ -3920,6 +4066,9 @@ RemoveOldXlogFiles(XLogSegNo segno, XLogRecPtr lastredoptr, XLogRecPtr endptr, char lastoff[MAXFNAMELEN]; XLogSegNo endlogSegNo; XLogSegNo recycleSegNo; + List *recycled_paths = NIL; + List *cleanup_names = NIL; + ListCell *lc; /* Initialize info about where to try to recycle to */ XLByteToSeg(endptr, endlogSegNo, wal_segment_size); @@ -3962,12 +4111,48 @@ RemoveOldXlogFiles(XLogSegNo segno, XLogRecPtr lastredoptr, XLogRecPtr endptr, /* Update the last removed location in shared memory first */ UpdateLastRemovedPtr(xlde->d_name); - RemoveXlogFile(xlde, recycleSegNo, &endlogSegNo, insertTLI); + RemoveXlogFile(xlde, recycleSegNo, &endlogSegNo, insertTLI, + true, &recycled_paths, &cleanup_names); } } } FreeDir(xldir); + + /* + * Test hook: pause here, after the renames but before they are made + * durable, so a test can drive the write frontier into a just-recycled + * segment and crash, exercising the write-path barrier. + */ + INJECTION_POINT("wal-recycle-before-batch-fsync", NULL); + + /* + * Make the whole pass durable at once: fsync each recycled file, then + * fsync pg_wal a single time. Renaming first and fsyncing afterwards lets + * the filesystem coalesce what used to be one journal flush per segment. + * The per-file fsyncs mostly do nothing (the contents were already durable) + * but persist the rename where we cannot fsync a directory, e.g. Windows. + * A segment concurrently removed (e.g. by promotion cleanup) is skipped. + */ + foreach(lc, recycled_paths) + fsync_fname_recycled((char *) lfirst(lc)); + + if (recycled_paths != NIL || cleanup_names != NIL) + { + fsync_fname(XLOGDIR, true); + AdvanceInstalledDurableSeg(insertTLI, endlogSegNo - 1); + } + + /* + * Now that the batch is durable, drop the old segments' archive-status + * files. Doing it earlier could, after a crash that lost a rename, leave + * an old segment whose .done marker was already gone and re-archive it. + */ + foreach(lc, cleanup_names) + XLogArchiveCleanup((char *) lfirst(lc)); + + list_free_deep(recycled_paths); + list_free_deep(cleanup_names); } /* @@ -4035,7 +4220,8 @@ RemoveNonParentXlogFiles(XLogRecPtr switchpoint, TimeLineID newTLI) * - but seems safer to let them be archived and removed later. */ if (!XLogArchiveIsReady(xlde->d_name)) - RemoveXlogFile(xlde, recycleSegNo, &endLogSegNo, newTLI); + RemoveXlogFile(xlde, recycleSegNo, &endLogSegNo, newTLI, + false, NULL, NULL); } } @@ -4058,7 +4244,8 @@ RemoveNonParentXlogFiles(XLogRecPtr switchpoint, TimeLineID newTLI) static void RemoveXlogFile(const struct dirent *segment_de, XLogSegNo recycleSegNo, XLogSegNo *endlogSegNo, - TimeLineID insertTLI) + TimeLineID insertTLI, bool batch, + List **recycled_paths, List **cleanup_names) { char path[MAXPGPATH]; #ifdef WIN32 @@ -4078,12 +4265,25 @@ RemoveXlogFile(const struct dirent *segment_de, XLogCtl->InstallXLogFileSegmentActive && /* callee rechecks this */ get_dirent_type(path, segment_de, false, DEBUG2) == PGFILETYPE_REG && InstallXLogFileSegment(endlogSegNo, path, - true, recycleSegNo, insertTLI)) + true, recycleSegNo, insertTLI, batch)) { ereport(DEBUG2, (errmsg_internal("recycled write-ahead log file \"%s\"", segname))); CheckpointStats.ckpt_segs_recycled++; + + /* + * Batched mode only renamed the segment; remember its new path so the + * caller can fsync it once all renames are done. + */ + if (recycled_paths != NULL) + { + char dstpath[MAXPGPATH]; + + XLogFilePath(dstpath, insertTLI, *endlogSegNo, wal_segment_size); + *recycled_paths = lappend(*recycled_paths, pstrdup(dstpath)); + } + /* Needn't recheck that slot on future iterations */ (*endlogSegNo)++; } @@ -4117,19 +4317,39 @@ RemoveXlogFile(const struct dirent *segment_de, path))); return; } - rc = durable_unlink(newpath, LOG); + if (batch) + rc = unlink(newpath); + else + rc = durable_unlink(newpath, LOG); #else - rc = durable_unlink(path, LOG); + if (batch) + rc = unlink(path); + else + rc = durable_unlink(path, LOG); #endif if (rc != 0) { - /* Message already logged by durable_unlink() */ + /* + * In batched mode the caller's pg_wal fsync makes the unlink durable; + * plain unlink() only sets errno, so report failures here. + */ + if (batch) + ereport(LOG, + (errcode_for_file_access(), + errmsg("could not remove file \"%s\": %m", path))); return; } CheckpointStats.ckpt_segs_removed++; } - XLogArchiveCleanup(segname); + /* + * Batched mode defers archive-status cleanup to the caller (after the + * batch fsync); unbatched mode is already durable, so clean up now. + */ + if (cleanup_names != NULL) + *cleanup_names = lappend(*cleanup_names, pstrdup(segname)); + else + XLogArchiveCleanup(segname); } /* diff --git a/src/test/recovery/t/056_wal_recycle_durability.pl b/src/test/recovery/t/056_wal_recycle_durability.pl new file mode 100644 index 00000000000..1f2fa180d04 --- /dev/null +++ b/src/test/recovery/t/056_wal_recycle_durability.pl @@ -0,0 +1,120 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group +# +# Crash-durability test for batched WAL-segment recycling. +# +# When the checkpointer recycles old WAL segments into future ones it renames +# them and defers making the renames durable to a single fsync of pg_wal at the +# end of the pass (plus a per-file fsync of each recycled segment). A recycled +# segment becomes usable by the WAL write path as soon as it is renamed, so if +# the write frontier reaches such a segment before the checkpointer's batched +# fsync, XLogFileInit() must make the rename durable itself, the write-path +# "durability barrier" (EnsureXLogSegDirDurable()). +# +# This test opens that window with an injection point placed just after the +# recycle renames but before the batched fsync, drives a committed transaction +# into a just-recycled segment, crashes the server with the window still open, +# and verifies the committed data survives crash recovery. +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +wal_recycle = on +min_wal_size = 32MB +max_wal_size = 64MB +checkpoint_timeout = 1h +log_checkpoints = on +}); +$node->start; + +# Skip if the injection_points extension is not installed, e.g. under +# installcheck where the module may not be present. +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} + +$node->safe_psql('postgres', q(CREATE EXTENSION injection_points)); + +$node->safe_psql( + 'postgres', q{ + CREATE TABLE t (id int primary key, v text); + INSERT INTO t VALUES (0, 'baseline'); + CREATE TABLE filler (id int, pad text); +}); + +# Build up a pool of recycled segments and advance the durability frontier by +# generating several segments' worth of WAL and checkpointing. +for (1 .. 8) +{ + $node->safe_psql('postgres', + q{INSERT INTO filler SELECT g, repeat('x', 900) FROM generate_series(1, 20000) g} + ); + $node->safe_psql('postgres', q{SELECT pg_switch_wal()}); +} +$node->safe_psql('postgres', q{CHECKPOINT}); + +# Generate more WAL so the next checkpoint has fresh future slots to recycle. +$node->safe_psql('postgres', + q{INSERT INTO filler SELECT g, repeat('y', 900) FROM generate_series(1, 60000) g} +); +$node->safe_psql('postgres', q{SELECT pg_switch_wal()}); + +# Start a checkpoint in the background and make it pause right after the recycle +# renames but before the batched pg_wal fsync. +my $checkpoint = $node->background_psql('postgres'); +$checkpoint->query_safe( + q{select injection_points_attach('wal-recycle-before-batch-fsync', 'wait')}); +$checkpoint->query_until( + qr/starting_checkpoint/, q(\echo starting_checkpoint +checkpoint; +\q +)); + +# Wait until the checkpointer is parked in the batch window: the recycle renames +# are done but not yet durable. +$node->wait_for_event('checkpointer', 'wal-recycle-before-batch-fsync'); + +# The window is open. Drive the WAL write frontier into the just-recycled +# segments and commit, which must trip the write-path durability barrier so the +# renames become durable even though the checkpointer's batched fsync has not +# run. +for my $j (1 .. 6) +{ + $node->safe_psql('postgres', q{SELECT pg_switch_wal()}); + $node->safe_psql('postgres', + "INSERT INTO t VALUES ($j, 'committed-in-window-$j')"); +} + +is( $node->safe_psql('postgres', q{SELECT count(*) FROM t}), + '7', 'all rows committed before crash'); + +# Crash with the window still open: the checkpointer never ran its batched +# fsync, so durability of the recycled segments rests entirely on the barrier. +$node->stop('immediate'); + +# The checkpoint session's connection died with the crash; reap it quietly. +eval { $checkpoint->quit; }; + +# Crash recovery. +$node->start; + +# Every committed row must still be present. +is( $node->safe_psql( + 'postgres', q{SELECT string_agg(v, ',' ORDER BY id) FROM t}), + 'baseline,committed-in-window-1,committed-in-window-2,committed-in-window-3,' + . 'committed-in-window-4,committed-in-window-5,committed-in-window-6', + 'committed rows survived crash with the recycle-durability window open'); + +done_testing(); -- 2.34.1