From 1423ad2b2f4aa1a091b0303a1119f6e878e4503c Mon Sep 17 00:00:00 2001
From: JoongHyuk Shin <sjh910805@gmail.com>
Date: Sun, 6 Sep 2026 13:37:00 +0900
Subject: [PATCH 2/2] Make recovery_min_apply_delay wait on recovery boundary
 records

recovery_min_apply_delay delays only commit records, on the grounds
that the effects of other records are not visible until the commit is
applied. That does not hold for the file operations of database and
tablespace commands. A delayed standby drops a database directory or
creates a tablespace directory immediately, and only the catalog
change waits for the delay.

Use the timestamp of the recovery boundary record as the delay
reference as well, so that these file operations wait for the
configured delay like the commit does. Adjust the config.sgml paragraph
that describes the commit-only behavior and add a test case to
005_replay_delay.pl.
---
 doc/src/sgml/config.sgml                  | 11 +++++++----
 src/backend/access/transam/xlogrecovery.c | 24 ++++++++++++++---------
 src/test/recovery/t/005_replay_delay.pl   | 13 ++++++++++++
 3 files changed, 35 insertions(+), 13 deletions(-)

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 0165eb9ec02..1d31982a6d7 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -5644,10 +5644,13 @@ ANY <replaceable class="parameter">num_sync</replaceable> ( <replaceable class="
         than typical time deviations between servers.
        </para>
        <para>
-        The delay occurs only on WAL records for transaction commits.
-        Other records are replayed as quickly as possible, which
-        is not a problem because MVCC visibility rules ensure their effects
-        are not visible until the corresponding commit record is applied.
+        The delay occurs only on WAL records for transaction commits and on
+        the records that database and tablespace commands write before their
+        non-transactional file operations.
+        The remaining records are replayed as quickly as possible, either
+        because MVCC visibility rules ensure their effects are not visible
+        until the corresponding commit record is applied, or because they
+        follow a record that has already been delayed.
        </para>
        <para>
         The delay occurs once the database in recovery has reached a consistent
diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c
index e711a9e822d..08508f8e4ce 100644
--- a/src/backend/access/transam/xlogrecovery.c
+++ b/src/backend/access/transam/xlogrecovery.c
@@ -3017,24 +3017,30 @@ recoveryApplyDelay(XLogReaderState *record)
 		return false;
 
 	/*
-	 * Is it a COMMIT record?
+	 * Is it a COMMIT record, or a recovery boundary record?
 	 *
 	 * We deliberately choose not to delay aborts since they have no effect on
 	 * MVCC. We already allow replay of records that don't have a timestamp,
 	 * so there is already opportunity for issues caused by early conflicts on
 	 * standbys.
 	 */
-	if (XLogRecGetRmid(record) != RM_XACT_ID)
-		return false;
+	if (XLogRecGetRmid(record) == RM_XLOG2_ID &&
+		(XLogRecGetInfo(record) & ~XLR_INFO_MASK) == XLOG2_RECOVERY_BOUNDARY)
+		xtime = ((xl_recovery_boundary *) XLogRecGetData(record))->boundary_time;
+	else
+	{
+		if (XLogRecGetRmid(record) != RM_XACT_ID)
+			return false;
 
-	xact_info = XLogRecGetInfo(record) & XLOG_XACT_OPMASK;
+		xact_info = XLogRecGetInfo(record) & XLOG_XACT_OPMASK;
 
-	if (xact_info != XLOG_XACT_COMMIT &&
-		xact_info != XLOG_XACT_COMMIT_PREPARED)
-		return false;
+		if (xact_info != XLOG_XACT_COMMIT &&
+			xact_info != XLOG_XACT_COMMIT_PREPARED)
+			return false;
 
-	if (!getRecordTimestamp(record, &xtime))
-		return false;
+		if (!getRecordTimestamp(record, &xtime))
+			return false;
+	}
 
 	delayUntil = TimestampTzPlusMilliseconds(xtime, recovery_min_apply_delay);
 
diff --git a/src/test/recovery/t/005_replay_delay.pl b/src/test/recovery/t/005_replay_delay.pl
index caec921e549..8b2770b36d3 100644
--- a/src/test/recovery/t/005_replay_delay.pl
+++ b/src/test/recovery/t/005_replay_delay.pl
@@ -17,6 +17,7 @@ $node_primary->start;
 # And some content
 $node_primary->safe_psql('postgres',
 	"CREATE TABLE tab_int AS SELECT generate_series(1, 10) AS a");
+$node_primary->safe_psql('postgres', "CREATE DATABASE db_dropped");
 
 # Take backup
 my $backup_name = 'my_backup';
@@ -55,6 +56,18 @@ $node_standby->poll_query_until('postgres',
 # the configured apply delay.
 cmp_ok(time() - $primary_insert_time,
 	'>=', $delay, "standby applies WAL only after replication delay");
+
+my $oid_dropped = $node_primary->safe_psql('postgres',
+	"SELECT oid FROM pg_database WHERE datname = 'db_dropped'");
+my $primary_drop_time = time();
+$node_primary->safe_psql('postgres', "DROP DATABASE db_dropped");
+$node_standby->poll_query_until('postgres',
+	"SELECT pg_stat_file('base/$oid_dropped', true) IS NULL")
+  or die "standby never removed the directory of db_dropped";
+cmp_ok(time() - $primary_drop_time,
+	'>=', $delay,
+	"standby removes database files only after replication delay");
+
 # Check that recovery can be paused or resumed expectedly.
 my $node_standby2 = PostgreSQL::Test::Cluster->new('standby2');
 $node_standby2->init_from_backup($node_primary, $backup_name,
-- 
2.54.0

