From e51d7891e088178be511551ac3bbd6b69b9e975d Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Wed, 11 Mar 2026 15:46:44 +0200
Subject: [PATCH v3] Introduce a new 'wal_sender_shutdown_timeout' GUC

Previously, at shutdown, walsender processes were always waiting to send all
pending data and ensure that all data was flushed to the remote node.  But in
some cases, an unexpected wait may be unacceptable.  For example, in logical
replication, apply_workers may hang on locks for some time, preventing the
sender from shutting down.

New guc allows specifying the maximum time the receiver can wait for a flush
of WAL data without changing the default behavior.

The value of -1 (default value) disables the timeout.  If set, the walsender
will wait for all WALs to be flushed on the receiver side before exiting the
process.

If timeout is enabled, the walsender will exit after expiration without
confirming the remote flush.  This may break the consistency between sender
and receiver. This timeout might be useful for a system with a high-latency
network (to reduce the time to shutdown) or to allow the publisher to
shutdown even when the subscribers' apply_worker is waiting for locks to be
released.

Discussion: https://postgr.es/m/TYAPR01MB586668E50FC2447AD7F92491F5E89%40TYAPR01MB5866.jpnprd01.prod.outlook.com
Author: Andrey Silitskiy <a.silitskiy@postgrespro.ru>
Co-authored-by: Hayato Kuroda <kuroda.hayato@fujitsu.com>
Reviewed-by: Ashutosh Bapat <ashutosh.bapat.oss@gmail.com>
Reviewed-by: Kyotaro Horiguchi <horikyota.ntt@gmail.com>
Reviewed-by: Amit Kapila <amit.kapila16@gmail.com>
Reviewed-by: Dilip Kumar <dilipbalaut@gmail.com>
Reviewed-by: Masahiko Sawada <sawada.mshk@gmail.com>
Reviewed-by: Andres Freund <andres@anarazel.de>
Reviewed-by: Takamichi Osumi <osumi.takamichi@fujitsu.com>
Reviewed-by: Peter Smith <smithpb2250@gmail.com>
Reviewed-by: Greg Sabino Mullane <htamfids@gmail.com>
Reviewed-by: Vitaly Davydov <v.davydov@postgrespro.ru>
Reviewed-by: Fujii Masao <masao.fujii@gmail.com>
Reviewed-by: Ronan Dunklau <ronan@dunklau.fr>
Reviewed-by: Michael Paquier <michael@paquier.xyz>
Reviewed-by: Japin Li <japinli@hotmail.com>
---
 doc/src/sgml/config.sgml                      |  34 +++++
 doc/src/sgml/high-availability.sgml           |   9 +-
 src/backend/replication/walsender.c           |  93 +++++++++++++
 src/backend/utils/misc/guc_parameters.dat     |  10 ++
 src/backend/utils/misc/postgresql.conf.sample |   4 +
 src/include/replication/walsender.h           |   1 +
 src/test/subscription/meson.build             |   1 +
 .../t/038_walsnd_immediate_shutdown.pl        | 127 ++++++++++++++++++
 8 files changed, 275 insertions(+), 4 deletions(-)
 create mode 100644 src/test/subscription/t/038_walsnd_immediate_shutdown.pl

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 8cdd826fbd3..086653d4fd4 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -4806,6 +4806,40 @@ restore_command = 'copy "C:\\server\\archivedir\\%f" "%p"'  # Windows
       </listitem>
      </varlistentry>
 
+     <varlistentry id="guc-wal_sender_shutdown_timeout" xreflabel="wal_sender_shutdown_timeout">
+      <term><varname>wal_sender_shutdown_timeout</varname> (<type>integer</type>)
+      <indexterm>
+       <primary><varname>wal_sender_shutdown_timeout</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Specifies the maximum period of time the walsender process will wait
+        for a successful flush of WAL data by the receiver after receipt of
+        a shutdown request. If this value is specified without units, it is
+        taken as milliseconds. A value of -1 disables the timeout mechanism.
+        It is disabled by default. This parameter can be set for each
+        walsender.
+       </para>
+       <para>
+        If disabled, the walsender will wait for all WAL data to be
+        successfully flushed on the receiver side before exiting the process.
+        This helps to keep the sender and receiver in sync after shutdown,
+        which is especially important for physical replication switchovers.
+        However, it can delay server shutdown.
+       </para>
+       <para>
+        If enabled, then after receipt of a shutdown request, the walsender
+        process will wait for the specified timeout and terminate without
+        further waiting for WAL data replication to the receiver.  This can
+        reduce shutdown time when flushing WAL data to the receiver would
+        take a long time, for example, on high-latency networks or when
+        the subscriber's apply worker is blocked waiting for locks in
+        logical replication.
+       </para>
+      </listitem>
+     </varlistentry>
+
      </variablelist>
     </sect2>
 
diff --git a/doc/src/sgml/high-availability.sgml b/doc/src/sgml/high-availability.sgml
index c3f269e0364..2f67d6dfd18 100644
--- a/doc/src/sgml/high-availability.sgml
+++ b/doc/src/sgml/high-availability.sgml
@@ -1190,10 +1190,11 @@ primary_slot_name = 'node_a_slot'
    </para>
 
    <para>
-    Users will stop waiting if a fast shutdown is requested.  However, as
-    when using asynchronous replication, the server will not fully
-    shutdown until all outstanding WAL records are transferred to the currently
-    connected standby servers.
+    Users will stop waiting if a fast shutdown is requested. However, if
+    <varname>wal_sender_shutdown_timeout</varname> is not set, the server will
+    not fully shutdown until all outstanding WAL records are transferred to
+    the currently connected standby servers. This waiting applies to both
+    asynchronous and synchronous replication.
    </para>
 
    </sect3>
diff --git a/src/backend/replication/walsender.c b/src/backend/replication/walsender.c
index 2cde8ebc729..e4736927a10 100644
--- a/src/backend/replication/walsender.c
+++ b/src/backend/replication/walsender.c
@@ -35,6 +35,7 @@
  * checkpoint finishes, the postmaster sends us SIGUSR2. This instructs
  * walsender to send any outstanding WAL, including the shutdown checkpoint
  * record, wait for it to be replicated to the standby, and then exit.
+ * This waiting time can be limited by wal_sender_shutdown_timeout parameter.
  *
  *
  * Portions Copyright (c) 2010-2026, PostgreSQL Global Development Group
@@ -130,6 +131,11 @@ int			max_wal_senders = 10;	/* the maximum number of concurrent
 									 * walsenders */
 int			wal_sender_timeout = 60 * 1000; /* maximum time to send one WAL
 											 * data message */
+
+int			wal_sender_shutdown_timeout = -1;	/* maximum time to wait for
+												 * flush by receiver after
+												 * shutdown request */
+
 bool		log_replication_commands = false;
 
 /*
@@ -189,6 +195,11 @@ static TimestampTz last_reply_timestamp = 0;
 /* Have we sent a heartbeat message asking for reply, since last reply? */
 static bool waiting_for_ping_response = false;
 
+/*
+ * Timestamp of receival of shutdown request by walsender.
+ */
+static TimestampTz shutdown_request_timestamp = 0;
+
 /*
  * While streaming WAL in Copy mode, streamingDoneSending is set to true
  * after we have sent CopyDone. We should not send any more CopyData messages
@@ -262,6 +273,7 @@ static void WalSndKill(int code, Datum arg);
 pg_noreturn static void WalSndShutdown(void);
 static void XLogSendPhysical(void);
 static void XLogSendLogical(void);
+pg_noreturn static void WalSndDoneImmediate(void);
 static void WalSndDone(WalSndSendDataCallback send_data);
 static void IdentifySystem(void);
 static void UploadManifest(void);
@@ -281,6 +293,7 @@ static void ProcessPendingWrites(void);
 static void WalSndKeepalive(bool requestReply, XLogRecPtr writePtr);
 static void WalSndKeepaliveIfNecessary(void);
 static void WalSndCheckTimeOut(void);
+static void WalSndCheckShutdownTimeOut(void);
 static long WalSndComputeSleeptime(TimestampTz now);
 static void WalSndWait(uint32 socket_events, long timeout, uint32 wait_event);
 static void WalSndPrepareWrite(LogicalDecodingContext *ctx, XLogRecPtr lsn, TransactionId xid, bool last_write);
@@ -1677,6 +1690,9 @@ ProcessPendingWrites(void)
 		/* Try to flush pending output to the client */
 		if (pq_flush_if_writable() != 0)
 			WalSndShutdown();
+
+		/* If wal_sender_shutdown_timeout is expired, exit the process */
+		WalSndCheckShutdownTimeOut();
 	}
 
 	/* reactivate latch so WalSndLoop knows to continue */
@@ -2890,6 +2906,41 @@ WalSndCheckTimeOut(void)
 	}
 }
 
+/*
+ * Check whether the walsender process should terminate due to the expiration
+ * of wal_sender_shutdown_timeout after the receipt of a shutdown request.
+ */
+static void
+WalSndCheckShutdownTimeOut(void)
+{
+	TimestampTz now;
+
+	/* Ignored if wal_sender_shutdown_timeout is disabled. */
+	if (wal_sender_shutdown_timeout == -1)
+		return;
+
+	if (!(got_STOPPING || got_SIGUSR2))
+		return;
+
+	/* Terminate immediately if wal_sender_shutdown_timeout is set to 0. */
+	if (wal_sender_shutdown_timeout == 0)
+		WalSndDoneImmediate();
+
+	now = GetCurrentTimestamp();
+
+	if (shutdown_request_timestamp == 0)
+	{
+		shutdown_request_timestamp = now;
+		return;
+	}
+
+	if (TimestampDifferenceExceeds(shutdown_request_timestamp, now,
+								   wal_sender_shutdown_timeout))
+	{
+		WalSndDoneImmediate();
+	}
+}
+
 /* Main loop of walsender process that streams the WAL over Copy messages. */
 static void
 WalSndLoop(WalSndSendDataCallback send_data)
@@ -2944,6 +2995,12 @@ WalSndLoop(WalSndSendDataCallback send_data)
 		if (pq_flush_if_writable() != 0)
 			WalSndShutdown();
 
+		/*
+		 * Check for wal_sender_shutdown_timeout. If timeout is expired, we do
+		 * not wait for successfull sending of all data to the receiver.
+		 */
+		WalSndCheckShutdownTimeOut();
+
 		/* If nothing remains to be sent right now ... */
 		if (WalSndCaughtUp && !pq_is_send_pending())
 		{
@@ -3592,6 +3649,42 @@ XLogSendLogical(void)
 	}
 }
 
+/*
+ * Forced shutdown of walsender if wal_sender_shutdown_timeout has expired.
+ */
+static void
+WalSndDoneImmediate()
+{
+	QueryCompletion qc;
+
+	/* Try to inform receiver that XLOG streaming is done */
+	SetQueryCompletion(&qc, CMDTAG_COPY, 0);
+	EndCommand(&qc, DestRemote, false);
+
+	/*
+	 * Note that the output buffer may be full during the forced shutdown of
+	 * walsender. If pq_flush() is called at that time, the walsender process
+	 * will be stuck. Therefore, call pq_flush_if_writable() instead.
+	 * Successful reception of the done message with the walsender forced into
+	 * a shutdown is not guaranteed.
+	 */
+	pq_flush_if_writable();
+
+	/*
+	 * Prevent ereport from attempting to send any more messages to the
+	 * standby. Otherwise, it can cause the process to get stuck if the output
+	 * buffers are full.
+	 */
+	if (whereToSendOutput == DestRemote)
+		whereToSendOutput = DestNone;
+
+	ereport(WARNING,
+			(errmsg("walsender shutting down due to wal_sender_shutdown_timeout expiration"),
+			 errhint("Replication may be incomplete.")));
+
+	proc_exit(0);
+}
+
 /*
  * Shutdown if the sender is caught up.
  *
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index 5ee84a639d8..0b2e1e51567 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -3448,6 +3448,16 @@
   check_hook => 'check_wal_segment_size',
 },
 
+{ name => 'wal_sender_shutdown_timeout', type => 'int', context => 'PGC_USERSET', group => 'REPLICATION_SENDING',
+  short_desc => 'Sets the maximum time to wait for receiver to flush WAL data after shutdown request.',
+  long_desc => '-1 disables timeout',
+  flags => 'GUC_UNIT_MS',
+  variable => 'wal_sender_shutdown_timeout',
+  boot_val => '-1',
+  min => '-1',
+  max => 'INT_MAX',
+},
+
 { name => 'wal_sender_timeout', type => 'int', context => 'PGC_USERSET', group => 'REPLICATION_SENDING',
   short_desc => 'Sets the maximum time to wait for WAL replication.',
   flags => 'GUC_UNIT_MS',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index e686d88afc4..cdc781830ed 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -349,6 +349,10 @@
 #max_slot_wal_keep_size = -1    # in megabytes; -1 disables
 #idle_replication_slot_timeout = 0      # in seconds; 0 disables
 #wal_sender_timeout = 60s       # in milliseconds; 0 disables
+#wal_sender_shutdown_timeout = -1       # max time to wait for receiver to flush data
+                                        # after receival of shutdown request; in milliseconds
+                                        # -1 disables (means waiting for complete flush)
+                                        # 0 sets immediate termination of walsender
 #track_commit_timestamp = off   # collect timestamp of transaction commit
                                 # (change requires restart)
 
diff --git a/src/include/replication/walsender.h b/src/include/replication/walsender.h
index a4df3b8e0ae..999876b7699 100644
--- a/src/include/replication/walsender.h
+++ b/src/include/replication/walsender.h
@@ -33,6 +33,7 @@ extern PGDLLIMPORT bool wake_wal_senders;
 /* user-settable parameters */
 extern PGDLLIMPORT int max_wal_senders;
 extern PGDLLIMPORT int wal_sender_timeout;
+extern PGDLLIMPORT int wal_sender_shutdown_timeout;
 extern PGDLLIMPORT bool log_replication_commands;
 
 extern void InitWalSender(void);
diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build
index f4a9cf5057f..870f41c0eac 100644
--- a/src/test/subscription/meson.build
+++ b/src/test/subscription/meson.build
@@ -47,6 +47,7 @@ tests += {
       't/035_conflicts.pl',
       't/036_sequences.pl',
       't/037_except.pl',
+      't/038_walsnd_immediate_shutdown.pl',
       't/100_bugs.pl',
     ],
   },
diff --git a/src/test/subscription/t/038_walsnd_immediate_shutdown.pl b/src/test/subscription/t/038_walsnd_immediate_shutdown.pl
new file mode 100644
index 00000000000..fd38dbedd82
--- /dev/null
+++ b/src/test/subscription/t/038_walsnd_immediate_shutdown.pl
@@ -0,0 +1,127 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Checks that the publisher is able to shut down without
+# waiting for sending of all pending data to the subscriber
+# with wal_sender_shutdown_timeout is set to immediate termination
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use Test::More;
+
+# create publisher
+my $publisher = PostgreSQL::Test::Cluster->new('publisher');
+$publisher->init(allows_streaming => 'logical');
+# set wal_sender_shutdown_timeout GUC parameter to immediate termination
+$publisher->append_conf(
+	'postgresql.conf',
+	"wal_sender_timeout = 0
+	 wal_sender_shutdown_timeout = 0");
+$publisher->start();
+
+# create subscriber
+my $subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$subscriber->init();
+$subscriber->append_conf('postgresql.conf',
+	"wal_receiver_status_interval = 1");
+$subscriber->start();
+
+# create publication for test table
+$publisher->safe_psql(
+	'postgres', q{
+	CREATE TABLE pub_test (id int PRIMARY KEY);
+	CREATE PUBLICATION pub_all FOR TABLE pub_test;
+});
+
+# create matching table on subscriber
+$subscriber->safe_psql(
+	'postgres', q{
+	CREATE TABLE pub_test (id int PRIMARY KEY);
+});
+
+# form connection string to publisher
+my $pub_connstr = $publisher->connstr;
+
+# create the subscription on subscriber
+$subscriber->safe_psql(
+	'postgres', qq{
+	CREATE SUBSCRIPTION sub_all
+	CONNECTION '$pub_connstr'
+	PUBLICATION pub_all;
+});
+
+# wait for initial sync to finish
+$subscriber->wait_for_subscription_sync($publisher, 'sub_all');
+
+# create background psql session
+my $bpgsql = $subscriber->background_psql('postgres', on_error_stop => 0);
+
+# =============================================================================
+# Testcase start: Shutdown of publisher with empty output buffers
+
+# start transaction on subscriber to hold locks
+$bpgsql->query_safe("BEGIN; INSERT INTO pub_test VALUES (0);");
+
+# run concurrent transaction on publisher and commit
+$publisher->safe_psql('postgres',
+	'BEGIN; INSERT INTO pub_test VALUES (0); COMMIT;');
+
+# test publisher shutdown
+$publisher->stop('fast');
+pass('successfull fast shutdown of server with empty output buffers');
+
+# Testcase end: Shutdown of publisher with empty output buffers
+# =============================================================================
+
+$bpgsql->query_safe("ABORT;");
+
+# restart publisher for the next testcase
+$publisher->start();
+
+$publisher->wait_for_catchup('sub_all');
+
+# =============================================================================
+# Testcase start: Shutdown of publisher with full output buffers
+
+# lock table to make apply_worker hang
+$bpgsql->query_safe("BEGIN; LOCK TABLE pub_test IN EXCLUSIVE MODE;");
+
+my $last_sent_lsn = $publisher->safe_psql('postgres',
+	"select sent_lsn from pg_stat_replication where application_name = 'sub_all';"
+);
+my $cur_sent_lsn;
+
+# generate big amount of wal records for locked table
+$publisher->safe_psql('postgres',
+	'BEGIN; INSERT INTO pub_test SELECT i from generate_series(1, 25000) s(i); COMMIT;'
+);
+
+# waiting for walsender to fill output buffers
+my $max_attempts = $PostgreSQL::Test::Utils::timeout_default;
+while ($max_attempts-- >= 0)
+{
+	sleep 1;
+
+	$cur_sent_lsn = $publisher->safe_psql('postgres',
+		"select sent_lsn from pg_stat_replication where application_name = 'sub_all';"
+	);
+
+	my $diff = $publisher->safe_psql(
+		'postgres', qq(
+		SELECT pg_wal_lsn_diff('$cur_sent_lsn', '$last_sent_lsn');
+	));
+
+	last if $diff == 0;
+
+	$last_sent_lsn = $cur_sent_lsn;
+}
+
+# test publisher shutdown
+$publisher->stop('fast');
+pass('successfull fast shutdown of server with full output buffers');
+
+# Testcase end: Shutdown of publisher with full output buffers
+# =============================================================================
+
+done_testing();
-- 
2.39.5 (Apple Git-154)

