From 2e1ccd990114a393c4537b1e2e688778b3511e48 Mon Sep 17 00:00:00 2001
From: Vadim Ponomarev <vbponomarev@gmail.com>
Date: Sat, 15 Aug 2026 12:12:19 +0300
Subject: [PATCH v1 3/4] Release the sync-rep waiters once per drained batch of
 standby replies

ProcessStandbyReplyMessage() calls SyncRepReleaseWaiters() for every reply
it processes, and several replies routinely sit in the walsender's socket
together.  Each of those calls takes SyncRepLock exclusively, so a batch of
replies costs the committers one period of that lock apiece -- computed,
for all but the last reply, from positions the next message in the same
batch immediately makes stale.

Have a reply only mark a release as pending, and run one release at the end
of the drain.  The positions in shared memory are the newest of the batch
by then, so the single pass releases everything the individual passes would
have.

A deferred release must survive every way out of the drain, because the
positions the reply already stored are valid whatever follows and the
committers it acknowledged have no other process to wake them:

- the standby's goodbye, an EOF, an invalid message type and an unexpected
  message type each run the pending release before leaving.  A clean
  standby shutdown sends its final reply and the goodbye back to back,
  which makes that exit the routine one rather than the exotic one.

- an error thrown while a later message in the same drain is parsed -- a
  torn message above all -- leaves through WalSndErrorCleanup(), which runs
  the pending release after the locks are dropped.

The test makes both coincidences certain instead of likely.  For the first,
a paused standby holds a remote_apply committer in the queue, the walsender
is held with SIGSTOP while the standby applies past the commit and shuts
down, and the released walsender drains the final reply and the goodbye in
one pass.  For the second, an injection point right after a drained reply
stands in for the torn message; it fires on every reply, so the walreceiver
is held with SIGSTOP while replay proceeds from WAL already on standby
disk, which makes the first reply after release the one carrying the apply
position the committer waits for.  Both halves fail without their fix.
---
 src/backend/replication/walsender.c        |  59 +++++-
 src/test/recovery/meson.build              |   1 +
 src/test/recovery/t/056_syncrep_release.pl | 206 +++++++++++++++++++++
 3 files changed, 264 insertions(+), 2 deletions(-)
 create mode 100644 src/test/recovery/t/056_syncrep_release.pl

diff --git a/src/backend/replication/walsender.c b/src/backend/replication/walsender.c
index c65dd324325..0db72ac85b3 100644
--- a/src/backend/replication/walsender.c
+++ b/src/backend/replication/walsender.c
@@ -94,6 +94,7 @@
 #include "utils/acl.h"
 #include "utils/builtins.h"
 #include "utils/guc.h"
+#include "utils/injection_point.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/pg_lsn.h"
@@ -206,6 +207,13 @@ 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;
 
+/*
+ * Set when a standby reply has updated this walsender's positions and the
+ * waiters those positions release have not been released yet.  Raised per
+ * reply, acted on once per drain of the socket.
+ */
+static bool syncrep_release_pending = false;
+
 /* Timestamp when walsender received the shutdown request */
 static TimestampTz shutdown_request_timestamp = 0;
 
@@ -300,6 +308,7 @@ static void CreateReplicationSlot(CreateReplicationSlotCmd *cmd);
 static void DropReplicationSlot(DropReplicationSlotCmd *cmd);
 static void StartReplication(StartReplicationCmd *cmd);
 static void StartLogicalReplication(StartReplicationCmd *cmd);
+static void SyncRepFlushPendingRelease(void);
 static void ProcessStandbyMessage(void);
 static void ProcessStandbyReplyMessage(void);
 static void ProcessStandbyHSFeedbackMessage(void);
@@ -381,6 +390,15 @@ WalSndErrorCleanup(void)
 	pgstat_report_wait_end();
 	pgaio_error_cleanup();
 
+	/*
+	 * A release deferred by the reply drain survives an error thrown while a
+	 * later message in the same drain was being parsed.  The positions the
+	 * drained reply put in shared memory are valid whatever came after it,
+	 * and the committers it acknowledged have no other process to wake them.
+	 * The locks are released above, so the queue lock is free to take.
+	 */
+	SyncRepFlushPendingRelease();
+
 	if (xlogreader != NULL && xlogreader->seg.ws_file >= 0)
 		wal_segment_close(xlogreader);
 
@@ -2353,6 +2371,22 @@ exec_replication_command(const char *cmd_string)
 	return true;
 }
 
+/*
+ * Run the release a drained reply deferred.  A reply already processed has
+ * put its positions in shared memory, and the committers it acknowledged
+ * have nothing but this process to wake them, so every exit out of the reply
+ * drain runs through here before leaving.  The standby's goodbye is the
+ * common one.
+ */
+static void
+SyncRepFlushPendingRelease(void)
+{
+	if (!syncrep_release_pending)
+		return;
+	syncrep_release_pending = false;
+	SyncRepReleaseWaiters();
+}
+
 /*
  * Process any incoming messages while streaming. Also checks if the remote
  * end has closed the connection.
@@ -2379,6 +2413,7 @@ ProcessRepliesIfAny(void)
 		if (r < 0)
 		{
 			/* unexpected error or EOF */
+			SyncRepFlushPendingRelease();
 			ereport(COMMERROR,
 					(errcode(ERRCODE_PROTOCOL_VIOLATION),
 					 errmsg("unexpected EOF on standby connection")));
@@ -2402,6 +2437,7 @@ ProcessRepliesIfAny(void)
 				maxmsglen = PQ_SMALL_MESSAGE_LIMIT;
 				break;
 			default:
+				SyncRepFlushPendingRelease();
 				ereport(FATAL,
 						(errcode(ERRCODE_PROTOCOL_VIOLATION),
 						 errmsg("invalid standby message type \"%c\"",
@@ -2414,6 +2450,7 @@ ProcessRepliesIfAny(void)
 		resetStringInfo(&reply_message);
 		if (pq_getmessage(&reply_message, maxmsglen))
 		{
+			SyncRepFlushPendingRelease();
 			ereport(COMMERROR,
 					(errcode(ERRCODE_PROTOCOL_VIOLATION),
 					 errmsg("unexpected EOF on standby connection")));
@@ -2429,6 +2466,7 @@ ProcessRepliesIfAny(void)
 				 */
 			case PqMsg_CopyData:
 				ProcessStandbyMessage();
+				INJECTION_POINT("walsender-reply-drained", NULL);
 				received = true;
 				break;
 
@@ -2450,9 +2488,12 @@ ProcessRepliesIfAny(void)
 
 				/*
 				 * PqMsg_Terminate means that the standby is closing down the
-				 * socket.
+				 * socket.  The last reply it sent is drained already, and
+				 * what that reply acknowledged must not leave with this
+				 * process.
 				 */
 			case PqMsg_Terminate:
+				SyncRepFlushPendingRelease();
 				proc_exit(0);
 
 			default:
@@ -2468,6 +2509,13 @@ ProcessRepliesIfAny(void)
 		last_reply_timestamp = last_processing;
 		waiting_for_ping_response = false;
 	}
+
+	/*
+	 * One release covers every reply drained above: the positions in shared
+	 * memory are already the newest ones, and each release takes SyncRepLock
+	 * exclusively.
+	 */
+	SyncRepFlushPendingRelease();
 }
 
 /*
@@ -2498,6 +2546,7 @@ ProcessStandbyMessage(void)
 			break;
 
 		default:
+			SyncRepFlushPendingRelease();
 			ereport(COMMERROR,
 					(errcode(ERRCODE_PROTOCOL_VIOLATION),
 					 errmsg("unexpected message type \"%c\"", msgtype)));
@@ -2633,8 +2682,14 @@ ProcessStandbyReplyMessage(void)
 		SpinLockRelease(&walsnd->mutex);
 	}
 
+	/*
+	 * The release is left for ProcessRepliesIfAny() to run once per drain of
+	 * the socket: several replies routinely sit in the buffer together, and
+	 * every release takes SyncRepLock exclusively to compute positions this
+	 * message has just made stale anyway.
+	 */
 	if (!am_cascading_walsender)
-		SyncRepReleaseWaiters();
+		syncrep_release_pending = true;
 
 	/*
 	 * Advance our local xmin horizon when the client confirmed a flush.
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index 39ec8c4946d..dca522ed0ce 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_syncrep_release.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/056_syncrep_release.pl b/src/test/recovery/t/056_syncrep_release.pl
new file mode 100644
index 00000000000..bc205356fe0
--- /dev/null
+++ b/src/test/recovery/t/056_syncrep_release.pl
@@ -0,0 +1,206 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# A walsender coalesces the releases a drained batch of standby replies
+# asks for into one pass at the end of the drain.  The drain has early
+# exits, the standby's goodbye being the common one, and a release owed
+# by a reply processed in the same drain must survive them: the positions are
+# in shared memory already, and a committer acknowledged by that reply has
+# nothing else to wake it.  What this file proves is that a commit whose
+# ack arrives in the same drain as the standby's goodbye comes back.
+#
+# The choreography makes that coincidence certain instead of likely.  A
+# paused standby holds a remote_apply committer in the queue while the
+# flush acks flow; the walsender is then held with SIGSTOP, the standby
+# is resumed, allowed to apply past the commit, and shut down, so its
+# final reply, the one carrying the apply position the committer waits
+# for, lands in the walsender's socket right next to the goodbye.  The
+# walsender, released, drains both in one pass.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Time::HiRes qw(usleep time);
+use Test::More;
+
+my $primary = PostgreSQL::Test::Cluster->new('rel_primary');
+$primary->init(allows_streaming => 1);
+$primary->append_conf(
+	'postgresql.conf', q(
+autovacuum = off
+checkpoint_timeout = 1h
+));
+$primary->start;
+$primary->safe_psql('postgres', 'CREATE TABLE t (id int)');
+$primary->backup('bkp');
+
+# A node streaming from a backup reports its own name as its
+# application_name, which is what the synchronous set goes by.
+my $standby = PostgreSQL::Test::Cluster->new('rel_standby');
+$standby->init_from_backup($primary, 'bkp', has_streaming => 1);
+$standby->start;
+$primary->wait_for_catchup($standby, 'replay');
+
+$primary->safe_psql('postgres',
+	"ALTER SYSTEM SET synchronous_standby_names = 'rel_standby'");
+$primary->reload;
+$primary->poll_query_until('postgres',
+	"SELECT sync_state = 'sync' FROM pg_stat_replication WHERE application_name = 'rel_standby'"
+) or die "standby never became synchronous";
+
+# Hold replay: the flush acks keep flowing, the apply position does not,
+# so a remote_apply commit queues and stays queued.
+$standby->safe_psql('postgres', 'SELECT pg_wal_replay_pause()');
+
+my $committer = $primary->background_psql('postgres');
+$committer->query_until(
+	qr/inserting/, q(
+\echo inserting
+SET synchronous_commit = remote_apply;
+INSERT INTO t VALUES (1);
+));
+
+$primary->poll_query_until('postgres',
+	"SELECT count(*) > 0 FROM pg_stat_activity WHERE wait_event = 'SyncRep'")
+  or die "committer never reached the sync-rep queue";
+my $commit_lsn =
+  $primary->safe_psql('postgres', 'SELECT pg_current_wal_lsn()');
+
+# Hold the walsender, so everything the standby says from here on is
+# drained in one pass.
+my $walsender = $primary->safe_psql('postgres',
+	"SELECT pid FROM pg_stat_replication WHERE application_name = 'rel_standby'"
+);
+die "no walsender pid" unless $walsender =~ /^\d+$/;
+kill 'STOP', $walsender or die "SIGSTOP walsender: $!";
+
+# Let the standby apply past the commit, then say goodbye.
+$standby->safe_psql('postgres', 'SELECT pg_wal_replay_resume()');
+my $deadline = time() + 30;
+while (time() < $deadline)
+{
+	my $replayed = $standby->safe_psql('postgres',
+		"SELECT pg_last_wal_replay_lsn() >= '$commit_lsn'::pg_lsn");
+	last if $replayed eq 't';
+	usleep(100_000);
+}
+$standby->stop('fast');
+
+# The final reply and the goodbye are now side by side in the held
+# walsender's socket.  Release it: the drain must not drop the release
+# the reply asks for on its way out.
+kill 'CONT', $walsender or die "SIGCONT walsender: $!";
+
+$deadline = time() + 10;
+my $released = 0;
+while (time() < $deadline)
+{
+	my $waiting = $primary->safe_psql('postgres',
+		"SELECT count(*) FROM pg_stat_activity WHERE wait_event = 'SyncRep'");
+	if ($waiting eq '0')
+	{
+		$released = 1;
+		last;
+	}
+	usleep(200_000);
+}
+ok($released,
+	'a commit acknowledged in the drain the standby left in is released');
+
+# Free the committer session whatever state it is in.
+$primary->safe_psql('postgres',
+	"SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE wait_event = 'SyncRep'"
+) if !$released;
+$committer->quit;
+
+# The other way out of the drain: an error thrown while parsing a later
+# message in the same pass.  A reply processed just before it has already
+# asked for a release, and the walsender's error cleanup must run that
+# release on the way out.  The connection is lost either way; the
+# committers the reply acknowledged are not.  An injection point right
+# after a drained reply stands in for the torn message.
+#
+# The reply the error lands on has to be the one that carries the apply
+# position the committer waits for, and the injection point fires on
+# every drained reply, so no reply may reach the walsender between arming
+# it and the apply position passing the commit.  Holding the
+# walreceiver with SIGSTOP is what guarantees that: replay proceeds from
+# WAL already on standby disk, and the receiver's first words on release
+# are the positions as they stand then.
+if (($ENV{enable_injection_points} // 'no') eq 'yes')
+{
+	$standby->start;
+	$primary->wait_for_catchup($standby, 'replay');
+	$primary->safe_psql('postgres', 'CREATE EXTENSION injection_points');
+
+	$standby->safe_psql('postgres', 'SELECT pg_wal_replay_pause()');
+
+	my $committer2 = $primary->background_psql('postgres');
+	$committer2->query_until(
+		qr/inserting2/, q(
+\echo inserting2
+SET synchronous_commit = remote_apply;
+INSERT INTO t VALUES (3);
+));
+	$primary->poll_query_until('postgres',
+		"SELECT count(*) > 0 FROM pg_stat_activity WHERE wait_event = 'SyncRep'"
+	) or die "second committer never reached the sync-rep queue";
+	my $lsn2 = $primary->safe_psql('postgres', 'SELECT pg_current_wal_lsn()');
+
+	# The commit's WAL must be on standby disk before the receiver is
+	# held, or replay below has nothing to apply.
+	$standby->poll_query_until('postgres',
+		"SELECT pg_last_wal_receive_lsn() >= '$lsn2'::pg_lsn")
+	  or die "standby never flushed the commit's WAL";
+
+	my $walreceiver =
+	  $standby->safe_psql('postgres', 'SELECT pid FROM pg_stat_wal_receiver');
+	die "no walreceiver pid" unless $walreceiver =~ /^\d+$/;
+	kill 'STOP', $walreceiver or die "SIGSTOP walreceiver: $!";
+
+	# A reply already in flight when the receiver stopped is drained --
+	# and released, long before the injection point is armed.
+	usleep(300_000);
+	$primary->safe_psql('postgres',
+		"SELECT injection_points_attach('walsender-reply-drained', 'error')");
+
+	$standby->safe_psql('postgres', 'SELECT pg_wal_replay_resume()');
+	$deadline = time() + 30;
+	while (time() < $deadline)
+	{
+		my $replayed = $standby->safe_psql('postgres',
+			"SELECT pg_last_wal_replay_lsn() >= '$lsn2'::pg_lsn");
+		last if $replayed eq 't';
+		usleep(100_000);
+	}
+
+	# The receiver's first reply now carries an apply position past the
+	# commit, and the injection point tears the drain right after it.
+	kill 'CONT', $walreceiver or die "SIGCONT walreceiver: $!";
+
+	$deadline = time() + 15;
+	my $released2 = 0;
+	while (time() < $deadline)
+	{
+		my $waiting = $primary->safe_psql('postgres',
+			"SELECT count(*) FROM pg_stat_activity WHERE wait_event = 'SyncRep'"
+		);
+		if ($waiting eq '0')
+		{
+			$released2 = 1;
+			last;
+		}
+		usleep(200_000);
+	}
+	ok($released2,
+		'a commit acknowledged right before a torn message is released');
+
+	$primary->safe_psql('postgres',
+		"SELECT injection_points_detach('walsender-reply-drained')");
+	$primary->safe_psql('postgres',
+		"SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE wait_event = 'SyncRep'"
+	) if !$released2;
+	$committer2->quit;
+}
+
+done_testing();
-- 
2.34.1

