From 71a7a7c27eb976cd93c353e5ca1144f149d25814 Mon Sep 17 00:00:00 2001
From: JoongHyuk Shin <sjh910805@gmail.com>
Date: Sun, 6 Sep 2026 13:37:00 +0900
Subject: [PATCH 1/2] Add recovery boundary WAL record for database and
 tablespace commands

CREATE DATABASE, DROP DATABASE, ALTER DATABASE SET TABLESPACE, CREATE
TABLESPACE and DROP TABLESPACE operate on the filesystem directly, and
their WAL records are replayed as soon as they are read. The stop check
for recovery_target_time only looks at commit and abort records, so a
target set before such a command does not stop before it, and the file
operations have already been replayed by the time the command's commit
record arrives. The recovered cluster is missing a dropped database or
carries an orphaned directory, although the target was earlier than
the command.

Emit a new XLOG2 record carrying a timestamp before the first
irreversible record of each of these commands, and make
recoveryStopsBefore() stop before it when the timestamp is past a
recovery_target_time target, using the same comparison as for commit
records. Other target types are not affected, and the record needs no
redo action.

Add test cases for the five commands to 003_recovery_targets.pl.

Bump XLOG_PAGE_MAGIC.
---
 src/backend/access/rmgrdesc/xlogdesc.c      |  10 ++
 src/backend/access/transam/xlog.c           |  13 ++
 src/backend/access/transam/xlogrecovery.c   |  31 ++++-
 src/backend/commands/dbcommands.c           |   6 +
 src/backend/commands/tablespace.c           |   4 +
 src/backend/replication/logical/decode.c    |   1 +
 src/include/access/xlog.h                   |   1 +
 src/include/access/xlog_internal.h          |   7 +-
 src/include/catalog/pg_control.h            |   1 +
 src/test/recovery/t/003_recovery_targets.pl | 126 ++++++++++++++++++++
 src/tools/pgindent/typedefs.list            |   1 +
 11 files changed, 198 insertions(+), 3 deletions(-)

diff --git a/src/backend/access/rmgrdesc/xlogdesc.c b/src/backend/access/rmgrdesc/xlogdesc.c
index 2468a7d2578..bdd08b9e2b1 100644
--- a/src/backend/access/rmgrdesc/xlogdesc.c
+++ b/src/backend/access/rmgrdesc/xlogdesc.c
@@ -87,6 +87,13 @@ xlog2_desc(StringInfo buf, XLogReaderState *record)
 		memcpy(&xlrec, rec, sizeof(xl_checksum_state));
 		appendStringInfoString(buf, get_checksum_state_string(xlrec.new_checksum_state));
 	}
+	else if (info == XLOG2_RECOVERY_BOUNDARY)
+	{
+		xl_recovery_boundary *xlrec = (xl_recovery_boundary *) rec;
+
+		appendStringInfo(buf, "time %s",
+						 timestamptz_to_str(xlrec->boundary_time));
+	}
 }
 
 void
@@ -290,6 +297,9 @@ xlog2_identify(uint8 info)
 		case XLOG2_CHECKSUMS:
 			id = "CHECKSUMS";
 			break;
+		case XLOG2_RECOVERY_BOUNDARY:
+			id = "RECOVERY_BOUNDARY";
+			break;
 	}
 
 	return id;
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 2e3f177100b..5f054e8c04d 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -8679,6 +8679,19 @@ XLogRestorePoint(const char *rpName)
 	return RecPtr;
 }
 
+void
+XLogRecoveryBoundary(void)
+{
+	xl_recovery_boundary xlrec;
+
+	xlrec.boundary_time = GetCurrentTimestamp();
+
+	XLogBeginInsert();
+	XLogRegisterData(&xlrec, sizeof(xlrec));
+
+	(void) XLogInsert(RM_XLOG2_ID, XLOG2_RECOVERY_BOUNDARY);
+}
+
 /*
  * Write an empty XLOG record to assign a distinct LSN.
  *
diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c
index acac97e89d3..e711a9e822d 100644
--- a/src/backend/access/transam/xlogrecovery.c
+++ b/src/backend/access/transam/xlogrecovery.c
@@ -2410,8 +2410,8 @@ checkTimeLineSwitch(XLogRecPtr lsn, TimeLineID newTLI, TimeLineID prevTLI,
  *
  * If the record contains a timestamp, returns true, and saves the timestamp
  * in *recordXtime. If the record type has no timestamp, returns false.
- * Currently, only transaction commit/abort records and restore points contain
- * timestamps.
+ * Currently, only transaction commit/abort records and restore points are
+ * handled here.
  */
 static bool
 getRecordTimestamp(XLogReaderState *record, TimestampTz *recordXtime)
@@ -2606,6 +2606,33 @@ recoveryStopsBefore(XLogReaderState *record)
 		return true;
 	}
 
+	if (recoveryTarget == RECOVERY_TARGET_TIME &&
+		XLogRecGetRmid(record) == RM_XLOG2_ID &&
+		(XLogRecGetInfo(record) & ~XLR_INFO_MASK) == XLOG2_RECOVERY_BOUNDARY)
+	{
+		xl_recovery_boundary *xlrec = (xl_recovery_boundary *) XLogRecGetData(record);
+
+		if (recoveryTargetInclusive)
+			stopsHere = (xlrec->boundary_time > recoveryTargetTime);
+		else
+			stopsHere = (xlrec->boundary_time >= recoveryTargetTime);
+
+		if (!stopsHere)
+			return false;
+
+		recoveryStopAfter = false;
+		recoveryStopXid = InvalidTransactionId;
+		recoveryStopLSN = InvalidXLogRecPtr;
+		recoveryStopTime = xlrec->boundary_time;
+		recoveryStopName[0] = '\0';
+
+		ereport(LOG,
+				errmsg("recovery stopping before recovery boundary, time %s",
+					   timestamptz_to_str(recoveryStopTime)));
+
+		return true;
+	}
+
 	/* Otherwise we only consider stopping before COMMIT or ABORT records. */
 	if (XLogRecGetRmid(record) != RM_XACT_ID)
 		return false;
diff --git a/src/backend/commands/dbcommands.c b/src/backend/commands/dbcommands.c
index 7e3fc59eafd..ea715053af6 100644
--- a/src/backend/commands/dbcommands.c
+++ b/src/backend/commands/dbcommands.c
@@ -1578,6 +1578,8 @@ createdb(ParseState *pstate, const CreatedbStmt *stmt)
 	fparms.dest_dboid = dboid;
 	fparms.strategy = dbstrategy;
 
+	XLogRecoveryBoundary();
+
 	PG_ENSURE_ERROR_CLEANUP(createdb_failure_callback,
 							PointerGetDatum(&fparms));
 	{
@@ -1859,6 +1861,8 @@ dropdb(const char *dbname, bool missing_ok, bool force)
 	 */
 	pgstat_drop_database(db_id);
 
+	XLogRecoveryBoundary();
+
 	/*
 	 * Except for the deletion of the catalog row, subsequent actions are not
 	 * transactional (consider DropDatabaseBuffers() discarding modified
@@ -2249,6 +2253,8 @@ movedb(const char *dbname, const char *tblspcname)
 		 */
 		copydir(src_dbpath, dst_dbpath, false);
 
+		XLogRecoveryBoundary();
+
 		/*
 		 * Record the filesystem change in XLOG
 		 */
diff --git a/src/backend/commands/tablespace.c b/src/backend/commands/tablespace.c
index e3c4a7fac87..ce825e1e410 100644
--- a/src/backend/commands/tablespace.c
+++ b/src/backend/commands/tablespace.c
@@ -364,6 +364,8 @@ CreateTableSpace(CreateTableSpaceStmt *stmt)
 
 	create_tablespace_directories(location, tablespaceoid);
 
+	XLogRecoveryBoundary();
+
 	/* Record the filesystem change in XLOG */
 	{
 		xl_tblspc_create_rec xlrec;
@@ -534,6 +536,8 @@ DropTableSpace(DropTableSpaceStmt *stmt)
 		}
 	}
 
+	XLogRecoveryBoundary();
+
 	/* Record the filesystem change in XLOG */
 	{
 		xl_tblspc_drop_rec xlrec;
diff --git a/src/backend/replication/logical/decode.c b/src/backend/replication/logical/decode.c
index c944be4ac83..379262a5ffb 100644
--- a/src/backend/replication/logical/decode.c
+++ b/src/backend/replication/logical/decode.c
@@ -200,6 +200,7 @@ xlog2_decode(LogicalDecodingContext *ctx, XLogRecordBuffer *buf)
 	switch (info)
 	{
 		case XLOG2_CHECKSUMS:
+		case XLOG2_RECOVERY_BOUNDARY:
 			break;
 		default:
 			elog(ERROR, "unexpected RM_XLOG2_ID record type: %u", info);
diff --git a/src/include/access/xlog.h b/src/include/access/xlog.h
index 130ba929109..327dd45a51a 100644
--- a/src/include/access/xlog.h
+++ b/src/include/access/xlog.h
@@ -286,6 +286,7 @@ extern bool CreateRestartPoint(int flags);
 extern WALAvailability GetWALAvailability(XLogRecPtr targetLSN);
 extern void XLogPutNextOid(Oid nextOid);
 extern XLogRecPtr XLogRestorePoint(const char *rpName);
+extern void XLogRecoveryBoundary(void);
 extern XLogRecPtr XLogAssignLSN(void);
 extern void UpdateFullPageWrites(void);
 extern void GetFullPageWriteInfo(XLogRecPtr *RedoRecPtr_p, bool *doPageWrites_p);
diff --git a/src/include/access/xlog_internal.h b/src/include/access/xlog_internal.h
index be718993401..efc43b80ed8 100644
--- a/src/include/access/xlog_internal.h
+++ b/src/include/access/xlog_internal.h
@@ -32,7 +32,7 @@
 /*
  * Each page of XLOG file has a header like this:
  */
-#define XLOG_PAGE_MAGIC 0xD121	/* can be used as WAL version indicator */
+#define XLOG_PAGE_MAGIC 0xD122	/* can be used as WAL version indicator */
 
 typedef struct XLogPageHeaderData
 {
@@ -294,6 +294,11 @@ typedef struct xl_checksum_state
 	ChecksumStateType new_checksum_state;
 } xl_checksum_state;
 
+typedef struct xl_recovery_boundary
+{
+	TimestampTz boundary_time;
+} xl_recovery_boundary;
+
 /* Overwrite of prior contrecord */
 typedef struct xl_overwrite_contrecord
 {
diff --git a/src/include/catalog/pg_control.h b/src/include/catalog/pg_control.h
index 7b5404460ec..4ebe99c97b0 100644
--- a/src/include/catalog/pg_control.h
+++ b/src/include/catalog/pg_control.h
@@ -88,6 +88,7 @@ typedef struct CheckPoint
 
 /* XLOG info values for XLOG2 rmgr */
 #define XLOG2_CHECKSUMS					0x00
+#define XLOG2_RECOVERY_BOUNDARY			0x10
 
 
 /*
diff --git a/src/test/recovery/t/003_recovery_targets.pl b/src/test/recovery/t/003_recovery_targets.pl
index db4a0ea74b2..b273ebbad04 100644
--- a/src/test/recovery/t/003_recovery_targets.pl
+++ b/src/test/recovery/t/003_recovery_targets.pl
@@ -51,6 +51,8 @@ sub test_recovery_standby
 	  $node_standby->safe_psql('postgres', "SELECT count(*) FROM tab_int");
 	is($result, qq($num_rows), "check standby content for $test_name");
 
+	$params{checks}->($node_standby) if defined $params{checks};
+
 	# Stop standby node
 	$node_standby->teardown_node;
 
@@ -60,6 +62,11 @@ sub test_recovery_standby
 # Initialize primary node
 my $node_primary = PostgreSQL::Test::Cluster->new('primary');
 $node_primary->init(has_archiving => 1, allows_streaming => 1);
+$node_primary->append_conf(
+	'postgresql.conf', qq(
+autovacuum = off
+allow_in_place_tablespaces = on
+));
 
 # Bump the transaction ID epoch.  This is useful to stress the portability
 # of recovery_target_xid parsing.
@@ -72,6 +79,14 @@ $node_primary->start;
 # recovery_target = 'immediate'
 $node_primary->safe_psql('postgres',
 	"CREATE TABLE tab_int AS SELECT generate_series(1,1000) AS a");
+$node_primary->safe_psql('postgres',
+	"CREATE TABLESPACE ts_dropped LOCATION ''");
+$node_primary->safe_psql('postgres',
+	"CREATE TABLESPACE ts_target LOCATION ''");
+$node_primary->safe_psql('postgres', "CREATE DATABASE db_dropped");
+$node_primary->safe_psql('db_dropped',
+	"CREATE TABLE tab_dropped AS SELECT generate_series(1,100) AS a");
+$node_primary->safe_psql('postgres', "CREATE DATABASE db_moved");
 my $lsn1 =
   $node_primary->safe_psql('postgres', "SELECT pg_current_wal_lsn();");
 
@@ -114,6 +129,41 @@ $node_primary->safe_psql('postgres',
 my $lsn6 =
   $node_primary->safe_psql('postgres', "SELECT pg_current_wal_lsn()");
 
+$ret =
+  $node_primary->safe_psql('postgres', "SELECT now(), pg_current_wal_lsn()");
+my ($time_before_dbdrop, $lsn_before_dbdrop) = split /\|/, $ret;
+$node_primary->safe_psql('postgres', "DROP DATABASE db_dropped");
+
+$ret =
+  $node_primary->safe_psql('postgres', "SELECT now(), pg_current_wal_lsn()");
+my ($time_before_dbcreate, $lsn_before_dbcreate) = split /\|/, $ret;
+$node_primary->safe_psql('postgres', "CREATE DATABASE db_created");
+my $oid_db_created = $node_primary->safe_psql('postgres',
+	"SELECT oid FROM pg_database WHERE datname = 'db_created'");
+
+$ret =
+  $node_primary->safe_psql('postgres', "SELECT now(), pg_current_wal_lsn()");
+my ($time_before_tscreate, $lsn_before_tscreate) = split /\|/, $ret;
+$node_primary->safe_psql('postgres',
+	"CREATE TABLESPACE ts_created LOCATION ''");
+my $oid_ts_created = $node_primary->safe_psql('postgres',
+	"SELECT oid FROM pg_tablespace WHERE spcname = 'ts_created'");
+
+my $oid_ts_target = $node_primary->safe_psql('postgres',
+	"SELECT oid FROM pg_tablespace WHERE spcname = 'ts_target'");
+$ret =
+  $node_primary->safe_psql('postgres', "SELECT now(), pg_current_wal_lsn()");
+my ($time_before_dbmove, $lsn_before_dbmove) = split /\|/, $ret;
+$node_primary->safe_psql('postgres',
+	"ALTER DATABASE db_moved SET TABLESPACE ts_target");
+
+my $oid_ts_dropped = $node_primary->safe_psql('postgres',
+	"SELECT oid FROM pg_tablespace WHERE spcname = 'ts_dropped'");
+$ret =
+  $node_primary->safe_psql('postgres', "SELECT now(), pg_current_wal_lsn()");
+my ($time_before_tsdrop, $lsn_before_tsdrop) = split /\|/, $ret;
+$node_primary->safe_psql('postgres', "DROP TABLESPACE ts_dropped");
+
 # Force archiving of WAL file containing $lsn6
 $node_primary->safe_psql('postgres', "SELECT pg_switch_wal()");
 
@@ -134,6 +184,82 @@ test_recovery_standby('name', 'standby_4', $node_primary, \@recovery_params,
 test_recovery_standby('LSN', 'standby_5', $node_primary, \@recovery_params,
 	"5000", $lsn5);
 
+@recovery_params = ("recovery_target_time = '$time_before_dbdrop'");
+test_recovery_standby(
+	'time before DROP DATABASE',
+	'standby_dbdrop',
+	$node_primary,
+	\@recovery_params,
+	"6000",
+	$lsn_before_dbdrop,
+	checks => sub {
+		my $node = shift;
+		my ($ret, $stdout, $stderr) =
+		  $node->psql('db_dropped', "SELECT count(*) FROM tab_dropped");
+		is($ret, 0, 'can connect to db_dropped');
+		is($stdout, '100', 'content of db_dropped is intact');
+	});
+
+@recovery_params = ("recovery_target_time = '$time_before_dbcreate'");
+test_recovery_standby(
+	'time before CREATE DATABASE',
+	'standby_dbcreate',
+	$node_primary,
+	\@recovery_params,
+	"6000",
+	$lsn_before_dbcreate,
+	checks => sub {
+		my $node = shift;
+		ok( !-d $node->data_dir . "/base/$oid_db_created",
+			'no orphaned directory of db_created');
+	});
+
+@recovery_params = ("recovery_target_time = '$time_before_tscreate'");
+test_recovery_standby(
+	'time before CREATE TABLESPACE',
+	'standby_tscreate',
+	$node_primary,
+	\@recovery_params,
+	"6000",
+	$lsn_before_tscreate,
+	checks => sub {
+		my $node = shift;
+		ok(!-e $node->data_dir . "/pg_tblspc/$oid_ts_created",
+			'no orphaned directory of ts_created');
+	});
+
+@recovery_params = ("recovery_target_time = '$time_before_dbmove'");
+test_recovery_standby(
+	'time before ALTER DATABASE SET TABLESPACE',
+	'standby_dbmove',
+	$node_primary,
+	\@recovery_params,
+	"6000",
+	$lsn_before_dbmove,
+	checks => sub {
+		my $node = shift;
+		is( $node->safe_psql(
+				'postgres',
+				"SELECT count(*) FROM pg_tablespace_databases($oid_ts_target)"
+			),
+			'0',
+			'no orphaned copy of db_moved in ts_target');
+	});
+
+@recovery_params = ("recovery_target_time = '$time_before_tsdrop'");
+test_recovery_standby(
+	'time before DROP TABLESPACE',
+	'standby_tsdrop',
+	$node_primary,
+	\@recovery_params,
+	"6000",
+	$lsn_before_tsdrop,
+	checks => sub {
+		my $node = shift;
+		ok(-e $node->data_dir . "/pg_tblspc/$oid_ts_dropped",
+			'directory of ts_dropped exists');
+	});
+
 # Regression: empty-string for one recovery_target_* GUC must not clobber
 # another non-empty target.  Setting recovery_target_xid + recovery_target_time
 # = '' must recover to the xid, not run as no-target recovery.
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 58c4749e7e4..e122478d9c3 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4528,6 +4528,7 @@ xl_multixact_create
 xl_multixact_truncate
 xl_overwrite_contrecord
 xl_parameter_change
+xl_recovery_boundary
 xl_relmap_update
 xl_replorigin_drop
 xl_replorigin_set
-- 
2.54.0

