0001-Add-support-for-multi-file-archiving-in-archive-modu.patch

text/x-patch

Filename: 0001-Add-support-for-multi-file-archiving-in-archive-modu.patch
Type: text/x-patch
Part: 0
Message: Making WAL archiving faster — multi-file support and async ideas

Patch

Same data as JSON: GET /api/v1/attachments/:id/patch the parsed metadata as JSON — format, series position, per-file stats; never the diff bytes. API reference →
Format: format-patch
Series: patch 0001
Subject: Add support for multi-file archiving in archive modules
File+
contrib/basic_archive/basic_archive.c 22 0
src/backend/archive/shell_archive.c 110 49
src/backend/postmaster/pgarch.c 203 97
src/backend/utils/misc/guc_tables.c 9 0
src/include/archive/archive_module.h 18 0
src/test/perl/PostgreSQL/Test/Cluster.pm 33 14
src/test/recovery/t/002_archiving.pl 1 0
From 08104dcdd3295a48827e1d58c4c2382620267f5d Mon Sep 17 00:00:00 2001
From: Stepan Neretin <slpmcf@gmail.com>
Date: Mon, 21 Jul 2025 14:13:51 +0700
Subject: [PATCH] Add support for multi-file archiving in archive modules

This patch introduces optional support for archiving multiple WAL files
at once via a new archive_files_cb callback in archive modules.
A new GUC archive_multi enables this behavior.
The shell and basic archive modules have been updated to support the new callback.
---
 contrib/basic_archive/basic_archive.c    |  22 ++
 src/backend/archive/shell_archive.c      | 159 ++++++++----
 src/backend/postmaster/pgarch.c          | 300 +++++++++++++++--------
 src/backend/utils/misc/guc_tables.c      |   9 +
 src/include/archive/archive_module.h     |  18 ++
 src/test/perl/PostgreSQL/Test/Cluster.pm |  47 ++--
 src/test/recovery/t/002_archiving.pl     |   1 +
 7 files changed, 396 insertions(+), 160 deletions(-)

diff --git a/contrib/basic_archive/basic_archive.c b/contrib/basic_archive/basic_archive.c
index 4a8b8c7ac29..0f009e510ec 100644
--- a/contrib/basic_archive/basic_archive.c
+++ b/contrib/basic_archive/basic_archive.c
@@ -46,6 +46,7 @@ static char *archive_directory = NULL;
 
 static bool basic_archive_configured(ArchiveModuleState *state);
 static bool basic_archive_file(ArchiveModuleState *state, const char *file, const char *path);
+static bool basic_archive_files(ArchiveModuleState *state, char **files, char **paths, int nfiles);
 static bool check_archive_directory(char **newval, void **extra, GucSource source);
 static bool compare_files(const char *file1, const char *file2);
 
@@ -53,6 +54,7 @@ static const ArchiveModuleCallbacks basic_archive_callbacks = {
 	.startup_cb = NULL,
 	.check_configured_cb = basic_archive_configured,
 	.archive_file_cb = basic_archive_file,
+	.archive_files_cb = basic_archive_files,
 	.shutdown_cb = NULL
 };
 
@@ -229,6 +231,26 @@ basic_archive_file(ArchiveModuleState *state, const char *file, const char *path
 	return true;
 }
 
+/*
++ * Archive multiple files.
++ */
+static bool
+basic_archive_files(ArchiveModuleState *state, char **files, char **paths, int nfiles)
+{
+	bool result = true;
+
+	for (int i = 0; i < nfiles; i++)
+	{
+		if (!basic_archive_file(state, files[i], paths[i]))
+		{
+			ereport(WARNING, (errmsg("failed to archive file \"%s\"", files[i])));
+			result = false;
+		}
+	}
+
+	return result;
+}
+
 /*
  * compare_files
  *
diff --git a/src/backend/archive/shell_archive.c b/src/backend/archive/shell_archive.c
index 828723afe47..9e8bf95c6c4 100644
--- a/src/backend/archive/shell_archive.c
+++ b/src/backend/archive/shell_archive.c
@@ -27,12 +27,17 @@ static bool shell_archive_configured(ArchiveModuleState *state);
 static bool shell_archive_file(ArchiveModuleState *state,
 							   const char *file,
 							   const char *path);
+static bool shell_archive_files(ArchiveModuleState *state,
+							   char **files,
+							   char **paths,
+							   int nfiles);
 static void shell_archive_shutdown(ArchiveModuleState *state);
 
 static const ArchiveModuleCallbacks shell_archive_callbacks = {
 	.startup_cb = NULL,
 	.check_configured_cb = shell_archive_configured,
 	.archive_file_cb = shell_archive_file,
+	.archive_files_cb = shell_archive_files,
 	.shutdown_cb = shell_archive_shutdown
 };
 
@@ -54,26 +59,10 @@ shell_archive_configured(ArchiveModuleState *state)
 }
 
 static bool
-shell_archive_file(ArchiveModuleState *state, const char *file,
-				   const char *path)
+run_archive_command(const char *xlogarchcmd, const char *context_info, int nfiles)
 {
-	char	   *xlogarchcmd;
-	char	   *nativePath = NULL;
-	int			rc;
-
-	if (path)
-	{
-		nativePath = pstrdup(path);
-		make_native_path(nativePath);
-	}
-
-	xlogarchcmd = replace_percent_placeholders(XLogArchiveCommand,
-											   "archive_command", "fp",
-											   file, nativePath);
-
-	ereport(DEBUG3,
-			(errmsg_internal("executing archive command \"%s\"",
-							 xlogarchcmd)));
+	int rc;
+	TimestampTz start_time = GetCurrentTimestamp();
 
 	fflush(NULL);
 	pgstat_report_wait_start(WAIT_EVENT_ARCHIVE_COMMAND);
@@ -82,59 +71,131 @@ shell_archive_file(ArchiveModuleState *state, const char *file,
 
 	if (rc != 0)
 	{
-		/*
-		 * If either the shell itself, or a called command, died on a signal,
-		 * abort the archiver.  We do this because system() ignores SIGINT and
-		 * SIGQUIT while waiting; so a signal is very likely something that
-		 * should have interrupted us too.  Also die if the shell got a hard
-		 * "command not found" type of error.  If we overreact it's no big
-		 * deal, the postmaster will just start the archiver again.
-		 */
-		int			lev = wait_result_is_any_signal(rc, true) ? FATAL : LOG;
+		int lev = wait_result_is_any_signal(rc, true) ? FATAL : LOG;
 
 		if (WIFEXITED(rc))
 		{
 			ereport(lev,
-					(errmsg("archive command failed with exit code %d",
-							WEXITSTATUS(rc)),
-					 errdetail("The failed archive command was: %s",
-							   xlogarchcmd)));
+				(errmsg("archive command failed with exit code %d", WEXITSTATUS(rc)),
+				 errdetail("The failed archive command was: %s", xlogarchcmd),
+				 context_info ? errhint("%s", context_info) : 0));
 		}
 		else if (WIFSIGNALED(rc))
 		{
 #if defined(WIN32)
 			ereport(lev,
-					(errmsg("archive command was terminated by exception 0x%X",
-							WTERMSIG(rc)),
-					 errhint("See C include file \"ntstatus.h\" for a description of the hexadecimal value."),
-					 errdetail("The failed archive command was: %s",
-							   xlogarchcmd)));
+				(errmsg("archive command was terminated by exception 0x%X", WTERMSIG(rc)),
+				 errhint("See C include file \"ntstatus.h\" for a description of the hexadecimal value."),
+				 errdetail("The failed archive command was: %s", xlogarchcmd),
+				 context_info ? errhint("%s", context_info) : 0));
 #else
 			ereport(lev,
-					(errmsg("archive command was terminated by signal %d: %s",
-							WTERMSIG(rc), pg_strsignal(WTERMSIG(rc))),
-					 errdetail("The failed archive command was: %s",
-							   xlogarchcmd)));
+				(errmsg("archive command was terminated by signal %d: %s",
+						WTERMSIG(rc), pg_strsignal(WTERMSIG(rc))),
+				 errdetail("The failed archive command was: %s", xlogarchcmd),
+				 context_info ? errhint("%s", context_info) : 0));
 #endif
 		}
 		else
 		{
 			ereport(lev,
-					(errmsg("archive command exited with unrecognized status %d",
-							rc),
-					 errdetail("The failed archive command was: %s",
-							   xlogarchcmd)));
+				(errmsg("archive command exited with unrecognized status %d", rc),
+				 errdetail("The failed archive command was: %s", xlogarchcmd),
+				 context_info ? errhint("%s", context_info) : 0));
 		}
-		pfree(xlogarchcmd);
-
 		return false;
 	}
-	pfree(xlogarchcmd);
 
-	elog(DEBUG1, "archived write-ahead log file \"%s\"", file);
 	return true;
 }
 
+static bool
+shell_archive_file(ArchiveModuleState *state, const char *file, const char *path)
+{
+	char	   *xlogarchcmd;
+	char	   *nativePath = NULL;
+	bool	    success;
+
+	if (path)
+	{
+		nativePath = pstrdup(path);
+		make_native_path(nativePath);
+	}
+
+	xlogarchcmd = replace_percent_placeholders(XLogArchiveCommand,
+												"archive_command", "fp",
+												file, nativePath);
+
+	ereport(DEBUG3,
+			(errmsg_internal("executing archive command \"%s\"", xlogarchcmd)));
+
+	success = run_archive_command(xlogarchcmd, file, 1);
+
+	if (success)
+		elog(DEBUG1, "archived write-ahead log file \"%s\"", file);
+
+	pfree(xlogarchcmd);
+	if (nativePath)
+		pfree(nativePath);
+
+	return success;
+}
+
+
+static bool
+shell_archive_files(ArchiveModuleState *state, char **files, char **paths, int nfiles)
+{
+	StringInfoData filebuf;
+	StringInfoData pathbuf;
+	char nfiles_str[16];
+	char *xlogarchcmd;
+	bool  success;
+
+	initStringInfo(&filebuf);
+	for (int i = 0; i < nfiles; i++)
+	{
+		if (i > 0)
+			appendStringInfoChar(&filebuf, ' ');
+		appendStringInfoString(&filebuf, files[i]);
+	}
+
+	initStringInfo(&pathbuf);
+	for (int i = 0; i < nfiles; i++)
+	{
+		char *nativePath = pstrdup(paths[i]);
+		make_native_path(nativePath);
+
+		if (i > 0)
+			appendStringInfoChar(&pathbuf, ' ');
+		appendStringInfoString(&pathbuf, nativePath);
+
+		pfree(nativePath);
+	}
+
+	snprintf(nfiles_str, sizeof(nfiles_str), "%d", nfiles);
+
+	xlogarchcmd = replace_percent_placeholders(XLogArchiveCommand,
+											   "archive_command", "FPN",
+											   filebuf.data, pathbuf.data, nfiles_str);
+
+	ereport(DEBUG3,
+			(errmsg_internal("executing multi-file archive command: %s", xlogarchcmd)));
+
+	success = run_archive_command(xlogarchcmd, filebuf.data, nfiles);
+
+	if (success)
+	{
+		for (int i = 0; i < nfiles; i++)
+			elog(DEBUG1, "archived write-ahead log file \"%s\"", files[i]);
+	}
+
+	pfree(xlogarchcmd);
+	pfree(filebuf.data);
+	pfree(pathbuf.data);
+
+	return success;
+}
+
 static void
 shell_archive_shutdown(ArchiveModuleState *state)
 {
diff --git a/src/backend/postmaster/pgarch.c b/src/backend/postmaster/pgarch.c
index 78e39e5f866..93c3677e25f 100644
--- a/src/backend/postmaster/pgarch.c
+++ b/src/backend/postmaster/pgarch.c
@@ -93,6 +93,7 @@ typedef struct PgArchData
 } PgArchData;
 
 char	   *XLogArchiveLibrary = "";
+bool	    XLogArchiveMulti = false;
 char	   *arch_module_check_errdetail_string;
 
 
@@ -144,6 +145,7 @@ static volatile sig_atomic_t ready_to_stop = false;
 static void pgarch_waken_stop(SIGNAL_ARGS);
 static void pgarch_MainLoop(void);
 static void pgarch_ArchiverCopyLoop(void);
+static void pgarch_ArchiverCopyLoopMulti(void);
 static bool pgarch_archiveXlog(char *xlog);
 static bool pgarch_readyXlog(char *xlog);
 static void pgarch_archiveDone(char *xlog);
@@ -346,7 +348,10 @@ pgarch_MainLoop(void)
 		}
 
 		/* Do what we're here for */
-		pgarch_ArchiverCopyLoop();
+		if (XLogArchiveMulti)
+			pgarch_ArchiverCopyLoopMulti();
+		else
+			pgarch_ArchiverCopyLoop();
 
 		/*
 		 * Sleep until a signal is received, or until a poll is forced by
@@ -372,6 +377,167 @@ pgarch_MainLoop(void)
 	} while (!time_to_stop);
 }
 
+typedef bool (*ArchiveCallbackFn)(void *arg);
+
+static bool
+pgarch_safe_callback(ArchiveCallbackFn fn, void *arg)
+{
+	sigjmp_buf local_sigjmp_buf;
+	MemoryContext oldcontext;
+	bool ret;
+
+	oldcontext = MemoryContextSwitchTo(archive_context);
+
+	if (sigsetjmp(local_sigjmp_buf, 1) != 0)
+	{
+		error_context_stack = NULL;
+		HOLD_INTERRUPTS();
+		EmitErrorReport();
+		disable_all_timeouts(false);
+		LWLockReleaseAll();
+		ConditionVariableCancelSleep();
+		pgstat_report_wait_end();
+		ReleaseAuxProcessResources(false);
+		AtEOXact_Files(false);
+		AtEOXact_HashTables(false);
+
+		MemoryContextSwitchTo(oldcontext);
+		FlushErrorState();
+		MemoryContextReset(archive_context);
+
+		PG_exception_stack = NULL;
+		RESUME_INTERRUPTS();
+
+		ret = false;
+	}
+	else
+	{
+		PG_exception_stack = &local_sigjmp_buf;
+		ret = fn(arg);
+		PG_exception_stack = NULL;
+
+		MemoryContextSwitchTo(oldcontext);
+		MemoryContextReset(archive_context);
+	}
+
+	return ret;
+}
+
+typedef struct {
+	char **filenames;
+	char **paths;
+	int    n;
+} ArchiveFilesArg;
+
+static bool
+archive_files_wrapper(void *arg)
+{
+	ArchiveFilesArg *a = (ArchiveFilesArg *) arg;
+	return ArchiveCallbacks->archive_files_cb(archive_module_state,
+											  a->filenames,
+											  a->paths,
+											  a->n);
+}
+
+/*
+ * pgarch_ArchiverCopyLoopMulti
+ *
+ * Archives multiple outstanding WAL files in one batch using archive_files_cb.
+ */
+static void
+pgarch_ArchiverCopyLoopMulti(void)
+{
+	char *filenames[NUM_FILES_PER_DIRECTORY_SCAN];
+	char *paths[NUM_FILES_PER_DIRECTORY_SCAN];
+	int   n;
+	bool  ret;
+	ArchiveFilesArg arg;
+
+
+	elog(DEBUG1, "Starting archiver copy(multi)");
+
+	Assert(XLogArchiveMulti);
+	Assert(ArchiveCallbacks->archive_files_cb != NULL);
+
+	while (1)
+	{
+		PgArchForceDirScan();
+		pgarch_readyXlog(NULL);
+		n = 0;
+
+		for (int i = 0; i < arch_files->arch_files_size; i++)
+		{
+			char	   *arch_file = arch_files->arch_files[i];
+			char		xlogpath[MAXPGPATH];
+			struct stat stat_buf;
+
+			snprintf(xlogpath, MAXPGPATH, XLOGDIR "/%s", arch_file);
+
+			if (stat(xlogpath, &stat_buf) != 0 && errno == ENOENT)
+			{
+				char xlogready[MAXPGPATH];
+
+				StatusFilePath(xlogready, arch_file, ".ready");
+				if (unlink(xlogready) == 0)
+				{
+					ereport(WARNING,
+							(errmsg("removed orphan archive status file \"%s\"", xlogready)));
+				}
+				else
+				{
+					ereport(WARNING,
+							(errmsg("could not remove orphan archive status file \"%s\": %m", xlogready)));
+				}
+				continue;
+			}
+
+			filenames[n] = arch_file;
+			paths[n] = psprintf(XLOGDIR "/%s", arch_file);
+			n++;
+		}
+
+		if (n == 0)
+			return;
+
+		if (ShutdownRequestPending || !PostmasterIsAlive())
+			return;
+
+		ProcessPgArchInterrupts();
+
+		if (ArchiveCallbacks->check_configured_cb != NULL &&
+			!ArchiveCallbacks->check_configured_cb(archive_module_state))
+		{
+			ereport(WARNING,
+					(errmsg("\"archive_mode\" enabled, yet archiving is not configured"),
+					 arch_module_check_errdetail_string ?
+					 errdetail_internal("%s", arch_module_check_errdetail_string) : 0));
+			return;
+		}
+
+		arg.filenames = filenames;
+		arg.paths = paths;
+		arg.n = n;
+
+		ret = pgarch_safe_callback(archive_files_wrapper, &arg);
+
+		if (!ret)
+		{
+			for (int i = 0; i < n; i++)
+				pgstat_report_archiver(filenames[i], 0);
+
+			ereport(WARNING,
+					(errmsg("archiving multiple WAL files failed")));
+			return;
+		}
+
+		for (int i = 0; i < n; i++)
+		{
+			pgarch_archiveDone(filenames[i]);
+			pgstat_report_archiver(filenames[i], 1);
+		}
+	}
+}
+
 /*
  * pgarch_ArchiverCopyLoop
  *
@@ -506,9 +672,19 @@ pgarch_ArchiverCopyLoop(void)
 	}
 }
 
+typedef struct {
+	const char *xlog;
+	const char *pathname;
+} ArchiveXlogArg;
+
+static bool
+archive_file_wrapper(void *arg)
+{
+	ArchiveXlogArg *a = (ArchiveXlogArg *) arg;
+	return ArchiveCallbacks->archive_file_cb(archive_module_state, a->xlog, a->pathname);
+}
+
 /*
- * pgarch_archiveXlog
- *
  * Invokes archive_file_cb to copy one archive file to wherever it should go
  *
  * Returns true if successful
@@ -516,104 +692,22 @@ pgarch_ArchiverCopyLoop(void)
 static bool
 pgarch_archiveXlog(char *xlog)
 {
-	sigjmp_buf	local_sigjmp_buf;
-	MemoryContext oldcontext;
-	char		pathname[MAXPGPATH];
-	char		activitymsg[MAXFNAMELEN + 16];
-	bool		ret;
+	char pathname[MAXPGPATH];
+	char activitymsg[MAXFNAMELEN + 16];
+	ArchiveXlogArg arg;
+	bool ret;
 
 	snprintf(pathname, MAXPGPATH, XLOGDIR "/%s", xlog);
-
-	/* Report archive activity in PS display */
 	snprintf(activitymsg, sizeof(activitymsg), "archiving %s", xlog);
 	set_ps_display(activitymsg);
 
-	oldcontext = MemoryContextSwitchTo(archive_context);
+	arg.xlog = xlog;
+	arg.pathname = pathname;
 
-	/*
-	 * Since the archiver operates at the bottom of the exception stack,
-	 * ERRORs turn into FATALs and cause the archiver process to restart.
-	 * However, using ereport(ERROR, ...) when there are problems is easy to
-	 * code and maintain.  Therefore, we create our own exception handler to
-	 * catch ERRORs and return false instead of restarting the archiver
-	 * whenever there is a failure.
-	 *
-	 * We assume ERRORs from the archiving callback are the most common
-	 * exceptions experienced by the archiver, so we opt to handle exceptions
-	 * here instead of PgArchiverMain() to avoid reinitializing the archiver
-	 * too frequently.  We could instead add a sigsetjmp() block to
-	 * PgArchiverMain() and use PG_TRY/PG_CATCH here, but the extra code to
-	 * avoid the odd archiver restart doesn't seem worth it.
-	 */
-	if (sigsetjmp(local_sigjmp_buf, 1) != 0)
-	{
-		/* Since not using PG_TRY, must reset error stack by hand */
-		error_context_stack = NULL;
+	ret = pgarch_safe_callback(archive_file_wrapper, &arg);
 
-		/* Prevent interrupts while cleaning up */
-		HOLD_INTERRUPTS();
-
-		/* Report the error to the server log. */
-		EmitErrorReport();
-
-		/*
-		 * Try to clean up anything the archive module left behind.  We try to
-		 * cover anything that an archive module could conceivably have left
-		 * behind, but it is of course possible that modules could be doing
-		 * unexpected things that require additional cleanup.  Module authors
-		 * should be sure to do any extra required cleanup in a PG_CATCH block
-		 * within the archiving callback, and they are encouraged to notify
-		 * the pgsql-hackers mailing list so that we can add it here.
-		 */
-		disable_all_timeouts(false);
-		LWLockReleaseAll();
-		ConditionVariableCancelSleep();
-		pgstat_report_wait_end();
-		pgaio_error_cleanup();
-		ReleaseAuxProcessResources(false);
-		AtEOXact_Files(false);
-		AtEOXact_HashTables(false);
-
-		/*
-		 * Return to the original memory context and clear ErrorContext for
-		 * next time.
-		 */
-		MemoryContextSwitchTo(oldcontext);
-		FlushErrorState();
-
-		/* Flush any leaked data */
-		MemoryContextReset(archive_context);
-
-		/* Remove our exception handler */
-		PG_exception_stack = NULL;
-
-		/* Now we can allow interrupts again */
-		RESUME_INTERRUPTS();
-
-		/* Report failure so that the archiver retries this file */
-		ret = false;
-	}
-	else
-	{
-		/* Enable our exception handler */
-		PG_exception_stack = &local_sigjmp_buf;
-
-		/* Archive the file! */
-		ret = ArchiveCallbacks->archive_file_cb(archive_module_state,
-												xlog, pathname);
-
-		/* Remove our exception handler */
-		PG_exception_stack = NULL;
-
-		/* Reset our memory context and switch back to the original one */
-		MemoryContextSwitchTo(oldcontext);
-		MemoryContextReset(archive_context);
-	}
-
-	if (ret)
-		snprintf(activitymsg, sizeof(activitymsg), "last was %s", xlog);
-	else
-		snprintf(activitymsg, sizeof(activitymsg), "failed on %s", xlog);
+	snprintf(activitymsg, sizeof(activitymsg),
+			 ret ? "last was %s" : "failed on %s", xlog);
 	set_ps_display(activitymsg);
 
 	return ret;
@@ -673,7 +767,10 @@ pgarch_readyXlog(char *xlog)
 
 		if (stat(status_file, &st) == 0)
 		{
-			strcpy(xlog, arch_file);
+			if (xlog)
+				strcpy(xlog, arch_file);
+			else
+				arch_files->arch_files_size++;
 			return true;
 		}
 		else if (errno != ENOENT)
@@ -763,8 +860,11 @@ pgarch_readyXlog(char *xlog)
 		arch_files->arch_files[i] = DatumGetCString(binaryheap_remove_first(arch_files->arch_heap));
 
 	/* Return the highest priority file. */
-	arch_files->arch_files_size--;
-	strcpy(xlog, arch_files->arch_files[arch_files->arch_files_size]);
+	if (xlog)
+	{
+		arch_files->arch_files_size--;
+		strcpy(xlog, arch_files->arch_files[arch_files->arch_files_size]);
+	}
 
 	return true;
 }
@@ -937,6 +1037,12 @@ LoadArchiveLibrary(void)
 
 	ArchiveCallbacks = (*archive_init) ();
 
+	if (XLogArchiveMulti && !ArchiveCallbacks->archive_files_cb)
+	ereport(ERROR,
+			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+			errmsg("archive module does not support multi-file archiving"),
+			errdetail("The parameter \"archive_multi\" is enabled, but the archive module does not define archive_files_cb.")));
+
 	if (ArchiveCallbacks->archive_file_cb == NULL)
 		ereport(ERROR,
 				(errmsg("archive modules must register an archive callback")));
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index d14b1678e7f..9e6630a4145 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -799,6 +799,15 @@ struct config_bool ConfigureNamesBool[] =
 		true,
 		NULL, NULL, NULL
 	},
+	{
+		{"archive_multi", PGC_SIGHUP, WAL_ARCHIVING,
+			gettext_noop("Enables support for archiving multiple WAL files at once."),
+			NULL
+		},
+		&XLogArchiveMulti,
+		false,
+		NULL, NULL, NULL
+	},
 	{
 		{"enable_indexscan", PGC_USERSET, QUERY_TUNING_METHOD,
 			gettext_noop("Enables the planner's use of index-scan plans."),
diff --git a/src/include/archive/archive_module.h b/src/include/archive/archive_module.h
index 3b17ca7eeca..b31282ba67f 100644
--- a/src/include/archive/archive_module.h
+++ b/src/include/archive/archive_module.h
@@ -16,6 +16,7 @@
  * The value of the archive_library GUC.
  */
 extern PGDLLIMPORT char *XLogArchiveLibrary;
+extern PGDLLIMPORT bool XLogArchiveMulti;
 
 typedef struct ArchiveModuleState
 {
@@ -40,11 +41,28 @@ typedef bool (*ArchiveCheckConfiguredCB) (ArchiveModuleState *state);
 typedef bool (*ArchiveFileCB) (ArchiveModuleState *state, const char *file, const char *path);
 typedef void (*ArchiveShutdownCB) (ArchiveModuleState *state);
 
+
+/*
+ * Optional callback for multi-file archiving.
+ *
+ * Called when multiple WAL segments should be archived at once.
+ * The `files` and `paths` arrays must have `n_files` elements each.
+ *
+ * Return true if all files were archived successfully.
+ */
+typedef bool (*ArchiveFilesCB) (ArchiveModuleState *state,
+								char **files,
+								char **paths,
+								int n_files);
+
+typedef void (*ArchiveShutdownCB) (ArchiveModuleState *state);
+
 typedef struct ArchiveModuleCallbacks
 {
 	ArchiveStartupCB startup_cb;
 	ArchiveCheckConfiguredCB check_configured_cb;
 	ArchiveFileCB archive_file_cb;
+	ArchiveFilesCB archive_files_cb; /* optional */
 	ArchiveShutdownCB shutdown_cb;
 } ArchiveModuleCallbacks;
 
diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm
index 61f68e0cc2e..ac24bb296b4 100644
--- a/src/test/perl/PostgreSQL/Test/Cluster.pm
+++ b/src/test/perl/PostgreSQL/Test/Cluster.pm
@@ -637,6 +637,7 @@ sub init
 	$params{allows_streaming} = 0 unless defined $params{allows_streaming};
 	$params{force_initdb} = 0 unless defined $params{force_initdb};
 	$params{has_archiving} = 0 unless defined $params{has_archiving};
+	$params{is_multi} = 0 unless defined $params{is_multi};
 
 	my $initdb_extra_opts_env = $ENV{PG_TEST_INITDB_EXTRA_OPTS};
 	if (defined $initdb_extra_opts_env)
@@ -766,7 +767,7 @@ sub init
 	  or die("unable to set permissions for $pgdata/postgresql.conf");
 
 	$self->set_replication_conf if $params{allows_streaming};
-	$self->enable_archiving if $params{has_archiving};
+	$self->enable_archiving(is_multi => $params{is_multi}) if $params{has_archiving};
 	return;
 }
 
@@ -1481,30 +1482,48 @@ sub set_standby_mode
 # Internal routine to enable archiving
 sub enable_archiving
 {
-	my ($self) = @_;
+	my ($self, %opts) = @_;
+	my $is_multi = $opts{is_multi} // 0;
 	my $path = $self->archive_dir;
 	my $name = $self->name;
 
 	print "### Enabling WAL archiving for node \"$name\"\n";
 
-	# On Windows, the path specified in the restore command needs to use
-	# double back-slashes to work properly and to be able to detect properly
-	# the file targeted by the copy command, so the directory value used
-	# in this routine, using only one back-slash, need to be properly changed
-	# first. Paths also need to be double-quoted to prevent failures where
-	# the path contains spaces.
-	$path =~ s{\\}{\\\\}g if ($PostgreSQL::Test::Utils::windows_os);
-	my $copy_command =
-	  $PostgreSQL::Test::Utils::windows_os
-	  ? qq{copy "%p" "$path\\\\%f"}
-	  : qq{cp "%p" "$path/%f"};
+	# On Windows, adjust slashes and quoting
+	if ($PostgreSQL::Test::Utils::windows_os)
+	{
+		$path =~ s{\\}{\\\\}g;
+	}
 
-	# Enable archive_mode and archive_command on node
+	my ($copy_command);
+
+	if ($PostgreSQL::Test::Utils::windows_os)
+	{
+		if ($is_multi) {
+			$copy_command = qq{copy %P "$path"};
+		} else {
+			$copy_command = qq{copy %p "$path\\\\%f"};
+		}
+	}
+	else
+	{
+		if ($is_multi) {
+			$copy_command = qq{cp %P "$path"};
+		} else {
+			$copy_command = qq{cp %p "$path/%f"};
+		}
+	}
+
+	my $archive_multi_conf = $is_multi ? "archive_multi = true\n" : "";
+
+	# Enable archive_mode, archive_command, and optionally archive_multi on node
 	$self->append_conf(
 		'postgresql.conf', qq(
 archive_mode = on
 archive_command = '$copy_command'
+$archive_multi_conf
 ));
+
 	return;
 }
 
diff --git a/src/test/recovery/t/002_archiving.pl b/src/test/recovery/t/002_archiving.pl
index 3acdb9ff1eb..a99e422f193 100644
--- a/src/test/recovery/t/002_archiving.pl
+++ b/src/test/recovery/t/002_archiving.pl
@@ -13,6 +13,7 @@ use File::Copy;
 my $node_primary = PostgreSQL::Test::Cluster->new('primary');
 $node_primary->init(
 	has_archiving => 1,
+	is_multi => 1,
 	allows_streaming => 1);
 my $backup_name = 'my_backup';
 
-- 
2.48.1