From 9074b812f4060e22683d18c38bc34fc234050cad Mon Sep 17 00:00:00 2001
From: John Naylor <john.naylor@postgresql.org>
Date: Mon, 20 Jul 2026 17:15:11 -0400
Subject: [PATCH v20261003 1/2] Allow marking a shutdown checkpoint as an
 upgrade boundary

A major version upgrade that keeps standbys current by replaying WAL,
instead of rebuilding them, needs a well-defined end to the old
server's WAL: the final shutdown checkpoint written by the old binary.
Standbys must replay up to that point and go no further, because
whatever follows is written by the new version.

pg_request_upgrade_boundary() records an upgrade request in shared
memory; the next shutdown checkpoint consumes it and records itself
as an upgrade boundary in a new CheckPoint field.

pg_cancel_upgrade_boundary() withdraws a pending request, and
pg_upgrade_boundary_requested() reports the current state.  The request
intentionally lives only in shared memory: a crash writes no shutdown
checkpoint, so it forgets the request.  Online checkpoints and
end-of-recovery checkpoints are never marked.

The flag is part of the checkpoint copy in pg_control, so pg_controldata
reports it after shutdown.  pg_waldump shows it in the checkpoint
record's description.  pg_resetwal clears the flag, since it discards
the WAL that contains the marked checkpoint.

TODO: pg_control_checkpoint()

Nothing acts on the marking yet; a follow-up commit will teach standbys
to pause when encountering it.

XXX: bump PG_CONTROL_VERSION
---
 doc/src/sgml/func/func-admin.sgml           |  92 +++++++++++++++++
 src/backend/access/rmgrdesc/xlogdesc.c      |  26 +++++
 src/backend/access/transam/xlog.c           |  67 ++++++++++++-
 src/backend/access/transam/xlogfuncs.c      |  58 +++++++++++
 src/bin/pg_controldata/pg_controldata.c     |  19 ++++
 src/bin/pg_resetwal/pg_resetwal.c           |   6 ++
 src/include/access/xlog.h                   |   4 +
 src/include/catalog/pg_control.h            |  18 ++++
 src/include/catalog/pg_proc.dat             |  13 +++
 src/test/recovery/meson.build               |   1 +
 src/test/recovery/t/057_upgrade_boundary.pl | 105 ++++++++++++++++++++
 src/tools/pgindent/typedefs.list            |   1 +
 12 files changed, 407 insertions(+), 3 deletions(-)
 create mode 100644 src/test/recovery/t/057_upgrade_boundary.pl

diff --git a/doc/src/sgml/func/func-admin.sgml b/doc/src/sgml/func/func-admin.sgml
index 64b0e7bb972..5fb55b1e14f 100644
--- a/doc/src/sgml/func/func-admin.sgml
+++ b/doc/src/sgml/func/func-admin.sgml
@@ -888,6 +888,98 @@ postgres=# SELECT '0/0'::pg_lsn + pd.segment_number * ps.setting::int + :offset
 
   </sect2>
 
+  <sect2 id="functions-upgrade-boundary">
+   <title>Upgrade Boundary Functions</title>
+
+   <para>
+    The functions shown in <xref
+    linkend="functions-upgrade-boundary-table"/> control the marking of an
+    <firstterm>upgrade boundary</firstterm>: a shutdown checkpoint that is
+    the deliberate end of the current major version's write-ahead log,
+    written in preparation for an in-place major version upgrade.
+   </para>
+
+   <table id="functions-upgrade-boundary-table">
+    <title>Upgrade Boundary Functions</title>
+    <tgroup cols="1">
+     <thead>
+      <row>
+       <entry role="func_table_entry"><para role="func_signature">
+        Function
+       </para>
+       <para>
+        Description
+       </para></entry>
+      </row>
+     </thead>
+
+     <tbody>
+      <row>
+       <entry role="func_table_entry"><para role="func_signature">
+        <indexterm>
+         <primary>pg_request_upgrade_boundary</primary>
+        </indexterm>
+        <function>pg_request_upgrade_boundary</function> ()
+        <returnvalue>void</returnvalue>
+       </para>
+       <para>
+        Requests that the next shutdown checkpoint be marked as an upgrade
+        boundary.  The request only takes effect at a smart or fast
+        shutdown; it does not survive a crash, and it does not affect
+        checkpoints of a running server.  This function cannot be executed
+        during recovery.
+       </para>
+       <para>
+        This function is restricted to superusers by default, but other users
+        can be granted EXECUTE to run the function.
+       </para></entry>
+      </row>
+
+      <row>
+       <entry role="func_table_entry"><para role="func_signature">
+        <indexterm>
+         <primary>pg_cancel_upgrade_boundary</primary>
+        </indexterm>
+        <function>pg_cancel_upgrade_boundary</function> ()
+        <returnvalue>void</returnvalue>
+       </para>
+       <para>
+        Cancels a requested upgrade boundary.  It is not an error to call
+        this when no boundary has been requested.  This function cannot be
+        executed during recovery.
+       </para>
+       <para>
+        This function is restricted to superusers by default, but other users
+        can be granted EXECUTE to run the function.
+       </para></entry>
+      </row>
+
+      <row>
+       <entry role="func_table_entry"><para role="func_signature">
+        <indexterm>
+         <primary>pg_upgrade_boundary_requested</primary>
+        </indexterm>
+        <function>pg_upgrade_boundary_requested</function> ()
+        <returnvalue>boolean</returnvalue>
+       </para>
+       <para>
+        Returns true if an upgrade boundary has been requested for the next
+        shutdown checkpoint.  During recovery this always returns
+        <literal>false</literal>.
+       </para></entry>
+      </row>
+     </tbody>
+    </tgroup>
+   </table>
+
+   <para>
+    If a server that was shut down at an upgrade boundary is simply started
+    again with the same major version, the boundary is cancelled: writing
+    new write-ahead log on the same timeline supersedes it.
+   </para>
+
+  </sect2>
+
   <sect2 id="functions-snapshot-synchronization">
    <title>Snapshot Synchronization Functions</title>
 
diff --git a/src/backend/access/rmgrdesc/xlogdesc.c b/src/backend/access/rmgrdesc/xlogdesc.c
index 64e749c1e6b..b227da79e63 100644
--- a/src/backend/access/rmgrdesc/xlogdesc.c
+++ b/src/backend/access/rmgrdesc/xlogdesc.c
@@ -55,6 +55,27 @@ get_wal_level_string(int wal_level)
 	return wal_level_str;
 }
 
+/*
+ * Find a string representation for a checkpoint's upgrade flag
+ */
+static const char *
+get_upgrade_flag_string(uint8 flag)
+{
+	switch (flag)
+	{
+		case UPGRADE_FLAG_NONE:
+			return "none";
+		case UPGRADE_FLAG_BOUNDARY:
+			return "boundary";
+		case UPGRADE_FLAG_START:
+			return "start";
+		case UPGRADE_FLAG_GOLIVE:
+			return "go-live";
+	}
+
+	return "?";
+}
+
 const char *
 get_checksum_state_string(uint32 state)
 {
@@ -126,6 +147,11 @@ xlog_desc(StringInfo buf, XLogReaderState *record)
 						 checkpoint->oldestActiveXid,
 						 get_checksum_state_string(checkpoint->dataChecksumState),
 						 (info == XLOG_CHECKPOINT_SHUTDOWN) ? "shutdown" : "online");
+
+		/* Only upgrade machinery writes a flag, so keep quiet otherwise */
+		if (checkpoint->upgradeFlag != UPGRADE_FLAG_NONE)
+			appendStringInfo(buf, "; upgrade flag %s",
+							 get_upgrade_flag_string(checkpoint->upgradeFlag));
 	}
 	else if (info == XLOG_NEXTOID)
 	{
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 9ec0be77ca0..83bba8220c5 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -538,6 +538,13 @@ typedef struct XLogCtlData
 	 */
 	bool		WalWriterSleeping;
 
+	/*
+	 * upgradeBoundaryRequested indicates that the next shutdown checkpoint is
+	 * to be marked as a major version upgrade boundary.  Protected by
+	 * info_lck.
+	 */
+	bool		upgradeBoundaryRequested;
+
 	/*
 	 * During recovery, we keep a copy of the latest checkpoint record here.
 	 * lastCheckPointRecPtr points to start of checkpoint record and
@@ -6161,6 +6168,17 @@ StartupXLOG(void)
 					 errmsg("control file contains invalid database cluster state")));
 	}
 
+	/*
+	 * If the last shutdown checkpoint was marked as an upgrade boundary but
+	 * we are starting up as usual, no upgrade happened.  The boundary is
+	 * cancelled as a side effect of writing new WAL on this timeline.
+	 */
+	if (ControlFile->state == DB_SHUTDOWNED &&
+		ControlFile->checkPointCopy.upgradeFlag == UPGRADE_FLAG_BOUNDARY)
+		ereport(LOG,
+				(errmsg("database system was shut down at an upgrade boundary"),
+				 errdetail("Starting up normally cancels the upgrade boundary.")));
+
 	/* This is just to allow attaching to startup process with a debugger */
 #ifdef XLOG_REPLAY_DELAY
 	if (ControlFile->state != DB_SHUTDOWNED)
@@ -7374,6 +7392,36 @@ GetLastSegSwitchData(XLogRecPtr *lastSwitchLSN)
 	return result;
 }
 
+/*
+ * Request or cancel an upgrade boundary: when requested, the next shutdown
+ * checkpoint is marked as an upgrade boundary.
+ *
+ * The request lives only in shared memory: a crash, which writes no
+ * shutdown checkpoint, forgets it.
+ */
+void
+SetUpgradeBoundaryRequest(bool request)
+{
+	SpinLockAcquire(&XLogCtl->info_lck);
+	XLogCtl->upgradeBoundaryRequested = request;
+	SpinLockRelease(&XLogCtl->info_lck);
+}
+
+/*
+ * Report whether an upgrade boundary is currently requested.
+ */
+bool
+UpgradeBoundaryActive(void)
+{
+	bool		active;
+
+	SpinLockAcquire(&XLogCtl->info_lck);
+	active = XLogCtl->upgradeBoundaryRequested;
+	SpinLockRelease(&XLogCtl->info_lck);
+
+	return active;
+}
+
 /*
  * This must be called ONCE during postmaster or standalone-backend shutdown
  */
@@ -7408,6 +7456,8 @@ ShutdownXLOG(int code, Datum arg)
 		CreateRestartPoint(CHECKPOINT_IS_SHUTDOWN | CHECKPOINT_FAST);
 	else
 	{
+		int			flags = CHECKPOINT_IS_SHUTDOWN | CHECKPOINT_FAST;
+
 		/*
 		 * If archiving is enabled, rotate the last XLOG file so that all the
 		 * remaining records are archived (postmaster wakes up the archiver
@@ -7417,7 +7467,11 @@ ShutdownXLOG(int code, Datum arg)
 		if (XLogArchivingActive())
 			RequestXLogSwitch(false);
 
-		CreateCheckPoint(CHECKPOINT_IS_SHUTDOWN | CHECKPOINT_FAST);
+		/* Mark the shutdown checkpoint as an upgrade boundary if requested. */
+		if (UpgradeBoundaryActive())
+			flags |= CHECKPOINT_UPGRADE_BOUNDARY;
+
+		CreateCheckPoint(flags);
 	}
 }
 
@@ -7430,7 +7484,7 @@ CheckpointFlagsString(int flags)
 {
 	static char buf[128];
 
-	snprintf(buf, sizeof(buf), "%s%s%s%s%s%s%s%s",
+	snprintf(buf, sizeof(buf), "%s%s%s%s%s%s%s%s%s",
 			 (flags & CHECKPOINT_IS_SHUTDOWN) ? " shutdown" : "",
 			 (flags & CHECKPOINT_END_OF_RECOVERY) ? " end-of-recovery" : "",
 			 (flags & CHECKPOINT_FAST) ? " fast" : "",
@@ -7438,7 +7492,8 @@ CheckpointFlagsString(int flags)
 			 (flags & CHECKPOINT_WAIT) ? " wait" : "",
 			 (flags & CHECKPOINT_CAUSE_XLOG) ? " wal" : "",
 			 (flags & CHECKPOINT_CAUSE_TIME) ? " time" : "",
-			 (flags & CHECKPOINT_FLUSH_UNLOGGED) ? " flush-unlogged" : "");
+			 (flags & CHECKPOINT_FLUSH_UNLOGGED) ? " flush-unlogged" : "",
+			 (flags & CHECKPOINT_UPGRADE_BOUNDARY) ? " upgrade-boundary" : "");
 
 	return buf;
 }
@@ -7948,6 +8003,12 @@ CreateCheckPoint(int flags)
 
 	checkPoint.logicalDecodingEnabled = IsLogicalDecodingEnabled();
 
+	if ((flags & CHECKPOINT_IS_SHUTDOWN) != 0 &&
+		(flags & CHECKPOINT_UPGRADE_BOUNDARY) != 0)
+		checkPoint.upgradeFlag = UPGRADE_FLAG_BOUNDARY;
+	else
+		checkPoint.upgradeFlag = UPGRADE_FLAG_NONE;
+
 	MultiXactGetCheckptMulti(shutdown,
 							 &checkPoint.nextMulti,
 							 &checkPoint.nextMultiOffset,
diff --git a/src/backend/access/transam/xlogfuncs.c b/src/backend/access/transam/xlogfuncs.c
index 1f52bf7b420..bffd724b473 100644
--- a/src/backend/access/transam/xlogfuncs.c
+++ b/src/backend/access/transam/xlogfuncs.c
@@ -222,6 +222,64 @@ pg_switch_wal(PG_FUNCTION_ARGS)
 	PG_RETURN_LSN(switchpoint);
 }
 
+/*
+ * pg_request_upgrade_boundary: request an upgrade boundary
+ *
+ * When requested, the next shutdown checkpoint is marked as an upgrade
+ * boundary.
+ *
+ * Permission checking for this function is managed through the normal
+ * GRANT system.
+ */
+Datum
+pg_request_upgrade_boundary(PG_FUNCTION_ARGS)
+{
+	if (RecoveryInProgress())
+		ereport(ERROR,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("recovery is in progress"),
+				 errhint("WAL control functions cannot be executed during recovery.")));
+
+	SetUpgradeBoundaryRequest(true);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * pg_cancel_upgrade_boundary: cancel a requested upgrade boundary
+ *
+ * Permission checking for this function is managed through the normal
+ * GRANT system.
+ */
+Datum
+pg_cancel_upgrade_boundary(PG_FUNCTION_ARGS)
+{
+	if (RecoveryInProgress())
+		ereport(ERROR,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("recovery is in progress"),
+				 errhint("WAL control functions cannot be executed during recovery.")));
+
+	SetUpgradeBoundaryRequest(false);
+
+	PG_RETURN_VOID();
+}
+
+/*
+ * pg_upgrade_boundary_requested: report whether an upgrade boundary is active
+ *
+ * During recovery there is no requesting a boundary, so simply return
+ * false rather than raising an error.
+ */
+Datum
+pg_upgrade_boundary_requested(PG_FUNCTION_ARGS)
+{
+	if (RecoveryInProgress())
+		PG_RETURN_BOOL(false);
+
+	PG_RETURN_BOOL(UpgradeBoundaryActive());
+}
+
 /*
  * pg_log_standby_snapshot: call LogStandbySnapshot()
  *
diff --git a/src/bin/pg_controldata/pg_controldata.c b/src/bin/pg_controldata/pg_controldata.c
index 6a0f848d8d0..cfea8d7325c 100644
--- a/src/bin/pg_controldata/pg_controldata.c
+++ b/src/bin/pg_controldata/pg_controldata.c
@@ -84,6 +84,23 @@ wal_level_str(WalLevel wal_level)
 	return _("unrecognized \"wal_level\"");
 }
 
+static const char *
+upgrade_flag_str(uint8 flag)
+{
+	switch (flag)
+	{
+		case UPGRADE_FLAG_NONE:
+			return "none";
+		case UPGRADE_FLAG_BOUNDARY:
+			return "boundary";
+		case UPGRADE_FLAG_START:
+			return "start";
+		case UPGRADE_FLAG_GOLIVE:
+			return "go-live";
+	}
+	return _("unrecognized upgrade flag");
+}
+
 
 int
 main(int argc, char *argv[])
@@ -291,6 +308,8 @@ main(int argc, char *argv[])
 		   ControlFile->checkPointCopy.newestCommitTsXid);
 	printf(_("Latest checkpoint's data_checksum_version:%u\n"),
 		   ControlFile->checkPointCopy.dataChecksumState);
+	printf(_("Latest checkpoint's upgrade flag:     %s\n"),
+		   upgrade_flag_str(ControlFile->checkPointCopy.upgradeFlag));
 	printf(_("Time of latest checkpoint:            %s\n"),
 		   ckpttime_str);
 	printf(_("Fake LSN counter for unlogged rels:   %X/%08X\n"),
diff --git a/src/bin/pg_resetwal/pg_resetwal.c b/src/bin/pg_resetwal/pg_resetwal.c
index 41afc4c1316..b3df37830ce 100644
--- a/src/bin/pg_resetwal/pg_resetwal.c
+++ b/src/bin/pg_resetwal/pg_resetwal.c
@@ -930,6 +930,12 @@ RewriteControlFile(void)
 	 */
 	ControlFile.data_checksum_lsn = InvalidXLogRecPtr;
 
+	/*
+	 * The checkpoint being rewritten is no longer the one that was marked as
+	 * an upgrade boundary, if any; the new empty WAL starts fresh.
+	 */
+	ControlFile.checkPointCopy.upgradeFlag = UPGRADE_FLAG_NONE;
+
 	/*
 	 * Force the defaults for max_* settings. The values don't really matter
 	 * as long as wal_level='minimal'; the postmaster will reset these fields
diff --git a/src/include/access/xlog.h b/src/include/access/xlog.h
index 7a590b7e1ea..23d788b427b 100644
--- a/src/include/access/xlog.h
+++ b/src/include/access/xlog.h
@@ -167,6 +167,8 @@ extern PGDLLIMPORT bool XLOG_DEBUG;
 #define CHECKPOINT_FAST			0x0004	/* Do it without delays */
 #define CHECKPOINT_FORCE		0x0008	/* Force even if no activity */
 #define CHECKPOINT_FLUSH_UNLOGGED	0x0010	/* Flush unlogged tables */
+#define CHECKPOINT_UPGRADE_BOUNDARY 0x0200	/* Mark shutdown checkpoint as an
+											 * upgrade boundary */
 /* These are important to RequestCheckpoint */
 #define CHECKPOINT_WAIT			0x0020	/* Wait for completion */
 #define CHECKPOINT_REQUESTED	0x0040	/* Checkpoint request has been made */
@@ -295,6 +297,8 @@ extern XLogRecPtr GetFlushRecPtr(TimeLineID *insertTLI);
 extern TimeLineID GetWALInsertionTimeLine(void);
 extern TimeLineID GetWALInsertionTimeLineIfSet(void);
 extern XLogRecPtr GetLastImportantRecPtr(void);
+extern void SetUpgradeBoundaryRequest(bool request);
+extern bool UpgradeBoundaryActive(void);
 
 extern void SetWalWriterSleeping(bool sleeping);
 
diff --git a/src/include/catalog/pg_control.h b/src/include/catalog/pg_control.h
index c3c934d0012..2a5e37d47e6 100644
--- a/src/include/catalog/pg_control.h
+++ b/src/include/catalog/pg_control.h
@@ -66,8 +66,26 @@ typedef struct CheckPoint
 
 	/* data checksums state at the time of the checkpoint  */
 	uint32		dataChecksumState;
+
+
+	/*
+	 * Role of this checkpoint during an in-place major version upgrade, or
+	 * UPGRADE_FLAG_NONE otherwise.
+	 */
+	uint8		upgradeFlag;	/* one of the UPGRADE_FLAG_* values below */
 } CheckPoint;
 
+/* Values for CheckPoint.upgradeFlag. */
+typedef enum UpgradeFlag
+{
+	UPGRADE_FLAG_NONE = 0,		/* no in-place major version upgrade */
+	UPGRADE_FLAG_BOUNDARY = 1,	/* final shutdown checkpoint of the old major
+								 * version */
+	UPGRADE_FLAG_START = 2,		/* first checkpoint written in the new major
+								 * version's format */
+	UPGRADE_FLAG_GOLIVE = 3,	/* checkpoint that ends the migration */
+} UpgradeFlag;
+
 /* XLOG info values for XLOG rmgr */
 #define XLOG_CHECKPOINT_SHUTDOWN		0x00
 #define XLOG_CHECKPOINT_ONLINE			0x10
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index f46427258e3..9a178c05465 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -6867,6 +6867,19 @@
 { oid => '2848', descr => 'switch to new wal file',
   proname => 'pg_switch_wal', provolatile => 'v', prorettype => 'pg_lsn',
   proargtypes => '', prosrc => 'pg_switch_wal', proacl => '{POSTGRES=X}' },
+{ oid => '9819',
+  descr => 'request an upgrade boundary at the next shutdown checkpoint',
+  proname => 'pg_request_upgrade_boundary', provolatile => 'v',
+  prorettype => 'void', proargtypes => '',
+  prosrc => 'pg_request_upgrade_boundary', proacl => '{POSTGRES=X}' },
+{ oid => '9822', descr => 'cancel a requested upgrade boundary',
+  proname => 'pg_cancel_upgrade_boundary', provolatile => 'v',
+  prorettype => 'void', proargtypes => '',
+  prosrc => 'pg_cancel_upgrade_boundary', proacl => '{POSTGRES=X}' },
+{ oid => '9820', descr => 'true if an upgrade boundary is active',
+  proname => 'pg_upgrade_boundary_requested', provolatile => 'v',
+  prorettype => 'bool', proargtypes => '',
+  prosrc => 'pg_upgrade_boundary_requested' },
 { oid => '6305', descr => 'log details of the current snapshot to WAL',
   proname => 'pg_log_standby_snapshot', provolatile => 'v',
   prorettype => 'pg_lsn', proargtypes => '',
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index 72113c5ac6e..28bcca86345 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -65,6 +65,7 @@ tests += {
       't/054_unlogged_sequence_promotion.pl',
       't/055_cascade_reconnect.pl',
       't/056_standby_snapshot_export.pl',
+      't/057_upgrade_boundary.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/057_upgrade_boundary.pl b/src/test/recovery/t/057_upgrade_boundary.pl
new file mode 100644
index 00000000000..4e40e3bb3f1
--- /dev/null
+++ b/src/test/recovery/t/057_upgrade_boundary.pl
@@ -0,0 +1,105 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test marking a shutdown checkpoint as an upgrade boundary: the
+# deliberate end of this major version's WAL, pending an in-place upgrade.
+# The marking is visible in the control file and in the WAL itself, and a
+# cluster simply restarted on the same binary cancels it.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Fetch one field from the control file
+sub control_field
+{
+	my ($node, $field) = @_;
+
+	my ($stdout, $stderr) =
+	  run_command([ 'pg_controldata', $node->data_dir ]);
+	die "$field not found in control file\n"
+	  unless $stdout =~ /^\Q$field\E:\s*(.*)$/m;
+
+	return $1;
+}
+
+my $primary = PostgreSQL::Test::Cluster->new('primary');
+$primary->init;
+$primary->start;
+
+# Request state round trip on the primary
+is($primary->safe_psql('postgres', 'SELECT pg_upgrade_boundary_requested()'),
+	'f', 'initially not active');
+$primary->safe_psql('postgres', 'SELECT pg_request_upgrade_boundary()');
+is($primary->safe_psql('postgres', 'SELECT pg_upgrade_boundary_requested()'),
+	't', 'active after request');
+$primary->safe_psql('postgres', 'SELECT pg_cancel_upgrade_boundary()');
+is($primary->safe_psql('postgres', 'SELECT pg_upgrade_boundary_requested()'),
+	'f', 'cancelled again');
+$primary->safe_psql('postgres', 'SELECT pg_cancel_upgrade_boundary()');
+is($primary->safe_psql('postgres', 'SELECT pg_upgrade_boundary_requested()'),
+	'f', 'cancelling with nothing requested is a no-op');
+
+# An online checkpoint neither consumes nor acts on a pending request;
+# only a shutdown checkpoint does.
+$primary->safe_psql('postgres', 'SELECT pg_request_upgrade_boundary()');
+$primary->safe_psql('postgres', 'CHECKPOINT');
+is(control_field($primary, "Latest checkpoint's upgrade flag"),
+	'none', 'online checkpoint is not marked as a boundary');
+is($primary->safe_psql('postgres', 'SELECT pg_upgrade_boundary_requested()'),
+	't', 'request stays active across an online checkpoint');
+$primary->safe_psql('postgres', 'SELECT pg_cancel_upgrade_boundary()');
+
+# Shutting down with a request pending marks the shutdown checkpoint, and
+# the control file keeps the marking, so the next binary started on this
+# data directory can see it.
+$primary->safe_psql('postgres', 'SELECT pg_request_upgrade_boundary()');
+$primary->stop('fast');
+is(control_field($primary, "Latest checkpoint's upgrade flag"),
+	'boundary', 'shutdown checkpoint is marked as a boundary');
+
+# The flag is in the WAL itself, not just the control file.
+command_like(
+	[
+		'pg_waldump', '-p', $primary->data_dir . '/pg_wal',
+		'-s', control_field($primary, 'Latest checkpoint location'),
+		'-n', '1'
+	],
+	qr/CHECKPOINT_SHUTDOWN .*upgrade flag boundary/,
+	'pg_waldump shows the flag on the boundary checkpoint record');
+
+# pg_resetwal discards the WAL after the boundary checkpoint and writes a
+# new one in its place, so the marking must not carry over to it.
+command_ok([ 'pg_resetwal', $primary->data_dir ], 'pg_resetwal succeeds');
+is(control_field($primary, "Latest checkpoint's upgrade flag"),
+	'none', 'pg_resetwal clears the boundary marking');
+command_like(
+	[
+		'pg_waldump', '-p', $primary->data_dir . '/pg_wal',
+		'-s', control_field($primary, 'Latest checkpoint location'),
+		'-n', '1'
+	],
+	qr/CHECKPOINT_SHUTDOWN (?!.*upgrade flag)/,
+	'checkpoint written by pg_resetwal is not marked');
+
+# A boundary can be requested again on the reset cluster.
+$primary->start;
+$primary->safe_psql('postgres', 'SELECT pg_request_upgrade_boundary()');
+$primary->stop('fast');
+is(control_field($primary, "Latest checkpoint's upgrade flag"),
+	'boundary', 'boundary re-established after pg_resetwal');
+
+# Abandon the upgrade: starting the primary on the same binary cancels the
+# boundary as a side effect of writing new WAL on this timeline.
+$primary->start;
+ok( $primary->log_contains(
+		'database system was shut down at an upgrade boundary'),
+	'primary logged the cancelled boundary');
+is($primary->safe_psql('postgres', 'SELECT pg_upgrade_boundary_requested()'),
+	'f', 'restarting does not re-request');
+$primary->safe_psql('postgres', 'CHECKPOINT');
+is(control_field($primary, "Latest checkpoint's upgrade flag"),
+	'none', 'cancelled boundary is gone from the primary control file');
+
+done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 5d432074c2c..95ccfec4a8e 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3320,6 +3320,7 @@ UnresolvedTup
 UnresolvedTupData
 UpdateContext
 UpdateStmt
+UpgradeFlag
 UpgradeTask
 UpgradeTaskProcessCB
 UpgradeTaskReport
-- 
2.55.0

