From d1cbcbd1cf3cc09a4e70f6f65ce711c0b7cc37b2 Mon Sep 17 00:00:00 2001
From: Ayush Tiwari <ayushtiwari.slg01@gmail.com>
Date: Wed, 22 Jul 2026 05:15:44 +0000
Subject: [PATCH v6] Add pg_wal_preallocate() to eagerly create future WAL
 segments

Write performance can depend heavily on whether a WAL segment is newly
created or recycled.  Creating and initializing a segment in a foreground
backend can cause latency when the pool of future segments is empty.

This adds a superuser-only SQL function

    pg_wal_preallocate(bytes bigint DEFAULT NULL) returns bigint

that samples the current WAL insertion location and pre-creates
ceil(bytes / wal_segment_size) segment files, starting with the first unused
segment at or after that location.  It returns the number of files actually
created.  If bytes is omitted or NULL, min_wal_size is used as the requested
byte count.

A call cannot request more than max_wal_size worth of WAL segment files.
The function uses the existing XLogFileInitInternal machinery, is
interruptible, and cannot run during recovery.  Concurrent WAL insertion can
advance beyond the sampled location while the function runs, so this is a
best-effort warm-up.

Reviewed-by: Ian Lawrence Barwick <barwick@gmail.com>
Reviewed-by: solai v <solai.cdac@gmail.com>
Reviewed-by: Nazir Bilal Yavuz <byavuz81@gmail.com>
Reviewed-by: Bharath Rupireddy <bharath.rupireddyforpostgres@gmail.com>
---
 doc/src/sgml/func/func-admin.sgml          | 43 ++++++++++
 src/backend/access/transam/xlog.c          | 48 +++++++++++
 src/backend/access/transam/xlogfuncs.c     | 65 +++++++++++++++
 src/include/access/xlog.h                  |  1 +
 src/include/catalog/pg_proc.dat            |  6 ++
 src/test/recovery/meson.build              |  1 +
 src/test/recovery/t/056_wal_preallocate.pl | 93 ++++++++++++++++++++++
 7 files changed, 257 insertions(+)
 create mode 100644 src/test/recovery/t/056_wal_preallocate.pl

diff --git a/doc/src/sgml/func/func-admin.sgml b/doc/src/sgml/func/func-admin.sgml
index 0eae1c1f616..390b5c6b978 100644
--- a/doc/src/sgml/func/func-admin.sgml
+++ b/doc/src/sgml/func/func-admin.sgml
@@ -514,6 +514,49 @@ LOG:  Grand total: 1651920 bytes in 201 blocks; 622360 free (88 chunks); 1029560
        </para></entry>
       </row>
 
+      <row>
+       <entry role="func_table_entry"><para role="func_signature">
+        <indexterm>
+         <primary>pg_wal_preallocate</primary>
+        </indexterm>
+        <function>pg_wal_preallocate</function> ( <parameter>bytes</parameter> <type>bigint</type> <literal>DEFAULT</literal> <literal>NULL</literal> )
+        <returnvalue>bigint</returnvalue>
+       </para>
+       <para>
+        Eagerly pre-creates
+        <literal>ceil(<parameter>bytes</parameter> / wal_segment_size)</literal>
+        write-ahead log segment files, starting with the first unused segment
+        at or after the current WAL insertion location, and returns the number
+        of files that were actually created.  Segments that already exist are
+        not recreated, so this tops up the pool of ready future segments rather
+        than adding unconditionally, and the returned count may be smaller than
+        requested, possibly zero.  If <parameter>bytes</parameter> is omitted or
+        <literal>NULL</literal>, the value of <xref linkend="guc-min-wal-size"/>
+        is used, which keeps roughly that much ready ahead of the current
+        insertion location.  This can be used to warm up the pool of future WAL
+        files before a burst of write activity, such as a benchmark run or a
+        bulk load, so that foreground WAL insertion does not have to create and
+        initialize new segment files itself.  A call cannot request more than
+        <xref linkend="guc-max-wal-size"/> worth of WAL segment files.
+       </para>
+       <para>
+        The current WAL insertion location is sampled when the function starts,
+        so concurrent WAL insertion can advance beyond that location while the
+        function runs.  Preallocated files lie ahead of that location, so a
+        checkpoint does not remove them; they are reclaimed only once WAL
+        advances into them and a later checkpoint recycles or removes them.
+        On copy-on-write file systems (see <xref linkend="guc-wal-recycle"/>),
+        preallocation is most useful when
+        <xref linkend="guc-wal-init-zero"/> is enabled, since that is when
+        creating a segment does the full initialization write.  This function
+        cannot be executed during recovery.
+       </para>
+       <para>
+        This function is restricted to superusers by default, but other users
+        can be granted EXECUTE to run the function.
+       </para></entry>
+      </row>
+
       <row>
        <entry role="func_table_entry"><para role="func_signature">
         <indexterm>
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index c3baca5193b..5fda5e25d4f 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -3761,6 +3761,54 @@ PreallocXlogFiles(XLogRecPtr endptr, TimeLineID tli)
 	}
 }
 
+/*
+ * Eagerly pre-create up to 'nsegs' future WAL segments, starting with the
+ * first unused segment at or after the current WAL insertion location, and
+ * return the number of segments that were newly created.
+ *
+ * This backs the pg_wal_preallocate() SQL function.  Unlike
+ * PreallocXlogFiles(), which lazily creates a single segment near the end of a
+ * checkpoint, this fills the future-segment pool on demand so that a
+ * subsequent burst of WAL activity does not pay the cost of creating and
+ * zero-filling segments in the foreground.  It is a best-effort warm-up,
+ * since concurrent WAL insertion can advance beyond the location sampled
+ * below while this function runs.
+ *
+ * The caller must ensure that recovery is not in progress and WAL file
+ * installation is active.  The work is interruptible, since creating many
+ * segments can take a while.
+ */
+int64
+PreallocNXlogFiles(int64 nsegs)
+{
+	XLogSegNo	segno;
+	XLogRecPtr	insertptr;
+	TimeLineID	tli;
+	int64		nsegsadded = 0;
+
+	insertptr = GetXLogInsertRecPtr();
+	tli = GetWALInsertionTimeLine();
+	XLByteToPrevSeg(insertptr, segno, wal_segment_size);
+
+	for (int64 i = 0; i < nsegs; i++)
+	{
+		bool		added;
+		char		path[MAXPGPATH];
+		int			lf;
+
+		CHECK_FOR_INTERRUPTS();
+
+		segno++;
+		lf = XLogFileInitInternal(segno, tli, &added, path);
+		if (lf >= 0)
+			close(lf);
+		if (added)
+			nsegsadded++;
+	}
+
+	return nsegsadded;
+}
+
 /*
  * Throws an error if the given log segment has already been removed or
  * recycled. The caller should only pass a segment that it knows to have
diff --git a/src/backend/access/transam/xlogfuncs.c b/src/backend/access/transam/xlogfuncs.c
index 1f52bf7b420..3a7ee6d42a1 100644
--- a/src/backend/access/transam/xlogfuncs.c
+++ b/src/backend/access/transam/xlogfuncs.c
@@ -222,6 +222,71 @@ pg_switch_wal(PG_FUNCTION_ARGS)
 	PG_RETURN_LSN(switchpoint);
 }
 
+/*
+ * pg_wal_preallocate: eagerly pre-create future WAL segments
+ *
+ * Pre-creates ceil(bytes / wal_segment_size) WAL segments, starting with the
+ * first unused segment at or after the current WAL insertion location, and
+ * returns the number of segments that were newly created.  If bytes is NULL
+ * (the default), min_wal_size is used.  This lets an operator warm up the
+ * future-segment pool before a burst of write activity, so that foreground WAL
+ * insertion does not have to create and zero-fill segments itself.  Requests
+ * larger than max_wal_size are rejected.
+ *
+ * Permission checking for this function is managed through the normal GRANT
+ * system.
+ */
+Datum
+pg_wal_preallocate(PG_FUNCTION_ARGS)
+{
+	int64		bytes;
+	int64		maxsegs;
+	int64		nsegs;
+	int64		nsegsadded;
+
+	if (RecoveryInProgress())
+		ereport(ERROR,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("recovery is in progress"),
+				 errhint("WAL control functions cannot be executed during recovery.")));
+
+	/*
+	 * When the database is not in recovery, new WAL file creation is not
+	 * blocked by the startup process, so assert that invariant here.
+	 */
+	Assert(IsInstallXLogFileSegmentActive());
+
+	if (PG_ARGISNULL(0))
+		bytes = (int64) min_wal_size_mb * 1024 * 1024;
+	else
+	{
+		bytes = PG_GETARG_INT64(0);
+
+		if (bytes < 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
+					 errmsg("number of bytes to preallocate must not be negative")));
+	}
+
+	/* Round up to a whole number of segments (overflow-safe). */
+	nsegs = bytes / wal_segment_size;
+	if (bytes % wal_segment_size != 0)
+		nsegs++;
+
+	/* Do not silently carry out a smaller request than was asked for. */
+	maxsegs = XLogMBVarToSegs(max_wal_size_mb, wal_segment_size);
+	if (nsegs > maxsegs)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("WAL preallocation request exceeds \"max_wal_size\""),
+				 errdetail("The request needs %lld WAL segments, but \"max_wal_size\" allows %lld.",
+						   (long long) nsegs, (long long) maxsegs)));
+
+	nsegsadded = PreallocNXlogFiles(nsegs);
+
+	PG_RETURN_INT64(nsegsadded);
+}
+
 /*
  * pg_log_standby_snapshot: call LogStandbySnapshot()
  *
diff --git a/src/include/access/xlog.h b/src/include/access/xlog.h
index 338d68d7424..e605eefcd7f 100644
--- a/src/include/access/xlog.h
+++ b/src/include/access/xlog.h
@@ -234,6 +234,7 @@ extern bool XLogBackgroundFlush(void);
 extern bool XLogNeedsFlush(XLogRecPtr record);
 extern int	XLogFileInit(XLogSegNo logsegno, TimeLineID logtli);
 extern int	XLogFileOpen(XLogSegNo segno, TimeLineID tli);
+extern int64 PreallocNXlogFiles(int64 nsegs);
 
 extern void CheckXLogRemoved(XLogSegNo segno, TimeLineID tli);
 extern XLogSegNo XLogGetLastRemovedSegno(void);
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 66c3c9a04cf..b0fc0cd9ff7 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -6870,6 +6870,12 @@
 { oid => '2848', descr => 'switch to new wal file',
   proname => 'pg_switch_wal', provolatile => 'v', prorettype => 'pg_lsn',
   proargtypes => '', prosrc => 'pg_switch_wal', proacl => '{POSTGRES=X}' },
+{ oid => '9888',
+  descr => 'preallocate future WAL files, return number created',
+  proname => 'pg_wal_preallocate', provolatile => 'v', proisstrict => 'f',
+    prorettype => 'int8', proargtypes => 'int8', proargnames => '{bytes}',
+    proargdefaults => '{NULL}',
+  prosrc => 'pg_wal_preallocate', proacl => '{POSTGRES=X}' },
 { oid => '6305', descr => 'log details of the current snapshot to WAL',
   proname => 'pg_log_standby_snapshot', provolatile => 'v',
   prorettype => 'pg_lsn', proargtypes => '',
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index 39ec8c4946d..61dcadd268d 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -64,6 +64,7 @@ tests += {
       't/053_standby_login_event_trigger.pl',
       't/054_unlogged_sequence_promotion.pl',
       't/055_cascade_reconnect.pl',
+      't/056_wal_preallocate.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/056_wal_preallocate.pl b/src/test/recovery/t/056_wal_preallocate.pl
new file mode 100644
index 00000000000..483a1a298c9
--- /dev/null
+++ b/src/test/recovery/t/056_wal_preallocate.pl
@@ -0,0 +1,93 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test pg_wal_preallocate().
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Return regular WAL segment files (24 hex digits) in a node's pg_wal.
+sub get_segments
+{
+	my $node = shift;
+	my $waldir = $node->data_dir . '/pg_wal';
+	opendir(my $dh, $waldir) or die "could not open $waldir: $!";
+	my @segs = grep { /^[0-9A-F]{24}$/ } readdir($dh);
+	closedir($dh);
+	return sort @segs;
+}
+
+sub count_segments
+{
+	my @segments = get_segments(shift);
+	return scalar @segments;
+}
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init(allows_streaming => 1, extra => ['--wal-segsize=1']);
+$node->append_conf(
+	'postgresql.conf', q{
+autovacuum = off
+checkpoint_timeout = 1h
+min_wal_size = 2MB
+max_wal_size = 1GB
+wal_recycle = off
+});
+$node->start;
+
+my $segsize = $node->safe_psql('postgres',
+	"SELECT pg_size_bytes(current_setting('wal_segment_size'))");
+
+# Exercise the insertion position immediately after a segment switch.
+$node->safe_psql('postgres', q{
+CHECKPOINT;
+SELECT pg_switch_wal();
+SELECT pg_create_restore_point('preallocation boundary test');
+});
+my $current_segment = $node->safe_psql(
+	'postgres', "SELECT pg_walfile_name(pg_current_wal_insert_lsn())");
+my @segments = get_segments($node);
+my $last_existing_segment = $segments[-1];
+
+# Consume future files left by initdb without recycling replacements.
+while ($current_segment lt $last_existing_segment)
+{
+	$node->safe_psql('postgres', q{
+SELECT pg_switch_wal();
+SELECT pg_create_restore_point('preallocation boundary test');
+});
+	$current_segment = $node->safe_psql(
+		'postgres', "SELECT pg_walfile_name(pg_current_wal_insert_lsn())");
+}
+
+@segments = get_segments($node);
+
+my $last_segment = $node->safe_psql(
+	'postgres',
+	"SELECT pg_walfile_name(pg_current_wal_insert_lsn() + (8 * $segsize)::bigint)");
+my $last_path = $node->data_dir . "/pg_wal/$last_segment";
+
+my $before = scalar @segments;
+my $created =
+  $node->safe_psql('postgres', "SELECT pg_wal_preallocate(8 * $segsize)");
+is($created, 8, 'preallocation starts after the exact insertion segment');
+is(count_segments($node), $before + $created,
+	'pg_wal grew by the reported number of segments');
+ok(-f $last_path, 'last requested future segment exists');
+is($node->safe_psql('postgres', "SELECT pg_wal_preallocate(8 * $segsize)"),
+	0, 'existing files are not recreated');
+is($node->safe_psql('postgres', 'SELECT pg_wal_preallocate()'),
+	0, 'omitted size uses min_wal_size');
+
+my $limit_before = count_segments($node);
+my ($ret, $stdout, $stderr) = $node->psql(
+	'postgres', "SELECT pg_wal_preallocate(1025 * $segsize)");
+isnt($ret, 0, 'request above max_wal_size is rejected');
+like($stderr, qr/exceeds "max_wal_size"/, 'error reports max_wal_size');
+is(count_segments($node), $limit_before, 'rejected request creates nothing');
+
+$node->stop;
+
+done_testing();
-- 
2.43.0

