From acfc5414890ec68684a6665315c771434573a068 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Mon, 14 Sep 2026 13:03:39 +0000
Subject: [PATCH v9 1/3] Add a cluster-wide UNDO engine with records in the WAL
 stream

Add a cluster-wide UNDO engine whose records live in the common WAL
stream rather than in per-log segment files.  A transaction that must be
rolled back has its UNDO applied by walking its UNDO-batch chain backward
from the last batch LSN and restoring before-images.

Core pieces:

  * A new "Undo" resource manager (rmgrlist.h) and its WAL records:
    XLOG_UNDO_BATCH and friends, described by undodesc.c and applied by
    undo_xlog.c / undoapply.c.  ApplyUndoChainFromWAL() walks a chain
    from a given last-batch LSN.

  * An UNDO resource-manager dispatch layer (undormgr.c/.h, driven by the
    initially empty access/undormgrlist.h / undormgrs.h) so that later
    UNDO-writing consumers register themselves without the UNDO core
    needing to know their names at compile time.

  * Per-transaction UNDO record assembly and insertion into WAL
    (undorecord.c, undoinsert.c, xactundo.c), log/discard bookkeeping
    (undolog.c), the cluster-wide UNDO discard worker (undoworker.c), and
    statistics (undostats.c).

  * Transaction integration: AbortTransaction() and ROLLBACK PREPARED
    apply the transaction's cluster-wide UNDO chain synchronously
    (inline ApplyUndoChainFromWAL); at the end of crash recovery the
    startup process applies the chains of transactions the crash left in
    progress.  The chain-head LSN is durably carried
    across 2PC in the prepared-transaction state file, and WAL holding a
    still-needed UNDO chain is protected from recycling
    (UndoGetOldestBatchLSN, TwoPhaseGetOldestUndoBatchLSN).

  * A tableam.h am_supports_undo flag marking access methods that record
    UNDO for their own changes, a buffer-manager helper used by inline
    apply (BufferLockReleaseAll), and the UNDO GUCs, wait events, LWLock
    tranche, and documentation for the above.

The next patch adds the first UNDO-writing consumer, transactional
filesystem operations (FILEOPS).

This cluster-wide UNDO engine draws on two lines of prior work proposed on
pgsql-hackers: the zheap storage engine and its undo-log infrastructure, and
the zedstore columnar engine whose design discussion shaped the record and
apply model used here.

Author: Greg Burd <greg@burd.me>
Co-authored-by: Amit Kapila <amit.kapila16@gmail.com>
Co-authored-by: Dilip Kumar <dilipbalaut@gmail.com>
Co-authored-by: Thomas Munro <thomas.munro@gmail.com>
Co-authored-by: Kuntal Ghosh <kuntalghosh.2007@gmail.com>
Co-authored-by: Rafia Sabih <rafia.sabih@enterprisedb.com>
Co-authored-by: Mithun Cy <mithuncy@gmail.com>
Co-authored-by: Ashutosh Sharma <ashu.coek88@gmail.com>
Co-authored-by: Andres Freund <andres@anarazel.de>
Co-authored-by: Antonin Houska <ah@cybertec.at>
Co-authored-by: Ashwin Agrawal <ashwinstar@gmail.com>
Co-authored-by: Heikki Linnakangas <heikki.linnakangas@iki.fi>
Co-authored-by: Taylor Vesely <tvesely@pivotal.io>
Co-authored-by: Melanie Plageman <melanieplageman@gmail.com>
Co-authored-by: Alexandra Wang <alexandra.wang.oss@gmail.com>

Changes since v2:

  - Remove the unused table AM prepare hook [a1ef4339f9]
  - Headerscheck: skip undormgrlist.h [3e9124969b]
  - Describe the cluster-wide UNDO recovery model as implemented [3a1784e689]
  - Release buffer content locks before applying UNDO inline at abort [1fdfe0a8a9]
  - Do not claim a failed abort keeps its WAL hold until backend exit [4825775611]
  - Doc: make the UNDO page a chapter, as postgres.sgml includes it [479155221f]

Changes since v7:
  - An abort whose last batch LSN is not a valid UNDO batch no longer
    leaks its "Inline UNDO Apply" memory context; the early exit skipped
    the switch back to the caller's context and the delete, so the rest
    of the abort ran in, and returned into, a context that was never
    freed (test_fileops/023, with the FILEOPS commit's test module;
    injection point inline-undo-invalid-batch)
  - The startup undo phase counts a record that fails to apply as a
    failure, and its closing message says how many there were, as a
    WARNING.  It counted every record as applied and reported the
    transaction as rolled back (test_fileops/024: a real EACCES on a
    crash loser's inverse chmod)
  - Remove what only the commits that are no longer in the series used:
    undo_bufmgr.c/h, MarkBufferDirtyShared(), DropRelationBufferRange(),
    the deferred-batch and two-part record entry points the index AMs
    called, the per-backend UNDO fields in TransactionStateData, the
    nbtree/hash UNDO rmgr IDs, the revert worker's LWLock tranche, and the
    legacy no-op stubs and getters that had no caller at all, with
    AtAbort_XactUndo's and AtProcExit_XactUndo's calls to two of them,
    which the FILEOPS commit used to remove; about 1,700 lines
  - Comments, log messages, the README and the documentation chapter no
    longer describe the ATM, the logical revert worker, per-backend UNDO,
    index UNDO (index_undo) or FLUX.  The startup undo phase's message for
    a transaction it cannot apply now says that, not that it was handed to
    a revert worker.  doc/src/sgml/undo.sgml is rewritten around what this
    series does (FILEOPS) and drops pg_stat_undo, index_undo and the table
    AM DML records, none of which exist here
  - undostats.c no longer marks its three SQL functions with
    PG_FUNCTION_INFO_V1.  They are built-in (pg_proc.dat), so
    fmgrprotos.h already declares them, and MSVC rejected the second
    declaration with C2375
---
 doc/src/sgml/filelist.sgml                    |    1 +
 doc/src/sgml/postgres.sgml                    |    1 +
 doc/src/sgml/undo.sgml                        |  160 ++
 src/backend/access/Makefile                   |    3 +-
 src/backend/access/meson.build                |    1 +
 src/backend/access/rmgrdesc/Makefile          |    1 +
 src/backend/access/rmgrdesc/meson.build       |    1 +
 src/backend/access/rmgrdesc/undodesc.c        |  209 +++
 src/backend/access/transam/rmgr.c             |    1 +
 src/backend/access/transam/twophase.c         |  130 +-
 src/backend/access/transam/xact.c             |  184 +++
 src/backend/access/transam/xlog.c             |   89 ++
 src/backend/access/transam/xlogrecovery.c     |   25 +
 src/backend/access/undo/Makefile              |   27 +
 src/backend/access/undo/README                |  901 +++++++++++
 src/backend/access/undo/meson.build           |   14 +
 src/backend/access/undo/undo.c                |  254 ++++
 src/backend/access/undo/undo_xlog.c           | 1167 ++++++++++++++
 src/backend/access/undo/undoapply.c           |  327 ++++
 src/backend/access/undo/undoinsert.c          |  166 ++
 src/backend/access/undo/undolog.c             |  383 +++++
 src/backend/access/undo/undorecord.c          |  398 +++++
 src/backend/access/undo/undormgr.c            |   70 +
 src/backend/access/undo/undostats.c           |  348 +++++
 src/backend/access/undo/undoworker.c          |  625 ++++++++
 src/backend/access/undo/xactundo.c            | 1334 +++++++++++++++++
 src/backend/commands/tablecmds.c              |    1 +
 src/backend/postmaster/bgworker.c             |    5 +
 src/backend/storage/buffer/bufmgr.c           |   73 +
 src/backend/tcop/utility.c                    |    1 +
 .../utils/activity/wait_event_names.txt       |    4 +
 src/backend/utils/init/postinit.c             |    4 +
 src/backend/utils/misc/guc_parameters.dat     |   40 +
 src/backend/utils/misc/guc_tables.c           |    2 +
 src/backend/utils/misc/postgresql.conf.sample |    7 +
 src/bin/pg_dump/pg_dump.c                     |    1 +
 src/bin/pg_waldump/rmgrdesc.c                 |    1 +
 src/bin/pg_waldump/t/001_basic.pl             |    3 +-
 src/include/access/heapam.h                   |    1 +
 src/include/access/rmgrlist.h                 |    1 +
 src/include/access/tableam.h                  |    9 +
 src/include/access/twophase.h                 |    3 +
 src/include/access/undo.h                     |   50 +
 src/include/access/undo_xlog.h                |  284 ++++
 src/include/access/undodefs.h                 |   56 +
 src/include/access/undolog.h                  |  202 +++
 src/include/access/undorecord.h               |  218 +++
 src/include/access/undormgr.h                 |  115 ++
 src/include/access/undormgrlist.h             |   33 +
 src/include/access/undormgrs.h                |   28 +
 src/include/access/undostats.h                |   62 +
 src/include/access/undoworker.h               |   70 +
 src/include/access/xact.h                     |   44 +
 src/include/access/xactundo.h                 |  119 ++
 src/include/access/xlog_internal.h            |    2 +-
 src/include/catalog/pg_proc.dat               |   22 +
 src/include/storage/bufmgr.h                  |    1 +
 src/include/storage/lwlocklist.h              |    1 +
 src/include/storage/subsystemlist.h           |    3 +
 src/tools/pginclude/headerscheck              |    1 +
 src/tools/pgindent/typedefs.list              |   33 +
 61 files changed, 8316 insertions(+), 4 deletions(-)
 create mode 100644 doc/src/sgml/undo.sgml
 create mode 100644 src/backend/access/rmgrdesc/undodesc.c
 create mode 100644 src/backend/access/undo/Makefile
 create mode 100644 src/backend/access/undo/README
 create mode 100644 src/backend/access/undo/meson.build
 create mode 100644 src/backend/access/undo/undo.c
 create mode 100644 src/backend/access/undo/undo_xlog.c
 create mode 100644 src/backend/access/undo/undoapply.c
 create mode 100644 src/backend/access/undo/undoinsert.c
 create mode 100644 src/backend/access/undo/undolog.c
 create mode 100644 src/backend/access/undo/undorecord.c
 create mode 100644 src/backend/access/undo/undormgr.c
 create mode 100644 src/backend/access/undo/undostats.c
 create mode 100644 src/backend/access/undo/undoworker.c
 create mode 100644 src/backend/access/undo/xactundo.c
 create mode 100644 src/include/access/undo.h
 create mode 100644 src/include/access/undo_xlog.h
 create mode 100644 src/include/access/undodefs.h
 create mode 100644 src/include/access/undolog.h
 create mode 100644 src/include/access/undorecord.h
 create mode 100644 src/include/access/undormgr.h
 create mode 100644 src/include/access/undormgrlist.h
 create mode 100644 src/include/access/undormgrs.h
 create mode 100644 src/include/access/undostats.h
 create mode 100644 src/include/access/undoworker.h
 create mode 100644 src/include/access/xactundo.h

diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 0797dcf96da..7800bf9944b 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -49,6 +49,7 @@
 <!ENTITY runtime       SYSTEM "runtime.sgml">
 <!ENTITY config        SYSTEM "config.sgml">
 <!ENTITY user-manag    SYSTEM "user-manag.sgml">
+<!ENTITY undo          SYSTEM "undo.sgml">
 <!ENTITY wal           SYSTEM "wal.sgml">
 <!ENTITY logical-replication    SYSTEM "logical-replication.sgml">
 <!ENTITY jit    SYSTEM "jit.sgml">
diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml
index 2101442c90f..0940a557ffa 100644
--- a/doc/src/sgml/postgres.sgml
+++ b/doc/src/sgml/postgres.sgml
@@ -164,6 +164,7 @@ break is not needed in a wider output rendering.
   &high-availability;
   &monitoring;
   &wal;
+  &undo;
   &logical-replication;
   &jit;
   &regress;
diff --git a/doc/src/sgml/undo.sgml b/doc/src/sgml/undo.sgml
new file mode 100644
index 00000000000..ff4bd22f0ab
--- /dev/null
+++ b/doc/src/sgml/undo.sgml
@@ -0,0 +1,160 @@
+<!-- doc/src/sgml/undo.sgml -->
+
+<chapter id="undo" xreflabel="UNDO Logging">
+ <title>UNDO Logging</title>
+
+ <indexterm zone="undo">
+  <primary>UNDO logging</primary>
+ </indexterm>
+
+ <para>
+  <productname>PostgreSQL</productname> provides UNDO logging infrastructure
+  that records the inverse of a change, so that the change can be reversed if
+  the transaction that made it rolls back or is still in progress when the
+  server crashes.  UNDO records are written into the WAL stream, so they are
+  durable, replicated and archived like every other WAL record.
+ </para>
+
+ <para>
+  UNDO is not a cluster-wide switch.  It is always-on infrastructure that is
+  used only by the subsystems that write UNDO records.  The default
+  <literal>heap</literal> table access method does not, and ordinary heap and
+  index changes carry no UNDO overhead.  The one user of UNDO in
+  <productname>PostgreSQL</productname> today is transactional filesystem
+  operations (FILEOPS), which make the directory and file changes done by
+  <command>CREATE DATABASE</command>, <command>ALTER DATABASE SET
+  TABLESPACE</command>, <command>CREATE TABLESPACE</command> and
+  <command>DROP TABLESPACE</command> roll back with the transaction.
+ </para>
+
+ <sect1 id="undo-fileops">
+  <title>Transactional Filesystem Operations</title>
+
+  <para>
+   When a transaction creates a file or directory, changes a file's mode or
+   owner, truncates or writes a file, or creates a symbolic link, FILEOPS
+   writes a WAL record describing the change and an UNDO record describing
+   how to reverse it.  If the transaction commits, the UNDO record is never
+   used.  If it rolls back, the backend applies the transaction's UNDO
+   records, newest first, before <command>ROLLBACK</command> returns: a
+   created file is removed, a changed mode is restored, and so on.
+   Destructive operations (removing or renaming a file or directory) are not
+   done immediately; they are deferred to commit and simply dropped on
+   rollback.
+  </para>
+
+  <para>
+   FILEOPS writes WAL and UNDO only when <xref linkend="guc-wal-level"/> is
+   <literal>replica</literal> or higher.  At <literal>minimal</literal> it
+   writes neither, and a crash in the middle of a transaction can leave a
+   partly done filesystem change behind, as without FILEOPS.
+  </para>
+ </sect1>
+
+ <sect1 id="undo-crash-recovery">
+  <title>Crash Recovery and Replication</title>
+
+  <para>
+   At the end of crash recovery, after redo, the startup process finds every
+   transaction that wrote UNDO records but neither committed nor aborted
+   before the crash, and applies its UNDO records before the server accepts
+   connections.  This step takes time proportional to the number of UNDO
+   records those transactions wrote.  A prepared transaction is left
+   prepared; <command>ROLLBACK PREPARED</command> applies its UNDO
+   records later, and <command>COMMIT PREPARED</command> discards them.
+  </para>
+
+  <para>
+   An UNDO record that cannot be applied, for example because the server no
+   longer has permission to change the file it describes, is reported with a
+   <literal>WARNING</literal> and is not retried, and the change it describes
+   stays in place.  The end-of-recovery summary then also reports how many
+   records could not be applied.
+  </para>
+
+  <para>
+   A physical standby replays the WAL record of each filesystem change.
+  </para>
+ </sect1>
+
+ <sect1 id="undo-configuration">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+   <varlistentry>
+    <term><varname>undo_worker_naptime</varname> (<type>integer</type>)</term>
+    <listitem>
+     <para>
+      Time in milliseconds between runs of the UNDO discard worker.
+      Default: <literal>10000</literal> (10 seconds).
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><varname>undo_max_wal_retention_size</varname> (<type>integer</type>)</term>
+    <listitem>
+     <para>
+      If the WAL held back because of in-progress UNDO exceeds this size, in
+      megabytes, the discard worker logs a <literal>WARNING</literal>.
+      <literal>0</literal>, the default, disables the check.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><varname>undo_batch_size_kb</varname> (<type>integer</type>)</term>
+    <term><varname>undo_batch_record_limit</varname> (<type>integer</type>)</term>
+    <listitem>
+     <para>
+      A transaction's UNDO records are collected in memory and written as one
+      WAL record when this many kilobytes (default <literal>256</literal>) or
+      this many records (default <literal>1000</literal>) have accumulated,
+      and at the end of the transaction.
+     </para>
+    </listitem>
+   </varlistentry>
+  </variablelist>
+ </sect1>
+
+ <sect1 id="undo-space-management">
+  <title>UNDO Space Management</title>
+
+  <para>
+   UNDO records live in the WAL stream as <literal>XLOG_UNDO_BATCH</literal>
+   records; there are no separate UNDO files.  A checkpoint does not remove
+   WAL that still holds the UNDO records of a transaction that is in progress
+   or prepared, so a long-running or long-prepared transaction that wrote UNDO
+   holds back WAL removal until it ends, much as a replication slot does.
+  </para>
+ </sect1>
+
+ <sect1 id="undo-monitoring">
+  <title>Monitoring</title>
+
+  <para>
+   The server log shows UNDO being applied during crash recovery:
+  </para>
+
+<programlisting>
+LOG:  UNDO recovery: rolling back transaction 742
+LOG:  UNDO recovery complete: 1 transactions rolled back, 3 records applied
+</programlisting>
+
+  <para>
+   and, if some records could not be applied:
+  </para>
+
+<programlisting>
+WARNING:  UNDO recovery complete: 1 transactions processed, 0 records applied, 1 records could not be applied
+DETAIL:  The changes those records describe were not rolled back.
+</programlisting>
+
+  <para>
+   <application>pg_waldump</application> shows UNDO records with
+   <option>--rmgr=Undo</option> and the filesystem changes themselves with
+   <option>--rmgr=FileOps</option>.
+  </para>
+ </sect1>
+
+</chapter>
diff --git a/src/backend/access/Makefile b/src/backend/access/Makefile
index e88d72ea039..5778d0d7f82 100644
--- a/src/backend/access/Makefile
+++ b/src/backend/access/Makefile
@@ -22,6 +22,7 @@ SUBDIRS	    = \
 	sequence \
 	table \
 	tablesample \
-	transam
+	transam \
+	undo \
 
 include $(top_srcdir)/src/backend/common.mk
diff --git a/src/backend/access/meson.build b/src/backend/access/meson.build
index 5fd18de74f9..d569ac4e6e3 100644
--- a/src/backend/access/meson.build
+++ b/src/backend/access/meson.build
@@ -14,3 +14,4 @@ subdir('spgist')
 subdir('table')
 subdir('tablesample')
 subdir('transam')
+subdir('undo')
diff --git a/src/backend/access/rmgrdesc/Makefile b/src/backend/access/rmgrdesc/Makefile
index cd95eec37f1..bf6709e738d 100644
--- a/src/backend/access/rmgrdesc/Makefile
+++ b/src/backend/access/rmgrdesc/Makefile
@@ -29,6 +29,7 @@ OBJS = \
 	spgdesc.o \
 	standbydesc.o \
 	tblspcdesc.o \
+	undodesc.o \
 	xactdesc.o \
 	xlogdesc.o
 
diff --git a/src/backend/access/rmgrdesc/meson.build b/src/backend/access/rmgrdesc/meson.build
index d9000ccd9fd..d0dc4cb229a 100644
--- a/src/backend/access/rmgrdesc/meson.build
+++ b/src/backend/access/rmgrdesc/meson.build
@@ -22,6 +22,7 @@ rmgr_desc_sources = files(
   'spgdesc.c',
   'standbydesc.c',
   'tblspcdesc.c',
+  'undodesc.c',
   'xactdesc.c',
   'xlogdesc.c',
 )
diff --git a/src/backend/access/rmgrdesc/undodesc.c b/src/backend/access/rmgrdesc/undodesc.c
new file mode 100644
index 00000000000..d4817684ad4
--- /dev/null
+++ b/src/backend/access/rmgrdesc/undodesc.c
@@ -0,0 +1,209 @@
+/*-------------------------------------------------------------------------
+ *
+ * undodesc.c
+ *	  rmgr descriptor routines for access/undo
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/backend/access/rmgrdesc/undodesc.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/undo_xlog.h"
+#include "access/xlogreader.h"
+
+/*
+ * undo_desc - Describe an UNDO WAL record for pg_waldump
+ *
+ * This function generates human-readable output for UNDO WAL records,
+ * used by pg_waldump and other debugging tools.
+ */
+void
+undo_desc(StringInfo buf, XLogReaderState *record)
+{
+	char	   *rec = XLogRecGetData(record);
+	uint8		info = XLogRecGetInfo(record) & ~XLR_INFO_MASK;
+
+	switch (info)
+	{
+		case XLOG_UNDO_ALLOCATE:
+			{
+				xl_undo_allocate *xlrec = (xl_undo_allocate *) rec;
+
+				appendStringInfo(buf, "log %u, start %llu, len %u, xid %u",
+								 xlrec->log_number,
+								 (unsigned long long) xlrec->start_ptr,
+								 xlrec->length,
+								 xlrec->xid);
+			}
+			break;
+
+		case XLOG_UNDO_DISCARD:
+			{
+				xl_undo_discard *xlrec = (xl_undo_discard *) rec;
+
+				appendStringInfo(buf, "log %u, discard_ptr %llu, oldest_xid %u",
+								 xlrec->log_number,
+								 (unsigned long long) xlrec->discard_ptr,
+								 xlrec->oldest_xid);
+			}
+			break;
+
+		case XLOG_UNDO_EXTEND:
+			{
+				xl_undo_extend *xlrec = (xl_undo_extend *) rec;
+
+				appendStringInfo(buf, "log %u, new_size %llu",
+								 xlrec->log_number,
+								 (unsigned long long) xlrec->new_size);
+			}
+			break;
+
+		case XLOG_UNDO_APPLY_RECORD:
+			{
+				xl_undo_apply *xlrec = (xl_undo_apply *) rec;
+				const char *op_name;
+
+				switch (xlrec->operation_type)
+				{
+					case 0x0001:
+						op_name = "INSERT";
+						break;
+					case 0x0002:
+						op_name = "DELETE";
+						break;
+					case 0x0003:
+						op_name = "UPDATE";
+						break;
+					case 0x0004:
+						op_name = "PRUNE";
+						break;
+					case 0x0005:
+						op_name = "INPLACE";
+						break;
+					case 0x0006:
+						op_name = "HOT_UPDATE";
+						break;
+					default:
+						op_name = "UNKNOWN";
+						break;
+				}
+
+				appendStringInfo(buf,
+								 "undo apply %s: urec_ptr %llu, xid %u, "
+								 "block %u, offset %u, clr_flags 0x%04x, "
+								 "tuple_len %u",
+								 op_name,
+								 (unsigned long long) xlrec->urec_ptr,
+								 xlrec->xid,
+								 xlrec->target_block,
+								 xlrec->target_offset,
+								 xlrec->clr_flags,
+								 xlrec->tuple_len);
+			}
+			break;
+
+		case XLOG_UNDO_PAGE_WRITE:
+			{
+				xl_undo_page_write *xlrec = (xl_undo_page_write *) rec;
+
+				appendStringInfo(buf, "page_offset %u, data_len %u",
+								 xlrec->page_offset,
+								 xlrec->data_len);
+			}
+			break;
+
+		case XLOG_UNDO_BATCH:
+			{
+				xl_undo_batch *xlrec = (xl_undo_batch *) rec;
+
+				appendStringInfo(buf,
+								 "undo batch: xid %u, nrecords %u, "
+								 "total_len %u, chain_prev %X/%X, "
+								 "primary_reloid %u, persistence %d",
+								 xlrec->xid,
+								 xlrec->nrecords,
+								 xlrec->total_len,
+								 LSN_FORMAT_ARGS(xlrec->chain_prev),
+								 xlrec->primary_reloid,
+								 xlrec->persistence);
+			}
+			break;
+
+		case XLOG_UNDO_ROTATE:
+			{
+				xl_undo_rotate *xlrec = (xl_undo_rotate *) rec;
+				const char *trigger_name;
+
+				switch (xlrec->trigger)
+				{
+					case UNDO_ROTATE_CAPACITY:
+						trigger_name = "capacity";
+						break;
+					case UNDO_ROTATE_CHECKPOINT:
+						trigger_name = "checkpoint";
+						break;
+					case UNDO_ROTATE_PRESSURE:
+						trigger_name = "pressure";
+						break;
+					case UNDO_ROTATE_MANUAL:
+						trigger_name = "manual";
+						break;
+					default:
+						trigger_name = "unknown";
+						break;
+				}
+
+				appendStringInfo(buf,
+								 "old_log %u, seal_ptr %llu, new_log %u, "
+								 "trigger %s",
+								 xlrec->old_log_number,
+								 (unsigned long long) xlrec->old_seal_ptr,
+								 xlrec->new_log_number,
+								 trigger_name);
+			}
+			break;
+	}
+}
+
+/*
+ * undo_identify - Identify an UNDO WAL record type
+ *
+ * Returns a string identifying the operation type for debugging output.
+ */
+const char *
+undo_identify(uint8 info)
+{
+	const char *id = NULL;
+
+	switch (info & ~XLR_INFO_MASK)
+	{
+		case XLOG_UNDO_ALLOCATE:
+			id = "ALLOCATE";
+			break;
+		case XLOG_UNDO_DISCARD:
+			id = "DISCARD";
+			break;
+		case XLOG_UNDO_EXTEND:
+			id = "EXTEND";
+			break;
+		case XLOG_UNDO_APPLY_RECORD:
+			id = "APPLY_RECORD";
+			break;
+		case XLOG_UNDO_ROTATE:
+			id = "ROTATE";
+			break;
+		case XLOG_UNDO_PAGE_WRITE:
+			id = "PAGE_WRITE";
+			break;
+		case XLOG_UNDO_BATCH:
+			id = "BATCH";
+			break;
+	}
+
+	return id;
+}
diff --git a/src/backend/access/transam/rmgr.c b/src/backend/access/transam/rmgr.c
index 4fda03a3cfc..130eb06bee3 100644
--- a/src/backend/access/transam/rmgr.c
+++ b/src/backend/access/transam/rmgr.c
@@ -40,6 +40,7 @@
 #include "replication/origin.h"
 #include "storage/standby.h"
 #include "utils/relmapper.h"
+#include "access/undo_xlog.h"
 /* IWYU pragma: end_keep */
 
 
diff --git a/src/backend/access/transam/twophase.c b/src/backend/access/transam/twophase.c
index d89afb09320..9463c065e1f 100644
--- a/src/backend/access/transam/twophase.c
+++ b/src/backend/access/transam/twophase.c
@@ -77,6 +77,8 @@
 #include <unistd.h>
 
 #include "access/commit_ts.h"
+#include "access/undorecord.h"
+#include "access/xactundo.h"
 #include "access/htup_details.h"
 #include "access/subtrans.h"
 #include "access/transam.h"
@@ -163,6 +165,17 @@ typedef struct GlobalTransactionData
 	 */
 	XLogRecPtr	prepare_start_lsn;	/* XLOG offset of prepare record start */
 	XLogRecPtr	prepare_end_lsn;	/* XLOG offset of prepare record end */
+
+	/*
+	 * Permanent-level UNDO chain-head LSN for this prepared xact, or
+	 * InvalidXLogRecPtr if it generated no cluster-wide UNDO.  Set at PREPARE
+	 * (MarkAsPreparingGuts), at redo (PrepareRedoAdd), and when a stale 2PC
+	 * file is recovered from disk.  UndoGetOldestBatchLSN() scans this so the
+	 * UNDO-batch WAL a still-prepared xact needs for ROLLBACK PREPARED is not
+	 * recycled -- the per-backend retention slot is cleared when the preparing
+	 * backend exits, so it does not cover this case.
+	 */
+	XLogRecPtr	undo_batch_lsn;
 	FullTransactionId fxid;		/* The GXACT full xid */
 
 	Oid			owner;			/* ID of user that executed the xact */
@@ -493,6 +506,7 @@ MarkAsPreparingGuts(GlobalTransaction gxact, FullTransactionId fxid,
 	gxact->locking_backend = MyProcNumber;
 	gxact->valid = false;
 	gxact->inredo = false;
+	gxact->undo_batch_lsn = GetCurrentXactLastBatchLSN(UNDOPERSISTENCE_PERMANENT);
 	strlcpy(gxact->gid, gid, GIDSIZE);
 
 	/*
@@ -979,8 +993,14 @@ TwoPhaseFilePath(char *path, FullTransactionId fxid)
 
 /*
  * Header for a 2PC state file
+ *
+ * TWOPHASE_MAGIC must be bumped whenever xl_xact_prepare changes layout.
+ * The struct gained last_batch_lsn[NUndoPersistenceLevels] (24 bytes) for
+ * UNDO chain tracking across 2PC boundaries, requiring this bump from
+ * 0x57F94534 to 0x57F94535 to prevent old servers from silently misreading
+ * the variable-length arrays that follow the fixed header at the wrong offsets.
  */
-#define TWOPHASE_MAGIC	0x57F94534	/* format identifier */
+#define TWOPHASE_MAGIC	0x57F94535	/* format identifier */
 
 typedef xl_xact_prepare TwoPhaseFileHeader;
 
@@ -1102,6 +1122,10 @@ StartPrepare(GlobalTransaction gxact)
 	hdr.origin_lsn = InvalidXLogRecPtr;
 	hdr.origin_timestamp = 0;
 
+	/* Save UNDO chain head LSNs so recovery can find UNDO records */
+	for (int j = 0; j < NUndoPersistenceLevels; j++)
+		hdr.last_batch_lsn[j] = GetCurrentXactLastBatchLSN(j);
+
 	save_state_data(&hdr, sizeof(TwoPhaseFileHeader));
 	save_state_data(gxact->gid, hdr.gidlen);
 
@@ -1499,6 +1523,47 @@ StandbyTransactionIdIsPrepared(TransactionId xid)
 	return result;
 }
 
+/*
+ * RecoveryTransactionIdIsPrepared
+ *		Check if a transaction ID is in the in-memory prepared transaction list.
+ *
+ * This is used during crash recovery UNDO phase, before prepared transaction
+ * files exist on disk. It checks the in-memory TwoPhaseState that was
+ * reconstructed from WAL replay.
+ */
+bool
+RecoveryTransactionIdIsPrepared(TransactionId xid)
+{
+	int			i;
+	FullTransactionId fxid;
+
+	Assert(TransactionIdIsValid(xid));
+
+	if (max_prepared_xacts <= 0)
+		return false;			/* 2PC not enabled */
+
+	if (TwoPhaseState == NULL)
+		return false;			/* 2PC not initialized yet */
+
+	fxid = AdjustToFullTransactionId(xid);
+
+	LWLockAcquire(TwoPhaseStateLock, LW_SHARED);
+
+	for (i = 0; i < TwoPhaseState->numPrepXacts; i++)
+	{
+		GlobalTransaction gxact = TwoPhaseState->prepXacts[i];
+
+		if (FullTransactionIdEquals(gxact->fxid, fxid))
+		{
+			LWLockRelease(TwoPhaseStateLock);
+			return true;
+		}
+	}
+
+	LWLockRelease(TwoPhaseStateLock);
+	return false;
+}
+
 /*
  * FinishPreparedTransaction: execute COMMIT PREPARED or ROLLBACK PREPARED
  */
@@ -1586,6 +1651,7 @@ FinishPreparedTransaction(const char *gid, bool isCommit)
 										hdr->ninvalmsgs, invalmsgs,
 										hdr->initfileinval, gid);
 	else
+	{
 		RecordTransactionAbortPrepared(xid,
 									   hdr->nsubxacts, children,
 									   hdr->nabortrels, abortrels,
@@ -1593,6 +1659,26 @@ FinishPreparedTransaction(const char *gid, bool isCommit)
 									   abortstats,
 									   gid);
 
+		/*
+		 * ROLLBACK PREPARED: apply any cluster-wide UNDO chain synchronously,
+		 * here, before the prepared transaction's resources are torn down.
+		 * The permanent-level chain-head LSN was durably saved in the 2PC
+		 * header at PREPARE; ApplyUndoChainFromWAL() walks the UNDO chain
+		 * backwards from that LSN, restoring before-images.
+		 *
+		 * Only the permanent level is applied: TEMP undo is gone (the
+		 * originating backend exited) and UNLOGGED forks are reset on crash;
+		 * this matches the crash-recovery UNDO phase in undo_xlog.c.
+		 */
+		{
+			XLogRecPtr	perm_lsn =
+				hdr->last_batch_lsn[UNDOPERSISTENCE_PERMANENT];
+
+			if (XLogRecPtrIsValid(perm_lsn))
+				(void) ApplyUndoChainFromWAL(perm_lsn);
+		}
+	}
+
 	ProcArrayRemove(proc, latestXid);
 
 	/*
@@ -2173,6 +2259,14 @@ RecoverPreparedTransactions(void)
 							hdr->prepared_at,
 							hdr->owner, hdr->database);
 
+		/*
+		 * MarkAsPreparingGuts read the live backend's last_batch_lsn (wrong
+		 * xact during recovery); restore this xact's real value from the 2PC
+		 * header so WAL retention pins its UNDO batch until ROLLBACK
+		 * PREPARED.
+		 */
+		gxact->undo_batch_lsn = hdr->last_batch_lsn[UNDOPERSISTENCE_PERMANENT];
+
 		/* recovered, so reset the flag for entries generated by redo */
 		gxact->inredo = false;
 
@@ -2646,6 +2740,7 @@ PrepareRedoAdd(FullTransactionId fxid, char *buf,
 	gxact->valid = false;
 	gxact->ondisk = !XLogRecPtrIsValid(start_lsn);
 	gxact->inredo = true;		/* yes, added in redo */
+	gxact->undo_batch_lsn = hdr->last_batch_lsn[UNDOPERSISTENCE_PERMANENT];
 	strlcpy(gxact->gid, gid, GIDSIZE);
 
 	/* And insert it into the active array */
@@ -2927,3 +3022,36 @@ TwoPhaseGetOldestXidInCommit(void)
 
 	return oldestRunningXid;
 }
+
+/*
+ * TwoPhaseGetOldestUndoBatchLSN
+ *		Return the oldest permanent-level UNDO chain-head LSN across all
+ *		prepared transactions, or InvalidXLogRecPtr if none generated UNDO.
+ *
+ * UndoGetOldestBatchLSN() folds this into the WAL-retention horizon so the
+ * UNDO-batch WAL a still-prepared xact needs for ROLLBACK PREPARED survives
+ * checkpoints and backend exit (see undo_batch_lsn in GlobalTransactionData).
+ */
+XLogRecPtr
+TwoPhaseGetOldestUndoBatchLSN(void)
+{
+	XLogRecPtr	oldest = InvalidXLogRecPtr;
+
+	if (max_prepared_xacts <= 0)
+		return InvalidXLogRecPtr;
+
+	LWLockAcquire(TwoPhaseStateLock, LW_SHARED);
+
+	for (int i = 0; i < TwoPhaseState->numPrepXacts; i++)
+	{
+		XLogRecPtr	lsn = TwoPhaseState->prepXacts[i]->undo_batch_lsn;
+
+		if (XLogRecPtrIsValid(lsn) &&
+			(!XLogRecPtrIsValid(oldest) || lsn < oldest))
+			oldest = lsn;
+	}
+
+	LWLockRelease(TwoPhaseStateLock);
+
+	return oldest;
+}
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index 49e64939667..7b0ef34508b 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -26,6 +26,10 @@
 #include "access/subtrans.h"
 #include "access/transam.h"
 #include "access/twophase.h"
+#include "access/undo_xlog.h"
+#include "access/undolog.h"
+#include "access/undorecord.h"
+#include "access/xactundo.h"
 #include "access/xact.h"
 #include "access/xlog.h"
 #include "access/xloginsert.h"
@@ -218,6 +222,7 @@ typedef struct TransactionStateData
 	bool		parallelChildXact;	/* is any parent transaction parallel? */
 	bool		chain;			/* start a new block after this one */
 	bool		topXidLogged;	/* for a subxact: is top-level XID logged? */
+	uint64		undoRecPtr;		/* most recent UNDO record in chain */
 	struct TransactionStateData *parent;	/* back link to parent */
 } TransactionStateData;
 
@@ -317,6 +322,16 @@ typedef struct XactCallbackItem
 
 static XactCallbackItem *Xact_callbacks = NULL;
 
+/*
+ * Pending-structural-file-operation hooks, mirroring smgr's
+ * own pending-deletes housekeeping ordering (see access/xact.h).  NULL when
+ * no such subsystem is compiled in.
+ */
+void		(*PendingPhysOpsDo_hook) (bool isCommit) = NULL;
+void		(*PendingPhysOpsPostPrepare_hook) (void) = NULL;
+void		(*PendingPhysOpsAtSubCommit_hook) (void) = NULL;
+void		(*PendingPhysOpsAtSubAbort_hook) (void) = NULL;
+
 /*
  * List of add-on start- and end-of-subxact callbacks
  */
@@ -419,6 +434,37 @@ IsAbortedTransactionBlockState(void)
 }
 
 
+/*
+ *	EnterInlineUndoApplyState / LeaveInlineUndoApplyState
+ *
+ * AtAbort_XactUndo() may apply UNDO records synchronously in this backend
+ * while AbortTransaction() has already set s->state = TRANS_ABORT but has
+ * not yet torn down the relcache, locks, or resource owner.  UNDO appliers
+ * (e.g. a table AM's undo-apply callback) need to open relations, which trips
+ * Assert(IsTransactionState()) because TRANS_ABORT is not TRANS_INPROGRESS.
+ *
+ * These helpers temporarily present TRANS_INPROGRESS for the duration of the
+ * inline apply.  This is safe because all backing resources are still live at
+ * this point in the abort sequence; only the state enum has advanced.  The
+ * caller MUST pair Enter/Leave (use PG_TRY/PG_FINALLY) so the real abort state
+ * is always restored before the rest of AbortTransaction() proceeds.
+ */
+int
+EnterInlineUndoApplyState(void)
+{
+	TransactionState s = CurrentTransactionState;
+	int			saved = (int) s->state;
+
+	s->state = TRANS_INPROGRESS;
+	return saved;
+}
+
+void
+LeaveInlineUndoApplyState(int saved)
+{
+	CurrentTransactionState->state = (TransState) saved;
+}
+
 /*
  *	GetTopTransactionId
  *
@@ -1124,6 +1170,36 @@ IsInParallelMode(void)
 	return s->parallelModeLevel != 0 || s->parallelChildXact;
 }
 
+/*
+ * SetCurrentTransactionUndoRecPtr
+ *		Set the most recent UNDO record pointer for the current transaction.
+ *
+ * Called from heap_insert/delete/update when they generate UNDO records.
+ * The pointer is used during abort to walk the UNDO chain and apply
+ * compensation operations.
+ */
+void
+SetCurrentTransactionUndoRecPtr(uint64 undo_ptr)
+{
+	TransactionState s = CurrentTransactionState;
+
+	s->undoRecPtr = undo_ptr;
+}
+
+/*
+ * GetCurrentTransactionUndoRecPtr
+ *		Get the most recent UNDO record pointer for the current transaction.
+ *
+ * Returns InvalidUndoRecPtr (0) if no UNDO records have been generated.
+ */
+uint64
+GetCurrentTransactionUndoRecPtr(void)
+{
+	TransactionState s = CurrentTransactionState;
+
+	return s->undoRecPtr;
+}
+
 /*
  *	CommandCounterIncrement
  */
@@ -2158,6 +2234,7 @@ StartTransaction(void)
 	s->childXids = NULL;
 	s->nChildXids = 0;
 	s->maxChildXids = 0;
+	s->undoRecPtr = 0;			/* no UNDO records yet */
 
 	/*
 	 * Once the current user ID and the security context flags are fetched,
@@ -2464,6 +2541,9 @@ CommitTransaction(void)
 	CallXactCallbacks(is_parallel_worker ? XACT_EVENT_PARALLEL_COMMIT
 					  : XACT_EVENT_COMMIT);
 
+	/* Clean up transaction undo state (free per-persistence record sets) */
+	AtCommit_XactUndo();
+
 	CurrentResourceOwner = NULL;
 	ResourceOwnerRelease(TopTransactionResourceOwner,
 						 RESOURCE_RELEASE_BEFORE_LOCKS,
@@ -2508,6 +2588,8 @@ CommitTransaction(void)
 	 * attempt to access affected files.
 	 */
 	smgrDoPendingDeletes(true);
+	if (PendingPhysOpsDo_hook)
+		PendingPhysOpsDo_hook(true);
 
 	/*
 	 * Send out notification signals to other backends (and do other
@@ -2684,6 +2766,40 @@ PrepareTransaction(void)
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("cannot PREPARE a transaction that has exported snapshots")));
 
+	/*
+	 * Don't allow PREPARE TRANSACTION if this transaction generated any UNDO
+	 * that has no working ROLLBACK PREPARED apply path.  cluster-wide UNDO's
+	 * chain-head LSN is durably saved in the 2PC state file
+	 * (xl_xact_prepare.last_batch_lsn) and FinishPreparedTransaction()
+	 * applies it synchronously via ApplyUndoChainFromWAL() on ROLLBACK
+	 * PREPARED, so cluster-wide UNDO is recoverable across 2PC.  This choke
+	 * point stays so any future UNDO mechanism that is not 2PC-safe can
+	 * re-assert a guard.
+	 */
+	if (XactUndoHasUnrecoverableUndo())
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("cannot PREPARE a transaction that generated UNDO"),
+				 errhint("Commit or roll back the transaction without PREPARE TRANSACTION.")));
+
+	/*
+	 * Write out any UNDO records the index AMs deferred for batching.
+	 *
+	 * This must happen before MarkAsPreparing() and StartPrepare(), both of
+	 * which snapshot GetCurrentXactLastBatchLSN() into the 2PC state
+	 * (gxact->undo_batch_lsn and xl_xact_prepare.last_batch_lsn).  That
+	 * snapshot is the only chain head ROLLBACK PREPARED and recovery ever
+	 * get, so a record still sitting in the pending batch at that moment
+	 * would be dropped from the prepared transaction's chain -- and, unlike
+	 * the abort path, there is no later opportunity to notice.  Flushing here
+	 * also keeps the ERROR above as the last thing that can fail before 2PC
+	 * state is written.
+	 *
+	 * Not inside AtPrepare_XactUndo(): that runs at the very end of
+	 * PrepareTransaction(), long after the LSN has been captured.
+	 */
+	XactUndoFlushPending();
+
 	/* Prevent cancel/die interrupt while cleaning up */
 	HOLD_INTERRUPTS();
 
@@ -2796,6 +2912,8 @@ PrepareTransaction(void)
 	PostPrepare_Inval();
 
 	PostPrepare_smgr();
+	if (PendingPhysOpsPostPrepare_hook)
+		PendingPhysOpsPostPrepare_hook();
 
 	PostPrepare_MultiXact(fxid);
 
@@ -2834,6 +2952,16 @@ PrepareTransaction(void)
 	AtEOXact_LogicalCtl();
 	pgstat_report_xact_timestamp(0);
 
+	/*
+	 * Drop this backend's references to the transaction's cluster-wide UNDO
+	 * record sets.  Ownership passed to the 2PC state (undo_batch_lsn) in
+	 * EndPrepare; the record sets live in TopTransactionContext, which
+	 * AtCommit_Memory() destroys just below, so the backend-private pointers
+	 * must be dropped first (otherwise AtProcExit_XactUndo frees freed
+	 * memory).
+	 */
+	AtPrepare_XactUndo();
+
 	CurrentResourceOwner = NULL;
 	ResourceOwnerDelete(TopTransactionResourceOwner);
 	s->curTransactionOwner = NULL;
@@ -2943,6 +3071,12 @@ AbortTransaction(void)
 			 TransStateAsString(s->state));
 	Assert(s->parent == NULL);
 
+	/*
+	 * Discard the UNDO record pointer for this transaction.  AtAbort_XactUndo(),
+	 * called below, has the chain head it needs in its own state.
+	 */
+	s->undoRecPtr = 0;
+
 	/*
 	 * set the current transaction state information appropriately during the
 	 * abort processing
@@ -2978,6 +3112,9 @@ AbortTransaction(void)
 	s->parallelModeLevel = 0;
 	s->parallelChildXact = false;	/* should be false already */
 
+	/* Clean up transaction undo state (free per-persistence record sets) */
+	AtAbort_XactUndo();
+
 	/*
 	 * do abort processing
 	 */
@@ -3045,7 +3182,10 @@ AbortTransaction(void)
 		ResourceOwnerRelease(TopTransactionResourceOwner,
 							 RESOURCE_RELEASE_AFTER_LOCKS,
 							 false, true);
+
 		smgrDoPendingDeletes(false);
+		if (PendingPhysOpsDo_hook)
+			PendingPhysOpsDo_hook(false);
 
 		AtEOXact_GUC(false, 1);
 		AtEOXact_SPI(false);
@@ -5244,6 +5384,8 @@ CommitSubTransaction(void)
 	AtEOSubXact_TypeCache();
 	AtEOSubXact_Inval(true);
 	AtSubCommit_smgr();
+	if (PendingPhysOpsAtSubCommit_hook)
+		PendingPhysOpsAtSubCommit_hook();
 
 	/*
 	 * The only lock we actually release here is the subtransaction XID lock.
@@ -5435,6 +5577,8 @@ AbortSubTransaction(void)
 							 RESOURCE_RELEASE_AFTER_LOCKS,
 							 false, false);
 		AtSubAbort_smgr();
+		if (PendingPhysOpsAtSubAbort_hook)
+			PendingPhysOpsAtSubAbort_hook();
 
 		AtEOXact_GUC(false, s->gucNestLevel);
 		AtEOSubXact_SPI(false, s->subTransactionId);
@@ -6461,6 +6605,11 @@ xact_redo(XLogReaderState *record)
 		ParseCommitRecord(XLogRecGetInfo(record), xlrec, &parsed);
 		xact_redo_commit(&parsed, XLogRecGetXid(record),
 						 record->EndRecPtr, XLogRecGetOrigin(record));
+
+		/*
+		 * Remove from UNDO recovery tracking; committed, no rollback needed
+		 */
+		UndoRecoveryRemoveXid(XLogRecGetXid(record));
 	}
 	else if (info == XLOG_XACT_COMMIT_PREPARED)
 	{
@@ -6475,6 +6624,9 @@ xact_redo(XLogReaderState *record)
 		LWLockAcquire(TwoPhaseStateLock, LW_EXCLUSIVE);
 		PrepareRedoRemove(parsed.twophase_xid, false);
 		LWLockRelease(TwoPhaseStateLock);
+
+		/* Remove from UNDO recovery tracking */
+		UndoRecoveryRemoveXid(parsed.twophase_xid);
 	}
 	else if (info == XLOG_XACT_ABORT)
 	{
@@ -6484,6 +6636,13 @@ xact_redo(XLogReaderState *record)
 		ParseAbortRecord(XLogRecGetInfo(record), xlrec, &parsed);
 		xact_redo_abort(&parsed, XLogRecGetXid(record),
 						record->EndRecPtr, XLogRecGetOrigin(record));
+
+		/*
+		 * Remove from UNDO recovery tracking; abort record present means the
+		 * UNDO rollback was already completed (or will be handled by the
+		 * abort record's own redo logic).
+		 */
+		UndoRecoveryRemoveXid(XLogRecGetXid(record));
 	}
 	else if (info == XLOG_XACT_ABORT_PREPARED)
 	{
@@ -6498,12 +6657,22 @@ xact_redo(XLogReaderState *record)
 		LWLockAcquire(TwoPhaseStateLock, LW_EXCLUSIVE);
 		PrepareRedoRemove(parsed.twophase_xid, false);
 		LWLockRelease(TwoPhaseStateLock);
+
+		/* Remove from UNDO recovery tracking */
+		UndoRecoveryRemoveXid(parsed.twophase_xid);
 	}
 	else if (info == XLOG_XACT_PREPARE)
 	{
+		xl_xact_prepare *xlrec = (xl_xact_prepare *) XLogRecGetData(record);
+
 		/*
 		 * Store xid and start/end pointers of the WAL record in TwoPhaseState
 		 * gxact entry.
+		 *
+		 * NB: xl_xact_prepare includes last_batch_lsn[NUndoPersistenceLevels]
+		 * for UNDO chain tracking across 2PC boundaries.  This extended the
+		 * on-disk struct by 24 bytes and required a XLOG_PAGE_MAGIC bump
+		 * (0xD120 -> 0xD121) to prevent misinterpretation by older replicas.
 		 */
 		LWLockAcquire(TwoPhaseStateLock, LW_EXCLUSIVE);
 		PrepareRedoAdd(InvalidFullTransactionId,
@@ -6512,6 +6681,21 @@ xact_redo(XLogReaderState *record)
 					   record->EndRecPtr,
 					   XLogRecGetOrigin(record));
 		LWLockRelease(TwoPhaseStateLock);
+
+		/*
+		 * Restore UNDO recovery tracking for the prepared transaction. The
+		 * UNDO chain LSNs were saved in the prepare record so that if the
+		 * server crashes after PREPARE but before COMMIT/ROLLBACK PREPARED,
+		 * recovery can still find and roll back UNDO records.
+		 */
+		for (int j = 0; j < NUndoPersistenceLevels; j++)
+		{
+			if (!XLogRecPtrIsInvalid(xlrec->last_batch_lsn[j]))
+				UndoRecoveryTrackBatch(xlrec->xid,
+									   xlrec->last_batch_lsn[j],
+									   InvalidXLogRecPtr,
+									   (UndoPersistenceLevel) j);
+		}
 	}
 	else if (info == XLOG_XACT_ASSIGNMENT)
 	{
diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index 8636ad9ab2c..bf13a7f6377 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -55,6 +55,8 @@
 #include "access/timeline.h"
 #include "access/transam.h"
 #include "access/twophase.h"
+#include "access/undolog.h"
+#include "access/undo_xlog.h"
 #include "access/xact.h"
 #include "access/xlog_internal.h"
 #include "access/xlogarchive.h"
@@ -753,6 +755,7 @@ static XLogRecPtr CreateOverwriteContrecordRecord(XLogRecPtr aborted_lsn,
 												  XLogRecPtr pagePtr,
 												  TimeLineID newTLI);
 static void CheckPointGuts(XLogRecPtr checkPointRedo, int flags);
+
 static void KeepLogSeg(XLogRecPtr recptr, XLogSegNo *logSegNo);
 
 static void AdvanceXLInsertBuffer(XLogRecPtr upto, TimeLineID tli,
@@ -6842,6 +6845,15 @@ StartupXLOG(void)
 	if (performedWalRecovery)
 		promoted = PerformRecoveryXLogAction();
 
+	/*
+	 * Apply any UNDO transactions deferred during the UNDO phase.  During the
+	 * UNDO phase, if syscache wasn't available, we deferred applying a
+	 * transaction's UNDO chain.  Now that recovery is complete and relations
+	 * can be opened, apply each deferred chain synchronously.
+	 */
+	if (performedWalRecovery)
+		FlushDeferredUndoXacts();
+
 	/*
 	 * If any of the critical GUCs have changed, log them before we allow
 	 * backends to write WAL.
@@ -7703,6 +7715,16 @@ CreateCheckPoint(int flags)
 	VirtualTransactionId *vxids;
 	int			nvxids;
 	int			oldXLogAllowed = 0;
+	instr_time	phase_start,
+				phase_end;
+	double		syncpre_ms = 0,
+				delay_start_ms = 0,
+				delay_complete_ms = 0,
+				xlogflush_ms = 0,
+				ctlfile_ms = 0,
+				syncpost_ms = 0,
+				removewal_ms = 0,
+				truncsub_ms = 0;
 
 	/*
 	 * An end-of-recovery checkpoint is really a shutdown checkpoint, just
@@ -7733,7 +7755,11 @@ CreateCheckPoint(int flags)
 	 * smgr must not do anything that'd have to be undone if we decide no
 	 * checkpoint is needed.
 	 */
+	INSTR_TIME_SET_CURRENT(phase_start);
 	SyncPreCheckpoint();
+	INSTR_TIME_SET_CURRENT(phase_end);
+	INSTR_TIME_SUBTRACT(phase_end, phase_start);
+	syncpre_ms = INSTR_TIME_GET_MILLISEC(phase_end);
 
 	/* Run these points outside the critical section. */
 	INJECTION_POINT("create-checkpoint-initial", NULL);
@@ -8007,6 +8033,7 @@ CreateCheckPoint(int flags)
 	 * clog and we will correctly flush the update below.  So we cannot miss
 	 * any xacts we need to wait for.
 	 */
+	INSTR_TIME_SET_CURRENT(phase_start);
 	vxids = GetVirtualXIDsDelayingChkpt(&nvxids, DELAY_CHKPT_START);
 	if (nvxids > 0)
 	{
@@ -8026,9 +8053,13 @@ CreateCheckPoint(int flags)
 											  DELAY_CHKPT_START));
 	}
 	pfree(vxids);
+	INSTR_TIME_SET_CURRENT(phase_end);
+	INSTR_TIME_SUBTRACT(phase_end, phase_start);
+	delay_start_ms = INSTR_TIME_GET_MILLISEC(phase_end);
 
 	CheckPointGuts(checkPoint.redo, flags);
 
+	INSTR_TIME_SET_CURRENT(phase_start);
 	vxids = GetVirtualXIDsDelayingChkpt(&nvxids, DELAY_CHKPT_COMPLETE);
 	if (nvxids > 0)
 	{
@@ -8043,6 +8074,9 @@ CreateCheckPoint(int flags)
 											  DELAY_CHKPT_COMPLETE));
 	}
 	pfree(vxids);
+	INSTR_TIME_SET_CURRENT(phase_end);
+	INSTR_TIME_SUBTRACT(phase_end, phase_start);
+	delay_complete_ms = INSTR_TIME_GET_MILLISEC(phase_end);
 
 	/*
 	 * Take a snapshot of running transactions and write this to WAL. This
@@ -8066,7 +8100,11 @@ CreateCheckPoint(int flags)
 						shutdown ? XLOG_CHECKPOINT_SHUTDOWN :
 						XLOG_CHECKPOINT_ONLINE);
 
+	INSTR_TIME_SET_CURRENT(phase_start);
 	XLogFlush(recptr);
+	INSTR_TIME_SET_CURRENT(phase_end);
+	INSTR_TIME_SUBTRACT(phase_end, phase_start);
+	xlogflush_ms = INSTR_TIME_GET_MILLISEC(phase_end);
 
 	/*
 	 * We mustn't write any new WAL after a shutdown checkpoint, or it will be
@@ -8100,6 +8138,7 @@ CreateCheckPoint(int flags)
 	/*
 	 * Update the control file.
 	 */
+	INSTR_TIME_SET_CURRENT(phase_start);
 	LWLockAcquire(ControlFileLock, LW_EXCLUSIVE);
 	if (shutdown)
 		ControlFile->state = DB_SHUTDOWNED;
@@ -8153,6 +8192,9 @@ CreateCheckPoint(int flags)
 
 	UpdateControlFile();
 	LWLockRelease(ControlFileLock);
+	INSTR_TIME_SET_CURRENT(phase_end);
+	INSTR_TIME_SUBTRACT(phase_end, phase_start);
+	ctlfile_ms = INSTR_TIME_GET_MILLISEC(phase_end);
 
 	/*
 	 * We are now done with critical updates; no need for system panic if we
@@ -8182,7 +8224,11 @@ CreateCheckPoint(int flags)
 	/*
 	 * Let smgr do post-checkpoint cleanup (eg, deleting old files).
 	 */
+	INSTR_TIME_SET_CURRENT(phase_start);
 	SyncPostCheckpoint();
+	INSTR_TIME_SET_CURRENT(phase_end);
+	INSTR_TIME_SUBTRACT(phase_end, phase_start);
+	syncpost_ms = INSTR_TIME_GET_MILLISEC(phase_end);
 
 	/*
 	 * Update the average distance between checkpoints if the prior checkpoint
@@ -8211,8 +8257,12 @@ CreateCheckPoint(int flags)
 		KeepLogSeg(recptr, &_logSegNo);
 	}
 	_logSegNo--;
+	INSTR_TIME_SET_CURRENT(phase_start);
 	RemoveOldXlogFiles(_logSegNo, RedoRecPtr, recptr,
 					   checkPoint.ThisTimeLineID);
+	INSTR_TIME_SET_CURRENT(phase_end);
+	INSTR_TIME_SUBTRACT(phase_end, phase_start);
+	removewal_ms = INSTR_TIME_GET_MILLISEC(phase_end);
 
 	/*
 	 * Make more log segments if needed.  (Do this after recycling old log
@@ -8228,8 +8278,24 @@ CreateCheckPoint(int flags)
 	 * in subtrans.c).  During recovery, though, we mustn't do this because
 	 * StartupSUBTRANS hasn't been called yet.
 	 */
+	INSTR_TIME_SET_CURRENT(phase_start);
 	if (!RecoveryInProgress())
 		TruncateSUBTRANS(GetOldestTransactionIdConsideredRunning());
+	INSTR_TIME_SET_CURRENT(phase_end);
+	INSTR_TIME_SUBTRACT(phase_end, phase_start);
+	truncsub_ms = INSTR_TIME_GET_MILLISEC(phase_end);
+
+	/* Log phase breakdown for diagnosing slow checkpoints. */
+	if (log_checkpoints)
+		ereport(LOG,
+				(errmsg("checkpoint phase breakdown: "
+						"SyncPre=%.3f s, DelayStart=%.3f s, DelayComplete=%.3f s, "
+						"XLogFlush=%.3f s, ControlFile=%.3f s, SyncPost=%.3f s, "
+						"RemoveWAL=%.3f s, TruncSub=%.3f s",
+						syncpre_ms / 1000.0, delay_start_ms / 1000.0,
+						delay_complete_ms / 1000.0, xlogflush_ms / 1000.0,
+						ctlfile_ms / 1000.0, syncpost_ms / 1000.0,
+						removewal_ms / 1000.0, truncsub_ms / 1000.0)));
 
 	/* Real work is done; log and update stats. */
 	LogCheckpointEnd(false, flags);
@@ -8403,6 +8469,9 @@ CheckPointGuts(XLogRecPtr checkPointRedo, int flags)
 	CheckPointRelationMap();
 	CheckPointReplicationOrigin();
 
+	/* Persist UNDO log discard pointers and log statistics */
+	CheckPointUndoLog();
+
 	/* Write out all dirty data in SLRUs and the main buffer pool */
 	TRACE_POSTGRESQL_BUFFER_CHECKPOINT_START(flags);
 	CheckpointStats.ckpt_write_t = GetCurrentTimestamp();
@@ -8971,6 +9040,26 @@ KeepLogSeg(XLogRecPtr recptr, XLogSegNo *logSegNo)
 			segno = unsummarized_segno;
 	}
 
+	/*
+	 * If UNDO-in-WAL is active, retain WAL segments that contain UNDO records
+	 * still needed for rollback of in-progress transactions.
+	 *
+	 * Scan live per-backend UNDO batch LSN slots at every checkpoint rather
+	 * than using the worker-updated cached horizon, to ensure WAL retention
+	 * is accurate even when the UNDO worker lags.
+	 */
+	{
+		keep = UndoGetOldestBatchLSN();
+		if (XLogRecPtrIsValid(keep))
+		{
+			XLogSegNo	undo_segno;
+
+			XLByteToSeg(keep, undo_segno, wal_segment_size);
+			if (undo_segno < segno)
+				segno = undo_segno;
+		}
+	}
+
 	/* but, keep at least wal_keep_size if that's set */
 	if (wal_keep_size_mb > 0)
 	{
diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c
index 52ac6d58423..069f25de509 100644
--- a/src/backend/access/transam/xlogrecovery.c
+++ b/src/backend/access/transam/xlogrecovery.c
@@ -32,6 +32,8 @@
 
 #include "access/timeline.h"
 #include "access/transam.h"
+#include "access/undo_xlog.h"
+#include "access/undolog.h"
 #include "access/xact.h"
 #include "access/xlog_internal.h"
 #include "access/xlogarchive.h"
@@ -1878,6 +1880,29 @@ PerformWalRecovery(void)
 					(errmsg("last completed transaction was at log time %s",
 							timestamptz_to_str(xtime))));
 
+		/*
+		 * ARIES-style undo phase: roll back incomplete transactions that
+		 * wrote UNDO records (XLOG_UNDO_BATCH) but did not commit.
+		 *
+		 * During the redo phase above, UndoRecoveryTrackBatch() was called
+		 * from the XLOG_UNDO_BATCH redo handler to record which transactions
+		 * have UNDO data.  UndoRecoveryRemoveXid() was called from the
+		 * XLOG_XACT_COMMIT and XLOG_XACT_ABORT redo handlers to remove
+		 * completed transactions.  Any remaining entries represent incomplete
+		 * transactions that need their UNDO chains walked for rollback.
+		 *
+		 * We check UndoRecoveryNeeded() to avoid overhead when no UNDO
+		 * records were present in the WAL stream.
+		 */
+		if (UndoRecoveryNeeded())
+		{
+			ereport(LOG,
+					(errmsg("starting undo phase for incomplete transactions")));
+			PerformUndoRecovery();
+			ereport(LOG,
+					(errmsg("undo phase complete")));
+		}
+
 		InRedo = false;
 	}
 	else
diff --git a/src/backend/access/undo/Makefile b/src/backend/access/undo/Makefile
new file mode 100644
index 00000000000..ba565c07028
--- /dev/null
+++ b/src/backend/access/undo/Makefile
@@ -0,0 +1,27 @@
+#-------------------------------------------------------------------------
+#
+# Makefile--
+#    Makefile for access/undo
+#
+# IDENTIFICATION
+#    src/backend/access/undo/Makefile
+#
+#-------------------------------------------------------------------------
+
+subdir = src/backend/access/undo
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+
+OBJS = \
+	undo.o \
+	undo_xlog.o \
+	undoapply.o \
+	undoinsert.o \
+	undolog.o \
+	undorecord.o \
+	undormgr.o \
+	undostats.o \
+	undoworker.o \
+	xactundo.o
+
+include $(top_srcdir)/src/backend/common.mk
diff --git a/src/backend/access/undo/README b/src/backend/access/undo/README
new file mode 100644
index 00000000000..361d72c3f9d
--- /dev/null
+++ b/src/backend/access/undo/README
@@ -0,0 +1,901 @@
+UNDO Log Management for PostgreSQL
+===================================
+
+This directory contains the implementation of the generic UNDO log system
+for PostgreSQL, providing transactional UNDO logging for in-place-update
+table operations, transactional rollback, and crash recovery of incomplete
+transactions.
+
+## 1. Architecture Overview
+
+The UNDO system adds a separate, append-only log that records the inverse
+of each data modification. Every INSERT, DELETE, UPDATE, and PRUNE
+operation on an UNDO-enabled table writes a record to the UNDO log
+before (or just after, for INSERT) the actual modification. This
+enables two key capabilities:
+
+  1. **Transaction rollback**: On ABORT, the UNDO chain is walked backward
+     and each operation is reversed (delete the inserted row, re-insert
+     the deleted row, etc.).
+
+  2. **Crash recovery**: After ordinary WAL redo, the UNDO of the
+     transactions that were in progress at the crash is applied, so their
+     changes are rolled back.  See "Recovery Model" below.
+
+### UNDO Chain Model
+
+Each transaction that modifies an UNDO-enabled table builds a backward
+chain of UNDO *batches*:
+
+    newest batch --> ... --> oldest batch
+       (last_batch_lsn)         (first batch)
+
+Records accumulate in an UndoRecordSet buffer and are written as one
+`XLOG_UNDO_BATCH` WAL record per batch (UndoRecordSetInsert).  A batch is
+flushed when it crosses `undo_batch_size_kb` / `undo_batch_record_limit`, and
+at every point that can consume the chain -- transaction commit/abort,
+subtransaction commit/abort, and PREPARE.
+
+The chain is linked at BATCH granularity, through the `chain_prev` field of
+the batch's `xl_undo_batch` header, which is set from the writing backend's
+`last_batch_lsn` for that persistence level.  Rollback therefore walks WAL:
+it reads the batch at `last_batch_lsn`, applies that batch's records, then
+follows `chain_prev` to the preceding batch's LSN and repeats.
+
+Within one batch, records are applied in REVERSE of serialization order, as
+ARIES requires.  Order is established positionally -- the applier scans the
+batch forward collecting record offsets, then walks them backwards -- not by
+a per-record link.  The `urec_prev` field in the record header is written but
+is not read by the apply path; see the note on it in undorecord.h.
+
+Subtransaction commit merges the child's chain into the parent.
+Subtransaction abort applies the child's chain immediately, bounded to the
+range of batches the subtransaction produced.
+
+### Opt-In Model
+
+UNDO is always-on infrastructure.  Table access methods opt in by
+implementing the am_supports_undo callback.
+
+### The UNDO Store: Cluster-Wide UndoLog
+
+The cluster-wide UndoLog (undolog.c, undorecord.c, undoapply.c) is one
+logical, append-only UNDO stream shared by all consumers, embedded in the
+WAL (XLOG_UNDO_BATCH).  Discard is governed globally by a single
+cluster-wide horizon (undo_discard_horizon): a batch's WAL is retained
+until every transaction that could still need it has resolved, then the
+WAL segment is recycled normally.  This suits **sparse, cross-object**
+UNDO -- a transaction that touches a handful of unrelated objects (for
+example filesystem operations, or an index AM logging a few structural
+changes) writes one interleaved chain, and the single global horizon is
+the right granularity because such operations are not concentrated on
+any one relation.
+
+
+## Recovery Model
+
+Rollback of a live transaction and crash recovery work as follows.
+
+**Rollback:** At ABORT time, xactundo.c's AtAbort_XactUndo() applies the
+transaction's UNDO chain inline in the aborting backend before ROLLBACK
+returns, so the time ROLLBACK takes grows with the amount of UNDO the
+transaction wrote.  The sparse, cross-object UNDO the cluster-wide store is
+built for (a handful of filesystem or structural operations per
+transaction) keeps that chain short.  Physical application walks the chain
+backward via ApplyUndoChainFromWAL(), generating a CLR
+(XLOG_UNDO_APPLY_RECORD) per page it restores for crash-safety.
+
+**Redo:** Crash recovery first runs PostgreSQL's ordinary WAL replay
+forward from the checkpoint redo point, including XLOG_UNDO_BATCH records
+(which contain UNDO payload data, not a rollback action) and any
+XLOG_UNDO_APPLY_RECORD CLRs already generated before the crash.  There is
+no separate analysis pass building a dirty-page table.
+
+**Undo of loser transactions:** During redo, each XLOG_UNDO_BATCH record
+is registered in an in-memory table keyed by XID, and removed when a
+commit or abort record for that XID is replayed.  After redo,
+PerformUndoRecovery() (undo_xlog.c) walks and applies the UNDO chains of
+the remaining entries -- transactions that wrote UNDO but reached neither
+COMMIT nor ABORT before the crash, other than prepared transactions.  This
+runs in the startup process before the database accepts connections, so
+recovery time grows with the amount of UNDO the loser transactions wrote.
+
+**Inter-transaction UNDO ordering:** Records are applied per-transaction in
+newest-batch-first order within each transaction.  No global LSN ordering across
+concurrent aborted transactions is enforced.  This is safe because PostgreSQL's
+locking model prevents two concurrent transactions from holding conflicting physical
+locks on the same tuple -- there can be no conflicting physical UNDO operations
+between concurrent transactions.
+
+**Idempotency (crash-during-rollback safety):** Redo of a CLR is idempotent
+through full-page-image + page-LSN comparison during ordinary WAL replay.
+See "CLR idempotency" below.
+
+**TEMP and UNLOGGED skip:** During crash recovery, UNDOPERSISTENCE_TEMP records
+are skipped (temporary tables do not survive server restart) and
+UNDOPERSISTENCE_UNLOGGED records are skipped (unlogged table data forks are reset
+to their empty init fork on crash recovery).  This mirrors the behavior of the
+standard heap AM for these persistence levels.
+
+## 2. UndoRecPtr Format
+
+UndoRecPtr is a 64-bit pointer encoding both log identity and position:
+
+    Bits 63-40:  Log number   (24 bits = up to 16M logs)
+    Bits 39-0:   Byte offset  (40 bits = up to 1TB per log)
+
+    #define MakeUndoRecPtr(logno, offset)  (((uint64)(logno) << 40) | (uint64)(offset))
+    #define UndoRecPtrGetLogNo(ptr)        ((uint32)(((uint64)(ptr)) >> 40))
+    #define UndoRecPtrGetOffset(ptr)       (((uint64)(ptr)) & 0xFFFFFFFFFFULL)
+
+InvalidUndoRecPtr is defined as 0. Log number 0 is never allocated
+(next_log_number starts at 1), so offset 0 in log 0 is always invalid.
+
+## 3. UNDO Record Format
+
+Every UNDO record starts with a fixed UndoRecordHeader (see undorecord.h).
+The serialized size is given by SizeOfUndoRecordHeader:
+
+    Offset  Size  Field             Description
+    ------  ----  -----             -----------
+    0       1     urec_rmid         UNDO resource manager ID (dispatches apply)
+    1       1     urec_flags        Generic flags (UNDO_INFO_HAS_PAYLOAD, etc.)
+    2       2     urec_info         RM-specific subtype and flags
+    4       4     urec_len          Total record length including header + payload
+    8       4     urec_xid          Transaction ID
+    12      4     (padding)         Alignment for 8-byte urec_prev
+    16      8     urec_prev         Previous UNDO record in chain (UndoRecPtr)
+    24      4     urec_reloid       Relation OID (InvalidOid if N/A)
+    28      4     urec_payload_len  Length of RM-specific payload that follows
+
+Each record is padded at its tail so that urec_len (the stride to the next
+record) is a multiple of MAXIMUM_ALIGNOF.  This keeps every record header in a
+packed stream at a MAXALIGN'd address, which the writers require because they
+build the header in place through an (UndoRecordHeader *) cast and the header
+has 8-byte members.  The padding bytes are zeroed and are not part of the
+payload: readers advance by urec_len and read urec_payload_len payload bytes,
+so urec_len - SizeOfUndoRecordHeader may exceed urec_payload_len by up to
+MAXIMUM_ALIGNOF - 1 bytes of padding.
+
+The header is AM-agnostic.  The urec_rmid field identifies the resource manager
+that owns the record.  Block number, offset, and tuple data are part of the
+RM-specific opaque payload, not the generic header.
+
+The header carries no per-record "already applied" marker.  Idempotency of
+rollback under crash-during-rollback is achieved through full-page-image +
+page-LSN comparison during ordinary WAL redo; see "CLR idempotency" below for
+the mechanism.
+
+### Record Types (example: an in-place-update table AM's resource manager)
+
+The engine treats urec_info record types as opaque and RM-specific: it does
+not interpret them.  During rollback it dispatches each record to the owning
+resource manager's rm_undo callback (selected by urec_rmid), and that RM
+defines and interprets its own record types in its own urec_info space.
+
+The types below are an example set that a consumer providing an
+in-place-update table AM defines for its own resource manager.  They belong
+to that consumer, not to the engine, and the reversal semantics operate on
+that consumer's page format (the engine is page-format-agnostic):
+
+    UNDO_INSERT  (0x0001)  Marks an INSERT; no tuple payload needed.
+                           Rollback: ItemId marked dead (indexed) or unused.
+
+    UNDO_DELETE  (0x0002)  Stores the full old tuple.
+                           Rollback: restore the old tuple bytes to the page.
+
+    UNDO_UPDATE  (0x0003)  Stores the old tuple version.
+                           Rollback: restore the old tuple bytes to the
+                           original location.
+
+    UNDO_PRUNE   (0x0004)  Stores a pruned tuple (LP_DEAD or LP_UNUSED).
+                           Not rolled back; retained for diagnostics.
+
+    UNDO_INPLACE (0x0005)  Stores old data from in-place update.
+                           Rollback: restore the old tuple bytes in place.
+
+Other resource managers define their own record types in their own
+urec_info space.
+
+### Payload
+
+The payload is an opaque byte sequence whose interpretation is entirely
+RM-specific.  For the example in-place-update AM above, DELETE/UPDATE/PRUNE/INPLACE
+payloads contain a small RM-specific header (block number, offset, tuple
+length) followed by the consumer's raw tuple data.  INSERT records have no
+payload (urec_payload_len = 0).
+
+## 4. Storage Architecture (UNDO-in-WAL)
+
+UNDO records are embedded directly in the standard WAL stream as
+XLOG_UNDO_BATCH records.  There are NO separate UNDO segment files or
+directories.  Embedding UNDO in the WAL eliminates a separate storage
+tier and leverages existing WAL infrastructure for durability, replication,
+and archival.
+
+WAL retention of UNDO batches is governed by undo_discard_horizon, which
+is the oldest XLogRecPtr still needed by an in-flight transaction that
+may abort (always retained).
+
+UNDO records for unresolved (uncommitted/unaborted) transactions are
+NEVER discarded regardless of any retention timer.
+
+## 5. Module Organization
+
+The undo subsystem is split into several modules with clean separation
+of concerns, following the architecture of the EDB undo-record-set branch:
+
+    undo.c          - Central coordination: UndoShmemInit initializes
+                      every subsystem's shared memory.
+                      UndoContext memory context management.
+
+    undolog.c       - UNDO log control structures and WAL batch coordination.
+                      UndoLogControl/UndoLogSharedData structures.
+
+    undorecord.c    - UndoRecordSet and UndoRecordHeader: record format
+                      and batch buffering.
+
+    xactundo.c      - Per-transaction undo management. Maintains up to 3
+                      UndoRecordSets per transaction (one per persistence
+                      level: permanent, unlogged, temporary). Hooks into
+                      xact.c via AtCommit/AtAbort_XactUndo.
+
+    undoapply.c     - Physical undo application during rollback. Walks the
+                      undo chain backward and applies page-level restores
+                      via memcpy. Generates CLRs for crash safety.
+
+    undoinsert.c    - Batch insertion of accumulated records into undo log.
+
+    undo_xlog.c     - WAL redo routines for the RM_UNDO_ID resource manager.
+                      Handles CLR replay (XLOG_UNDO_APPLY_RECORD) using
+                      full page images via XLogReadBufferForRedo.
+
+    undostats.c     - Statistics and monitoring functions.
+
+    undoworker.c    - Background worker for undo record discard.
+
+    undormgr.c      - UNDO resource manager registry. RegisterUndoRmgr()
+                      allows any AM to register an rm_undo callback keyed
+                      by urec_rmid. undoapply.c dispatches to these callbacks.
+
+### Key Types (from undodefs.h)
+
+    UndoRecPtr              - 64-bit pointer to an undo record
+    UndoPersistenceLevel    - Enum: PERMANENT, UNLOGGED, TEMP
+    NUndoPersistenceLevels  - 3 (array index bound)
+    UndoRecordSet           - Opaque batch container for undo records
+    UndoRecordSetType       - URST_TRANSACTION, URST_MULTI, URST_EPHEMERAL
+    UndoRecordSetChunkHeader - On-disk chunk header for multi-chunk sets
+
+### Initialization Flow
+
+    The UNDO subsystem registers ShmemCallbacks via
+    PG_SHMEM_SUBSYSTEM(UndoShmemCallbacks) in storage/subsystemlist.h; the
+    generic shmem machinery calls its request/init/attach hooks, which reach
+    UndoShmemInit() in undo.c:
+
+        UndoShmemInit()  -> UndoLogShmemInit()
+                         -> XactUndoShmemInit()
+                         -> UndoWorkerShmemInit()
+                         -> RegisterUndoRmgrs()
+
+    Per-backend initialization is done by InitializeUndo() which calls
+    InitializeXactUndo() and registers the exit callback.
+
+## 6. Shared Memory Structures (detail)
+
+### UndoLogSharedData
+
+Global control structure in shared memory:
+
+    - logs[MAX_UNDO_LOGS]   Array of UndoLogControl (one per active log)
+    - next_log_number       Counter for allocating new log numbers
+    - allocation_lock       LWLock protecting log allocation
+
+### UndoLogControl
+
+Per-log metadata (one per active log slot):
+
+    - log_number    Log file identity
+    - insert_ptr    UndoRecPtr of next insertion position
+    - discard_ptr   UndoRecPtr; data before this has been discarded
+    - oldest_xid    Oldest transaction still referencing this log
+    - lock          LWLock protecting concurrent access
+    - in_use        Whether this slot is active
+
+## 7. Physical UNDO Application (undoapply.c)
+
+The core design decision is **physical** UNDO application: during rollback,
+stored tuple data is copied directly back to heap pages via memcpy, rather
+than using logical operations (simple_heap_delete, simple_heap_insert).
+
+### Why Physical Over Logical
+
+Physical application stores the complete before-image and restores it with a
+direct page copy, so it cannot fail: there is no executor path, no index
+maintenance, no page-full or TOAST complication, and no visibility check to
+fail during rollback.  Logical application, which would reconstruct the
+inverse operation through table-AM logic, can fail on exactly those
+conditions and therefore is not used on the rollback path.
+
+    Physical (used here):
+    - Stores: Complete tuple data (HeapTupleHeaderData + payload)
+    - Apply:  Direct memcpy to restore exact page state
+    - Safety: Cannot fail (no page-full, no toast, no index conflicts)
+    - WAL:    CLR with full page image (~8 KB per record)
+
+    Logical (not used on the rollback path):
+    - Stores: Operation metadata (INSERT/DELETE/UPDATE type + TID)
+    - Apply:  Reconstruct operation using table AM logic
+    - Safety: Can fail on page-full, toast complications, visibility checks
+    - WAL:    Standard heap WAL records (~50-100 bytes per record)
+
+### Critical Section Pattern
+
+Each UNDO application follows this pattern (from ApplyOneUndoRecord):
+
+    1. Open relation with RowExclusiveLock
+    2. ReadBuffer to get the target page
+    3. LockBuffer(BUFFER_LOCK_EXCLUSIVE)
+    4. START_CRIT_SECTION
+    5. Physical modification (memcpy / ItemId manipulation)
+    6. MarkBufferDirty
+    7. Generate CLR via XLogInsert(RM_UNDO_ID, XLOG_UNDO_APPLY_RECORD)
+       with REGBUF_FORCE_IMAGE for full page image
+    8. PageSetLSN(page, lsn)
+    9. END_CRIT_SECTION
+    10. UnlockReleaseBuffer
+
+There is no "write a CLR pointer back into the UNDO record" step: an UNDO
+record is never updated after it is written.  Step 8 (PageSetLSN) is what makes
+step 9 onward idempotent -- see "CLR idempotency" below.
+
+Key principle: **UNDO record I/O (reading) occurs BEFORE the critical
+section.  Only the page modification and WAL write occur inside the
+critical section.**
+
+### CLR idempotency
+
+The UNDO record header carries no per-record "already applied" marker.
+Idempotency comes from full-page-image (FPI) + page-LSN comparison,
+the same mechanism ordinary WAL redo uses everywhere else in PostgreSQL:
+
+    1. Every CLR (XLOG_UNDO_APPLY_RECORD) is written with
+       REGBUF_FORCE_IMAGE, so it always carries a full-page image of the
+       page it restores.
+    2. After the physical page modification, PageSetLSN(page, lsn) sets
+       the page's LSN to the CLR's own LSN.
+    3. If a crash happens and recovery replays this CLR again (because
+       redo starts from a checkpoint before the CLR), the standard
+       XLogReadBufferForRedo() call used by the CLR's own redo handler
+       compares the on-disk page's LSN to the record being replayed: if
+       the page's LSN is already >= the record's LSN, it returns
+       BLK_DONE (page already reflects this change or a later one) or
+       BLK_RESTORED (an FPI was applied) rather than BLK_NEEDS_REDO, and
+       no further modification happens.
+    4. If instead a NEW rollback attempt (not a WAL replay) re-walks the
+       same UNDO chain -- e.g. ApplyUndoChainFromWAL() called again after
+       an interrupted first attempt -- there is no per-record "already
+       applied" flag to check at all; the record's rm_undo callback is
+       simply re-invoked.  This is safe because the callbacks are
+       themselves idempotent: restoring the same before-image bytes twice
+       produces the same final page state (memcpy is idempotent), and any
+       second CLR this produces is itself subject to the same page-LSN
+       redo-skip logic in step 3.
+
+This prevents double-application and enables idempotent crash recovery
+without ever needing to mutate an already-written UNDO record.
+
+## 8. WAL Integration
+
+### Resource Managers and the RM-ID Namespaces
+
+The UNDO subsystem introduces new WAL resource-manager IDs (in
+access/rmgrlist.h) and, separately, a set of UNDO-record dispatch IDs (in
+access/undormgr.h).  These are two different namespaces and both are shared,
+finite resources, so each new ID must be justified and, once shipped, kept
+stable.
+
+**WAL resource-manager IDs (rmgrlist.h).**  A WAL RM ID is stamped into
+every WAL record and is limited to a one-byte field (RM_MAX_ID = 255, with
+128..255 reserved for custom/extension RMs).  A facility needs its own WAL
+RM only when it emits WAL records with a redo/desc/identify behavior distinct
+from every existing RM; sharing another RM's opcode space would force that
+RM's redo routine to understand records it does not own.  The UNDO stack adds
+these, each because it replays a distinct on-disk change:
+
+    RM_UNDO_ID     "Undo"     Cluster-wide UndoLog: batch allocation, discard,
+                             log extension, and physical CLRs
+                             (XLOG_UNDO_APPLY_RECORD).  Its own RM because
+                             none of these map onto any table/index RM's redo.
+
+  A consumer that writes UNDO through one of these facilities does NOT get
+  its own WAL RM for that purpose; it reuses the facility's RM.  A consumer
+  adds a WAL RM only for WAL it emits itself that no existing RM can redo
+  (for example a table AM's own heap-change records).  Those consumer WAL RMs
+  are added in the consumer's own commit, at the end of rmgrlist.h, so
+  existing IDs never shift (IDs are position-defined and WAL-durable).
+
+**UNDO-record dispatch IDs (undormgr.h, UNDO_RMID_*).**  Independently of WAL
+RM IDs, every UNDO record header carries a urec_rmid that selects which
+registered rm_undo callback reverses it during rollback (see
+access/undormgr.h and access/undormgrlist.h).  This namespace is also finite
+(MAX_UNDO_RMGRS = 256) and stamped into WAL-durable UNDO records, so IDs must
+be unique and stable.  The core header defines only UNDO_RMID_INVALID and the
+built-in index-AM IDs; every other consumer defines its own UNDO_RMID_*
+constant in its own header, in the same commit that registers it via
+access/undormgrlist.h, so the core names no specific consumer.  A new consumer
+takes the next free value and never reuses a retired one.
+
+A resource manager is registered for UNDO-related WAL:
+
+    RM_UNDO_ID    (23)  - UNDO log management operations
+
+### UNDO WAL Record Types
+
+    XLOG_UNDO_ALLOCATE      (0x00)  Space allocated in UNDO log.
+        Fields: start_ptr, length, xid, log_number
+
+    XLOG_UNDO_DISCARD       (0x10)  Discard pointer advanced.
+        Fields: discard_ptr, oldest_xid, log_number
+
+    XLOG_UNDO_EXTEND        (0x20)  Log file extended.
+        Fields: log_number, new_size
+
+    XLOG_UNDO_APPLY_RECORD  (0x30)  CLR: Physical UNDO applied to page.
+        Fields: urec_ptr, xid, target_locator, target_block,
+                target_offset, operation_type
+        Always includes REGBUF_FORCE_IMAGE (full page image).
+
+### WAL Replay
+
+During crash recovery:
+
+    undo_redo() replays UNDO WAL records:
+    - ALLOCATE:     Creates/updates log control structures, advances insert_ptr
+    - DISCARD:      Updates discard_ptr and oldest_xid
+    - EXTEND:       Extends the physical log file
+    - APPLY_RECORD: CLR -- restores full page image via XLogReadBufferForRedo.
+                     Since CLRs use REGBUF_FORCE_IMAGE, the page is restored
+                     directly from the WAL record without re-reading UNDO data.
+
+## 9. Recovery Process
+
+Crash recovery has two steps (see "Recovery Model" above):
+
+    Redo:  Ordinary WAL replay forward (includes UNDO batches and any
+           CLRs generated before the crash) -- PostgreSQL's normal single
+           forward pass, with no separate analysis pass.
+    Undo:  Transactions that were in progress at crash time (neither
+           committed, aborted, nor prepared) are rolled back by
+           PerformUndoRecovery() after redo, before the database
+           accepts connections.
+
+During normal operation, UNDO rollback is handled in-process by
+ApplyUndoChainFromWAL() called from AtAbort_XactUndo() (xactundo.c) on
+abort.
+
+During crash recovery, the UNDO log state is reconstructed by
+redo (including replaying any CLRs generated before the crash),
+and any transactions that were in progress at crash time will be
+rolled back as part of normal recovery.
+
+### ApplyUndoChainFromWAL() -- Physical Application
+
+Walks the UNDO chain backward from the most recent WAL batch,
+applying each record using
+physical page modifications (memcpy, ItemId manipulation):
+
+    INSERT  -> ItemIdSetDead (if indexed) or ItemIdSetUnused
+    DELETE  -> memcpy(page_htup, tuple_data, tuple_len) to restore old tuple
+    UPDATE  -> memcpy(page_htup, tuple_data, tuple_len) to restore old version
+    PRUNE   -> skipped (informational only)
+    INPLACE -> memcpy(page_htup, tuple_data, tuple_len) to restore old data
+
+For each applied record, a CLR is generated via XLogInsert with
+REGBUF_FORCE_IMAGE.  An UNDO record is never rewritten after it is applied --
+see "CLR idempotency" above for the mechanism actually in use
+(full-page image + page-LSN comparison during redo).
+
+Physical application restores the exact before-image bytes with a direct
+page copy, so it cannot fail during rollback: it does not go through the
+executor path, does not maintain indexes, and does not run visibility
+checks.  This is why the rollback path stores and restores complete tuple
+bytes rather than reconstructing the inverse operation logically.
+
+Error handling on the rollback path treats a concurrently dropped relation
+as the one legitimate skip: if the relation an UNDO record targets has been
+dropped, its storage is gone and its UNDO is moot, so a WARNING is emitted
+and processing continues to the next record.  This is not a general
+tolerance for un-appliable records: a live relation whose before-image
+cannot be restored during rollback is a corruption condition, not a
+warn-and-continue case.
+
+### Crash During Rollback
+
+If a crash occurs during rollback:
+
+    1. Recovery replays WAL forward, including any CLRs already generated.
+    2. Pages modified by already-applied UNDO records are restored via
+       the full page images in the CLRs, via the ordinary redo path's
+       page-LSN check (XLogReadBufferForRedo returning BLK_DONE /
+       BLK_RESTORED for a page already at or past the CLR's LSN).
+    3. Any UNDO record whose page-level change was already durable is
+       therefore a no-op if its rm_undo callback runs again; this is decided
+       by the page LSN, not by any per-record applied marker.
+    4. Remaining UNDO records are applied normally, generating new CLRs.
+
+Result: Rollback always completes, even after repeated crashes.
+
+## 10. UNDO Discard Worker
+
+The undoworker background process (undoworker.c) periodically scans
+active transactions and advances discard pointers:
+
+    1. Queries ProcArray for the oldest active transaction
+    2. Identifies UNDO records older than oldest_xid
+    3. Advances discard_ptr (WAL-logged via XLOG_UNDO_DISCARD)
+
+Because the cluster-wide engine stores UNDO records in the normal WAL
+stream rather than in separate segment files, advancing discard_ptr is all
+that is required to reclaim UNDO: the underlying storage is freed when the
+containing WAL segments are recycled at the next checkpoint.  There are no
+UNDO log files to truncate or delete.
+
+### GUC Parameters
+
+Defined in src/backend/utils/misc/guc_parameters.dat; the defaults below are
+that file's boot_val.
+
+    undo_worker_naptime     Sleep interval between discard cycles (ms)
+                            Default: 10000 (10 seconds)
+
+## 11. Performance Characteristics
+
+### Zero Overhead When Disabled
+
+For AMs that do not support UNDO, the only overhead is the
+am_supports_undo check -- a single pointer dereference and comparison.
+No UNDO allocations, writes, or locks are taken.
+
+### Overhead When Active
+
+    INSERT:  One UNDO record (header only, no payload). ~40 bytes
+             (SizeOfUndoRecordHeader).
+    DELETE:  One UNDO record + full tuple copy. 40-byte header + t_len bytes.
+    UPDATE:  One UNDO record + old tuple copy. 40-byte header + t_len bytes.
+    PRUNE:   One UNDO record per pruned tuple. Batched via UndoRecordSet.
+
+UNDO I/O occurs outside critical sections to avoid holding buffer locks
+during writes. For INSERT, UNDO is generated after END_CRIT_SECTION.
+For DELETE/UPDATE/PRUNE, UNDO is generated before START_CRIT_SECTION.
+
+### Abort Overhead
+
+    ABORT:   Each UNDO record applied during rollback generates a CLR
+             WAL record with a full page image (~8 KB per record).
+             Abort latency increases approximately 20-50% compared to
+             PostgreSQL's default rollback, which generates no WAL.
+             WAL volume per abort increases significantly due to CLRs.
+
+    RECOVERY: Checkpoint time increases 7-15% due to more dirty buffers.
+              Recovery time increases 10-20% due to CLR replay.
+
+Trade-off: Higher abort overhead in exchange for crash safety and
+standby support.  For workloads where aborts are rare, the overhead
+is negligible.
+
+## 13. Monitoring and Troubleshooting
+
+### Monitoring Functions
+
+The following C functions are implemented (undostats.c):
+
+    pg_stat_get_undo_logs()      Per-log statistics (size, discard progress)
+    pg_stat_get_undo_buffers()   Buffer hit/miss/eviction statistics
+    pg_undo_force_discard()      Force discard of old UNDO records
+
+They have pg_proc.dat entries.  In this series nothing creates an UNDO log
+(no XLOG_UNDO_ALLOCATE writer), so pg_stat_get_undo_logs() returns no rows,
+pg_stat_get_undo_buffers() returns zeros, and pg_undo_force_discard()
+returns 0.  UNDO lives in WAL; its retention is set by
+UndoGetOldestBatchLSN() at checkpoint.
+
+### Key Log Messages
+
+    DEBUG1  "created UNDO log file: ..."
+    DEBUG1  "applying UNDO chain starting at ..."
+    DEBUG2  "transaction %u committed with UNDO chain starting at %llu"
+    DEBUG2  "UNDO log %u: discard pointer updated to offset %llu"
+    WARNING "UNDO rollback: relation %u no longer exists, skipping"
+
+### Common Issues
+
+    "too many UNDO logs active"
+        The compile-time limit MAX_UNDO_LOGS (100) was reached. Each
+        concurrent writer to an UNDO-enabled table needs an active log.
+
+    "UNDO log %u would exceed segment size"
+        The segment capacity threshold was reached.  The UNDO log will
+        seal the current segment and rotate to a fresh one via
+        UndoLogSealAndRotate().  If rotation fails due to backpressure,
+        the transaction may block until space is reclaimed.
+
+    Growing WAL retention from UNDO
+        Check that the UNDO worker is running (pg_stat_activity).
+        Long-running transactions prevent discard.
+
+## 14. File Structure
+
+### Backend Implementation (src/backend/access/undo/)
+
+    undo.c         Central coordination, shared memory aggregation
+    undolog.c      Core log file management, allocation, I/O, segment rotation
+    undorecord.c   Record format, UndoRecordSet
+    undoinsert.c   Batch insertion of accumulated records
+    undoapply.c    Physical rollback: ApplyUndoChainFromWAL(), memcpy-based restore, CLRs
+    xactundo.c     Per-transaction undo management, per-persistence-level sets
+    undo_xlog.c    WAL redo routines, CLR replay, segment rotation WAL
+    undoworker.c   Background discard worker, rotation checks
+    undostats.c    Statistics collection, segment state tracking
+    undormgr.c     UNDO resource manager dispatch (RegisterUndoRmgr, per-AM callbacks)
+
+### Header Files (src/include/access/)
+
+    undodefs.h      Core type definitions (UndoRecPtr, UndoPersistenceLevel)
+    undo.h          Central coordination API
+    undolog.h       UndoLogControl, UndoLogSharedData, log management API
+    undorecord.h    UndoRecordHeader, record types, UndoRecordSet
+    undo_xlog.h     WAL record structures (xl_undo_allocate, xl_undo_apply, etc.)
+    xactundo.h      Per-transaction undo API (PrepareXactUndoData, etc.)
+    undoworker.h    Worker shared memory and GUC declarations
+    undostats.h     Statistics structures and functions
+    undormgr.h      UNDO resource manager registration API (per-AM dispatch)
+
+### Modified Core Files
+
+    src/backend/access/transam/xact.c         Transaction UNDO chain tracking
+    src/backend/access/transam/rmgr.c         Resource manager registration
+    src/include/storage/subsystemlist.h       Shared memory subsystem registration
+    src/include/access/rmgrlist.h             RM_UNDO_ID
+    src/include/access/xact.h                 UNDO chain accessors
+
+The heap AM is NOT modified for UNDO: heapam.c, heapam_handler.c and
+pruneheap.c contain no UNDO code, and heap leaves am_supports_undo false.
+
+## 15. Limitations and Future Work
+
+### Current Limitations
+
+    - TOAST tables are ordinary heap relations and are not UNDO-protected;
+      wide-value rollback is handled by the base-table UNDO plus ordinary
+      VACUUM of the dead TOAST chunks (see Section 15's TOAST notes below)
+    - No delta compression for UPDATE records (full old tuple stored)
+    - ProcArray integration for oldest XID is simplified
+
+### Planned Future Work
+
+    - Delta compression for UPDATE records
+    - Parallel UNDO application for faster rollback
+    - Online UNDO log compaction
+
+## Known Performance Gaps and Future Work
+
+**Rollback complexity:**
+
+On abort the backend walks the UNDO chain and restores the target pages
+before ROLLBACK returns.  Complexity is O(N) in the number of UNDO records
+the transaction wrote.  Each record requires:
+
+  - 1 WAL batch record read (amortized: N/``undo_batch_record_limit`` reads,
+    default 1000 records per batch)
+  - 1 buffer write (cache hit if the target fits in ``shared_buffers``;
+    otherwise a cold I/O read + write)
+  - 1 CLR WAL record written per batch (~8 KB each)
+
+For the sparse, cross-object UNDO the cluster-wide store is built for, N is
+usually small.
+
+Tuning: increase ``undo_batch_size_kb`` (default 256 KB) and
+``undo_batch_record_limit`` (default 1000 records) to reduce the number of
+WAL reads during UNDO application.
+
+**TOAST:** An UNDO-based table AM's TOAST table is an ordinary heap
+relation and is not enrolled in any UNDO log.  UNDO does not apply to the
+TOAST table itself.  Rollback correctness for wide values comes the
+ordinary way: rolling back the base-table UPDATE (via UNDO) restores the
+base tuple, which references the OLD TOAST pointer.  The NEW TOAST chunks
+written during the update become dead heap tuples and are reclaimed by
+ordinary VACUUM, exactly as heap-on-heap TOAST behaves.  No UNDO is applied
+to the TOAST chunks; the base tuple simply points at the old chunks again
+once its UPDATE is reversed.
+
+**Autovacuum interaction:** This engine itself creates no relations that
+autovacuum scans; the consideration below applies to a future table access
+method built on it.  Such an AM reverses a rolled-back tuple in place
+rather than leaving a dead tuple, so ``pg_stat_user_tables.n_dead_tup``
+stays near zero under active DML and the standard dead-tuple trigger
+(``autovacuum_vacuum_threshold`` + ``autovacuum_vacuum_scale_factor``) does
+not fire on dead-tuple count alone.
+
+An UNDO-based table AM that wants autovacuum coverage should drive it from a
+modification count rather than a dead-tuple count.  Two mechanisms already
+exist and need no core change:
+
+  * ``n_mod_since_analyze`` counts every modification regardless of outcome,
+    so ``autovacuum_analyze_scale_factor`` still triggers ANALYZE, and
+
+  * setting ``autovacuum_vacuum_threshold = 0`` (and a small
+    ``autovacuum_vacuum_scale_factor``) at the table level makes autovacuum
+    fire on modification volume even with zero dead tuples:
+
+    .. code-block:: sql
+
+        ALTER TABLE my_table
+            SET (autovacuum_vacuum_threshold = 0,
+                 autovacuum_vacuum_scale_factor = 0.05);
+
+A dedicated autovacuum trigger keyed on in-place modification volume would
+belong with that AM, not with this engine.
+
+## 16. References
+
+    Antonopoulos et al., "Constant Time Recovery in Azure SQL Database"
+        Proceedings of the VLDB Endowment, Vol. 12, No. 12, August 2019.
+        Related work; this engine does not implement that recovery
+        model.  Here UNDO records are embedded in the WAL stream rather
+        than kept in a separate version store, an aborting transaction
+        applies its UNDO chain physically with CLRs, and the UNDO of
+        loser transactions is applied during crash recovery.
+
+## 17. Known Limitations
+
+The current implementation has the following known limitations:
+
+### WAL Retention for UNDO
+- UNDO batches in WAL are retained until undo_discard_horizon advances
+- Horizon is gated by oldest in-flight transaction (never discards unresolved)
+- WAL segment recycling respects undo_discard_horizon automatically
+
+### WAL Level
+UNDO-enabled tables require ``wal_level = replica`` or higher for
+streaming standbys to receive CLR records written during UNDO application.
+At ``wal_level = minimal``, single-row DML UNDO records are still written
+and crash recovery works correctly, but standbys will not receive them.
+
+### Logical Decoding
+``XLOG_UNDO_BATCH`` records use a custom resource manager.  The UNDO RM's
+``rm_decode`` callback is a no-op: logical decoding filters these records and
+they do not appear as change events.  Logical replication of UNDO-enabled
+tables works correctly -- the logical decoder sees heap INSERT/UPDATE/DELETE
+changes normally.
+
+### AM Compatibility
+UNDO is always-on infrastructure.  Only in-place-update table AMs benefit
+from it; they opt in via the am_supports_undo callback.  The default heap AM
+sets am_supports_undo = false and does not use UNDO -- heap operates without
+UNDO overhead and reclaims dead tuples through ordinary MVCC/VACUUM.
+
+### TOAST
+TOAST tables are ordinary heap relations and are not UNDO-protected.  An
+UNDO-based table AM that stores wide values does so in a standard heap TOAST
+table; that table writes normal heap WAL and is reclaimed by ordinary
+MVCC/VACUUM.  Rolling back an UPDATE that touched a wide value restores the
+base tuple via UNDO, and the base tuple again references the OLD TOAST
+pointer; the NEW TOAST chunks written during the update become dead heap
+tuples reclaimed by VACUUM (see Section 15).  UNDO is not applied to the
+TOAST table itself.
+
+### Delta Compression
+Not implemented.  The cluster-wide record format stores whatever payload a
+producer hands it; a producer that passes a full old tuple gets no
+compression.  The engine does not impose a delta format on its consumers.
+
+### ProcArray Integration
+- The discard worker uses GetOldestActiveTransactionId(), which scans the
+  ProcArray under ProcArrayLock and returns the oldest transaction still
+  active anywhere in the cluster.  UNDO records from transactions older than
+  this may be discarded.
+- This is a correct but conservative horizon: it holds UNDO behind any
+  long-running transaction, even one that produced no UNDO.  A dedicated
+  UNDO-specific horizon (advanced only as logs are fully discarded) would
+  permit more aggressive discard.  It is not implemented.
+
+### UNDO-Based MVCC
+- The heap AM does not use UNDO; it leaves am_supports_undo false and its
+  code paths are unchanged.
+- The cluster-wide engine records and applies UNDO, but does not by itself
+  provide multi-version visibility.  An access method that updates a tuple
+  in place, destroying the prior version, must supply its own mechanism to
+  reconstruct earlier versions for concurrent readers; that machinery lives
+  in the consumer, not in this engine.
+
+### Platform Support
+- Tested on: Linux (primary), FreeBSD, Windows, macOS
+- Full platform matrix testing pending
+- Extended file attributes (xattr) support varies by platform
+
+### Parallel UNDO Apply
+- Transaction rollback applies the UNDO chain sequentially in one backend.
+- A large abort is therefore proportional to the volume of UNDO it wrote.
+- Applying a chain across several workers is possible but not implemented.
+
+## 18. Upgrade Guide
+
+### Prerequisites
+- PostgreSQL 17+ (uses current rmgrlist.h structure)
+- Sufficient WAL disk space (UNDO batches share WAL retention)
+
+### Enabling UNDO
+
+UNDO is not a switch.  It is always-on infrastructure, and a table gets UNDO by
+being created with an AM that opts in (am_supports_undo):
+
+    CREATE TABLE important_data (id int, data text) USING <undo_am>;
+
+### Monitoring UNDO
+
+UNDO batches share the WAL stream. Monitor WAL retention:
+
+    SELECT slot_name, restart_lsn, confirmed_flush_lsn
+    FROM pg_replication_slots;
+
+There is no SQL-level UNDO log view: the pg_stat_get_undo_logs() C function has
+no pg_proc.dat entry, so it cannot be called from SQL as the tree stands.
+
+### Rollback Plan
+
+If issues arise:
+
+1. UNDO is integral to AMs that use it -- it cannot be
+   disabled per-table.  To stop UNDO activity, convert the table to
+   a different AM (e.g., heap).
+
+2. Existing UNDO batches in WAL are retained until retention expires.
+
+3. Stop UNDO worker if needed:
+   SELECT pg_terminate_backend(pid)
+   FROM pg_stat_activity
+   WHERE backend_type = 'undo worker';
+
+### Performance Tuning
+
+Recommended initial settings:
+
+    # UNDO worker wakes every second
+    undo_worker_naptime = 1000
+
+    # Up to MAX_UNDO_LOGS (100) concurrent UNDO logs supported
+
+    # WAL retention handles UNDO storage automatically
+    # Ensure max_wal_size accommodates UNDO batch retention
+    max_wal_size = 4GB
+
+Monitor and adjust based on:
+- Long-running transaction frequency
+- Update-heavy workload patterns
+- Disk space availability
+
+## Lock Ordering
+
+All UNDO LWLocks must be acquired in the order listed below to
+prevent deadlocks.  An agent holding lock N must never acquire lock M
+where M < N.  Standard PostgreSQL buffer-content locks and heavyweight
+locks sit outside this hierarchy; they must be acquired before any
+UNDO LWLock.  A consumer AM's own locks (for example an optional
+per-tuple tracking structure, or an AM-private dirty-page map) extend this
+hierarchy at higher-numbered levels and are documented with that consumer,
+not here.
+
+    Level  Lock                            Holder(s)
+    -----  ----                            ---------
+    1      Buffer content locks            All backends (standard PG)
+    2      LWTRANCHE_UNDO_LOG              Per-log lock (undolog.c)
+           (per-log instance)              Protects log metadata and append
+    3      LWTRANCHE_UNDO_LOG              Allocation lock (undolog.c)
+           (allocation instance)           Serializes log space allocation
+    4      LWTRANCHE_UNDO_LOG              Discard-worker state (undoworker.c)
+           (worker instance)               Protects discard worker state
+
+Rules:
+- Never acquire a buffer content lock while holding any UNDO LWLock.
diff --git a/src/backend/access/undo/meson.build b/src/backend/access/undo/meson.build
new file mode 100644
index 00000000000..dd67f28e5d4
--- /dev/null
+++ b/src/backend/access/undo/meson.build
@@ -0,0 +1,14 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+backend_sources += files(
+  'undo.c',
+  'undo_xlog.c',
+  'undoapply.c',
+  'undoinsert.c',
+  'undolog.c',
+  'undorecord.c',
+  'undormgr.c',
+  'undostats.c',
+  'undoworker.c',
+  'xactundo.c',
+)
diff --git a/src/backend/access/undo/undo.c b/src/backend/access/undo/undo.c
new file mode 100644
index 00000000000..2136b9baea8
--- /dev/null
+++ b/src/backend/access/undo/undo.c
@@ -0,0 +1,254 @@
+/*-------------------------------------------------------------------------
+ *
+ * undo.c
+ *	  Common undo layer coordination
+ *
+ * The undo subsystem consists of several logically separate subsystems
+ * that work together to achieve a common goal. The code in this file
+ * provides a limited amount of common infrastructure that can be used
+ * by all of those various subsystems, and helps coordinate activities
+ * such as shared memory initialization and startup/shutdown.
+ *
+ * This file has no compile-time or link-time dependency on any specific
+ * table or index access method.  Shared memory for AM-specific tracking
+ * structures (such as an optional per-tuple tracking hash an AM may register)
+ * is registered as its own sibling entry in storage/subsystemlist.h, not
+ * sized or initialized here.  Likewise, the
+ * set of UNDO resource managers to register comes from access/undormgrlist.h
+ * (mirroring the subsystemlist.h idiom): a new consumer adds itself to that
+ * list, and never edits this file.
+ *
+ * Shared memory initialization uses the PG_SHMEM_SUBSYSTEM pattern:
+ * UndoShmemCallbacks is registered in subsystemlist.h, and the framework
+ * calls UndoShmemRequest() and UndoShmemInit() at the appropriate times
+ * during postmaster startup.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/access/undo/undo.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/undo.h"
+#include "access/undolog.h"
+#include "access/undormgr.h"
+#include "access/undormgrs.h"
+#include "access/undoworker.h"
+#include "access/xactundo.h"
+#include "miscadmin.h"
+#include "storage/ipc.h"
+#include "storage/shmem.h"
+#include "storage/subsystems.h"
+#include "utils/memutils.h"
+
+/*
+ * UndoContext is a child of TopMemoryContext which is never reset. The only
+ * reason for having a separate context is to make it easier to spot leaks or
+ * excessive memory utilization related to undo operations.
+ */
+MemoryContext UndoContext = NULL;
+
+static void AtProcExit_Undo(int code, Datum arg);
+static void UndoShmemRequest_internal(void *arg);
+static void UndoShmemInit_internal(void *arg);
+static void UndoShmemAttach_internal(void *arg);
+static void RegisterUndoRmgrs(void);
+
+/*
+ * ShmemCallbacks for the UNDO subsystem.
+ *
+ * Registered via PG_SHMEM_SUBSYSTEM(UndoShmemCallbacks) in subsystemlist.h.
+ *
+ * init_fn initializes the contents of all UNDO shared memory structures.
+ */
+const ShmemCallbacks UndoShmemCallbacks = {
+	.request_fn = UndoShmemRequest_internal,
+	.init_fn = UndoShmemInit_internal,
+	.attach_fn = UndoShmemAttach_internal,
+};
+
+/*
+ * UndoShmemRequest_internal
+ *		Register shared memory needs for UNDO subsystems.
+ *
+ * Called during the request_fn phase of postmaster startup, before shared
+ * memory is allocated.
+ */
+static void
+UndoShmemRequest_internal(void *arg)
+{
+	/*
+	 * Register the UNDO background worker.  This must happen during the
+	 * request_fn phase (before BackgroundWorkerShmemInit runs in the init_fn
+	 * phase), because RegisterBackgroundWorker() cannot be called after
+	 * BackgroundWorkerShmemInit().
+	 *
+	 * Use a static flag to ensure we only register once.  The request_fn
+	 * callback is called again during postmaster reinitialize (after a child
+	 * crash), and RegisterBackgroundWorker() would fail if called after the
+	 * first shmem init.
+	 */
+	{
+		static bool undo_worker_registered = false;
+
+		/*
+		 * Only the postmaster can register a static background worker.  In
+		 * bootstrap and single-user mode (initdb) there is no postmaster, so
+		 * skip registration; RegisterBackgroundWorker() would otherwise just
+		 * emit a LOG and return without registering.
+		 */
+		if (!undo_worker_registered &&
+			!IsUnderPostmaster && IsPostmasterEnvironment)
+		{
+			UndoWorkerRegister();
+
+			undo_worker_registered = true;
+		}
+	}
+
+	/*
+	 * Initialize the UNDO resource manager dispatch table and register the
+	 * built-in resource managers listed in access/undormgrlist.h.
+	 */
+
+	/* Shared areas of the UNDO sub-modules. */
+	UndoLogShmemRequest();
+	UndoWorkerShmemRequest();
+}
+
+/*
+ * UndoShmemInit / UndoShmemInit_internal
+ *		Initialize undo-related shared memory.
+ *
+ * Also, perform other initialization steps that need to be done very early.
+ * This is called once during postmaster startup via the ShmemCallbacks
+ * framework.
+ */
+static void
+UndoShmemInit_internal(void *arg)
+{
+	UndoShmemInit();
+}
+
+void
+UndoShmemInit(void)
+{
+	/*
+	 * Initialize the undo memory context. If it already exists (crash restart
+	 * via reset_shared()), reset it instead.
+	 */
+	if (UndoContext)
+		MemoryContextReset(UndoContext);
+	else
+		UndoContext = AllocSetContextCreate(TopMemoryContext, "Undo",
+											ALLOCSET_DEFAULT_SIZES);
+
+	/* Now give various undo subsystems a chance to initialize. */
+	UndoLogShmemInit();
+	XactUndoShmemInit();
+	UndoWorkerShmemInit();
+
+	/*
+	 * Initialize the UNDO resource manager dispatch table and register the
+	 * built-in resource managers listed in access/undormgrlist.h.
+	 */
+	RegisterUndoRmgrs();
+}
+
+/*
+ * UndoShmemAttach_internal
+ *		Re-establish per-process UNDO state in EXEC_BACKEND children.
+ *
+ * Under EXEC_BACKEND (Windows, or --enable-exec-backend builds) a child does
+ * not inherit the postmaster's address space, so module-scope state populated
+ * by UndoShmemInit() is not present and must be rebuilt here.  Two distinct
+ * kinds of state need re-establishing:
+ *
+ * 1. Process-local pointers derived from the requested shmem areas.  Every
+ *    UNDO shmem area is requested with ShmemRequestStruct() from
+ *    UndoShmemRequest_internal(), so the framework has already set each
+ *    module's base pointer before calling us; the sub-modules' *ShmemInit()
+ *    functions initialize shared contents and must not be called here.  What
+ *    remains is state a module derives from its area in each process (the
+ *    per-backend engine's singleton locks and queue wrappers), recomputed by
+ *    its *ShmemInit() when IsUnderPostmaster.
+ *
+ * 2. The UndoRmgrs[] dispatch table and each registered AM's undo hook
+ *    function pointers, both rebuilt by RegisterUndoRmgrs() (idempotent:
+ *    InitUndoRmgrs() zeroes the table first).
+ *
+ * XactUndoShmemInit() is a no-op and so omitted here.  Any
+ * AM-specific shared structure with the same "no found-guard" hazard
+ * handles its own EXEC_BACKEND re-attach via its own PG_SHMEM_SUBSYSTEM entry
+ * (see storage/subsystemlist.h); this function is only responsible for the
+ * generic UNDO sub-modules listed above.
+ */
+static void
+UndoShmemAttach_internal(void *arg)
+{
+	RegisterUndoRmgrs();
+}
+
+/*
+ * RegisterUndoRmgrs
+ *		Initialize the UNDO resource manager dispatch table and register the
+ *		built-in resource managers listed in access/undormgrlist.h.
+ *
+ * Called from both UndoShmemInit() (postmaster/standalone) and
+ * UndoShmemAttach_internal() (EXEC_BACKEND children).  Idempotent:
+ * InitUndoRmgrs() zeroes the dispatch table before the *UndoRmgrInit() calls
+ * re-register, so RegisterUndoRmgr()'s double-registration guard is not
+ * tripped on a second invocation in the same process.
+ *
+ * This function has no compile-time knowledge of which resource managers
+ * exist: it just expands access/undormgrlist.h through the UNDO_RMGR_INIT
+ * macro.  A new UNDO-writing consumer (a new index AM, table AM, or other
+ * subsystem) adds one line to that list; this function and the rest of
+ * undo.c are never modified.
+ */
+static void
+RegisterUndoRmgrs(void)
+{
+	/*
+	 * Initialize the UNDO resource manager dispatch table.
+	 */
+	InitUndoRmgrs();
+
+	/*
+	 * Register every built-in resource manager listed in
+	 * access/undormgrlist.h.
+	 */
+#define UNDO_RMGR_INIT(initfunc) \
+	initfunc();
+#include "access/undormgrlist.h"
+#undef UNDO_RMGR_INIT
+}
+
+/*
+ * InitializeUndo
+ *		Per-backend initialization for the undo subsystem.
+ *
+ * Called once per backend from InitPostgres().
+ */
+void
+InitializeUndo(void)
+{
+	InitializeXactUndo();
+
+	on_shmem_exit(AtProcExit_Undo, 0);
+}
+
+/*
+ * AtProcExit_Undo
+ *		Shut down undo subsystems in the correct order.
+ *
+ * Higher-level stuff should be shut down first.
+ */
+static void
+AtProcExit_Undo(int code, Datum arg)
+{
+	AtProcExit_XactUndo();
+}
diff --git a/src/backend/access/undo/undo_xlog.c b/src/backend/access/undo/undo_xlog.c
new file mode 100644
index 00000000000..6ff7db84f0d
--- /dev/null
+++ b/src/backend/access/undo/undo_xlog.c
@@ -0,0 +1,1167 @@
+/*-------------------------------------------------------------------------
+ *
+ * undo_xlog.c
+ *	  UNDO resource manager WAL redo routines
+ *
+ * This module implements the WAL redo callback for the RM_UNDO_ID resource
+ * manager.  It handles replay of:
+ *
+ *   XLOG_UNDO_ALLOCATE       - Replay UNDO log space allocation
+ *   XLOG_UNDO_DISCARD        - Replay UNDO record discard
+ *   XLOG_UNDO_EXTEND         - Replay UNDO log file extension
+ *   XLOG_UNDO_APPLY_RECORD   - Replay CLR (Compensation Log Record)
+ *
+ * CLR Redo Strategy
+ * -----------------
+ * CLRs for UNDO application use REGBUF_FORCE_IMAGE to store a full page
+ * image.  During redo, XLogReadBufferForRedo() will restore the full page
+ * image automatically (returning BLK_RESTORED).  No additional replay
+ * logic is needed because the page image already contains the result of
+ * the UNDO application.
+ *
+ * This is the same strategy used by ZHeap (log_zheap_undo_actions with
+ * REGBUF_FORCE_IMAGE) and is the simplest correct approach for crash
+ * recovery of UNDO operations.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/backend/access/undo/undo_xlog.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/htup_details.h"
+#include "access/twophase.h"
+#include "access/undo_xlog.h"
+#include "access/undolog.h"
+#include "access/undorecord.h"
+#include "access/undormgr.h"
+#include "access/xlog.h"
+#include "access/xlog_internal.h"
+#include "access/xlogreader.h"
+#include "access/xlogrecovery.h"
+#include "access/xlogutils.h"
+#include "miscadmin.h"
+#include "storage/bufmgr.h"
+#include "storage/bufpage.h"
+#include "storage/itemid.h"
+#include "utils/memutils.h"
+
+/*
+ * undo_redo - Replay an UNDO WAL record during crash recovery
+ *
+ * This function handles all UNDO resource manager WAL record types.
+ * For CLRs (XLOG_UNDO_APPLY_RECORD), the full page image is restored
+ * automatically by XLogReadBufferForRedo(), so no additional replay
+ * logic is needed.
+ */
+void
+undo_redo(XLogReaderState *record)
+{
+	uint8		info = XLogRecGetInfo(record) & ~XLR_INFO_MASK;
+
+	switch (info)
+	{
+		case XLOG_UNDO_ALLOCATE:
+			{
+				xl_undo_allocate *xlrec = (xl_undo_allocate *) XLogRecGetData(record);
+
+				/*
+				 * During recovery, update the UNDO log's insert pointer to
+				 * reflect this allocation.  This ensures that after crash
+				 * recovery the UNDO log metadata is consistent.
+				 *
+				 * Note: UndoLogShared may not be initialized yet during early
+				 * recovery.  We guard against that.
+				 */
+				if (UndoLogShared != NULL)
+				{
+					UndoLogControl *log = NULL;
+					int			i;
+
+					/*
+					 * Find the log control structure. O(MAX_UNDO_LOGS) scan:
+					 * with MAX_UNDO_LOGS=64 this is acceptable at recovery
+					 * time (called once per record).
+					 */
+					for (i = 0; i < MAX_UNDO_LOGS; i++)
+					{
+						if (UndoLogShared->logs[i].in_use &&
+							UndoLogShared->logs[i].log_number == xlrec->log_number)
+						{
+							log = &UndoLogShared->logs[i];
+							break;
+						}
+					}
+
+					if (log == NULL)
+					{
+						/* Log doesn't exist yet, create it */
+						for (i = 0; i < MAX_UNDO_LOGS; i++)
+						{
+							if (!UndoLogShared->logs[i].in_use)
+							{
+								log = &UndoLogShared->logs[i];
+								log->log_number = xlrec->log_number;
+								pg_atomic_write_u64(&log->insert_ptr, xlrec->start_ptr);
+								log->discard_ptr = MakeUndoRecPtr(xlrec->log_number, 0);
+								log->oldest_xid = InvalidTransactionId;
+								log->in_use = true;
+								break;
+							}
+						}
+					}
+
+					if (log != NULL)
+					{
+						/*
+						 * Advance insert pointer past this allocation. Only
+						 * move forward, never regress -- with coalesced WAL
+						 * records from concurrent backends, a later record
+						 * may cover a range already subsumed by an earlier
+						 * one.
+						 */
+						UndoRecPtr	new_end = xlrec->start_ptr + xlrec->length;
+						UndoRecPtr	cur_ptr = pg_atomic_read_u64(&log->insert_ptr);
+
+						if (new_end > cur_ptr)
+							pg_atomic_write_u64(&log->insert_ptr, new_end);
+					}
+				}
+			}
+			break;
+
+		case XLOG_UNDO_DISCARD:
+			{
+				xl_undo_discard *xlrec = (xl_undo_discard *) XLogRecGetData(record);
+
+				if (UndoLogShared != NULL)
+				{
+					int			i;
+
+					for (i = 0; i < MAX_UNDO_LOGS; i++)
+					{
+						if (UndoLogShared->logs[i].in_use &&
+							UndoLogShared->logs[i].log_number == xlrec->log_number)
+						{
+							UndoLogShared->logs[i].discard_ptr = xlrec->discard_ptr;
+							UndoLogShared->logs[i].oldest_xid = xlrec->oldest_xid;
+							break;
+						}
+					}
+				}
+			}
+			break;
+
+		case XLOG_UNDO_EXTEND:
+			{
+				xl_undo_extend *xlrec = (xl_undo_extend *) XLogRecGetData(record);
+
+				/*
+				 * Legacy record: UNDO-in-WAL has no segment file to extend,
+				 * so ExtendUndoLogFile() is a no-op.
+				 */
+				ExtendUndoLogFile(xlrec->log_number, xlrec->new_size);
+			}
+			break;
+
+		case XLOG_UNDO_APPLY_RECORD:
+			{
+				/*
+				 * Physiological CLR redo: re-apply the exact page
+				 * modification that was performed during UNDO application.
+				 *
+				 * If a full page image is present (BLK_RESTORED), the page is
+				 * already correct.  Otherwise (BLK_NEEDS_REDO), replay the
+				 * operation from the record: UNDO_CLR_LP_DEAD for index
+				 * INSERT undo, UNDO_CLR_HAS_TUPLE to restore a table AM's
+				 * before-image.
+				 */
+				xl_undo_apply *xlrec;
+				Buffer		buffer;
+				XLogRedoAction action;
+
+				xlrec = (xl_undo_apply *) XLogRecGetData(record);
+				action = XLogReadBufferForRedo(record, 0, &buffer);
+
+				switch (action)
+				{
+					case BLK_RESTORED:
+						/* Full page image applied -- nothing more to do */
+						break;
+
+					case BLK_DONE:
+						/* Page already up-to-date (LSN check) */
+						break;
+
+					case BLK_NEEDS_REDO:
+						{
+							Page		page = BufferGetPage(buffer);
+
+							if (xlrec->clr_flags & UNDO_CLR_LP_DEAD)
+							{
+								/*
+								 * Mark the line pointer LP_DEAD, keeping its
+								 * storage.  For index INSERT undo, where the
+								 * index's binary search may still read the
+								 * dead tuple's key until VACUUM physically
+								 * removes it.  Must match the forward-apply
+								 * path (ItemIdMarkDead), which does not zero
+								 * lp_off/lp_len.
+								 */
+								ItemId		lp = PageGetItemId(page,
+															   xlrec->target_offset);
+
+								if (ItemIdIsNormal(lp))
+									ItemIdMarkDead(lp);
+							}
+							else if (xlrec->clr_flags & UNDO_CLR_HAS_TUPLE)
+							{
+								/*
+								 * Restore a table AM's before-image.  The
+								 * bytes are in the block data
+								 * registered with block reference 0 and are
+								 * opaque here: they are copied back into the
+								 * line pointer's storage verbatim.
+								 */
+								ItemId		lp = PageGetItemId(page,
+															   xlrec->target_offset);
+
+								if (ItemIdIsUsed(lp) && ItemIdHasStorage(lp) &&
+									xlrec->tuple_len > 0)
+								{
+									HeapTupleHeader htup;
+									char	   *data;
+									Size		datalen;
+
+									data = XLogRecGetBlockData(record, 0,
+															   &datalen);
+									Assert(data != NULL);
+									Assert(datalen >= xlrec->tuple_len);
+
+									ItemIdSetNormal(lp, ItemIdGetOffset(lp),
+													xlrec->tuple_len);
+									htup = (HeapTupleHeader) PageGetItem(page, lp);
+									memcpy(htup, data, xlrec->tuple_len);
+								}
+							}
+
+							PageSetLSN(page, record->EndRecPtr);
+							MarkBufferDirty(buffer);
+						}
+						break;
+
+					case BLK_NOTFOUND:
+						/* Block doesn't exist (truncated?) -- skip */
+						break;
+				}
+
+				if (BufferIsValid(buffer))
+					UnlockReleaseBuffer(buffer);
+			}
+			break;
+
+		case XLOG_UNDO_PAGE_WRITE:
+
+			/*
+			 * XLOG_UNDO_PAGE_WRITE is no longer emitted (append-only I/O
+			 * architecture writes directly via pwrite, not through
+			 * shared_buffers).  We keep this case for backward compatibility
+			 * with WAL from before the transition.  Old records are simply
+			 * ignored -- the UNDO data was already written to the segment
+			 * file by the originating backend.
+			 */
+			break;
+
+		case XLOG_UNDO_BATCH:
+			{
+				xl_undo_batch *xlrec = (xl_undo_batch *) XLogRecGetData(record);
+
+				/*
+				 * During recovery, track this batch for incomplete
+				 * transaction detection.  After redo completes, any
+				 * transaction that wrote UNDO batches but did not commit will
+				 * need its UNDO chain walked for rollback.
+				 *
+				 * The batch payload (serialized UNDO records) is part of the
+				 * WAL record and can be re-read later via XLogReadRecord()
+				 * during the undo phase.
+				 */
+				UndoRecoveryTrackBatch(xlrec->xid, record->ReadRecPtr,
+									   xlrec->chain_prev,
+									   xlrec->persistence);
+
+				ereport(DEBUG2,
+						(errmsg("undo_redo: BATCH xid %u, nrecords %u, "
+								"total_len %u, chain_prev %X/%X",
+								xlrec->xid, xlrec->nrecords,
+								xlrec->total_len,
+								LSN_FORMAT_ARGS(xlrec->chain_prev))));
+			}
+			break;
+
+		case XLOG_UNDO_ROTATE:
+			{
+				xl_undo_rotate *xlrec = (xl_undo_rotate *) XLogRecGetData(record);
+
+				/*
+				 * Replay segment rotation: mark the old log SEALED and the
+				 * new log ACTIVE.  This reconstructs the lifecycle state so
+				 * that after recovery the discard worker can clean up sealed
+				 * logs properly.
+				 */
+				if (UndoLogShared != NULL)
+				{
+					int			j;
+
+					/* Seal the old log */
+					if (xlrec->old_log_number != 0)
+					{
+						for (j = 0; j < MAX_UNDO_LOGS; j++)
+						{
+							UndoLogControl *old_log = &UndoLogShared->logs[j];
+
+							if (old_log->in_use &&
+								old_log->log_number == xlrec->old_log_number)
+							{
+								old_log->state = UNDO_LOG_SEALED;
+								pg_atomic_write_u64(&old_log->seal_ptr,
+													xlrec->old_seal_ptr);
+								break;
+							}
+						}
+					}
+
+					/* Activate the new log (find or create slot) */
+					{
+						UndoLogControl *new_log = NULL;
+						int			new_slot = -1;
+
+						/* Check if it already exists (idempotent replay) */
+						for (j = 0; j < MAX_UNDO_LOGS; j++)
+						{
+							if (UndoLogShared->logs[j].in_use &&
+								UndoLogShared->logs[j].log_number == xlrec->new_log_number)
+							{
+								new_log = &UndoLogShared->logs[j];
+								new_slot = j;
+								break;
+							}
+						}
+
+						/* If not found, allocate a free slot */
+						if (new_log == NULL)
+						{
+							for (j = 0; j < MAX_UNDO_LOGS; j++)
+							{
+								if (!UndoLogShared->logs[j].in_use)
+								{
+									new_log = &UndoLogShared->logs[j];
+									new_slot = j;
+									new_log->log_number = xlrec->new_log_number;
+									pg_atomic_write_u64(&new_log->insert_ptr,
+														MakeUndoRecPtr(xlrec->new_log_number, 0));
+									new_log->discard_ptr = MakeUndoRecPtr(xlrec->new_log_number, 0);
+									new_log->oldest_xid = InvalidTransactionId;
+									new_log->in_use = true;
+									break;
+								}
+							}
+						}
+
+						if (new_log != NULL)
+						{
+							new_log->state = UNDO_LOG_ACTIVE;
+							pg_atomic_write_u64(&new_log->seal_ptr, InvalidUndoRecPtr);
+							pg_atomic_write_u32(&UndoLogShared->active_log_idx,
+												(uint32) new_slot);
+						}
+					}
+				}
+			}
+			break;
+
+		default:
+			elog(PANIC, "undo_redo: unknown op code %u", info);
+	}
+}
+
+/* ----------------------------------------------------------------
+ *	UNDO recovery tracking
+ *
+ *	During WAL redo, we track which transactions wrote UNDO batches.
+ *	When a commit/abort record is redone, the XID is removed.
+ *	After redo completes, remaining entries represent incomplete
+ *	transactions that need their UNDO chains walked.
+ * ----------------------------------------------------------------
+ */
+
+/* Hash table entry for tracking incomplete UNDO transactions */
+typedef struct UndoRecoveryEntry
+{
+	TransactionId xid;			/* hash key */
+	XLogRecPtr	last_batch_lsn[NUndoPersistenceLevels]; /* chain heads per
+														 * persistence level */
+	char		status;			/* in use */
+} UndoRecoveryEntry;
+
+/* Simple dynamic array for recovery tracking (used during startup only) */
+static UndoRecoveryEntry *undo_recovery_entries = NULL;
+static int	undo_recovery_nentries = 0;
+static int	undo_recovery_capacity = 0;
+
+/*
+ * Safety cap to prevent OOM during recovery.  If more than this many
+ * distinct in-flight XIDs are found in WAL at crash time, we stop
+ * tracking new ones and log a warning.  The untracked transactions will
+ * need manual resolution (e.g. via pg_resetwal or targeted UNDO apply).
+ *
+ * 1 million entries x ~40 bytes each ~ 40 MB, which is reasonable for
+ * a recovery-only allocation.
+ */
+#define UNDO_RECOVERY_MAX_ENTRIES	1048576
+
+/*
+ * UndoRecoveryTrackBatch - Record an UNDO batch during WAL redo
+ *
+ * Called from the XLOG_UNDO_BATCH redo handler to track which
+ * transactions have UNDO data that may need rollback.
+ */
+void
+UndoRecoveryTrackBatch(TransactionId xid, XLogRecPtr batch_lsn,
+					   XLogRecPtr chain_prev,
+					   UndoPersistenceLevel persistence)
+{
+	int			i;
+	UndoRecoveryEntry *entry = NULL;
+
+	if (!TransactionIdIsValid(xid))
+		return;
+
+	/* Find existing entry for this XID */
+	for (i = 0; i < undo_recovery_nentries; i++)
+	{
+		if (undo_recovery_entries[i].xid == xid)
+		{
+			entry = &undo_recovery_entries[i];
+			break;
+		}
+	}
+
+	/* Create new entry if needed */
+	if (entry == NULL)
+	{
+		/* Safety cap: refuse to track more XIDs to prevent OOM */
+		if (undo_recovery_nentries >= UNDO_RECOVERY_MAX_ENTRIES)
+		{
+			static bool warned = false;
+
+			if (!warned)
+			{
+				ereport(WARNING,
+						(errmsg("UNDO recovery: reached maximum tracked transaction limit (%d)",
+								UNDO_RECOVERY_MAX_ENTRIES),
+						 errhint("Transactions beyond this limit will not be automatically rolled back. "
+								 "Manual intervention may be required after recovery completes.")));
+				warned = true;
+			}
+			return;
+		}
+
+		if (undo_recovery_nentries >= undo_recovery_capacity)
+		{
+			int			new_capacity = (undo_recovery_capacity == 0) ? 64 :
+				undo_recovery_capacity * 2;
+			UndoRecoveryEntry *new_entries;
+
+			/* Clamp doubling to not exceed the safety cap */
+			if (new_capacity > UNDO_RECOVERY_MAX_ENTRIES)
+				new_capacity = UNDO_RECOVERY_MAX_ENTRIES;
+
+			if (undo_recovery_entries == NULL)
+			{
+				new_entries = (UndoRecoveryEntry *)
+					palloc0(sizeof(UndoRecoveryEntry) * new_capacity);
+			}
+			else
+			{
+				new_entries = (UndoRecoveryEntry *)
+					repalloc(undo_recovery_entries,
+							 sizeof(UndoRecoveryEntry) * new_capacity);
+				memset(&new_entries[undo_recovery_capacity], 0,
+					   sizeof(UndoRecoveryEntry) * (new_capacity - undo_recovery_capacity));
+			}
+			undo_recovery_entries = new_entries;
+			undo_recovery_capacity = new_capacity;
+		}
+
+		entry = &undo_recovery_entries[undo_recovery_nentries++];
+		entry->xid = xid;
+		for (i = 0; i < NUndoPersistenceLevels; i++)
+			entry->last_batch_lsn[i] = InvalidXLogRecPtr;
+	}
+
+	/* Update the chain head for this persistence level */
+	if (persistence < NUndoPersistenceLevels)
+		entry->last_batch_lsn[persistence] = batch_lsn;
+}
+
+/*
+ * UndoRecoveryRemoveXid - Remove an XID from recovery tracking
+ *
+ * Called when a commit or abort record is redone during recovery.
+ * Committed transactions don't need UNDO rollback.  Aborted transactions
+ * that were already fully rolled back (abort record present) also don't
+ * need further work.
+ */
+void
+UndoRecoveryRemoveXid(TransactionId xid)
+{
+	int			i;
+
+	if (!TransactionIdIsValid(xid))
+		return;
+
+	for (i = 0; i < undo_recovery_nentries; i++)
+	{
+		if (undo_recovery_entries[i].xid == xid)
+		{
+			/* Mark as removed by zeroing XID */
+			undo_recovery_entries[i].xid = InvalidTransactionId;
+			break;
+		}
+	}
+}
+
+/*
+ * UndoRecoveryNeeded - Check if there are incomplete transactions needing UNDO
+ *
+ * Returns true if any tracked transactions remain after redo is complete.
+ */
+bool
+UndoRecoveryNeeded(void)
+{
+	int			i;
+
+	for (i = 0; i < undo_recovery_nentries; i++)
+	{
+		if (TransactionIdIsValid(undo_recovery_entries[i].xid))
+			return true;
+	}
+
+	return false;
+}
+
+/*
+ * DeferredUndoXact - Transaction whose UNDO the undo phase could not apply
+ *
+ * During crash recovery, if a record's applier cannot run yet (it needs the
+ * syscache), the undo phase skips it and remembers the transaction here;
+ * FlushDeferredUndoXacts() reports it at the end of recovery.
+ */
+typedef struct DeferredUndoXact
+{
+	TransactionId xid;
+	Oid			dboid;
+	XLogRecPtr	last_batch_lsn;
+	struct DeferredUndoXact *next;
+} DeferredUndoXact;
+
+static DeferredUndoXact *deferred_undo_xacts = NULL;
+
+/*
+ * PerformUndoRecovery - Walk and apply UNDO chains for incomplete transactions
+ *
+ * This is the ARIES-style undo phase, called after the redo loop completes.
+ * For each incomplete transaction that wrote UNDO batches, we walk the
+ * UNDO chain backward and apply each record via the RM dispatch table.
+ *
+ * CLRs are generated during this phase to ensure idempotency in case of
+ * a crash during the undo phase itself.
+ *
+ * If UNDO application is skipped (e.g., due to syscache not being available),
+ * the transaction is tracked for deferred processing after recovery completes.
+ */
+void
+PerformUndoRecovery(void)
+{
+	int			i,
+				j;
+	int			total_xacts = 0;
+	int			total_records = 0;
+	int			failed_records = 0;
+	int			pending_xacts = 0;
+	int			deferred_xacts = 0;
+
+	/* Count pending transactions for the opening log message. */
+	for (i = 0; i < undo_recovery_nentries; i++)
+	{
+		if (TransactionIdIsValid(undo_recovery_entries[i].xid))
+			pending_xacts++;
+	}
+
+	if (pending_xacts > 0)
+		ereport(LOG,
+				(errmsg("UNDO recovery: %d incomplete transaction(s) to roll back",
+						pending_xacts)));
+
+	for (i = 0; i < undo_recovery_nentries; i++)
+	{
+		UndoRecoveryEntry *entry = &undo_recovery_entries[i];
+		bool		any_skipped = false;
+
+		if (!TransactionIdIsValid(entry->xid))
+			continue;
+
+		/*
+		 * Skip prepared transactions. Prepared (2PC) transactions must remain
+		 * in the prepared state after crash recovery, not be automatically
+		 * rolled back. They will be explicitly committed or rolled back later
+		 * via COMMIT PREPARED or ROLLBACK PREPARED.
+		 *
+		 * During recovery, RecoveryTransactionIdIsPrepared() checks the
+		 * in-memory prepared transaction state reconstructed from WAL replay.
+		 */
+		if (RecoveryTransactionIdIsPrepared(entry->xid))
+		{
+			ereport(LOG,
+					(errmsg("UNDO recovery: skipping prepared transaction %u "
+							"(will remain in prepared state)",
+							entry->xid)));
+			continue;
+		}
+
+		total_xacts++;
+
+		ereport(LOG,
+				(errmsg("UNDO recovery: rolling back transaction %u",
+						entry->xid)));
+
+		/*
+		 * Walk each persistence level's UNDO chain independently. This
+		 * mirrors the normal abort path in AtAbort_XactUndo().
+		 *
+		 * TEMP and UNLOGGED levels are skipped during crash recovery: - TEMP:
+		 * temporary tables are destroyed on server restart, so there is
+		 * nothing to roll back and the pages no longer exist. - UNLOGGED:
+		 * unlogged table files are reset to empty on crash recovery
+		 * (initfork), making any prior UNDO application wrong.
+		 */
+		for (j = 0; j < NUndoPersistenceLevels; j++)
+		{
+			XLogRecPtr	batch_lsn = entry->last_batch_lsn[j];
+
+			if (j == UNDOPERSISTENCE_TEMP || j == UNDOPERSISTENCE_UNLOGGED)
+				continue;
+
+			while (XLogRecPtrIsValid(batch_lsn))
+			{
+				UndoBatchData *batch;
+				char	   *pos;
+				char	   *end;
+
+				batch = UndoReadBatchFromWAL(batch_lsn);
+				if (batch == NULL)
+				{
+					/*
+					 * A missing or unreadable UNDO batch during crash
+					 * recovery.  This can happen with fsync=off when WAL was
+					 * not persisted before the crash, or when WAL segments
+					 * were recycled before the UNDO chain was fully applied.
+					 *
+					 * Rather than PANIC (which makes the database permanently
+					 * unrecoverable), skip this transaction's rollback.  The
+					 * affected tuples will retain their UNCOMMITTED flag and
+					 * be invisible until VACUUM removes them.  This is a
+					 * bounded anomaly similar to the old hash-overflow
+					 * degraded mode.
+					 */
+					ereport(WARNING,
+							(errmsg("UNDO recovery: could not read batch at %X/%X "
+									"for transaction %u; skipping rollback "
+									"(affected tuples will be cleaned by VACUUM)",
+									LSN_FORMAT_ARGS(batch_lsn),
+									entry->xid)));
+					break;		/* skip remaining chain for this persistence
+								 * level */
+				}
+
+				/* Walk records within this batch */
+				pos = batch->payload;
+				end = pos + batch->payload_len;
+
+				while (pos < end)
+				{
+					UndoRecordHeader header;
+					char	   *payload = NULL;
+
+					if ((Size) (end - pos) < SizeOfUndoRecordHeader)
+						break;
+
+					memcpy(&header, pos, SizeOfUndoRecordHeader);
+
+					if (header.urec_len < SizeOfUndoRecordHeader ||
+						(Size) (end - pos) < header.urec_len)
+						break;
+
+					if (header.urec_payload_len > 0)
+						payload = pos + SizeOfUndoRecordHeader;
+
+					/*
+					 * Apply this UNDO record via the RM dispatch table.
+					 *
+					 * Idempotency note: UNDO records are immutable in WAL and
+					 * carry no per-record applied marker; the CLR is a
+					 * separate WAL record.  Double-application is prevented
+					 * by page LSN: each CLR bumps the heap page LSN to the
+					 * CLR's EndRecPtr.  When rm_undo reads the buffer,
+					 * XLogReadBufferForRedo returns BLK_DONE or BLK_RESTORED
+					 * for pages that were already restored by a CLR during
+					 * the forward redo pass, preventing re-application.
+					 */
+					{
+						const UndoRmgrData *rmgr = GetUndoRmgr(header.urec_rmid);
+
+						if (rmgr != NULL)
+						{
+							UndoApplyResult result;
+
+							result = rmgr->rm_undo(header.urec_rmid,
+												   header.urec_info,
+												   header.urec_xid,
+												   header.urec_reloid,
+												   payload,
+												   header.urec_payload_len,
+												   InvalidUndoRecPtr);
+							if (result == UNDO_APPLY_ERROR)
+								failed_records++;
+							else
+								total_records++;
+
+							/*
+							 * If any UNDO record was skipped (e.g., due to
+							 * syscache not being initialized), remember the
+							 * transaction so the end of recovery reports it.
+							 */
+							if (result == UNDO_APPLY_SKIPPED)
+								any_skipped = true;
+						}
+					}
+
+					pos += header.urec_len;
+				}
+
+				/* Follow chain to previous batch */
+				{
+					XLogRecPtr	next_lsn = batch->header.chain_prev;
+
+					/*
+					 * Guard against circular or forward-pointing chains:
+					 * chain_prev must be strictly older (smaller LSN) than
+					 * the current batch or invalid (end of chain).  A
+					 * forward- pointing chain_prev would cause an infinite
+					 * loop.
+					 */
+					if (XLogRecPtrIsValid(next_lsn) && next_lsn >= batch_lsn)
+						ereport(PANIC,
+								(errmsg("UNDO recovery: chain_prev %X/%X >= batch_lsn %X/%X "
+										"for transaction %u; corrupt UNDO chain",
+										LSN_FORMAT_ARGS(next_lsn),
+										LSN_FORMAT_ARGS(batch_lsn),
+										entry->xid)));
+					UndoFreeBatchData(batch);
+					batch_lsn = next_lsn;
+				}
+			}
+		}
+
+		/*
+		 * If any UNDO records were skipped (e.g., due to syscache not being
+		 * initialized during early recovery), add the transaction to an
+		 * in-memory list that FlushDeferredUndoXacts() reports once recovery
+		 * completes.  Nothing in this series applies those chains later.
+		 *
+		 * Use the permanent persistence level's last_batch_lsn for tracking.
+		 * TEMP and UNLOGGED are skipped during crash recovery anyway.
+		 */
+		if (any_skipped)
+		{
+			XLogRecPtr	perm_lsn = entry->last_batch_lsn[UNDOPERSISTENCE_PERMANENT];
+
+			if (XLogRecPtrIsValid(perm_lsn))
+			{
+				DeferredUndoXact *deferred = (DeferredUndoXact *)
+					palloc(sizeof(DeferredUndoXact));
+
+				deferred->xid = entry->xid;
+				deferred->dboid = MyDatabaseId;
+				deferred->last_batch_lsn = perm_lsn;
+				deferred->next = deferred_undo_xacts;
+				deferred_undo_xacts = deferred;
+
+				deferred_xacts++;
+				ereport(LOG,
+						(errmsg("UNDO recovery: transaction %u has UNDO records that "
+								"cannot be applied before the system catalogs are available",
+								entry->xid)));
+			}
+		}
+	}
+
+	if (total_xacts > 0)
+	{
+		/* The resource manager has already said why each one failed. */
+		if (failed_records > 0)
+			ereport(WARNING,
+					(errmsg("UNDO recovery complete: %d transactions processed, "
+							"%d records applied, %d records could not be applied",
+							total_xacts, total_records, failed_records),
+					 errdetail("The changes those records describe were not "
+							   "rolled back.")));
+		else if (deferred_xacts > 0)
+			ereport(LOG,
+					(errmsg("UNDO recovery complete: %d transactions processed, "
+							"%d records applied, %d transactions deferred",
+							total_xacts, total_records, deferred_xacts)));
+		else
+			ereport(LOG,
+					(errmsg("UNDO recovery complete: %d transactions rolled back, "
+							"%d records applied",
+							total_xacts, total_records)));
+	}
+
+	/* Free tracking data */
+	if (undo_recovery_entries != NULL)
+	{
+		pfree(undo_recovery_entries);
+		undo_recovery_entries = NULL;
+	}
+	undo_recovery_nentries = 0;
+	undo_recovery_capacity = 0;
+}
+
+/*
+ * FlushDeferredUndoXacts - Report UNDO chains deferred during recovery
+ *
+ * Called after recovery completes (when InRedo is false) for any transactions
+ * that were deferred during UNDO recovery because their records could not be
+ * applied inline (e.g. the syscache was not yet initialized).
+ *
+ * These chains cannot be applied from the startup process: applying UNDO opens
+ * relations, which requires the syscache/relcache that only a database-
+ * connected backend has, and this series has no asynchronous applier.  The
+ * deferred chains are therefore reported and dropped here so recovery can
+ * complete.  FILEOPS, the only UNDO consumer in this series, never defers.
+ */
+void
+FlushDeferredUndoXacts(void)
+{
+	DeferredUndoXact *deferred;
+	int			count = 0;
+
+	if (deferred_undo_xacts == NULL)
+		return;
+
+	/* Walk the list, reporting each deferred transaction. */
+	while (deferred_undo_xacts != NULL)
+	{
+		deferred = deferred_undo_xacts;
+		deferred_undo_xacts = deferred->next;
+		count++;
+		pfree(deferred);
+	}
+
+	if (count > 0)
+		ereport(WARNING,
+				(errmsg("%d transaction(s) with UNDO were deferred during recovery "
+						"and could not be applied from the startup process",
+						count)));
+}
+
+/* ----------------------------------------------------------------
+ *	UNDO batch reading from WAL
+ * ----------------------------------------------------------------
+ */
+
+/*
+ * UndoReadBatchFromWAL - Read a single XLOG_UNDO_BATCH record from WAL
+ *
+ * Uses XLogReader to read the WAL record at the given LSN.
+ * Returns a palloc'd UndoBatchData containing the header and a copy
+ * of the payload.  The caller must pfree via UndoFreeBatchData().
+ *
+ * Returns NULL if the record cannot be read or is not an UNDO batch.
+ */
+/*
+ * Module-level cached XLogReader for UndoReadBatchFromWAL.
+ * Allocated once and reused across calls to avoid per-batch
+ * open/close overhead on WAL segment files during rollback.
+ */
+static XLogReaderState *undo_batch_reader = NULL;
+static XLogReaderRoutine undo_batch_reader_routine = {
+	.page_read = read_local_xlog_page,
+	.segment_open = wal_segment_open,
+	.segment_close = wal_segment_close,
+};
+
+/*
+ * UndoValidateBatchLSN
+ *		Quick check that the WAL record at batch_lsn is a valid UNDO source.
+ *
+ * Returns true if the record is a standalone RM_UNDO_ID batch.  Returns
+ * false for any other unrecognized record type.  Used by the inline UNDO
+ * path to avoid calling ApplyUndoChainFromWAL on a batch_lsn that points to
+ * the wrong record type.
+ */
+bool
+UndoValidateBatchLSN(XLogRecPtr batch_lsn)
+{
+	XLogRecord *record_hdr;
+	char	   *errormsg = NULL;
+	uint8		rmid;
+	MemoryContext old_ctx;
+	bool		result = false;
+
+	if (!XLogRecPtrIsValid(batch_lsn))
+		return false;
+
+	/*
+	 * Perform the entire read under TopMemoryContext.  The cached reader and,
+	 * critically, its lazily-allocated decode_buffer (xlogreader.c) are
+	 * palloc'd in CurrentMemoryContext.  Callers such as the inline-abort
+	 * path run with a transient context that is deleted right after;
+	 * allocating the reader state there would leave the static
+	 * undo_batch_reader (and its decode buffer) dangling, crashing the next
+	 * abort.  TopMemoryContext makes the cache truly persistent.
+	 */
+	old_ctx = MemoryContextSwitchTo(TopMemoryContext);
+
+	if (undo_batch_reader == NULL)
+	{
+		undo_batch_reader = XLogReaderAllocate(wal_segment_size, NULL,
+											   &undo_batch_reader_routine, NULL);
+		if (undo_batch_reader == NULL)
+		{
+			MemoryContextSwitchTo(old_ctx);
+			return false;
+		}
+	}
+
+	XLogBeginRead(undo_batch_reader, batch_lsn);
+	record_hdr = XLogReadRecord(undo_batch_reader, &errormsg);
+	if (record_hdr == NULL)
+	{
+		MemoryContextSwitchTo(old_ctx);
+		return false;
+	}
+
+	rmid = XLogRecGetRmid(undo_batch_reader);
+
+	/* Only a standalone UNDO batch is a valid inline UNDO source */
+	if (rmid == RM_UNDO_ID)
+		result = true;
+
+	MemoryContextSwitchTo(old_ctx);
+	return result;
+}
+
+UndoBatchData *
+UndoReadBatchFromWAL(XLogRecPtr batch_lsn)
+{
+	XLogRecord *record_hdr;
+	char	   *errormsg = NULL;
+	UndoBatchData *result;
+	xl_undo_batch *xlrec;
+	char	   *record_data;
+	Size		record_len;
+	Size		payload_offset;
+
+	if (!XLogRecPtrIsValid(batch_lsn))
+		return NULL;
+
+	/*
+	 * Safety check: verify the WAL segment containing this LSN has not been
+	 * recycled by a checkpoint.  If the LSN is behind the current redo
+	 * pointer and the segment file doesn't exist, reading would cause SIGBUS
+	 * (signal 10: Bus error) or SIGSEGV.  Return NULL gracefully instead.
+	 *
+	 * Compare against GetRedoRecPtr(); if our target is well behind the redo
+	 * pointer AND behind the last checkpoint's redo location, the segment may
+	 * have been recycled.
+	 */
+	{
+		XLogRecPtr	redo_ptr = GetRedoRecPtr();
+		XLogSegNo	target_segno;
+		char		path[MAXPGPATH];
+		TimeLineID	tli;
+
+		if (batch_lsn < redo_ptr)
+		{
+			/*
+			 * Determine the timeline for the segment path.
+			 * GetWALInsertionTimeLine() is only valid once recovery is DONE
+			 * (it asserts RECOVERY_STATE_DONE); during the crash-recovery
+			 * undo phase we must instead use the replay timeline.
+			 * RecoveryInProgress() distinguishes the two callers
+			 * (crash-recovery undo phase vs. online abort).
+			 */
+			if (RecoveryInProgress())
+			{
+				(void) GetXLogReplayRecPtr(&tli);
+				if (tli == 0)
+					tli = 1;	/* no replay position yet: default to TLI 1 */
+			}
+			else
+				tli = GetWALInsertionTimeLine();
+
+			XLByteToSeg(batch_lsn, target_segno, wal_segment_size);
+			XLogFilePath(path, tli, target_segno,
+						 wal_segment_size);
+
+			if (access(path, F_OK) != 0)
+			{
+				ereport(WARNING,
+						(errmsg("UNDO batch at %X/%X: WAL segment \"%s\" no longer "
+								"exists (recycled by checkpoint); skipping rollback",
+								LSN_FORMAT_ARGS(batch_lsn), path)));
+				return NULL;
+			}
+		}
+	}
+
+	/*
+	 * Allocate the reader and perform the read under TopMemoryContext.  Both
+	 * the cached reader and its lazily-allocated decode_buffer (xlogreader.c)
+	 * are palloc'd in CurrentMemoryContext; if that is a transient caller
+	 * context (e.g. the inline-abort context) it gets deleted, leaving the
+	 * static undo_batch_reader and its decode buffer dangling and crashing
+	 * the next abort.  We switch back to the caller's context before
+	 * allocating the returned UndoBatchData, which the caller owns.  See the
+	 * matching comment in UndoValidateBatchLSN.
+	 */
+	{
+		MemoryContext read_ctx = MemoryContextSwitchTo(TopMemoryContext);
+
+		if (undo_batch_reader == NULL)
+		{
+			undo_batch_reader = XLogReaderAllocate(wal_segment_size, NULL,
+												   &undo_batch_reader_routine, NULL);
+			if (undo_batch_reader == NULL)
+			{
+				MemoryContextSwitchTo(read_ctx);
+				ereport(WARNING,
+						(errmsg("could not allocate XLogReader for UNDO batch read")));
+				return NULL;
+			}
+		}
+
+		/* Position the reader at the target LSN, then read */
+		XLogBeginRead(undo_batch_reader, batch_lsn);
+		record_hdr = XLogReadRecord(undo_batch_reader, &errormsg);
+		MemoryContextSwitchTo(read_ctx);
+	}
+	if (record_hdr == NULL)
+	{
+		if (errormsg)
+			ereport(WARNING,
+					(errmsg("could not read WAL record at %X/%X: %s",
+							LSN_FORMAT_ARGS(batch_lsn), errormsg)));
+		return NULL;
+	}
+
+	/*
+	 * Determine record format: a standalone XLOG_UNDO_BATCH record carrying
+	 * the UNDO payload.
+	 */
+	record_data = XLogRecGetData(undo_batch_reader);
+	record_len = XLogRecGetDataLen(undo_batch_reader);
+
+	if (XLogRecGetRmid(undo_batch_reader) == RM_UNDO_ID &&
+		(XLogRecGetInfo(undo_batch_reader) & ~XLR_INFO_MASK) == XLOG_UNDO_BATCH)
+	{
+		/* Standalone XLOG_UNDO_BATCH record */
+		if (record_len < SizeOfUndoBatch)
+		{
+			ereport(WARNING,
+					(errmsg("UNDO batch record at %X/%X too short: %zu bytes",
+							LSN_FORMAT_ARGS(batch_lsn), record_len)));
+			return NULL;
+		}
+
+		xlrec = (xl_undo_batch *) record_data;
+		payload_offset = SizeOfUndoBatch;
+	}
+	else
+	{
+		ereport(WARNING,
+				(errmsg("WAL record at %X/%X is not an UNDO batch (rmid=%u, info=0x%02x)",
+						LSN_FORMAT_ARGS(batch_lsn),
+						XLogRecGetRmid(undo_batch_reader),
+						XLogRecGetInfo(undo_batch_reader) & ~XLR_INFO_MASK)));
+		return NULL;
+	}
+
+	/*
+	 * Validate that the claimed payload length fits within the WAL record. A
+	 * mismatch here means the WAL segment was recycled and overwritten (the
+	 * LSN now points to a different record), or the record is corrupt.
+	 * Without this check, the memcpy below would read past the XLogReader's
+	 * internal buffer, potentially accessing unmapped memory
+	 * (SIGBUS/SIGSEGV).
+	 */
+	if (payload_offset + (Size) xlrec->total_len > record_len)
+	{
+		ereport(WARNING,
+				(errmsg("UNDO batch at %X/%X: payload length %u exceeds "
+						"record data (offset %zu, record_len %zu)",
+						LSN_FORMAT_ARGS(batch_lsn),
+						xlrec->total_len, payload_offset, record_len)));
+		return NULL;
+	}
+
+	/*
+	 * Allocate UndoBatchData. We use palloc (CurrentMemoryContext) because
+	 * this structure is only needed until ApplyUndoChainFromWAL processes the
+	 * batch. We intentionally do NOT pfree in UndoFreeBatchData() because
+	 * calling pfree on BumpContext memory would ERROR. The memory will be
+	 * reclaimed when the current memory context is reset.
+	 */
+	result = (UndoBatchData *) palloc(sizeof(UndoBatchData));
+	memcpy(&result->header, xlrec, SizeOfUndoBatch);
+	result->payload_len = (Size) xlrec->total_len;
+	if (result->payload_len > 0)
+	{
+		result->payload = (char *) palloc(result->payload_len);
+		memcpy(result->payload, record_data + payload_offset,
+			   result->payload_len);
+	}
+	else
+	{
+		result->payload = NULL;
+	}
+
+	/* Do not free reader -- it is cached for reuse. */
+	return result;
+}
+
+/*
+ * UndoFreeBatchData - Release a UndoBatchData structure
+ *
+ * This is a no-op function. We don't actually pfree the batch or payload
+ * because they were allocated with palloc() from CurrentMemoryContext, which
+ * may be a BumpContext. Calling pfree on BumpContext memory would ERROR.
+ * The memory will be automatically reclaimed when the current memory context
+ * is reset (e.g., at end of query, transaction, or subtransaction).
+ *
+ * This function exists to maintain API compatibility and to serve as a
+ * clear marker in the code where batch data is no longer needed.
+ */
+void
+UndoFreeBatchData(UndoBatchData *batch)
+{
+	/* Intentionally empty - memory reclaimed by context reset */
+	(void) batch;
+}
diff --git a/src/backend/access/undo/undoapply.c b/src/backend/access/undo/undoapply.c
new file mode 100644
index 00000000000..437ec4804e8
--- /dev/null
+++ b/src/backend/access/undo/undoapply.c
@@ -0,0 +1,327 @@
+/*-------------------------------------------------------------------------
+ *
+ * undoapply.c
+ *	  Generic UNDO record application during transaction rollback
+ *
+ * When a transaction aborts, this module walks the UNDO chain backward
+ * from the most recent record to the first.  For each record, it
+ * dispatches to the appropriate resource manager's rm_undo callback
+ * based on the urec_rmid field in the record header.
+ *
+ * This module is AM-agnostic: it contains no AM- or subsystem-specific
+ * code.  All such UNDO application logic lives in the respective resource
+ * managers, each registered via RegisterUndoRmgr() (see access/undormgr.h).
+ *
+ * The dispatch pattern is analogous to WAL resource managers: each RM
+ * registers its callbacks via RegisterUndoRmgr(), and this module
+ * routes UNDO records to the correct handler.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/backend/access/undo/undoapply.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/undolog.h"
+#include "access/undorecord.h"
+#include "access/undormgr.h"
+#include "access/undo_xlog.h"
+#include "miscadmin.h"
+#include "utils/injection_point.h"
+#include "utils/memutils.h"
+
+/*
+ * ApplyOneUndoRecord - Apply a single UNDO record via RM dispatch
+ *
+ * Checks the CLR pointer to avoid double-application, then dispatches
+ * to the appropriate resource manager's rm_undo callback.
+ *
+ * Returns true if successfully applied, false if skipped.
+ */
+static bool
+ApplyOneUndoRecord(UndoRecordHeader *header, char *payload,
+				   UndoRecPtr urec_ptr)
+{
+	const UndoRmgrData *rmgr;
+	UndoApplyResult result;
+
+	/*
+	 * Idempotency design note:
+	 *
+	 * UNDO records are immutable once written to WAL and carry no per-record
+	 * applied marker. Double-application is prevented by page LSN instead:
+	 * each CLR (XLOG_UNDO_APPLY_RECORD) written by rm_undo bumps the heap
+	 * page LSN to the CLR's EndRecPtr.  When the undo apply pass runs after a
+	 * crash, rm_undo reads the buffer via XLogReadBufferForRedo; if the page
+	 * LSN >= CLR LSN (meaning the CLR was already replayed by the forward
+	 * redo pass), the buffer read returns BLK_DONE or BLK_RESTORED and no
+	 * modification is made.
+	 *
+	 * This function is therefore unconditionally correct to call for every
+	 * UNDO record encountered during chain walking.
+	 */
+
+	/*
+	 * Look up the resource manager for this record.
+	 */
+	rmgr = GetUndoRmgr(header->urec_rmid);
+	if (rmgr == NULL)
+	{
+		ereport(WARNING,
+				(errmsg("UNDO rollback: unknown RM ID %u for record at %llu, skipping",
+						header->urec_rmid,
+						(unsigned long long) urec_ptr)));
+		return false;
+	}
+
+	/*
+	 * Dispatch to the RM's undo-apply callback.  The callback is responsible
+	 * for all AM-specific work: opening relations, locking buffers, modifying
+	 * pages, generating CLRs, and releasing resources.
+	 */
+	result = rmgr->rm_undo(header->urec_rmid,
+						   header->urec_info,
+						   header->urec_xid,
+						   header->urec_reloid,
+						   payload,
+						   header->urec_payload_len,
+						   urec_ptr);
+
+	if (result == UNDO_APPLY_SUCCESS)
+	{
+		ereport(DEBUG2,
+				(errmsg("UNDO rollback: applied %s record at %llu",
+						rmgr->rm_name,
+						(unsigned long long) urec_ptr)));
+		return true;
+	}
+	else if (result == UNDO_APPLY_SKIPPED)
+	{
+		ereport(DEBUG2,
+				(errmsg("UNDO rollback: skipped %s record at %llu",
+						rmgr->rm_name,
+						(unsigned long long) urec_ptr)));
+		return false;
+	}
+	else
+	{
+		ereport(WARNING,
+				(errmsg("UNDO rollback: error applying %s record at %llu",
+						rmgr->rm_name,
+						(unsigned long long) urec_ptr)));
+		return false;
+	}
+}
+
+/*
+ * ApplyUndoChainFromWAL - Walk and apply an UNDO chain from WAL
+ *
+ * Reads XLOG_UNDO_BATCH WAL records via UndoReadBatchFromWAL() and
+ * iterates through the serialized records within each batch.
+ *
+ * The chain is walked backward via the xl_undo_batch.chain_prev LSN
+ * from the most recent batch to the first.  Within each batch, records
+ * are applied in reverse order (newest to oldest) as required by
+ * ARIES-style rollback semantics.  This is achieved by first scanning
+ * forward through the serialized records to collect their start offsets,
+ * then iterating the collected offsets in reverse to apply each record.
+ */
+bool
+ApplyUndoChainFromWAL(XLogRecPtr last_batch_lsn)
+{
+	return ApplyUndoChainFromWALBounded(last_batch_lsn, InvalidXLogRecPtr);
+}
+
+/*
+ * ApplyUndoChainFromWALBounded - Apply an UNDO chain, stopping at a boundary
+ *
+ * Identical to ApplyUndoChainFromWAL() except that the chain walk halts before
+ * processing any batch whose LSN is at or below stop_at_lsn.  This applies only
+ * the batches strictly newer than stop_at_lsn, which is what subtransaction
+ * abort needs: stop_at_lsn is the parent's saved chain head, so the parent's
+ * (and earlier subtransactions') batches are left intact while the aborting
+ * subtransaction's batches are reverted.
+ *
+ * Pass InvalidXLogRecPtr as stop_at_lsn to walk the entire chain (top-level
+ * rollback / recovery semantics).
+ */
+bool
+ApplyUndoChainFromWALBounded(XLogRecPtr last_batch_lsn, XLogRecPtr stop_at_lsn)
+{
+	XLogRecPtr	batch_lsn;
+	int			records_applied = 0;
+	int			records_skipped = 0;
+	int			batches_processed = 0;
+
+	if (!XLogRecPtrIsValid(last_batch_lsn))
+		return false;
+
+	ereport(DEBUG1,
+			(errmsg("applying UNDO chain from WAL starting at %X/%X "
+					"(stop boundary %X/%X)",
+					LSN_FORMAT_ARGS(last_batch_lsn),
+					LSN_FORMAT_ARGS(stop_at_lsn))));
+
+	batch_lsn = last_batch_lsn;
+
+	while (XLogRecPtrIsValid(batch_lsn))
+	{
+		UndoBatchData *batch;
+		char	   *pos;
+		char	   *end;
+
+		/*
+		 * Bounded walk: stop once we reach a batch that belongs to the parent
+		 * (or an earlier subtransaction).  chain_prev LSNs decrease strictly
+		 * as we walk backward, so the first batch at or below the boundary
+		 * marks the start of the region we must preserve.
+		 */
+		if (XLogRecPtrIsValid(stop_at_lsn) && batch_lsn <= stop_at_lsn)
+			break;
+
+		INJECTION_POINT("undo-apply-before-batch", NULL);
+
+		batch = UndoReadBatchFromWAL(batch_lsn);
+		if (batch == NULL)
+		{
+			ereport(WARNING,
+					(errmsg("UNDO rollback: could not read batch at %X/%X, "
+							"stopping chain walk",
+							LSN_FORMAT_ARGS(batch_lsn))));
+			break;
+		}
+
+		batches_processed++;
+
+		/*
+		 * Walk through records within this batch in reverse order.
+		 *
+		 * ARIES requires that UNDO records within a batch be applied
+		 * newest-first (reverse of serialization order).  We first scan
+		 * forward to collect pointers to each record start, then iterate the
+		 * collected pointers in reverse to apply them.
+		 */
+		pos = batch->payload;
+		end = pos + batch->payload_len;
+
+		{
+			char	  **record_starts;
+			int			nrecords_in_batch = 0;
+			int			max_records = 1024;
+			int			i;
+			MemoryContext batchctx;
+			MemoryContext oldctx;
+
+			/*
+			 * record_starts must grow to hold EVERY record in the batch --
+			 * silently truncating rollback is a data-integrity bug (the
+			 * dropped records are never applied, so a partially-applied
+			 * in-place UPDATE/DELETE is left on the page).  We therefore
+			 * repalloc() to double the array whenever it fills.
+			 *
+			 * The caller's CurrentMemoryContext may be a BumpContext
+			 * (executor abort path), which supports neither repalloc() nor
+			 * pfree().  So we do all growth in a private AllocSet child
+			 * context and delete it whole at the end of the batch --
+			 * context-agnostic, leak-free, and unbounded.
+			 */
+			batchctx = AllocSetContextCreate(CurrentMemoryContext,
+											 "UNDO batch record_starts",
+											 ALLOCSET_DEFAULT_SIZES);
+			oldctx = MemoryContextSwitchTo(batchctx);
+
+			record_starts = (char **) palloc(max_records * sizeof(char *));
+
+			/* First pass: collect record start pointers by scanning forward */
+			while (pos < end)
+			{
+				UndoRecordHeader hdr;
+
+				if ((Size) (end - pos) < SizeOfUndoRecordHeader)
+				{
+					ereport(WARNING,
+							(errmsg("UNDO rollback: truncated record in batch at %X/%X",
+									LSN_FORMAT_ARGS(batch_lsn))));
+					break;
+				}
+
+				memcpy(&hdr, pos, SizeOfUndoRecordHeader);
+
+				if (hdr.urec_len < SizeOfUndoRecordHeader ||
+					(Size) (end - pos) < hdr.urec_len)
+				{
+					ereport(WARNING,
+							(errmsg("UNDO rollback: invalid record size %u in batch at %X/%X",
+									hdr.urec_len, LSN_FORMAT_ARGS(batch_lsn))));
+					break;
+				}
+
+				/*
+				 * Grow the array (double it) rather than truncate the
+				 * rollback
+				 */
+				if (nrecords_in_batch >= max_records)
+				{
+					max_records *= 2;
+					record_starts = (char **) repalloc(record_starts,
+													   max_records * sizeof(char *));
+				}
+
+				record_starts[nrecords_in_batch++] = pos;
+				pos += hdr.urec_len;
+			}
+
+			/*
+			 * Second pass: apply records in reverse order (newest first).
+			 * Even if there was a scan error, apply whatever records we
+			 * successfully collected.
+			 */
+			for (i = nrecords_in_batch - 1; i >= 0; i--)
+			{
+				UndoRecordHeader header;
+				char	   *payload = NULL;
+
+				memcpy(&header, record_starts[i], SizeOfUndoRecordHeader);
+
+				if (header.urec_payload_len > 0)
+					payload = record_starts[i] + SizeOfUndoRecordHeader;
+
+				if (ApplyOneUndoRecord(&header, payload, InvalidUndoRecPtr))
+					records_applied++;
+				else
+					records_skipped++;
+			}
+
+			MemoryContextSwitchTo(oldctx);
+			MemoryContextDelete(batchctx);
+		}
+
+		INJECTION_POINT("undo-apply-after-batch", NULL);
+
+		/* Follow chain to previous batch */
+		batch_lsn = batch->header.chain_prev;
+		UndoFreeBatchData(batch);
+	}
+
+	/* Report results */
+	if (records_skipped > 0)
+	{
+		ereport(WARNING,
+				(errmsg("UNDO rollback from WAL: %d batches, %d records applied, "
+						"%d skipped",
+						batches_processed, records_applied, records_skipped)));
+	}
+	else
+	{
+		ereport(DEBUG1,
+				(errmsg("UNDO rollback from WAL complete: %d batches, "
+						"%d records applied",
+						batches_processed, records_applied)));
+	}
+
+	return (batches_processed > 0);
+}
diff --git a/src/backend/access/undo/undoinsert.c b/src/backend/access/undo/undoinsert.c
new file mode 100644
index 00000000000..47ceb92877a
--- /dev/null
+++ b/src/backend/access/undo/undoinsert.c
@@ -0,0 +1,166 @@
+/*-------------------------------------------------------------------------
+ *
+ * undoinsert.c
+ *	  UNDO record batch insertion operations
+ *
+ * This file implements batch insertion of UNDO records into the WAL
+ * stream.  Records are accumulated in an UndoRecordSet and then
+ * written as a single XLOG_UNDO_BATCH WAL record.
+ *
+ * UNDO-IN-WAL ARCHITECTURE
+ * ------------------------
+ * All UNDO record data flows through the standard WAL pipeline:
+ *   UndoRecordSetInsert() -> XLogBeginInsert()
+ *                         -> XLogRegisterData(batch_header)
+ *                         -> XLogRegisterData(uset->buffer, uset->buffer_size)
+ *                         -> XLogInsert(RM_UNDO_ID, XLOG_UNDO_BATCH)
+ *
+ * This eliminates the separate UNDO segment file I/O path (pwrite +
+ * fdatasync) and provides:
+ * - Replicas receive and can apply UNDO records
+ * - One durability path, one sync at commit
+ * - Unified crash recovery with explicit UNDO phase
+ *
+ * Coalescing: The existing UndoRecordSet mechanism batches records.
+ * This batch becomes one WAL record.  A 1000-row INSERT produces ~1
+ * WAL record containing 1000 UNDO records.
+ *
+ * Legacy support: UndoWalBatchFlush/Reset are kept as no-ops for
+ * callers that haven't been updated yet.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/backend/access/undo/undoinsert.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/undolog.h"
+#include "access/undorecord.h"
+#include "access/undo_xlog.h"
+#include "access/xloginsert.h"
+#include "access/xlog.h"
+#include "utils/injection_point.h"
+
+/*
+ * UndoWalBatchFlush - Legacy no-op
+ *
+ * With UNDO-in-WAL, there is no separate deferred WAL batch to flush.
+ * UNDO data is written directly to WAL in UndoRecordSetInsert().
+ * This function is kept for callers that haven't been updated yet.
+ */
+void
+UndoWalBatchFlush(void)
+{
+	/* No-op: UNDO data is now written directly to WAL */
+}
+
+/*
+ * UndoWalBatchReset - Legacy no-op
+ *
+ * With UNDO-in-WAL, there is no separate deferred WAL batch to reset.
+ */
+void
+UndoWalBatchReset(void)
+{
+	/* No-op: UNDO data is now written directly to WAL */
+}
+
+/*
+ * UndoRecordSetInsert - Insert accumulated UNDO records into WAL
+ *
+ * This function writes all UNDO records in the set as a single
+ * XLOG_UNDO_BATCH WAL record.  The batch payload is the serialized
+ * content of uset->buffer (concatenated UndoRecordHeader+payload).
+ *
+ * Returns the (legacy) UndoRecPtr for backward compatibility.
+ * The actual record location is the XLogRecPtr stored in
+ * uset->last_batch_lsn after this call.
+ */
+UndoRecPtr
+UndoRecordSetInsert(UndoRecordSet *uset)
+{
+	xl_undo_batch xlrec;
+	XLogRecPtr	batch_lsn;
+	Oid			primary_reloid = InvalidOid;
+
+	if (uset == NULL || uset->nrecords == 0)
+		return InvalidUndoRecPtr;
+
+	/*
+	 * Extract the primary relation OID from the first record in the batch as
+	 * an optimization hint.  Most batches contain records for a single
+	 * relation.
+	 */
+	if (uset->buffer_size >= SizeOfUndoRecordHeader)
+	{
+		UndoRecordHeader *first_hdr = (UndoRecordHeader *) uset->buffer;
+
+		primary_reloid = first_hdr->urec_reloid;
+	}
+
+	/* Build the batch header */
+	xlrec.xid = uset->xid;
+	xlrec.chain_prev = uset->last_batch_lsn;
+	xlrec.nrecords = (uint32) uset->nrecords;
+	xlrec.total_len = (uint32) uset->buffer_size;
+	xlrec.primary_reloid = primary_reloid;
+	xlrec.persistence = uset->persistence;
+
+	/*
+	 * Write the UNDO batch as a single WAL record.
+	 *
+	 * XLogRegisterData has no size limit on main data (tracked as uint64 in
+	 * xloginsert.c), so even a 256KB batch is fine.  The WAL insertion lock
+	 * will be held for the duration of the record write, which is acceptable
+	 * for batch sizes up to a few hundred KB.
+	 */
+	XLogBeginInsert();
+	XLogRegisterData((char *) &xlrec, SizeOfUndoBatch);
+	XLogRegisterData(uset->buffer, uset->buffer_size);
+
+	/*
+	 * Use the CACHED variant: UndoRecordSetInsert() always runs inside the
+	 * caller's critical section (it emits a WAL record), and
+	 * InjectionPointRun() may palloc (dlopen the callback library, or a
+	 * wait-mode callback allocating to suspend the backend), which is
+	 * forbidden in a critical section.  The matching INJECTION_POINT_LOAD is
+	 * issued pre-crit in PrepareXactUndoData(); INJECTION_POINT_CACHED is
+	 * palloc-free.
+	 */
+	INJECTION_POINT_CACHED("undo-batch-before-wal-insert", NULL);
+
+	(void) XLogInsert(RM_UNDO_ID, XLOG_UNDO_BATCH);
+
+	/*
+	 * XLogInsert() returns the end+1 position of the record, but the rollback
+	 * path must re-read the batch by its START LSN.  ProcLastRecPtr holds the
+	 * start of the record we just inserted; use it so UndoReadBatchFromWAL
+	 * lands on this XLOG_UNDO_BATCH instead of the following record.
+	 */
+	batch_lsn = ProcLastRecPtr;
+
+	INJECTION_POINT_CACHED("undo-batch-after-wal-insert", NULL);
+
+	/* Update the record set's chain pointer for subsequent batches */
+	uset->last_batch_lsn = batch_lsn;
+
+	/*
+	 * Register the batch LSN for WAL retention tracking.  Only the first call
+	 * per transaction takes effect (UndoRegisterBatchLSN is a no-op if the
+	 * slot is already occupied), so this records the oldest batch for this
+	 * transaction without additional bookkeeping.
+	 */
+	UndoRegisterBatchLSN(batch_lsn);
+
+	/*
+	 * For legacy compatibility, return a non-zero UndoRecPtr. The actual
+	 * location is in uset->last_batch_lsn (XLogRecPtr).
+	 */
+	uset->prev_undo_ptr = (UndoRecPtr) batch_lsn;
+
+	return (UndoRecPtr) batch_lsn;
+}
diff --git a/src/backend/access/undo/undolog.c b/src/backend/access/undo/undolog.c
new file mode 100644
index 00000000000..26b6271501b
--- /dev/null
+++ b/src/backend/access/undo/undolog.c
@@ -0,0 +1,383 @@
+/*-------------------------------------------------------------------------
+ *
+ * undolog.c
+ *	  PostgreSQL UNDO log manager -- WAL-integrated version
+ *
+ * With UNDO-in-WAL, UNDO records are stored in the standard WAL stream
+ * as XLOG_UNDO_BATCH records.  The separate base/undo/ segment files,
+ * direct pwrite()/pread() I/O path, and per-backend fd cache have been
+ * removed.  This file retains:
+ *
+ * - GUC parameters (undo_worker_naptime, etc.)
+ * - Shared memory structures for UNDO state tracking
+ * - Discard pointer management (repurposed for WAL-based UNDO)
+ * - Checkpoint support (statistics logging)
+ *
+ * The previous functions (UndoLogAllocate, UndoLogWrite, UndoLogRead,
+ * UndoLogSync, UndoLogSealAndRotate, etc.) are removed.  Callers now
+ * use UndoRecordSetInsert() which writes directly to WAL via XLogInsert.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/backend/access/undo/undolog.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/transam.h"
+#include "access/twophase.h"
+#include "access/undolog.h"
+#include "access/xlog.h"
+#include "miscadmin.h"
+#include "storage/lwlock.h"
+#include "storage/procnumber.h"
+#include "storage/shmem.h"
+#include "utils/guc.h"
+#include "utils/timestamp.h"
+
+/* GUC parameters */
+int			undo_worker_naptime = 10000;	/* 10 seconds */
+int			undo_max_wal_retention_size = 0;	/* 0 = unlimited, in MB */
+int			undo_batch_size_kb = 256;	/* UNDO batch flush threshold in KB */
+int			undo_batch_record_limit = 1000; /* UNDO batch flush threshold in
+											 * records */
+
+/* Shared memory pointer */
+UndoLogSharedData *UndoLogShared = NULL;
+
+/*
+ * UndoLogShmemSize
+ *		Calculate shared memory size for UNDO log management
+ *
+ * The size includes the fixed UndoLogSharedData fields plus a per-backend
+ * array of pg_atomic_uint64 for first UNDO batch LSN tracking.
+ */
+Size
+UndoLogShmemSize(void)
+{
+	Size		size;
+
+	/* Fixed struct size up to (but not including) the flexible array */
+	size = offsetof(UndoLogSharedData, backend_undo_lsns);
+
+	/* Per-backend first-batch LSN slots */
+	size = add_size(size, mul_size(MaxBackends, sizeof(pg_atomic_uint64)));
+
+	return size;
+}
+
+/*
+ * UndoLogShmemRequest
+ *		Request the UNDO log control area from the shared memory framework.
+ *
+ * Called from the UNDO subsystem's request_fn.  The framework sets UndoLogShared,
+ * in the postmaster and, under EXEC_BACKEND, in every child.
+ */
+void
+UndoLogShmemRequest(void)
+{
+	ShmemRequestStruct(.name = "UNDO Log Control",
+					   .size = UndoLogShmemSize(),
+					   .ptr = (void **) &UndoLogShared);
+}
+
+/*
+ * UndoLogShmemInit
+ *		Initialize the contents of the area requested by UndoLogShmemRequest().
+ *
+ * Called once, by the postmaster or a standalone backend, from the UNDO
+ * subsystem's init_fn.  UndoLogShared has already been set by the shared memory
+ * framework; an EXEC_BACKEND child gets the pointer from the framework's
+ * attach step and never calls this.
+ */
+void
+UndoLogShmemInit(void)
+{
+	int			i;
+
+	/* Initialize all log control structures */
+	for (i = 0; i < MAX_UNDO_LOGS; i++)
+	{
+		UndoLogControl *log = &UndoLogShared->logs[i];
+
+		log->log_number = 0;
+		pg_atomic_init_u64(&log->insert_ptr, InvalidUndoRecPtr);
+		log->discard_ptr = InvalidUndoRecPtr;
+		log->oldest_xid = InvalidTransactionId;
+		LWLockInitialize(&log->lock, LWTRANCHE_UNDO_LOG);
+		log->in_use = false;
+		log->state = UNDO_LOG_FREE;
+		pg_atomic_init_u64(&log->seal_ptr, InvalidUndoRecPtr);
+		log->sealed_time = 0;
+	}
+
+	UndoLogShared->next_log_number = 1;
+	LWLockInitialize(&UndoLogShared->allocation_lock, LWTRANCHE_UNDO_LOG);
+	pg_atomic_init_u32(&UndoLogShared->active_log_idx, MAX_UNDO_LOGS);
+	pg_atomic_init_u64(&UndoLogShared->total_allocated, 0);
+	pg_atomic_init_u64(&UndoLogShared->total_discarded, 0);
+	pg_atomic_init_u64(&UndoLogShared->undo_discard_horizon,
+					   InvalidXLogRecPtr);
+
+	/* Initialize per-backend first UNDO batch LSN slots */
+	for (i = 0; i < MaxBackends; i++)
+		pg_atomic_init_u64(&UndoLogShared->backend_undo_lsns[i],
+						   InvalidXLogRecPtr);
+}
+
+/*
+ * UndoLogDiscard
+ *		Advance the UNDO discard horizon.
+ *
+ * With UNDO-in-WAL, discard means advancing the WAL retention horizon
+ * past which UNDO records are no longer needed for rollback.  The
+ * background UNDO worker calls this after confirming all transactions
+ * older than oldest_needed have committed or had their UNDO applied.
+ */
+void
+UndoLogDiscard(UndoRecPtr oldest_needed)
+{
+	int			i;
+
+	if (!UndoRecPtrIsValid(oldest_needed))
+		return;
+
+	for (i = 0; i < MAX_UNDO_LOGS; i++)
+	{
+		UndoLogControl *log = &UndoLogShared->logs[i];
+
+		if (!log->in_use)
+			continue;
+
+		LWLockAcquire(&log->lock, LW_EXCLUSIVE);
+
+		if (UndoRecPtrGetLogNo(oldest_needed) == log->log_number)
+		{
+			if (UndoRecPtrGetOffset(oldest_needed) > UndoRecPtrGetOffset(log->discard_ptr))
+			{
+				log->discard_ptr = oldest_needed;
+				ereport(DEBUG2,
+						(errmsg("UNDO discard: log %u advanced to offset %llu",
+								log->log_number,
+								(unsigned long long) UndoRecPtrGetOffset(oldest_needed))));
+			}
+		}
+
+		LWLockRelease(&log->lock);
+	}
+}
+
+/*
+ * CheckPointUndoLog
+ *		Perform checkpoint processing for the UNDO log subsystem.
+ *
+ * With UNDO-in-WAL, there are no UNDO segment files to sync.
+ * This function logs statistics when log_checkpoints is enabled.
+ */
+void
+CheckPointUndoLog(void)
+{
+	int			active_logs = 0;
+	uint64		total_allocated = 0;
+	uint64		total_discarded = 0;
+	int			i;
+
+	if (UndoLogShared == NULL)
+		return;
+
+	for (i = 0; i < MAX_UNDO_LOGS; i++)
+	{
+		UndoLogControl *log = &UndoLogShared->logs[i];
+
+		if (!log->in_use)
+			continue;
+
+		active_logs++;
+		total_allocated += UndoRecPtrGetOffset(pg_atomic_read_u64(&log->insert_ptr));
+
+		LWLockAcquire(&log->lock, LW_SHARED);
+		total_discarded += UndoRecPtrGetOffset(log->discard_ptr);
+		LWLockRelease(&log->lock);
+	}
+
+	if (log_checkpoints && active_logs > 0)
+	{
+		ereport(LOG,
+				(errmsg("UNDO checkpoint: %d active log(s), "
+						"%llu bytes allocated, %llu bytes discarded, "
+						"%llu bytes retained",
+						active_logs,
+						(unsigned long long) total_allocated,
+						(unsigned long long) total_discarded,
+						(unsigned long long) (total_allocated - total_discarded))));
+	}
+}
+
+/*
+ * UndoSetDiscardHorizon
+ *		Advance the UNDO discard horizon to a new LSN.
+ *
+ * Called by the UNDO discard worker after confirming that all UNDO
+ * records before 'horizon' have been processed (transactions committed
+ * or rolled back, index pruning completed).
+ *
+ * The horizon only moves forward -- if the new value is older than
+ * the current horizon, the call is a no-op.
+ */
+void
+UndoSetDiscardHorizon(XLogRecPtr horizon)
+{
+	uint64		old_horizon;
+
+	if (UndoLogShared == NULL || !XLogRecPtrIsValid(horizon))
+		return;
+
+	/* Advance forward only */
+	while (true)
+	{
+		old_horizon = pg_atomic_read_u64(&UndoLogShared->undo_discard_horizon);
+
+		if (XLogRecPtrIsValid((XLogRecPtr) old_horizon) &&
+			horizon <= (XLogRecPtr) old_horizon)
+			break;				/* already at or past this point */
+
+		if (pg_atomic_compare_exchange_u64(&UndoLogShared->undo_discard_horizon,
+										   &old_horizon, (uint64) horizon))
+			break;
+	}
+}
+
+/*
+ * UndoRegisterBatchLSN
+ *		Register the first UNDO batch LSN for the current backend.
+ *
+ * Called from UndoRecordSetInsert() the first time a transaction writes
+ * UNDO data.  Stores the LSN in the per-backend slot so that the UNDO
+ * discard worker can find the oldest in-flight UNDO batch and avoid
+ * recycling WAL segments that still contain needed UNDO data.
+ *
+ * Only the FIRST call per transaction takes effect (we want the oldest,
+ * i.e., smallest, LSN).  Subsequent calls for the same transaction are
+ * no-ops because the slot is already occupied.
+ */
+void
+UndoRegisterBatchLSN(XLogRecPtr batch_lsn)
+{
+	pg_atomic_uint64 *slot;
+	uint64		expected;
+
+	if (UndoLogShared == NULL || !XLogRecPtrIsValid(batch_lsn))
+		return;
+	if (MyProcNumber < 0 || MyProcNumber >= MaxBackends)
+		return;
+
+	slot = &UndoLogShared->backend_undo_lsns[MyProcNumber];
+	expected = InvalidXLogRecPtr;
+
+	/*
+	 * Only set if the slot is currently empty.  This records the first
+	 * (oldest) batch for this transaction; later batches have larger LSNs and
+	 * should not overwrite the stored value.
+	 */
+	(void) pg_atomic_compare_exchange_u64(slot, &expected, (uint64) batch_lsn);
+}
+
+/*
+ * UndoClearBatchLSN
+ *		Clear the per-backend UNDO batch LSN registration.
+ *
+ * Called at transaction commit or abort to release the WAL retention
+ * hold that was established by UndoRegisterBatchLSN().
+ */
+void
+UndoClearBatchLSN(void)
+{
+	if (UndoLogShared == NULL)
+		return;
+	if (MyProcNumber < 0 || MyProcNumber >= MaxBackends)
+		return;
+
+	pg_atomic_write_u64(&UndoLogShared->backend_undo_lsns[MyProcNumber],
+						(uint64) InvalidXLogRecPtr);
+}
+
+/*
+ * UndoGetOldestBatchLSN
+ *		Return the oldest UNDO batch LSN that must be retained in WAL.
+ *
+ * Considers both:
+ *   1. Per-backend slots (in-flight transactions with UNDO data)
+ *   2. Prepared transactions, whose chain ROLLBACK PREPARED still reads
+ *
+ * Returns InvalidXLogRecPtr if no WAL retention is needed for UNDO.
+ */
+XLogRecPtr
+UndoGetOldestBatchLSN(void)
+{
+	XLogRecPtr	oldest = InvalidXLogRecPtr;
+	int			i;
+
+	if (UndoLogShared == NULL)
+		return InvalidXLogRecPtr;
+
+	/* Check per-backend slots for in-flight transactions */
+	for (i = 0; i < MaxBackends; i++)
+	{
+		XLogRecPtr	lsn = (XLogRecPtr)
+			pg_atomic_read_u64(&UndoLogShared->backend_undo_lsns[i]);
+
+		if (XLogRecPtrIsValid(lsn))
+		{
+			if (!XLogRecPtrIsValid(oldest) || lsn < oldest)
+				oldest = lsn;
+		}
+	}
+
+	/*
+	 * Check prepared (2PC) transactions.  A xact can sit PREPARED
+	 * indefinitely; its UNDO-batch WAL must survive until ROLLBACK PREPARED
+	 * reads it.  The per-backend slot is cleared when the preparing backend
+	 * exits, so it does not cover this.
+	 */
+	{
+		XLogRecPtr	prep_oldest = TwoPhaseGetOldestUndoBatchLSN();
+
+		if (XLogRecPtrIsValid(prep_oldest) &&
+			(!XLogRecPtrIsValid(oldest) || prep_oldest < oldest))
+			oldest = prep_oldest;
+	}
+
+	return oldest;
+}
+
+/*
+ * No-op stubs
+ *
+ * UNDO-in-WAL has no per-log segment files: UNDO data lives in the WAL
+ * stream and is recycled with it.  These still have callers
+ * (UndoLogSealAndRotate and UndoLogDeleteSegmentFile from the discard worker
+ * and pg_undo_force_discard(), ExtendUndoLogFile from the XLOG_UNDO_EXTEND
+ * redo path), for which doing nothing is the correct behaviour.
+ */
+
+void
+UndoLogSealAndRotate(uint8 trigger pg_attribute_unused())
+{
+	/* No-op: no segment rotation with UNDO-in-WAL */
+}
+
+void
+UndoLogDeleteSegmentFile(uint32 log_number pg_attribute_unused())
+{
+	/* No-op: no segment files with UNDO-in-WAL */
+}
+
+void
+ExtendUndoLogFile(uint32 log_number pg_attribute_unused(),
+				  uint64 logical_end pg_attribute_unused())
+{
+	/* No-op: no segment files with UNDO-in-WAL */
+}
diff --git a/src/backend/access/undo/undorecord.c b/src/backend/access/undo/undorecord.c
new file mode 100644
index 00000000000..bdd3bdbbc81
--- /dev/null
+++ b/src/backend/access/undo/undorecord.c
@@ -0,0 +1,398 @@
+/*-------------------------------------------------------------------------
+ *
+ * undorecord.c
+ *	  UNDO record assembly and serialization
+ *
+ * This file implements the AM-agnostic UNDO record format and provides
+ * functions for creating, serializing, and deserializing UNDO records.
+ * All AM-specific knowledge is kept out of this module; records carry
+ * opaque payloads whose interpretation is delegated to the owning RM.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/backend/access/undo/undorecord.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/undorecord.h"
+#include "utils/memutils.h"
+
+/*
+ * Per-backend recycled memory context for UndoRecordSet.
+ *
+ * Instead of creating and destroying a MemoryContext for every
+ * UndoRecordSet, we recycle one context across operations within a
+ * transaction.  This avoids the overhead of repeated
+ * AllocSetContextCreate/MemoryContextDelete for high-frequency
+ * operations (e.g., 1000-row INSERTs).  The cached context is cleaned
+ * up at transaction end by UndoRecordSetResetCache().
+ */
+static MemoryContext UndoRecordReusableContext = NULL;
+
+/*
+ * UndoRecordGetPayloadSize - Calculate size needed for an UNDO record
+ *
+ * This includes the fixed header plus the RM-specific payload, rounded up to
+ * MAXALIGN so that each record in a packed stream begins at a MAXALIGN'd
+ * offset.  UndoRecordHeader contains 8-byte members (urec_xid, urec_prev),
+ * and the writers build the header in place through an (UndoRecordHeader *)
+ * cast into the packed buffer; without this rounding a record whose length is
+ * not a multiple of MAXIMUM_ALIGNOF would leave the next record's header at a
+ * mis-aligned address, which is undefined behavior (and traps under
+ * -fsanitize=alignment / crashes on alignment-strict hardware).  The padded
+ * size is stored in urec_len, so readers advance by the same stride; the true
+ * payload length travels separately in urec_payload_len, so the padding never
+ * affects payload extraction.
+ */
+Size
+UndoRecordGetPayloadSize(Size payload_len)
+{
+	return MAXALIGN(SizeOfUndoRecordHeader + payload_len);
+}
+
+/*
+ * UndoRecordSetCreate - Create a new UNDO record set
+ *
+ * A record set accumulates multiple UNDO records before writing them
+ * to the UNDO log in a batch. This improves performance by reducing
+ * I/O operations.
+ */
+UndoRecordSet *
+UndoRecordSetCreate(TransactionId xid, UndoRecPtr prev_undo_ptr)
+{
+	UndoRecordSet *uset;
+	MemoryContext mctx;
+	MemoryContext parent;
+
+	/*
+	 * Use TopTransactionContext as the parent so the record set survives
+	 * across SPI statement boundaries.  When called from PL/pgSQL DO blocks,
+	 * CurrentMemoryContext is the executor's per-query context
+	 * (es_query_cxt), which is destroyed in FreeExecutorState() after each
+	 * SPI_execute call.  Since xactundo.c stores the uset pointer in the
+	 * static XactUndo.record_set[] and reuses it across multiple statements
+	 * within a transaction, the context must outlive any single query.
+	 * TopTransactionContext is ideal: it survives until transaction
+	 * commit/abort, and AtAbort cleanup will free the uset via
+	 * UndoRecordSetFree().
+	 */
+	parent = TopTransactionContext;
+
+	/*
+	 * Reuse a previously recycled memory context if available. This avoids
+	 * the overhead of AllocSetContextCreate/MemoryContextDelete for every
+	 * UndoRecordSet within a transaction.  MemoryContextReset clears all
+	 * allocations but keeps the context's memory blocks for reuse.
+	 */
+	if (UndoRecordReusableContext != NULL)
+	{
+		mctx = UndoRecordReusableContext;
+		UndoRecordReusableContext = NULL;	/* take ownership */
+		MemoryContextReset(mctx);
+		MemoryContextSetParent(mctx, parent);
+	}
+	else
+	{
+		mctx = AllocSetContextCreate(parent,
+									 "UNDO record set",
+									 ALLOCSET_DEFAULT_SIZES);
+	}
+
+	/*
+	 * Allocate everything in the uset's memory context using direct
+	 * MemoryContextAlloc to avoid MemoryContextSwitchTo overhead.
+	 */
+	uset = (UndoRecordSet *) MemoryContextAllocZero(mctx, sizeof(UndoRecordSet));
+	uset->xid = xid;
+	uset->prev_undo_ptr = prev_undo_ptr;
+	uset->persistence = UNDOPERSISTENCE_PERMANENT;
+	uset->type = URST_TRANSACTION;
+
+	/*
+	 * Allocate initial buffer.  512 bytes is enough for a single UNDO record
+	 * (48-byte header + typical heap payload).  For bulk mode the buffer
+	 * grows dynamically via UndoRecordEnsureCapacity.
+	 */
+	uset->buffer_capacity = 512;
+	uset->buffer = (char *) MemoryContextAlloc(mctx, uset->buffer_capacity);
+	uset->buffer_size = 0;
+
+	uset->last_batch_lsn = InvalidXLogRecPtr;
+	uset->mctx = mctx;
+
+	return uset;
+}
+
+/*
+ * UndoRecordSetFree - Free an UNDO record set
+ *
+ * Recycles the memory context for later reuse if possible, otherwise
+ * destroys it.  We keep at most one recycled context to bound memory.
+ */
+void
+UndoRecordSetFree(UndoRecordSet *uset)
+{
+	MemoryContext mctx;
+
+	if (uset == NULL || uset->mctx == NULL)
+		return;
+
+	mctx = uset->mctx;
+
+	if (UndoRecordReusableContext == NULL)
+	{
+		/*
+		 * Recycle this context for the next UndoRecordSetCreate call.
+		 *
+		 * Re-parent to TopMemoryContext so the cached context is not
+		 * destroyed if its original parent is cleaned up before
+		 * UndoRecordSetResetCache() runs.  This can happen when the UNDO
+		 * record set was created inside an SPI execution context (e.g., DO $$
+		 * ... $$ blocks): SPI_finish() deletes its procCxt/execCxt, which
+		 * would recursively destroy this child context, leaving
+		 * UndoRecordReusableContext as a dangling pointer.
+		 * UndoRecordSetCreate() will re-parent it to the caller's
+		 * CurrentMemoryContext on reuse.
+		 */
+		MemoryContextSetParent(mctx, TopMemoryContext);
+		UndoRecordReusableContext = mctx;
+	}
+	else
+	{
+		/* Already have one recycled context; destroy this one */
+		MemoryContextDelete(mctx);
+	}
+}
+
+/*
+ * UndoRecordEnsureCapacity - Ensure the uset buffer can hold additional bytes
+ *
+ * Grows the buffer (using the uset's memory context) if needed.
+ * Avoids MemoryContextSwitchTo overhead by using MemoryContextAlloc directly.
+ */
+static void
+UndoRecordEnsureCapacity(UndoRecordSet *uset, Size additional)
+{
+	if (uset->buffer_size + additional > uset->buffer_capacity)
+	{
+		Size		new_capacity = uset->buffer_capacity * 2;
+		char	   *newbuf;
+
+		while (new_capacity < uset->buffer_size + additional)
+			new_capacity *= 2;
+
+		newbuf = (char *) MemoryContextAlloc(uset->mctx, new_capacity);
+		if (uset->buffer_size > 0)
+			memcpy(newbuf, uset->buffer, uset->buffer_size);
+		pfree(uset->buffer);
+		uset->buffer = newbuf;
+		uset->buffer_capacity = new_capacity;
+	}
+}
+
+/*
+ * UndoRecordSetReset - Reset a record set for reuse
+ *
+ * Resets the buffer position and record count without freeing the memory
+ * context or reallocating the buffer.  This is much cheaper than
+ * UndoRecordSetCreate/Free (~5 cycles vs ~300 cycles) because it avoids
+ * MemoryContextReset/AllocSetContextCreate overhead entirely.
+ *
+ * The prev_undo_ptr and other metadata are preserved so the record set
+ * can continue chaining records correctly across multiple insertions
+ * within the same transaction.
+ */
+void
+UndoRecordSetReset(UndoRecordSet *uset)
+{
+	if (uset == NULL)
+		return;
+
+	uset->buffer_size = 0;
+	uset->nrecords = 0;
+}
+
+/*
+ * UndoRecordSetResetCache - Release the recycled memory context.
+ *
+ * Called at transaction end (commit or abort) to ensure the cached
+ * context does not outlive the transaction.
+ */
+void
+UndoRecordSetResetCache(void)
+{
+	if (UndoRecordReusableContext != NULL)
+	{
+		MemoryContextDelete(UndoRecordReusableContext);
+		UndoRecordReusableContext = NULL;
+	}
+}
+
+/*
+ * UndoRecordAddPayload - Add an UNDO record with opaque payload to the set
+ *
+ * This is the main API for adding UNDO records.  The caller provides an
+ * RM ID, RM-specific info flags, a relation OID, and an opaque payload.
+ * The payload's interpretation is entirely RM-specific.
+ */
+void
+UndoRecordAddPayload(UndoRecordSet *uset,
+					 uint8 rmid,
+					 uint16 info,
+					 Oid reloid,
+					 const char *payload,
+					 Size payload_len)
+{
+	UndoRecordHeader *header;
+	Size		record_size;
+	char	   *dest;
+
+	if (uset == NULL)
+		elog(ERROR, "cannot add UNDO record to NULL set");
+
+	record_size = UndoRecordGetPayloadSize(payload_len);
+
+	/* Expand buffer if needed (allocate in the uset's memory context) */
+	UndoRecordEnsureCapacity(uset, record_size);
+
+	/*
+	 * Build the header directly in the buffer, avoiding a separate stack
+	 * variable, memset, and memcpy.  We zero the header in-place to avoid
+	 * uninitialized padding bytes in the on-disk format.
+	 */
+	dest = uset->buffer + uset->buffer_size;
+	header = (UndoRecordHeader *) dest;
+	memset(header, 0, SizeOfUndoRecordHeader);
+	header->urec_rmid = rmid;
+	header->urec_flags = UNDO_INFO_XID_VALID;
+	if (payload_len > 0)
+		header->urec_flags |= UNDO_INFO_HAS_PAYLOAD;
+	header->urec_info = info;
+	header->urec_len = (uint32) record_size;
+	header->urec_xid = uset->xid;
+	header->urec_prev = uset->prev_undo_ptr;
+	header->urec_reloid = reloid;
+	header->urec_payload_len = (uint32) payload_len;
+
+	/* Copy payload directly after header */
+	if (payload_len > 0 && payload != NULL)
+		memcpy(dest + SizeOfUndoRecordHeader, payload, payload_len);
+
+	/*
+	 * Zero the alignment padding between the payload and the next record so
+	 * the serialized stream is deterministic (the padding is skipped by
+	 * readers, which advance by urec_len and read urec_payload_len bytes of
+	 * payload, but deterministic bytes keep WAL consistency checking happy).
+	 */
+	if (record_size > SizeOfUndoRecordHeader + payload_len)
+		memset(dest + SizeOfUndoRecordHeader + payload_len, 0,
+			   record_size - SizeOfUndoRecordHeader - payload_len);
+
+	uset->buffer_size += record_size;
+	uset->nrecords++;
+}
+
+/*
+ * UndoRecordAddPayloadParts - Add an UNDO record with scatter-gather payload
+ *
+ * Like UndoRecordAddPayload, but takes the payload as two parts that are
+ * concatenated directly into the uset buffer.  This avoids allocating an
+ * intermediate payload buffer when the caller has the data in separate
+ * pieces (e.g., a fixed header struct + variable-length tuple data).
+ */
+void
+UndoRecordAddPayloadParts(UndoRecordSet *uset,
+						  uint8 rmid,
+						  uint16 info,
+						  Oid reloid,
+						  const char *part1,
+						  Size part1_len,
+						  const char *part2,
+						  Size part2_len)
+{
+	UndoRecordHeader *header;
+	Size		payload_len = part1_len + part2_len;
+	Size		record_size;
+	char	   *dest;
+
+	if (uset == NULL)
+		elog(ERROR, "cannot add UNDO record to NULL set");
+
+	record_size = UndoRecordGetPayloadSize(payload_len);
+
+	UndoRecordEnsureCapacity(uset, record_size);
+
+	/* Build header directly in the buffer */
+	dest = uset->buffer + uset->buffer_size;
+	header = (UndoRecordHeader *) dest;
+	memset(header, 0, SizeOfUndoRecordHeader);
+	header->urec_rmid = rmid;
+	header->urec_flags = UNDO_INFO_XID_VALID;
+	if (payload_len > 0)
+		header->urec_flags |= UNDO_INFO_HAS_PAYLOAD;
+	header->urec_info = info;
+	header->urec_len = (uint32) record_size;
+	header->urec_xid = uset->xid;
+	header->urec_prev = uset->prev_undo_ptr;
+	header->urec_reloid = reloid;
+	header->urec_payload_len = (uint32) payload_len;
+
+	/* Copy payload parts directly after header */
+	dest += SizeOfUndoRecordHeader;
+	if (part1_len > 0 && part1 != NULL)
+	{
+		memcpy(dest, part1, part1_len);
+		dest += part1_len;
+	}
+	if (part2_len > 0 && part2 != NULL)
+	{
+		memcpy(dest, part2, part2_len);
+		dest += part2_len;
+	}
+
+	/*
+	 * Zero the alignment padding between the payload and the next record (see
+	 * UndoRecordAddPayload); dest now points just past the payload.
+	 */
+	{
+		char	   *rec_start = uset->buffer + uset->buffer_size;
+
+		if ((Size) (dest - rec_start) < record_size)
+			memset(dest, 0, record_size - (Size) (dest - rec_start));
+	}
+
+	uset->buffer_size += record_size;
+	uset->nrecords++;
+}
+
+/*
+ * UndoRecordSetGetSize - Get total size of all records in set
+ */
+Size
+UndoRecordSetGetSize(UndoRecordSet *uset)
+{
+	if (uset == NULL)
+		return 0;
+
+	return uset->buffer_size;
+}
+
+/*
+ * UndoRecordSetGetNumRecords - How many records are accumulated in the set?
+ *
+ * Used by the deferred-batch path (DeferXactUndoData) to enforce the
+ * undo_batch_record_limit bound and to tell whether a flush would write
+ * anything.
+ */
+int
+UndoRecordSetGetNumRecords(UndoRecordSet *uset)
+{
+	if (uset == NULL)
+		return 0;
+
+	return uset->nrecords;
+}
diff --git a/src/backend/access/undo/undormgr.c b/src/backend/access/undo/undormgr.c
new file mode 100644
index 00000000000..851ef61ae8d
--- /dev/null
+++ b/src/backend/access/undo/undormgr.c
@@ -0,0 +1,70 @@
+/*-------------------------------------------------------------------------
+ *
+ * undormgr.c
+ *	  UNDO resource manager registration and dispatch
+ *
+ * This module manages the registration table for UNDO resource managers.
+ * Each access method or subsystem that writes UNDO records registers
+ * its callbacks here.  The generic UNDO infrastructure dispatches to
+ * the appropriate callback based on the urec_rmid in the record header.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/backend/access/undo/undormgr.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/undormgr.h"
+
+/* Global registration table, indexed by RM ID */
+const UndoRmgrData *UndoRmgrs[MAX_UNDO_RMGRS];
+
+/*
+ * RegisterUndoRmgr - Register an UNDO resource manager
+ *
+ * Called by each AM/subsystem during initialization to register its
+ * UNDO apply and describe callbacks.
+ */
+void
+RegisterUndoRmgr(uint8 rmid, const UndoRmgrData *rmgr)
+{
+	if (rmid == UNDO_RMID_INVALID)
+		elog(ERROR, "cannot register UNDO RM with invalid ID 0");
+
+	if (UndoRmgrs[rmid] != NULL)
+		elog(ERROR, "UNDO RM ID %u already registered as \"%s\"",
+			 rmid, UndoRmgrs[rmid]->rm_name);
+
+	if (rmgr->rm_undo == NULL)
+		elog(ERROR, "UNDO RM \"%s\" must provide an rm_undo callback",
+			 rmgr->rm_name ? rmgr->rm_name : "(null)");
+
+	UndoRmgrs[rmid] = rmgr;
+}
+
+/*
+ * GetUndoRmgr - Look up an UNDO resource manager by ID
+ *
+ * Returns the registration entry, or NULL if not registered.
+ */
+const UndoRmgrData *
+GetUndoRmgr(uint8 rmid)
+{
+	return UndoRmgrs[rmid];
+}
+
+/*
+ * InitUndoRmgrs - Initialize the UNDO resource manager table
+ *
+ * Called during postmaster startup.  Individual RMs register themselves
+ * via RegisterUndoRmgr() during their initialization.
+ */
+void
+InitUndoRmgrs(void)
+{
+	MemSet(UndoRmgrs, 0, sizeof(UndoRmgrs));
+}
diff --git a/src/backend/access/undo/undostats.c b/src/backend/access/undo/undostats.c
new file mode 100644
index 00000000000..603fe6e642c
--- /dev/null
+++ b/src/backend/access/undo/undostats.c
@@ -0,0 +1,348 @@
+/*-------------------------------------------------------------------------
+ *
+ * undostats.c
+ *	  UNDO log statistics collection and reporting
+ *
+ * This module provides monitoring and observability for the UNDO
+ * subsystem, including:
+ *   - Per-log statistics (insert/discard pointers, size, oldest xid, state)
+ *   - Buffer cache statistics (hits, misses, evictions)
+ *   - Aggregate counters (total records, bytes generated)
+ *   - Force discard and rotation SQL function
+ *
+ * Statistics can be queried via SQL functions pg_stat_get_undo_logs()
+ * and pg_stat_get_undo_buffers(), registered in pg_proc.dat.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/backend/access/undo/undostats.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/htup_details.h"
+#include "access/undolog.h"
+#include "access/undostats.h"
+#include "access/undoworker.h"
+#include "access/undo_xlog.h"
+#include "catalog/pg_authid.h"
+#include "fmgr.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "storage/lwlock.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+
+/*
+ * UndoLogStateToString - Convert lifecycle state to display string
+ */
+static const char *
+UndoLogStateToString(UndoLogState state)
+{
+	switch (state)
+	{
+		case UNDO_LOG_FREE:
+			return "free";
+		case UNDO_LOG_ACTIVE:
+			return "active";
+		case UNDO_LOG_SEALED:
+			return "sealed";
+		case UNDO_LOG_DISCARDABLE:
+			return "discardable";
+	}
+	return "unknown";
+}
+
+/*
+ * GetUndoLogStats - Get statistics for all active UNDO logs
+ *
+ * Fills the provided array with stats for each active log.
+ * Returns the number of active logs found.
+ */
+int
+GetUndoLogStats(UndoLogStat *stats, int max_stats)
+{
+	int			count = 0;
+	int			i;
+
+	if (UndoLogShared == NULL)
+		return 0;
+
+	for (i = 0; i < MAX_UNDO_LOGS && count < max_stats; i++)
+	{
+		UndoLogControl *log = &UndoLogShared->logs[i];
+
+		if (!log->in_use)
+			continue;
+
+		LWLockAcquire(&log->lock, LW_SHARED);
+
+		stats[count].log_number = log->log_number;
+		stats[count].insert_ptr = pg_atomic_read_u64(&log->insert_ptr);
+		stats[count].discard_ptr = log->discard_ptr;
+		stats[count].oldest_xid = log->oldest_xid;
+		stats[count].state = log->state;
+
+		/* Calculate size as difference between insert and discard offsets */
+		stats[count].size_bytes =
+			UndoRecPtrGetOffset(stats[count].insert_ptr) -
+			UndoRecPtrGetOffset(log->discard_ptr);
+
+		LWLockRelease(&log->lock);
+
+		count++;
+	}
+
+	return count;
+}
+
+/*
+ * GetUndoBufferStats - Get UNDO buffer statistics
+ *
+ * With the shared_buffers integration, UNDO pages are managed by the
+ * standard buffer pool.  Dedicated UNDO buffer statistics are no longer
+ * tracked separately.  This function returns zeros for all counters.
+ * Use pg_buffercache to inspect UNDO pages in shared_buffers if needed.
+ */
+void
+GetUndoBufferStats(UndoBufferStat *stats)
+{
+	stats->num_buffers = 0;
+	stats->cache_hits = 0;
+	stats->cache_misses = 0;
+	stats->cache_evictions = 0;
+	stats->cache_writes = 0;
+}
+
+/*
+ * pg_stat_get_undo_logs - SQL-callable function returning UNDO log stats
+ *
+ * Returns a set of rows, one per active UNDO log, with columns:
+ *   log_number, insert_offset, discard_offset, size_bytes, oldest_xid, state
+ */
+Datum
+pg_stat_get_undo_logs(PG_FUNCTION_ARGS)
+{
+	FuncCallContext *funcctx;
+	UndoLogStat *stats;
+
+	if (SRF_IS_FIRSTCALL())
+	{
+		MemoryContext oldcxt;
+		TupleDesc	tupdesc;
+		int			nstats;
+
+		funcctx = SRF_FIRSTCALL_INIT();
+		oldcxt = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
+
+		/* Use the result type declared for this function in the catalog. */
+		if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+			elog(ERROR, "return type must be a row type");
+
+		funcctx->tuple_desc = BlessTupleDesc(tupdesc);
+
+		/* Collect stats snapshot */
+		stats = (UndoLogStat *) palloc(sizeof(UndoLogStat) * MAX_UNDO_LOGS);
+		nstats = GetUndoLogStats(stats, MAX_UNDO_LOGS);
+
+		funcctx->user_fctx = stats;
+		funcctx->max_calls = nstats;
+
+		MemoryContextSwitchTo(oldcxt);
+	}
+
+	funcctx = SRF_PERCALL_SETUP();
+	stats = (UndoLogStat *) funcctx->user_fctx;
+
+	if (funcctx->call_cntr < funcctx->max_calls)
+	{
+		UndoLogStat *stat = &stats[funcctx->call_cntr];
+		Datum		values[6];
+		bool		nulls[6];
+		HeapTuple	tuple;
+
+		MemSet(nulls, 0, sizeof(nulls));
+
+		values[0] = Int32GetDatum(stat->log_number);
+		values[1] = Int64GetDatum(UndoRecPtrGetOffset(stat->insert_ptr));
+		values[2] = Int64GetDatum(UndoRecPtrGetOffset(stat->discard_ptr));
+		values[3] = Int64GetDatum(stat->size_bytes);
+		values[4] = TransactionIdGetDatum(stat->oldest_xid);
+		values[5] = CStringGetTextDatum(UndoLogStateToString(stat->state));
+
+		tuple = heap_form_tuple(funcctx->tuple_desc, values, nulls);
+
+		SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple));
+	}
+
+	SRF_RETURN_DONE(funcctx);
+}
+
+/*
+ * pg_stat_get_undo_buffers - SQL-callable function returning buffer stats
+ *
+ * Returns a single row with UNDO buffer cache statistics:
+ *   num_buffers, cache_hits, cache_misses, cache_evictions, cache_writes,
+ *   hit_ratio
+ */
+Datum
+pg_stat_get_undo_buffers(PG_FUNCTION_ARGS)
+{
+	TupleDesc	tupdesc;
+	Datum		values[6];
+	bool		nulls[6];
+	HeapTuple	tuple;
+	UndoBufferStat stats;
+
+	/* Use the result type declared for this function in the catalog. */
+	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+		elog(ERROR, "return type must be a row type");
+	tupdesc = BlessTupleDesc(tupdesc);
+
+	/* Get statistics */
+	GetUndoBufferStats(&stats);
+
+	MemSet(nulls, 0, sizeof(nulls));
+
+	values[0] = Int32GetDatum(stats.num_buffers);
+	values[1] = Int64GetDatum(stats.cache_hits);
+	values[2] = Int64GetDatum(stats.cache_misses);
+	values[3] = Int64GetDatum(stats.cache_evictions);
+	values[4] = Int64GetDatum(stats.cache_writes);
+
+	/* Calculate hit ratio */
+	{
+		uint64		total = stats.cache_hits + stats.cache_misses;
+
+		if (total > 0)
+			values[5] = Float4GetDatum((float4) stats.cache_hits / total);
+		else
+			values[5] = Float4GetDatum(0.0);
+	}
+
+	tuple = heap_form_tuple(tupdesc, values, nulls);
+
+	PG_RETURN_DATUM(HeapTupleGetDatum(tuple));
+}
+
+/*
+ * pg_undo_force_discard - Force UNDO log discard and optional rotation
+ *
+ * SQL-callable function that performs immediate discard of reclaimable
+ * UNDO records and optionally rotates the active log segment.
+ *
+ * Arguments:
+ *   force_rotate (bool) - If true, seal and rotate the active log first
+ *
+ * Returns the number of log segments freed (int4).
+ *
+ * Requires the pg_maintain role for access.
+ */
+Datum
+pg_undo_force_discard(PG_FUNCTION_ARGS)
+{
+	bool		force_rotate = PG_GETARG_BOOL(0);
+	int			freed_count = 0;
+	TransactionId oldest_xid;
+	int			i;
+
+	/* Permission check: require pg_maintain role */
+	if (!has_privs_of_role(GetUserId(), ROLE_PG_MAINTAIN))
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("must be a member of pg_maintain to force UNDO discard")));
+
+	if (UndoLogShared == NULL)
+		ereport(ERROR,
+				(errmsg("UNDO subsystem is not initialized")));
+
+	/* Optional rotation */
+	if (force_rotate)
+		UndoLogSealAndRotate(UNDO_ROTATE_MANUAL);
+
+	/* Perform inline discard (same two passes as the discard worker) */
+	oldest_xid = UndoWorkerGetOldestXid();
+	if (!TransactionIdIsValid(oldest_xid))
+		oldest_xid = ReadNextTransactionId();
+
+	/* First pass: advance discard pointers */
+	for (i = 0; i < MAX_UNDO_LOGS; i++)
+	{
+		UndoLogControl *log = &UndoLogShared->logs[i];
+
+		if (!log->in_use)
+			continue;
+
+		LWLockAcquire(&log->lock, LW_EXCLUSIVE);
+
+		if (TransactionIdIsValid(log->oldest_xid) &&
+			TransactionIdPrecedes(log->oldest_xid, oldest_xid))
+		{
+			UndoRecPtr	insert_ptr = pg_atomic_read_u64(&log->insert_ptr);
+
+			if (UndoRecPtrGetOffset(insert_ptr) >
+				UndoRecPtrGetOffset(log->discard_ptr))
+			{
+				log->discard_ptr = insert_ptr;
+				log->oldest_xid = oldest_xid;
+			}
+		}
+
+		LWLockRelease(&log->lock);
+	}
+
+	/* Second pass: lifecycle transitions */
+	for (i = 0; i < MAX_UNDO_LOGS; i++)
+	{
+		UndoLogControl *log = &UndoLogShared->logs[i];
+
+		if (!log->in_use)
+			continue;
+
+		LWLockAcquire(&log->lock, LW_EXCLUSIVE);
+
+		/* SEALED -> DISCARDABLE if fully discarded */
+		if (log->state == UNDO_LOG_SEALED)
+		{
+			UndoRecPtr	seal = pg_atomic_read_u64(&log->seal_ptr);
+			UndoRecPtr	discard = log->discard_ptr;
+
+			if (UndoRecPtrIsValid(seal) &&
+				UndoRecPtrGetOffset(discard) >= UndoRecPtrGetOffset(seal))
+			{
+				log->state = UNDO_LOG_DISCARDABLE;
+			}
+		}
+
+		/* DISCARDABLE -> FREE: clean up */
+		if (log->state == UNDO_LOG_DISCARDABLE)
+		{
+			uint32		log_number = log->log_number;
+
+			log->in_use = false;
+			log->state = UNDO_LOG_FREE;
+			log->log_number = 0;
+			pg_atomic_write_u64(&log->insert_ptr, InvalidUndoRecPtr);
+			log->discard_ptr = InvalidUndoRecPtr;
+			log->oldest_xid = InvalidTransactionId;
+			pg_atomic_write_u64(&log->seal_ptr, InvalidUndoRecPtr);
+			log->sealed_time = 0;
+
+			LWLockRelease(&log->lock);
+
+			UndoLogDeleteSegmentFile(log_number);
+			freed_count++;
+			continue;
+		}
+
+		LWLockRelease(&log->lock);
+	}
+
+	/* Wake the background worker for any remaining work */
+	WakeUndoDiscardWorker();
+
+	PG_RETURN_INT32(freed_count);
+}
diff --git a/src/backend/access/undo/undoworker.c b/src/backend/access/undo/undoworker.c
new file mode 100644
index 00000000000..ab878c7c230
--- /dev/null
+++ b/src/backend/access/undo/undoworker.c
@@ -0,0 +1,625 @@
+/*-------------------------------------------------------------------------
+ *
+ * undoworker.c
+ *	  UNDO worker background process implementation
+ *
+ * The UNDO worker periodically discards old UNDO records that are no
+ * longer needed by any active transaction. This is essential for
+ * preventing unbounded growth of UNDO logs.
+ *
+ * The worker also advances the undo_discard_horizon, allowing WAL
+ * segments containing fully-discarded UNDO batches to be recycled.
+ *
+ * Design based on ZHeap's UNDO worker and PostgreSQL's autovacuum
+ * launcher patterns.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/backend/access/undo/undoworker.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <setjmp.h>
+#include <unistd.h>
+
+#include "access/undolog.h"
+#include "access/undorecord.h"
+#include "access/undormgr.h"
+#include "access/undoworker.h"
+#include "access/transam.h"
+#include "access/xact.h"
+#include "access/xlog.h"
+#include "libpq/pqsignal.h"
+#include "miscadmin.h"
+#include "pgstat.h"
+#include "postmaster/bgworker.h"
+#include "postmaster/interrupt.h"
+#include "storage/aio_subsys.h"
+#include "storage/bufmgr.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "storage/latch.h"
+#include "storage/proc.h"
+#include "storage/procarray.h"
+#include "storage/procsignal.h"
+#include "storage/shmem.h"
+#include "storage/smgr.h"
+#include "tcop/tcopprot.h"
+#include "utils/guc.h"
+#include "utils/hsearch.h"
+#include "utils/injection_point.h"
+#include "utils/memutils.h"
+#include "utils/resowner.h"
+#include "utils/timeout.h"
+#include "utils/timestamp.h"
+#include "utils/wait_event.h"
+
+/* Shared memory state */
+static UndoWorkerShmemData *UndoWorkerShmem = NULL;
+
+/* Adaptive sleep: use shorter interval when sealed logs are pending */
+#define UNDO_WORKER_FAST_NAPTIME_MS		200
+
+/* Forward declarations */
+static void undo_worker_sighup(SIGNAL_ARGS);
+static void undo_worker_sigterm(SIGNAL_ARGS);
+static void perform_undo_discard(void);
+
+/*
+ * UndoWorkerShmemSize - Calculate shared memory needed
+ */
+Size
+UndoWorkerShmemSize(void)
+{
+	return sizeof(UndoWorkerShmemData);
+}
+
+/*
+ * UndoWorkerShmemRequest
+ *		Request the UNDO worker's shared state from the shared memory framework.
+ *
+ * Called from the UNDO subsystem's request_fn.  The framework sets UndoWorkerShmem,
+ * in the postmaster and, under EXEC_BACKEND, in every child.
+ */
+void
+UndoWorkerShmemRequest(void)
+{
+	ShmemRequestStruct(.name = "UNDO Worker Data",
+					   .size = UndoWorkerShmemSize(),
+					   .ptr = (void **) &UndoWorkerShmem);
+}
+
+/*
+ * UndoWorkerShmemInit
+ *		Initialize the contents of the area requested by UndoWorkerShmemRequest().
+ *
+ * Called once, by the postmaster or a standalone backend, from the UNDO
+ * subsystem's init_fn.  UndoWorkerShmem has already been set by the shared memory
+ * framework; an EXEC_BACKEND child gets the pointer from the framework's
+ * attach step and never calls this.
+ */
+void
+UndoWorkerShmemInit(void)
+{
+	LWLockInitialize(&UndoWorkerShmem->lock,
+					 LWTRANCHE_UNDO_LOG);
+
+	pg_atomic_init_u64(&UndoWorkerShmem->last_discard_time, 0);
+	UndoWorkerShmem->oldest_xid_checked = InvalidTransactionId;
+	UndoWorkerShmem->last_discard_ptr = InvalidUndoRecPtr;
+	UndoWorkerShmem->naptime_ms = undo_worker_naptime;
+	UndoWorkerShmem->shutdown_requested = false;
+
+	/* Rotation coordination fields */
+	UndoWorkerShmem->worker_proc = INVALID_PROC_NUMBER;
+	pg_atomic_init_u32(&UndoWorkerShmem->sealed_log_count, 0);
+}
+
+/*
+ * undo_worker_sighup - SIGHUP handler
+ */
+static void
+undo_worker_sighup(SIGNAL_ARGS)
+{
+	(void) postgres_signal_arg; /* unused */
+	ConfigReloadPending = true;
+	SetLatch(MyLatch);
+}
+
+/*
+ * undo_worker_sigterm - SIGTERM handler
+ */
+static void
+undo_worker_sigterm(SIGNAL_ARGS)
+{
+	(void) postgres_signal_arg; /* unused */
+	UndoWorkerShmem->shutdown_requested = true;
+	SetLatch(MyLatch);
+}
+
+/*
+ * WakeUndoDiscardWorker
+ *		Wake the UNDO discard worker via its latch.
+ *
+ * Follows the WAL writer wakeup pattern: read the worker's ProcNumber
+ * and set its latch to interrupt the WaitLatch sleep.  Safe to call
+ * from any backend, including during allocation pressure.
+ */
+void
+WakeUndoDiscardWorker(void)
+{
+	ProcNumber	proc;
+
+	if (UndoWorkerShmem == NULL)
+		return;
+
+	proc = UndoWorkerShmem->worker_proc;
+	if (proc != INVALID_PROC_NUMBER)
+		SetLatch(&GetPGProcByNumber(proc)->procLatch);
+}
+
+/*
+ * UndoWorkerGetOldestXid - Get oldest transaction still needing UNDO
+ *
+ * Returns the oldest transaction ID that is still active across all
+ * databases.  Any UNDO records created by transactions older than this
+ * can be safely discarded, because those transactions have already
+ * committed or aborted and their UNDO is no longer needed.
+ *
+ * We use GetOldestActiveTransactionId() from procarray.c which properly
+ * acquires ProcArrayLock and scans all backends.  We pass allDbs=true
+ * because UNDO logs are not per-database -- a single UNDO log may
+ * contain records for multiple databases.
+ *
+ * Returns InvalidTransactionId if there are no active transactions,
+ * meaning all UNDO records can potentially be discarded (subject to
+ * retention policy).
+ */
+TransactionId
+UndoWorkerGetOldestXid(void)
+{
+	TransactionId oldest_xid;
+
+	/*
+	 * Don't attempt the scan during recovery -- the UNDO worker should not be
+	 * running in that case, but guard defensively.
+	 */
+	if (RecoveryInProgress())
+		return InvalidTransactionId;
+
+	/*
+	 * GetOldestActiveTransactionId scans ProcArray under ProcArrayLock
+	 * (LW_SHARED) and returns the smallest XID among all active backends. We
+	 * pass inCommitOnly=false (we want all active XIDs, not just those in
+	 * commit critical section) and allDbs=true (UNDO spans all databases).
+	 */
+	oldest_xid = GetOldestActiveTransactionId(false, true);
+
+	return oldest_xid;
+}
+
+/*
+ * perform_undo_discard - Main discard logic
+ *
+ * Two passes:
+ *   First pass: Update discard pointers for all in-use logs based on
+ *            the oldest active transaction ID.
+ *   Second pass: Scan SEALED/DISCARDABLE logs and manage lifecycle
+ *            transitions: SEALED -> DISCARDABLE -> FREE.
+ */
+static void
+perform_undo_discard(void)
+{
+	TransactionId oldest_xid;
+	UndoRecPtr	oldest_undo_ptr;
+	TimestampTz current_time;
+	int			i;
+	int			freed_count = 0;
+
+	/* Get oldest active transaction */
+	oldest_xid = UndoWorkerGetOldestXid();
+
+	if (!TransactionIdIsValid(oldest_xid))
+	{
+		/* No active transactions, can discard all UNDO */
+		oldest_xid = ReadNextTransactionId();
+	}
+
+	current_time = GetCurrentTimestamp();
+
+	/*
+	 * Scan per-backend UNDO batch LSN slots and clear any that belong to dead
+	 * backends.  A backend that was SIGKILLed (or otherwise exited without
+	 * calling AtProcExit) will leave its slot occupied, which pins the WAL
+	 * discard horizon indefinitely.  We detect dead backends by checking
+	 * ProcGlobal->allProcs[i].pid == 0, which indicates the slot is not in
+	 * use by a live process (pid 0 also indicates prepared-xact dummy
+	 * PGPROCs, but those do not write UNDO data).
+	 */
+	for (i = 0; i < MaxBackends; i++)
+	{
+		XLogRecPtr	slot_lsn;
+
+		slot_lsn = (XLogRecPtr)
+			pg_atomic_read_u64(&UndoLogShared->backend_undo_lsns[i]);
+
+		if (!XLogRecPtrIsValid(slot_lsn))
+			continue;
+
+		if (GetPGProcByNumber(i)->pid == 0)
+		{
+			pg_atomic_write_u64(&UndoLogShared->backend_undo_lsns[i],
+								(uint64) InvalidXLogRecPtr);
+			ereport(DEBUG2,
+					(errmsg("UNDO worker: cleared stale batch LSN for dead backend slot %d", i)));
+		}
+	}
+
+	/*
+	 * First pass: For each UNDO log, determine what can be discarded.  We
+	 * need to respect the retention_time setting to allow point-in-time
+	 * recovery.
+	 */
+	for (i = 0; i < MAX_UNDO_LOGS; i++)
+	{
+		UndoLogControl *log = &UndoLogShared->logs[i];
+
+		if (!log->in_use)
+			continue;
+
+		/*
+		 * Calculate the oldest UNDO pointer that must be retained. This is
+		 * based on: 1. The oldest active transaction 2. The retention time
+		 * setting
+		 */
+		LWLockAcquire(&log->lock, LW_SHARED);
+
+		if (TransactionIdIsValid(log->oldest_xid) &&
+			TransactionIdPrecedes(log->oldest_xid, oldest_xid))
+		{
+			/* This log has UNDO that can be discarded */
+			oldest_undo_ptr = pg_atomic_read_u64(&log->insert_ptr);
+
+			LWLockRelease(&log->lock);
+
+			/* Update discard pointer */
+			UndoLogDiscard(oldest_undo_ptr);
+
+			/* Update cumulative discard counter */
+			pg_atomic_fetch_add_u64(&UndoLogShared->total_discarded,
+									UndoRecPtrGetOffset(oldest_undo_ptr));
+
+			ereport(DEBUG2,
+					(errmsg("UNDO worker: discarded log %u up to %llu",
+							log->log_number,
+							(unsigned long long) oldest_undo_ptr)));
+		}
+		else
+		{
+			LWLockRelease(&log->lock);
+		}
+	}
+
+	/*
+	 * Second pass: Manage lifecycle transitions for SEALED and DISCARDABLE
+	 * logs.
+	 *
+	 * SEALED logs whose discard_ptr >= seal_ptr have had all their records
+	 * discarded and can transition to DISCARDABLE.  DISCARDABLE logs can have
+	 * their slot freed and segment file deleted.
+	 */
+	for (i = 0; i < MAX_UNDO_LOGS; i++)
+	{
+		UndoLogControl *log = &UndoLogShared->logs[i];
+
+		if (!log->in_use)
+			continue;
+
+		LWLockAcquire(&log->lock, LW_EXCLUSIVE);
+
+		if (log->state == UNDO_LOG_SEALED)
+		{
+			UndoRecPtr	seal = pg_atomic_read_u64(&log->seal_ptr);
+			UndoRecPtr	discard = log->discard_ptr;
+
+			if (UndoRecPtrIsValid(seal) &&
+				UndoRecPtrGetOffset(discard) >= UndoRecPtrGetOffset(seal))
+			{
+				/* All records discarded -- transition to DISCARDABLE */
+				log->state = UNDO_LOG_DISCARDABLE;
+				ereport(DEBUG1,
+						(errmsg("UNDO worker: log %u transitioned to DISCARDABLE",
+								log->log_number)));
+			}
+		}
+
+		if (log->state == UNDO_LOG_DISCARDABLE)
+		{
+			uint32		log_number = log->log_number;
+
+			/* Free the slot */
+			log->in_use = false;
+			log->state = UNDO_LOG_FREE;
+			log->log_number = 0;
+			pg_atomic_write_u64(&log->insert_ptr, InvalidUndoRecPtr);
+			log->discard_ptr = InvalidUndoRecPtr;
+			log->oldest_xid = InvalidTransactionId;
+			pg_atomic_write_u64(&log->seal_ptr, InvalidUndoRecPtr);
+			log->sealed_time = 0;
+
+			LWLockRelease(&log->lock);
+
+			/* Delete the segment file outside the lock */
+			UndoLogDeleteSegmentFile(log_number);
+
+			/* Decrement sealed log count */
+			pg_atomic_fetch_sub_u32(&UndoWorkerShmem->sealed_log_count, 1);
+
+			freed_count++;
+			continue;
+		}
+
+		LWLockRelease(&log->lock);
+	}
+
+	if (freed_count > 0)
+		ereport(LOG,
+				(errmsg("UNDO worker: freed %d discardable log segment(s)",
+						freed_count)));
+
+	/*
+	 * Advance the WAL discard horizon so KeepLogSeg() can allow recycling of
+	 * WAL segments no longer needed for UNDO rollback.
+	 *
+	 * UndoGetOldestBatchLSN() scans per-backend slots and returns the minimum
+	 * first-batch LSN across all active transactions that have written UNDO
+	 * data.  WAL before this LSN cannot be recycled.
+	 *
+	 * If no backend has in-flight UNDO data the function returns
+	 * InvalidXLogRecPtr, meaning there is no UNDO-imposed WAL retention
+	 * requirement.  We do not call UndoSetDiscardHorizon in that case because
+	 * an invalid horizon is already the "no constraint" sentinel.
+	 */
+	{
+		XLogRecPtr	new_horizon = UndoGetOldestBatchLSN();
+
+		if (XLogRecPtrIsValid(new_horizon))
+			UndoSetDiscardHorizon(new_horizon);
+
+		/*
+		 * If undo_max_wal_retention_size is set, warn when the retained WAL
+		 * distance between the current write position and the UNDO discard
+		 * horizon exceeds the configured limit.  This helps operators detect
+		 * long-running transactions that prevent WAL recycling.
+		 */
+		if (undo_max_wal_retention_size > 0 && XLogRecPtrIsValid(new_horizon))
+		{
+			XLogRecPtr	write_ptr = GetXLogWriteRecPtr();
+
+			if (write_ptr > new_horizon)
+			{
+				uint64		retained_mb = (write_ptr - new_horizon) >> 20;
+
+				if (retained_mb > (uint64) undo_max_wal_retention_size)
+					ereport(WARNING,
+							(errmsg("UNDO WAL retention (%lu MB) exceeds undo_max_wal_retention_size (%d MB)",
+									(unsigned long) retained_mb, undo_max_wal_retention_size),
+							 errhint("Investigate long-running transactions or increase undo_max_wal_retention_size.")));
+			}
+		}
+	}
+
+	/* Record this discard operation */
+	LWLockAcquire(&UndoWorkerShmem->lock, LW_EXCLUSIVE);
+	pg_atomic_write_u64(&UndoWorkerShmem->last_discard_time,
+						(uint64) current_time);
+	UndoWorkerShmem->oldest_xid_checked = oldest_xid;
+	LWLockRelease(&UndoWorkerShmem->lock);
+}
+
+/*
+ * UndoWorkerMain - Main loop for UNDO worker
+ *
+ * This is the entry point for the UNDO worker background process.
+ * It runs continuously, waking periodically to discard old UNDO.
+ *
+ * Uses adaptive sleep: when sealed logs are pending cleanup, the worker
+ * wakes more frequently (200ms) to process them promptly.  Otherwise
+ * it uses the configured undo_worker_naptime.
+ */
+void
+UndoWorkerMain(Datum main_arg)
+{
+	sigjmp_buf	local_sigjmp_buf;
+	MemoryContext undo_worker_context;
+
+	(void) main_arg;			/* unused */
+
+	/* Establish signal handlers */
+	pqsignal(SIGHUP, undo_worker_sighup);
+	pqsignal(SIGTERM, undo_worker_sigterm);
+
+	/* We're now ready to receive signals */
+	BackgroundWorkerUnblockSignals();
+
+	/*
+	 * Connect with no specific database so the worker is a stats-reporting
+	 * backend (visible in pg_stat_activity as 'undo worker') while not
+	 * pinning any database against DROP.  Discard operates on cluster-wide
+	 * UNDO logs, so it needs no per-database catalog access.
+	 */
+	BackgroundWorkerInitializeConnection(NULL, NULL, 0);
+
+	/* Register our ProcNumber for latch-based wakeup by other backends */
+	UndoWorkerShmem->worker_proc = MyProcNumber;
+
+	/* Initialize worker state */
+	ereport(LOG,
+			(errmsg("UNDO worker started")));
+
+	/*
+	 * Create a memory context for the worker. This will be reset after each
+	 * iteration and during error recovery.
+	 */
+	undo_worker_context = AllocSetContextCreate(TopMemoryContext,
+												"UNDO Worker",
+												ALLOCSET_DEFAULT_SIZES);
+	MemoryContextSwitchTo(undo_worker_context);
+
+	/*
+	 * If an exception is encountered, processing resumes here.
+	 *
+	 * Unlike the autovacuum worker, this is a long-lived background process
+	 * that must survive transient errors (e.g. an ERROR raised while
+	 * discarding UNDO).  Letting the error propagate would terminate the
+	 * worker; the postmaster would restart it, but any in-flight discard
+	 * progress would be abandoned and discard could stall.  Instead we
+	 * recover in place, mirroring the autovacuum launcher.
+	 *
+	 * We use sigsetjmp(..., 1) so the prevailing signal mask is restored on
+	 * longjmp; signals other than SIGQUIT stay blocked until we exit.  The
+	 * HOLD_INTERRUPTS() call is still required because InterruptPending might
+	 * already be set.
+	 */
+	if (sigsetjmp(local_sigjmp_buf, 1) != 0)
+	{
+		/* since not using PG_TRY, must reset error stack by hand */
+		error_context_stack = NULL;
+
+		/* Prevents interrupts while cleaning up */
+		HOLD_INTERRUPTS();
+
+		/* Report the error to the server log */
+		EmitErrorReport();
+
+		/*
+		 * Abort the current transaction in order to recover, but only if one
+		 * is actually in progress.  perform_undo_discard() operates on
+		 * shared memory and does not normally open a transaction; guarding
+		 * avoids a spurious "AbortCurrentTransaction when not in
+		 * transaction" path.
+		 */
+		if (IsTransactionState())
+			AbortCurrentTransaction();
+
+		/* Release any other resources we might still be holding. */
+		LWLockReleaseAll();
+		pgstat_report_wait_end();
+		pgaio_error_cleanup();
+		UnlockBuffers();
+		if (AuxProcessResourceOwner)
+			ReleaseAuxProcessResources(false);
+		AtEOXact_Buffers(false);
+		AtEOXact_SMgr();
+		AtEOXact_Files(false);
+		AtEOXact_HashTables(false);
+
+		/* Return to the worker context and clear ErrorContext. */
+		MemoryContextSwitchTo(undo_worker_context);
+		FlushErrorState();
+
+		/* Flush any leaked data in the worker context. */
+		MemoryContextReset(undo_worker_context);
+
+		/* Now we can allow interrupts again */
+		RESUME_INTERRUPTS();
+
+		/*
+		 * Sleep at least 1 second after any error.  We don't want to be
+		 * filling the error logs as fast as we can.
+		 */
+		pg_usleep(1000000L);
+	}
+
+	/* We can now handle ereport(ERROR) */
+	PG_exception_stack = &local_sigjmp_buf;
+
+	/*
+	 * Main loop: wake up periodically and discard old UNDO
+	 */
+	while (!UndoWorkerShmem->shutdown_requested)
+	{
+		int			rc;
+		long		naptime;
+		uint32		sealed_count;
+
+		/* Process any pending configuration changes */
+		if (ConfigReloadPending)
+		{
+			ConfigReloadPending = false;
+			ProcessConfigFile(PGC_SIGHUP);
+
+			/* Update naptime from GUC */
+			UndoWorkerShmem->naptime_ms = undo_worker_naptime;
+		}
+
+		CHECK_FOR_INTERRUPTS();
+
+		INJECTION_POINT("undo-worker-before-discard", NULL);
+
+		/* Perform UNDO discard */
+		perform_undo_discard();
+
+		INJECTION_POINT("undo-worker-after-discard", NULL);
+
+		/*
+		 * Adaptive sleep: use a shorter interval when sealed logs are pending
+		 * cleanup, similar to the WAL writer's adaptive sleep.
+		 */
+		sealed_count = pg_atomic_read_u32(&UndoWorkerShmem->sealed_log_count);
+		if (sealed_count > 0)
+			naptime = UNDO_WORKER_FAST_NAPTIME_MS;
+		else
+			naptime = UndoWorkerShmem->naptime_ms;
+
+		/* Sleep until next iteration, latch set, or signal */
+		rc = WaitLatch(MyLatch,
+					   WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH,
+					   naptime,
+					   WAIT_EVENT_UNDO_WORKER_MAIN);
+
+		ResetLatch(MyLatch);
+
+		/* Emergency bailout if postmaster died */
+		if (rc & WL_POSTMASTER_DEATH)
+			proc_exit(1);
+	}
+
+	/* Clear our ProcNumber before exiting */
+	UndoWorkerShmem->worker_proc = INVALID_PROC_NUMBER;
+
+	/* Normal shutdown */
+	ereport(LOG,
+			(errmsg("UNDO worker shutting down")));
+
+	proc_exit(0);
+}
+
+/*
+ * UndoWorkerRegister - Register the UNDO worker at server start
+ *
+ * This is called from postmaster during server initialization.
+ */
+void
+UndoWorkerRegister(void)
+{
+	BackgroundWorker worker;
+
+	memset(&worker, 0, sizeof(BackgroundWorker));
+
+	worker.bgw_flags = BGWORKER_SHMEM_ACCESS |
+		BGWORKER_BACKEND_DATABASE_CONNECTION;
+	worker.bgw_start_time = BgWorkerStart_RecoveryFinished;
+	worker.bgw_restart_time = 10;	/* Restart after 10 seconds if crashed */
+
+	sprintf(worker.bgw_library_name, "postgres");
+	sprintf(worker.bgw_function_name, "UndoWorkerMain");
+	snprintf(worker.bgw_name, BGW_MAXLEN, "undo worker");
+	snprintf(worker.bgw_type, BGW_MAXLEN, "undo worker");
+
+	RegisterBackgroundWorker(&worker);
+}
diff --git a/src/backend/access/undo/xactundo.c b/src/backend/access/undo/xactundo.c
new file mode 100644
index 00000000000..47a4a6fdbce
--- /dev/null
+++ b/src/backend/access/undo/xactundo.c
@@ -0,0 +1,1334 @@
+/*-------------------------------------------------------------------------
+ *
+ * xactundo.c
+ *	  Management of undo record sets for transactions
+ *
+ * Undo records that need to be applied after a transaction or
+ * subtransaction abort should be inserted using the functions defined
+ * in this file; thus, every table or index access method that wants to
+ * use undo for post-abort cleanup should invoke these interfaces.
+ *
+ * The reason for this design is that we want to pack all of the undo
+ * records for a single transaction into one place, regardless of the
+ * AM which generated them. That way, we can apply the undo actions
+ * which pertain to that transaction in the correct order; namely,
+ * backwards as compared with the order in which the records were
+ * generated.
+ *
+ * We may use up to three undo record sets per transaction, one per
+ * persistence level (permanent, unlogged, temporary). We assume that
+ * it's OK to apply the undo records for each persistence level
+ * independently of the others. This is safe since the modifications
+ * must necessarily touch disjoint sets of pages.
+ *
+ * CROSS-RELATION ORDERING INVARIANT (important for TOAST correctness):
+ *
+ * All UNDO records for all relations touched by a single transaction are
+ * packed into the same UndoRecordSet, in strict WAL-LSN emission order.
+ * The newest-first application order during rollback guarantees correct
+ * restoration ordering for multi-object operations (e.g. a cluster-wide
+ * consumer that touches several relations in one transaction).
+ *
+ * PARALLEL RECOVERY: XLOG_UNDO_BATCH records are handled by the startup
+ * process via ApplyUndoChainFromWAL(); they are not dispatched to parallel
+ * workers because UNDO application requires coordinated per-transaction
+ * state.
+ *
+ * SUBTRANSACTION TRACKING:
+ *
+ * Subtransaction state is tracked using a dynamically-grown array allocated
+ * in TopMemoryContext.  The array starts at INITIAL_SUBXACT_CAPACITY (64)
+ * slots and doubles when needed via repalloc().  The array persists across
+ * transactions within the same backend to avoid repeated allocation for
+ * steady-state workloads.
+ *
+ * Growth via repalloc() in TopMemoryContext during SubXactCallbacks is safe
+ * because it does not interact with pgstat's per-subtransaction tracking
+ * (the original corruption bug was caused by palloc of new per-subtransaction
+ * nodes in CurTransactionContext, not by growing an existing TopMemoryContext
+ * allocation).
+ *
+ * This design follows the EDB undo-record-set branch architecture
+ * (xactundo.c) adapted for the physical undo approach used here.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/access/undo/xactundo.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/undo.h"
+#include "access/undo_xlog.h"
+#include "access/xlog.h"
+#include "access/undolog.h"
+#include "access/undorecord.h"
+#include "access/xact.h"
+#include "access/xactundo.h"
+#include "access/xlogdefs.h"
+#include "access/table.h"
+#include "catalog/pg_class.h"
+#include "miscadmin.h"
+#include "storage/bufmgr.h"
+#include "storage/ipc.h"
+#include "utils/injection_point.h"
+#include "storage/lmgr.h"
+#include "utils/memutils.h"
+#include "utils/rel.h"
+
+/*
+ * Initial capacity for the dynamically-grown subtransaction stack.
+ * Covers 99.9% of workloads without needing reallocation.  The stack
+ * doubles when needed, so there is no artificial upper limit.
+ */
+#define INITIAL_SUBXACT_CAPACITY	64
+
+/* Per-subtransaction backend-private undo state (array element). */
+typedef struct XactUndoSubTransactionState
+{
+	SubTransactionId nestingLevel;
+	UndoRecPtr	start_location[NUndoPersistenceLevels];
+
+	/*
+	 * Snapshot of the parent's last_batch_lsn at the time this subtransaction
+	 * started.  On subtransaction abort, after applying this subtransaction's
+	 * UNDO chain, we restore XactUndo.last_batch_lsn to these saved values so
+	 * the parent's subsequent abort (or further subtransactions) only covers
+	 * the parent's own records and does not double-apply already-reversed
+	 * batches.
+	 */
+	XLogRecPtr	last_batch_lsn[NUndoPersistenceLevels];
+} XactUndoSubTransactionState;
+
+/* Backend-private undo state. */
+typedef struct XactUndoData
+{
+	bool		has_undo;		/* has this xact generated any undo? */
+	int			subxact_depth;	/* 0 = top-level, 1+ = savepoints */
+	int			subxact_capacity;	/* allocated slots in subxact_stack */
+
+	/* Dynamically-grown subtransaction stack (TopMemoryContext). */
+	XactUndoSubTransactionState *subxact_stack;
+
+	/*
+	 * Per-persistence-level record sets. These are created lazily on first
+	 * use and destroyed at transaction end.
+	 */
+	UndoRecordSet *record_set[NUndoPersistenceLevels];
+
+	/* Tracking for the most recent undo insertion per persistence level. */
+	UndoRecPtr	last_location[NUndoPersistenceLevels];
+
+	/*
+	 * WAL-based UNDO chain heads.  When UNDO records are routed through WAL
+	 * via XLOG_UNDO_BATCH, this tracks the LSN of the most recent batch per
+	 * persistence level.  Used for rollback chain walking.
+	 */
+	XLogRecPtr	last_batch_lsn[NUndoPersistenceLevels];
+
+	/*
+	 * Deferred-batch state (see DeferXactUndoData).  When an index AM defers
+	 * a record instead of writing it, the record sits in this UndoRecordSet's
+	 * buffer until a choke point flushes it.  NULL means nothing is pending,
+	 * which is the state every path outside a single statement must see.
+	 */
+	UndoRecordSet *pending_uset;
+	UndoPersistenceLevel pending_plevel;
+} XactUndoData;
+
+static XactUndoData XactUndo;
+static bool subxact_callback_registered = false;
+
+/*
+ * Compile-time guard: xl_xact_prepare.last_batch_lsn[3] must match
+ * NUndoPersistenceLevels.  Both headers are available here; xact.h avoids
+ * including undodefs.h to keep its include footprint minimal.
+ */
+StaticAssertDecl(NUndoPersistenceLevels == 3,
+				 "xl_xact_prepare.last_batch_lsn array size (3) must match NUndoPersistenceLevels");
+
+static void ResetXactUndo(void);
+static void CollapseXactUndoSubTransactions(void);
+static UndoPersistenceLevel GetUndoPersistenceLevel(char relpersistence);
+static void EnsureSubxactStackCapacity(void);
+static void XactUndo_SubXactCallback(SubXactEvent event, SubTransactionId mySubid,
+									 SubTransactionId parentSubid, void *arg);
+
+/* Convenience macro: pointer to current subtransaction state. */
+#define CURRENT_SUBXACT() (&XactUndo.subxact_stack[XactUndo.subxact_depth])
+
+/*
+ * XactUndoShmemSize
+ *		How much shared memory do we need for transaction undo state?
+ *
+ * Currently no shared memory is needed -- all state is backend-private.
+ * This function exists for forward compatibility with the architecture
+ * where an UndoRequestManager will be added later.
+ */
+Size
+XactUndoShmemSize(void)
+{
+	return 0;
+}
+
+/*
+ * XactUndoShmemInit
+ *		Initialize shared memory for transaction undo state.
+ *
+ * Currently a no-op; provided for the unified UndoShmemInit() pattern.
+ */
+void
+XactUndoShmemInit(void)
+{
+	/* Nothing to do yet. */
+}
+
+/*
+ * InitializeXactUndo
+ *		Per-backend initialization for transaction undo.
+ */
+void
+InitializeXactUndo(void)
+{
+	/* Ensure the dynamic subxact stack is allocated */
+	EnsureSubxactStackCapacity();
+
+	ResetXactUndo();
+
+	/*
+	 * Register callback to track subtransaction lifecycle. Do this lazily on
+	 * first transaction to ensure it's registered for the backend that will
+	 * actually use UNDO.
+	 */
+	if (!subxact_callback_registered)
+	{
+		RegisterSubXactCallback(XactUndo_SubXactCallback, NULL);
+		subxact_callback_registered = true;
+	}
+}
+
+/*
+ * GetUndoPersistenceLevel
+ *		Map relation persistence character to UndoPersistenceLevel.
+ */
+static UndoPersistenceLevel
+GetUndoPersistenceLevel(char relpersistence)
+{
+	switch (relpersistence)
+	{
+		case RELPERSISTENCE_PERMANENT:
+			return UNDOPERSISTENCE_PERMANENT;
+		case RELPERSISTENCE_UNLOGGED:
+			return UNDOPERSISTENCE_UNLOGGED;
+		case RELPERSISTENCE_TEMP:
+			return UNDOPERSISTENCE_TEMP;
+		default:
+			elog(ERROR, "unrecognized relpersistence: %c", relpersistence);
+			return UNDOPERSISTENCE_PERMANENT;	/* keep compiler quiet */
+	}
+}
+
+/*
+ * PrepareXactUndoData
+ *		Prepare to insert a transactional undo record.
+ *
+ * Finds or creates the appropriate per-persistence-level UndoRecordSet
+ * for the current transaction and adds the record to it.
+ *
+ * The API is AM-agnostic: callers pass an RM ID, RM-specific info,
+ * a relation OID, and an opaque payload.
+ *
+ * Returns the UndoRecPtr where the record will be inserted (or
+ * InvalidUndoRecPtr if undo is disabled).
+ */
+UndoRecPtr
+PrepareXactUndoData(XactUndoContext *ctx, char persistence,
+					uint8 rmid, uint16 info, Oid reloid,
+					const char *payload, Size payload_len)
+{
+	int			nestingLevel = GetCurrentTransactionNestLevel();
+	UndoPersistenceLevel plevel = GetUndoPersistenceLevel(persistence);
+	UndoRecordSet *uset;
+	XactUndoSubTransactionState *cur;
+	UndoRecPtr *sub_start_location;
+
+	/* Remember that we've done something undo-related. */
+	XactUndo.has_undo = true;
+
+	/* The current (sub)transaction must have an xid; see below. */
+	(void) GetCurrentTransactionId();
+
+	/*
+	 * Load the UndoRecordSetInsert() injection points into this backend's
+	 * local cache now, while we are guaranteed not to be inside a critical
+	 * section (PrepareXactUndoData may itself palloc).  UndoRecordSetInsert()
+	 * runs inside the caller's crit section and can only fire these via the
+	 * palloc-free INJECTION_POINT_CACHED variant.
+	 */
+	INJECTION_POINT_LOAD("undo-batch-before-wal-insert");
+	INJECTION_POINT_LOAD("undo-batch-after-wal-insert");
+
+	/*
+	 * If we've entered a subtransaction deeper than what's currently tracked,
+	 * push a new entry onto the subxact_stack.  This handles the case where
+	 * PrepareXactUndoData is called for the first time in a subtransaction
+	 * that was started before the SubXactCallback fired (e.g., if the
+	 * callback hadn't been registered yet when the subtransaction began).
+	 */
+	cur = CURRENT_SUBXACT();
+	if (nestingLevel > (int) cur->nestingLevel)
+	{
+		int			i;
+
+		XactUndo.subxact_depth++;
+		EnsureSubxactStackCapacity();
+
+		cur = CURRENT_SUBXACT();
+		cur->nestingLevel = nestingLevel;
+		for (i = 0; i < NUndoPersistenceLevels; ++i)
+		{
+			cur->start_location[i] = InvalidUndoRecPtr;
+			cur->last_batch_lsn[i] = XactUndo.last_batch_lsn[i];
+		}
+	}
+
+	/*
+	 * Make sure we have an UndoRecordSet of the appropriate type open for
+	 * this persistence level.  These record sets are always associated with
+	 * the toplevel transaction, not a subtransaction, to avoid fragmentation.
+	 *
+	 * The set, and so every batch it writes, is stamped with the top-level
+	 * xid even when the first record comes from a subtransaction.  Crash
+	 * recovery tracks loser chains by that xid and forgets them at the top
+	 * level's commit or abort record; a subxid stamp would outlive the
+	 * commit and get a committed transaction's UNDO applied.
+	 */
+	uset = XactUndo.record_set[plevel];
+	if (uset == NULL)
+	{
+		uset = UndoRecordSetCreate(GetTopTransactionId(),
+								   GetCurrentTransactionUndoRecPtr());
+		XactUndo.record_set[plevel] = uset;
+	}
+
+	/* Remember persistence level for InsertXactUndoData. */
+	ctx->plevel = plevel;
+	ctx->uset = uset;
+
+	/* Add the record to the record set using generic payload API. */
+	UndoRecordAddPayload(uset, rmid, info, reloid, payload, payload_len);
+
+	/*
+	 * If this is the first undo for this persistence level in this
+	 * subtransaction, record the start location. The actual UndoRecPtr is not
+	 * known until insertion, so we use a sentinel for now and the caller will
+	 * update it after InsertXactUndoData.
+	 */
+	sub_start_location = &cur->start_location[plevel];
+	if (!UndoRecPtrIsValid(*sub_start_location))
+		*sub_start_location = (UndoRecPtr) 1;	/* will be set properly */
+
+	return InvalidUndoRecPtr;	/* actual ptr assigned during insert */
+}
+
+/*
+ * InsertXactUndoData
+ *		Insert the prepared undo data into the undo log.
+ *
+ * This performs the actual write of the accumulated records.
+ * Also updates the transaction-level undo record pointer (undoRecPtr
+ * in TransactionState) so that subsequent UNDO records chain correctly.
+ */
+void
+InsertXactUndoData(XactUndoContext *ctx)
+{
+	UndoRecordSet *uset = ctx->uset;
+	UndoRecPtr	ptr;
+
+	Assert(uset != NULL);
+
+	ptr = UndoRecordSetInsert(uset);
+	if (UndoRecPtrIsValid(ptr))
+	{
+		XactUndoSubTransactionState *cur = CURRENT_SUBXACT();
+
+		XactUndo.last_location[ctx->plevel] = ptr;
+
+		/*
+		 * Track the WAL LSN of the most recent UNDO batch for this
+		 * persistence level.  This is used during rollback to walk the UNDO
+		 * chain backward through WAL.
+		 *
+		 * The pointer must be a valid LSN; the caller must have produced an
+		 * RM_UNDO_ID XLOG_UNDO_BATCH record via UndoRecordSetInsert.  We do
+		 * not validate the rmid here because UndoValidateBatchLSN reads from
+		 * the on-disk WAL via read_local_xlog_page, which waits on flush and
+		 * cannot be safely called from the WAL-insertion hot path.  The
+		 * rollback path at AtAbort_XactUndo validates before applying.
+		 */
+		Assert(XLogRecPtrIsValid(uset->last_batch_lsn));
+		XactUndo.last_batch_lsn[ctx->plevel] = uset->last_batch_lsn;
+
+		/* Fix up subtransaction start location if needed */
+		if (cur->start_location[ctx->plevel] == (UndoRecPtr) 1)
+			cur->start_location[ctx->plevel] = ptr;
+
+		/*
+		 * Update the per-transaction undo pointer in TransactionState so that
+		 * the next UndoRecordSetCreate (if called directly by heap AM or
+		 * other subsystems) picks up the correct chain pointer.
+		 */
+		SetCurrentTransactionUndoRecPtr(ptr);
+	}
+	else
+	{
+		XactUndoSubTransactionState *cur = CURRENT_SUBXACT();
+
+		/*
+		 * The batch was empty or elided, so no record pointer exists to fix
+		 * up the sentinel with.  Leaving the (UndoRecPtr) 1 sentinel in place
+		 * would make UndoRecPtrIsValid() treat it as a real start location
+		 * and leak it into subxact->parent merges and rollback chain walks;
+		 * reset it so this level records no start location.
+		 */
+		if (cur->start_location[ctx->plevel] == (UndoRecPtr) 1)
+			cur->start_location[ctx->plevel] = InvalidUndoRecPtr;
+	}
+}
+
+/*
+ * CleanupXactUndoInsertion
+ *		Clean up after an undo insertion cycle.
+ *
+ * Resets the record set's buffer position and record count so it can
+ * accumulate more records.  Does NOT free the record set -- that
+ * happens at transaction end (AtCommit_XactUndo / AtAbort_XactUndo).
+ *
+ * The record set's prev_undo_ptr is preserved across resets (it was
+ * updated by UndoRecordSetInsert), so subsequent records chain
+ * correctly through the undo log.
+ */
+void
+CleanupXactUndoInsertion(XactUndoContext *ctx)
+{
+	if (ctx->uset != NULL)
+		UndoRecordSetReset(ctx->uset);
+}
+
+/*
+ * DeferXactUndoData
+ *		Leave this record in the UndoRecordSet buffer to share a batch.
+ *
+ * See the contract in access/xactundo.h.  PrepareXactUndoData[Parts] has
+ * already appended the record to ctx->uset and set XactUndo.has_undo; the only
+ * thing InsertXactUndoData() would add right now is a WAL record per call,
+ * which is precisely the cost we are avoiding.  So we do nothing except
+ * remember which record set is pending and enforce the memory bound.
+ *
+ * The thresholds are the existing undo_batch_size_kb / undo_batch_record_limit
+ * GUCs, so an unbounded statement (COPY, a 10M-row INSERT ... SELECT) still
+ * flushes at a fixed ceiling rather than buffering the whole thing.  An
+ * intermediate flush is a complete, self-contained batch: UndoRecordSetInsert()
+ * publishes its LSN into uset->last_batch_lsn, InsertXactUndoData() copies that
+ * to XactUndo.last_batch_lsn[] and chains the next batch onto it via
+ * SetCurrentTransactionUndoRecPtr(), so the chain that rollback walks is the
+ * same whether it was written in one batch or fifty.
+ */
+bool
+DeferXactUndoData(XactUndoContext *ctx)
+{
+	UndoRecordSet *uset = ctx->uset;
+
+	Assert(uset != NULL);
+
+	/*
+	 * Remember the pending set and its persistence level so the flush choke
+	 * points can find it without the original caller's context.
+	 */
+	XactUndo.pending_uset = uset;
+	XactUndo.pending_plevel = ctx->plevel;
+
+	/*
+	 * Bound the buffer.  Without this a single large statement would hold
+	 * every record in memory until transaction end.
+	 */
+	if (UndoRecordSetGetSize(uset) >= (Size) undo_batch_size_kb * 1024 ||
+		UndoRecordSetGetNumRecords(uset) >= undo_batch_record_limit)
+	{
+		XactUndoFlushPending();
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * XactUndoHasPendingData
+ *		Is a deferred record waiting to be written?
+ */
+bool
+XactUndoHasPendingData(void)
+{
+	return XactUndo.pending_uset != NULL &&
+		UndoRecordSetGetNumRecords(XactUndo.pending_uset) > 0;
+}
+
+/*
+ * XactUndoFlushPending
+ *		Write all deferred records as a single UNDO batch.
+ *
+ * This is the whole safety argument for deferral, so it is deliberately
+ * callable from anywhere that the chain might be needed and is a no-op when
+ * there is nothing pending.  It performs exactly what the old per-record path
+ * did -- InsertXactUndoData() + CleanupXactUndoInsertion() -- once, for the
+ * accumulated batch.
+ *
+ * The crit section matches the contract documented at UndoRecordSetInsert():
+ * the WAL insert itself runs in one, while the palloc-capable preparation
+ * already happened back in PrepareXactUndoData[Parts].
+ */
+void
+XactUndoFlushPending(void)
+{
+	XactUndoContext ctx;
+	UndoRecordSet *uset = XactUndo.pending_uset;
+
+	if (uset == NULL || UndoRecordSetGetNumRecords(uset) == 0)
+	{
+		XactUndo.pending_uset = NULL;
+		return;
+	}
+
+	ctx.uset = uset;
+	ctx.plevel = XactUndo.pending_plevel;
+
+	/*
+	 * Clear the pending marker BEFORE writing.  If InsertXactUndoData()
+	 * throws (it should not -- it is a WAL insert in a crit section, which
+	 * PANICs rather than throws), the error path must not find a stale
+	 * pending set and write these records a second time.
+	 */
+	XactUndo.pending_uset = NULL;
+
+	START_CRIT_SECTION();
+	InsertXactUndoData(&ctx);
+	CleanupXactUndoInsertion(&ctx);
+	END_CRIT_SECTION();
+}
+
+
+/*
+ * GetCurrentXactLastBatchLSN
+ *		Get the WAL LSN of the most recent UNDO batch for a persistence level.
+ *
+ * Used during transaction abort to start the WAL-based UNDO chain walk.
+ */
+XLogRecPtr
+GetCurrentXactLastBatchLSN(UndoPersistenceLevel plevel)
+{
+	return XactUndo.last_batch_lsn[plevel];
+}
+
+/*
+ * XactUndoHasUnrecoverableUndo
+ *		Does the current transaction hold UNDO that has no working ROLLBACK
+ *		PREPARED apply path yet?
+ *
+ * Cluster-wide UNDO is recoverable across 2PC: the permanent chain-head LSN
+ * is durably saved in xl_xact_prepare, its WAL is pinned while the xact
+ * stays prepared (undo_batch_lsn in twophase.c / UndoGetOldestBatchLSN), and
+ * FinishPreparedTransaction() applies it synchronously via
+ * ApplyUndoChainFromWAL() on ROLLBACK PREPARED.
+ *
+ * Nothing remains that PREPARE must reject on UNDO grounds, so this returns
+ * false unconditionally.  Kept as a single choke point (rather than deleting
+ * the call in PrepareTransaction) so any future UNDO mechanism that is not
+ * 2PC-safe has one obvious place to re-assert a guard.
+ */
+bool
+XactUndoHasUnrecoverableUndo(void)
+{
+	return false;
+}
+
+/*
+ * AtCommit_XactUndo
+ *		Post-commit cleanup of the undo state.
+ *
+ * On commit, undo records are no longer needed for rollback.
+ * Free all record sets and reset state.
+ *
+ * UNDO pages are managed by shared_buffers and flushed by the
+ * checkpointer -- no per-commit fdatasync is needed.  We only
+ * flush the deferred WAL allocation records so recovery can
+ * reconstruct the UNDO log insert pointer.
+ *
+ * NB: This code MUST NOT FAIL, since it is run as a post-commit step.
+ */
+void
+AtCommit_XactUndo(void)
+{
+	int			i;
+
+	/*
+	 * Write any deferred records before we decide there is nothing to do.
+	 *
+	 * A committing transaction will not apply its own UNDO, but the records
+	 * must still reach WAL: a later ROLLBACK PREPARED or the recovery UNDO
+	 * phase can need them, and the WAL retention bookkeeping keyed off the
+	 * batch LSN must see them too. Flushing here also keeps the invariant that no
+	 * UndoRecordSet is freed below while still holding unwritten records.
+	 */
+	XactUndoFlushPending();
+
+	if (!XactUndo.has_undo)
+	{
+		/* Flush any deferred WAL even if has_undo is false */
+		UndoWalBatchFlush();
+		return;
+	}
+
+	/*
+	 * With UNDO-in-WAL, all UNDO data was already written to WAL via
+	 * XLOG_UNDO_BATCH records during the transaction.  The single XLogFlush()
+	 * at commit time (in RecordTransactionCommit) ensures both the UNDO data
+	 * and the commit record are durable.  No separate fdatasync is needed.
+	 *
+	 * Legacy WAL batch flush is now a no-op but kept for safety.
+	 */
+	UndoWalBatchFlush();
+
+	/*
+	 * Free all per-persistence-level record sets.
+	 *
+	 * We can safely call UndoRecordSetFree() during commit because we're in
+	 * CurTransactionContext, not BumpContext (which is only used during
+	 * abort). The record sets are allocated in CurTransactionContext and will
+	 * be freed when that context is destroyed at transaction end.
+	 */
+	for (i = 0; i < NUndoPersistenceLevels; i++)
+	{
+		if (XactUndo.record_set[i] != NULL)
+		{
+			UndoRecordSetFree(XactUndo.record_set[i]);
+			XactUndo.record_set[i] = NULL;
+		}
+	}
+
+	/* Release WAL retention hold acquired in UndoRecordSetInsert(). */
+	UndoClearBatchLSN();
+
+	ResetXactUndo();
+}
+
+/*
+ * AtPrepare_XactUndo
+ *		End-of-PREPARE cleanup of the backend-private undo state.
+ *
+ * PREPARE TRANSACTION hands ownership of this transaction's cluster-wide UNDO
+ * to the 2PC state: the permanent chain-head LSN was durably saved in
+ * xl_xact_prepare (twophase.c gxact->undo_batch_lsn), its WAL is pinned while
+ * the xact stays prepared, and ROLLBACK PREPARED applies that LSN
+ * synchronously via ApplyUndoChainFromWAL().
+ *
+ * The in-memory UndoRecordSets, however, live in TopTransactionContext, which
+ * PrepareTransaction() destroys (AtCommit_Memory) right after this.  We must
+ * therefore drop the backend-private references to them here, or the dangling
+ * XactUndo.record_set[] pointers get freed at backend exit (AtProcExit_XactUndo
+ * -> UndoRecordSetFree on already-freed memory) and crash.  We only NULL the
+ * pointers (via ResetXactUndo); the record sets themselves are freed by
+ * TopTransactionContext destruction.
+ *
+ * Unlike AtCommit_XactUndo, we do NOT call UndoClearBatchLSN(): the WAL
+ * retention hold must persist for as long as the transaction stays prepared.
+ */
+void
+AtPrepare_XactUndo(void)
+{
+	/* Flush any deferred UNDO WAL so the prepared xact's chain is durable. */
+	UndoWalBatchFlush();
+
+	/*
+	 * Any deferred batch was already written in PrepareTransaction(), before
+	 * the 2PC state captured the chain-head LSN.  Assert that, because a
+	 * pending record here would have been silently excluded from the prepared
+	 * transaction's chain.
+	 */
+	Assert(!XactUndoHasPendingData());
+
+	/*
+	 * Drop the backend-private record-set references (memory freed with
+	 * TopTransactionContext); keep the WAL retention hold.
+	 */
+	ResetXactUndo();
+}
+
+/*
+ * AtAbort_XactUndo
+ *		Post-abort cleanup of the undo state.
+ *
+ * On abort, we need to apply the undo chain to roll back changes.
+ * The actual undo application is triggered by xact.c before calling
+ * this function.  Here we apply the transaction's UNDO and clean up the record sets.
+ *
+ * With append-only I/O, we sync UNDO files before UNDO replay so that
+ * if we crash during rollback, recovery can re-read the UNDO records
+ * from the segment file and continue the rollback.
+ */
+void
+AtAbort_XactUndo(void)
+{
+	int			i;
+	bool		lsn_safely_held = false;	/* true once inline UNDO completed */
+
+	/*
+	 * Write any deferred records FIRST.  This is the ordering the whole
+	 * deferred-batch scheme rests on.
+	 *
+	 * The has_undo test immediately below, and the last_batch_lsn[] chain
+	 * head the apply path walks, are both only set by InsertXactUndoData().
+	 * A record that is still pending has therefore published nothing, and
+	 * would never be applied -- the exact silent no-op this module's header
+	 * warns about.
+	 * Flushing here, at the top of the abort path and before
+	 * RecordTransactionAbort() runs, makes the deferral invisible to
+	 * everything downstream.
+	 *
+	 * This also covers an error thrown mid-statement: the error unwinds into
+	 * AbortTransaction(), which calls us, so the partial statement's records
+	 * are written and applied exactly like a completed statement's.
+	 */
+	XactUndoFlushPending();
+
+	/* Always clean up the recycled context; see AtCommit_XactUndo. */
+	UndoRecordSetResetCache();
+
+	if (!XactUndo.has_undo)
+	{
+		/* No UNDO data was written; nothing to do */
+		UndoWalBatchReset();
+		return;
+	}
+
+	/*
+	 * With UNDO-in-WAL, all UNDO data is already in the WAL stream. No
+	 * separate sync is needed.  The UNDO data can be read back from WAL for
+	 * rollback via UndoReadBatchFromWAL().
+	 *
+	 * For crash safety during abort: if we crash mid-rollback, the recovery
+	 * undo phase will find this transaction's UNDO batches in WAL and
+	 * complete the rollback.
+	 */
+	UndoWalBatchFlush();		/* no-op, kept for safety */
+
+	INJECTION_POINT("undo-xact-abort-before-apply", NULL);
+
+	/* Collapse all subtransaction state. */
+	CollapseXactUndoSubTransactions();
+
+	/*
+	 * UNDO application strategy: apply the transaction's cluster-wide UNDO
+	 * chain synchronously in this backend, here at abort.
+	 *
+	 * The BumpContext issue (pfree crashes during abort) is avoided by: -
+	 * Creating a temporary AllocSetContext for inline UNDO application -
+	 * ApplyUndoChainFromWAL already avoids pfree on its allocations -
+	 * Switching back to the abort context afterward
+	 */
+	{
+		XLogRecPtr	perm_lsn =
+			XactUndo.last_batch_lsn[UNDOPERSISTENCE_PERMANENT];
+
+		if (XLogRecPtrIsValid(perm_lsn))
+		{
+			/*
+			 * The UNDO batch records were inserted into the WAL buffers
+			 * during this transaction but may not yet be flushed to disk. The
+			 * inline apply path reads them back via the local XLog reader,
+			 * which would otherwise busy-wait (pg_usleep) for the walwriter
+			 * to flush past the batch LSN -- adding multi-second latency to
+			 * every rollback. Flush WAL up to the end of the last record this
+			 * backend wrote so the batch is immediately readable. We must not
+			 * flush to GetXLogInsertRecPtr(): under concurrency that global
+			 * insert position can point into the middle of a record another
+			 * backend has reserved but not finished copying, so XLogFlush
+			 * would fail with "xlog flush request is not satisfied".
+			 * XactLastRecEnd is always a valid record boundary and, since
+			 * this backend wrote the UNDO batch, is guaranteed to be at or
+			 * past the batch end. This mirrors the commit-path flush in
+			 * RecordTransactionCommit.
+			 */
+			XLogFlush(XactLastRecEnd);
+
+			{
+				/*
+				 * Apply UNDO inline.  Use a dedicated AllocSetContext to
+				 * avoid BumpContext pfree issues.
+				 */
+				MemoryContext undo_ctx;
+				MemoryContext old_ctx;
+				int			saved_trans_state;
+				bool		undo_applied = false;
+				bool		valid_batch;
+
+				undo_ctx = AllocSetContextCreate(TopMemoryContext,
+												 "Inline UNDO Apply",
+												 ALLOCSET_DEFAULT_SIZES);
+				old_ctx = MemoryContextSwitchTo(undo_ctx);
+
+				/*
+				 * Validate the batch LSN points to an actual UNDO record
+				 * before attempting inline application.  Stale LSNs from
+				 * chain_prev tracking anomalies can point to non-UNDO WAL
+				 * records, which would cause "not an UNDO batch" warnings.
+				 */
+				valid_batch = UndoValidateBatchLSN(perm_lsn);
+#ifdef USE_INJECTION_POINTS
+				/* Force the invalid-batch early exit for testing. */
+				if (IS_INJECTION_POINT_ATTACHED("inline-undo-invalid-batch"))
+					valid_batch = false;
+#endif
+				if (!valid_batch)
+				{
+					elog(DEBUG1, "inline UNDO: last_batch_lsn %X/%X is not "
+						 "a valid UNDO batch",
+						 LSN_FORMAT_ARGS(perm_lsn));
+					undo_applied = false;
+					MemoryContextSwitchTo(old_ctx);
+					MemoryContextDelete(undo_ctx);
+					goto inline_undo_done;
+				}
+
+				/*
+				 * AbortTransaction() has already advanced the transaction
+				 * state to TRANS_ABORT, but the relcache, locks, and resource
+				 * owner are all still live.  UNDO appliers open relations,
+				 * which asserts IsTransactionState(); temporarily present
+				 * TRANS_INPROGRESS for the duration of the inline apply and
+				 * always restore the real state afterward.
+				 */
+				saved_trans_state = EnterInlineUndoApplyState();
+				PG_TRY();
+				{
+					undo_applied = ApplyUndoChainFromWAL(perm_lsn);
+				}
+				PG_CATCH();
+				{
+					/*
+					 * If inline UNDO throws an error, release any content
+					 * locks the failed chain left held before unwinding.
+					 */
+					BufferLockReleaseAll();
+					LeaveInlineUndoApplyState(saved_trans_state);
+					FlushErrorState();
+					undo_applied = false;
+				}
+				PG_END_TRY();
+				LeaveInlineUndoApplyState(saved_trans_state);
+
+				MemoryContextSwitchTo(old_ctx);
+				MemoryContextDelete(undo_ctx);
+
+		inline_undo_done:
+				if (!undo_applied)
+				{
+					/*
+					 * Inline UNDO failed (WAL recycled, wrong record type, or
+					 * chain walk aborted).  Retain the UNDO WAL retention
+					 * slot so the WAL segment holding our UNDO data is not
+					 * recycled; the slot is cleared at backend exit.
+					 *
+					 * The error that is aborting us may have been raised
+					 * while this backend held buffer content locks, which
+					 * resource-owner cleanup releases only later.  The
+					 * appliers lock pages, possibly those very ones, so
+					 * release the stragglers first.  LWLockReleaseAll does
+					 * not cover them.
+					 */
+					BufferLockReleaseAll();
+					elog(WARNING, "inline UNDO failed for xid %u",
+						 GetCurrentTransactionId());
+				}
+				else
+				{
+					lsn_safely_held = true; /* UNDO fully applied, WAL can be
+											 * recycled */
+					ereport(DEBUG2,
+							(errmsg("inline UNDO applied for xid %u",
+									GetCurrentTransactionId())));
+				}
+			}
+		}
+	}
+
+	INJECTION_POINT("undo-xact-abort-after-apply", NULL);
+
+	/* Free all per-persistence-level record sets. */
+	for (i = 0; i < NUndoPersistenceLevels; i++)
+	{
+		if (XactUndo.record_set[i] != NULL)
+		{
+			UndoRecordSetFree(XactUndo.record_set[i]);
+			XactUndo.record_set[i] = NULL;
+		}
+	}
+
+	/*
+	 * Release the WAL retention hold if inline UNDO completed: nothing needs
+	 * the UNDO records any more.
+	 *
+	 * Otherwise the slot keeps the hold, but only until this backend's next
+	 * transaction commits or aborts, or the backend exits; both clear it.
+	 * Nothing re-applies the chain in the meantime.  The abort record is
+	 * already written, so crash recovery does not treat the transaction as a
+	 * loser either.
+	 */
+	if (lsn_safely_held)
+		UndoClearBatchLSN();
+	else
+		elog(DEBUG1, "retaining UNDO WAL retention slot (inline UNDO failed)");
+
+	ResetXactUndo();
+}
+
+/*
+ * AtSubCommit_XactUndo
+ *		Subtransaction commit: merge sub undo state into parent.
+ */
+void
+AtSubCommit_XactUndo(int level)
+{
+	XactUndoSubTransactionState *cur;
+	XactUndoSubTransactionState *parent;
+	int			i;
+
+	/*
+	 * Flush before merging this subtransaction's state into the parent.
+	 *
+	 * On subxact COMMIT the records stay live -- they now belong to the
+	 * parent and must still be applied if the parent later aborts.  Writing
+	 * them here rather than letting them ride into the parent's batch keeps
+	 * the chain segments aligned with the savepoint boundaries that
+	 * AtSubAbort's bounded walk uses, so a subsequent ROLLBACK TO an outer
+	 * savepoint reverts exactly the intended range.  This is also what makes
+	 * the START_SUB flush sufficient: every boundary, entering or leaving,
+	 * has a batch edge.
+	 */
+	XactUndoFlushPending();
+
+	if (XactUndo.subxact_depth <= 0)
+		return;
+
+	cur = CURRENT_SUBXACT();
+	if ((int) cur->nestingLevel != level)
+		return;
+
+	parent = &XactUndo.subxact_stack[XactUndo.subxact_depth - 1];
+
+	/*
+	 * Merge start locations into parent.
+	 *
+	 * Invariant: all UNDO records for this transaction, regardless of nesting
+	 * level, are stored in a single chain per persistence level (one
+	 * UndoRecordSet).  start_location tracks the earliest record the
+	 * subtransaction generated.  Since records are strictly append-only, the
+	 * parent's start location is always earlier than the subtransaction's if
+	 * it exists.  We only update the parent's start when it is not yet set
+	 * (the parent wrote no UNDO before this subtransaction).
+	 */
+	for (i = 0; i < NUndoPersistenceLevels; i++)
+	{
+		if (UndoRecPtrIsValid(cur->start_location[i]) &&
+			!UndoRecPtrIsValid(parent->start_location[i]))
+		{
+			parent->start_location[i] = cur->start_location[i];
+		}
+	}
+
+	XactUndo.subxact_depth--;
+}
+
+/*
+ * AtSubAbort_XactUndo
+ *		Subtransaction abort: apply undo for this sub-level, clean up.
+ *
+ * We apply the subtransaction's records synchronously
+ * by queuing work for the background UNDO worker.  This ensures that tuples
+ * inserted/modified by the aborting subtransaction are physically restored
+ * before control returns to the caller.
+ *
+ * Any AM-private per-subtransaction tracking state is cleaned up by the
+ * owning AM's own SubXactCallback.
+ */
+void
+AtSubAbort_XactUndo(int level)
+{
+	XactUndoSubTransactionState *cur;
+	XactUndoSubTransactionState *parent;
+	int			i;
+
+	/*
+	 * Write any deferred records before deciding what this subtransaction
+	 * wrote.
+	 *
+	 * Records accumulated in the pending batch belong to whichever
+	 * subtransaction was running when they were added -- this one.  The eager
+	 * rollback below detects the subtransaction's work by comparing
+	 * XactUndo.last_batch_lsn[i] against the value saved at subxact start,
+	 * and only InsertXactUndoData() moves that pointer.  Leaving records
+	 * pending would therefore make the comparison find nothing, and this is
+	 * the one place where that loses data permanently rather than deferring
+	 * it: an aborted subtransaction whose parent goes on to COMMIT is never
+	 * rolled back as a whole, so nothing would ever revert its changes --
+	 * they would persist as if committed.  Flushing here makes them part of
+	 * this subtransaction's chain segment, which the bounded walk then
+	 * reverses.
+	 *
+	 * Note the flush must precede the loop, not sit inside it: it advances
+	 * last_batch_lsn[] for the pending level, which is exactly the value the
+	 * loop reads.
+	 */
+	XactUndoFlushPending();
+
+	if (XactUndo.subxact_depth <= 0)
+		return;
+
+	cur = CURRENT_SUBXACT();
+	if ((int) cur->nestingLevel != level)
+		return;
+
+	parent = &XactUndo.subxact_stack[XactUndo.subxact_depth - 1];
+
+	/*
+	 * Apply UNDO for records generated during this subtransaction.  We
+	 * iterate the record sets and apply records whose UndoRecPtr is at or
+	 * after this subtransaction's start_location.
+	 *
+	 * For each persistence level where this subtransaction generated UNDO
+	 * records, queue the work for the UNDO worker to apply them
+	 * synchronously.  The parent transaction's records (before the
+	 * subtransaction's start_location) are preserved.
+	 */
+	for (i = 0; i < NUndoPersistenceLevels; i++)
+	{
+		UndoRecPtr	sub_start = cur->start_location[i];
+
+		if (!UndoRecPtrIsValid(sub_start))
+			continue;
+
+		/*
+		 * Eagerly roll back the cluster-wide UNDO this subtransaction wrote.
+		 *
+		 * If the subtransaction advanced XactUndo.last_batch_lsn[i] beyond
+		 * the value saved at subtransaction start, it wrote UNDO batches that
+		 * must now be reversed.  We must apply them here rather than leave
+		 * them to the top-level abort: a subtransaction can abort while its
+		 * parent goes on to COMMIT, in which case the top level never aborts
+		 * and the UNDO would never run, leaking the aborted subtransaction's
+		 * writes as if committed.
+		 *
+		 * Apply only the batches strictly newer than the parent's saved head
+		 * (cur->last_batch_lsn[i]) via the bounded chain walk, so the
+		 * parent's and earlier subtransactions' batches are preserved.
+		 * Afterward restore last_batch_lsn[i] to the parent's value so a
+		 * later parent abort walks only the parent's batches and does not
+		 * double-apply ours.
+		 *
+		 * BumpContext safety: subtransaction abort may run under a
+		 * BumpContext that does not support pfree(), so apply inside a
+		 * dedicated AllocSetContext and present an in-progress transaction
+		 * state for the relation opens, mirroring the inline path in
+		 * AtAbort_XactUndo().
+		 */
+		if (XLogRecPtrIsValid(XactUndo.last_batch_lsn[i]) &&
+			XactUndo.last_batch_lsn[i] != cur->last_batch_lsn[i])
+		{
+			XLogRecPtr	sub_head = XactUndo.last_batch_lsn[i];
+			XLogRecPtr	parent_head = cur->last_batch_lsn[i];
+
+			/*
+			 * Invariant: the current head must be strictly newer (larger)
+			 * than the parent's saved head.  If this fires, a sibling
+			 * subtransaction improperly modified last_batch_lsn after its own
+			 * abort, which would cause us to skip or double-apply batches.
+			 */
+			Assert(!XLogRecPtrIsValid(parent_head) ||
+				   sub_head > parent_head);
+
+			/*
+			 * Flush WAL so the subtransaction's batches are readable by the
+			 * local XLog reader without busy-waiting on the walwriter.  See
+			 * the matching flush in AtAbort_XactUndo() for why XactLastRecEnd
+			 * is the correct, always-valid record boundary to flush to.
+			 */
+			XLogFlush(XactLastRecEnd);
+
+			if (UndoValidateBatchLSN(sub_head))
+			{
+				MemoryContext undo_ctx;
+				MemoryContext old_ctx;
+				int			saved_trans_state;
+
+				undo_ctx = AllocSetContextCreate(TopMemoryContext,
+												 "Subxact UNDO Apply",
+												 ALLOCSET_DEFAULT_SIZES);
+				old_ctx = MemoryContextSwitchTo(undo_ctx);
+
+				/*
+				 * Release buffer content locks the failed subtransaction may
+				 * still hold -- an error raised mid-split leaves both halves
+				 * locked -- before the appliers lock pages again.  See
+				 * AtAbort_XactUndo.
+				 */
+				BufferLockReleaseAll();
+				saved_trans_state = EnterInlineUndoApplyState();
+				PG_TRY();
+				{
+					ApplyUndoChainFromWALBounded(sub_head, parent_head);
+				}
+				PG_CATCH();
+				{
+					/*
+					 * Release any content locks the failed subxact UNDO chain
+					 * left held before unwinding.
+					 */
+					BufferLockReleaseAll();
+					LeaveInlineUndoApplyState(saved_trans_state);
+					FlushErrorState();
+				}
+				PG_END_TRY();
+				LeaveInlineUndoApplyState(saved_trans_state);
+
+				MemoryContextSwitchTo(old_ctx);
+				MemoryContextDelete(undo_ctx);
+			}
+			else
+				elog(DEBUG1, "subxact UNDO: head %X/%X is not a valid UNDO "
+					 "batch, skipping eager rollback",
+					 LSN_FORMAT_ARGS(sub_head));
+
+			XactUndo.last_batch_lsn[i] = parent_head;
+		}
+
+		/*
+		 * Reset the last_location to what it was before this subtransaction,
+		 * so that if the parent transaction continues and then aborts, only
+		 * the parent's records are applied (the subtransaction's records have
+		 * already been applied).
+		 */
+		if (UndoRecPtrIsValid(parent->start_location[i]))
+			XactUndo.last_location[i] = parent->start_location[i];
+	}
+
+	XactUndo.subxact_depth--;
+}
+
+/*
+ * AtProcExit_XactUndo
+ *		Process exit cleanup for transaction undo.
+ */
+void
+AtProcExit_XactUndo(void)
+{
+	int			i;
+
+	/* Free any lingering record sets. */
+	for (i = 0; i < NUndoPersistenceLevels; i++)
+	{
+		if (XactUndo.record_set[i] != NULL)
+		{
+			UndoRecordSetFree(XactUndo.record_set[i]);
+			XactUndo.record_set[i] = NULL;
+		}
+	}
+
+	/* Release any WAL retention hold (in case process exits mid-transaction). */
+	UndoClearBatchLSN();
+
+	ResetXactUndo();
+}
+
+/*
+ * XactUndo_SubXactCallback
+ *		Subtransaction callback to manage UNDO subtransaction state.
+ *
+ * This ensures the UNDO subsystem properly tracks all subtransactions,
+ * including those created by ROLLBACK TO SAVEPOINT.
+ *
+ * The subtransaction state is stored in a dynamically-grown array in
+ * TopMemoryContext.  In the common case (depth < capacity), no allocation
+ * occurs.  Growth via repalloc is safe here because it operates on a
+ * TopMemoryContext allocation, not a per-subtransaction context.
+ */
+static void
+XactUndo_SubXactCallback(SubXactEvent event, SubTransactionId mySubid,
+						 SubTransactionId parentSubid, void *arg)
+{
+	int			i;
+
+	/* These parameters are mandated by the callback signature. */
+	(void) parentSubid;
+	(void) arg;
+
+	switch (event)
+	{
+		case SUBXACT_EVENT_START_SUB:
+
+			/*
+			 * Write out any deferred records before this subtransaction
+			 * begins.
+			 *
+			 * A pending batch must never span a subtransaction boundary.  The
+			 * records in it belong to the OUTER level, but the flush that
+			 * will eventually write them stamps them all with one batch LSN;
+			 * if that flush happened after this point, the outer level's
+			 * records would land above the boundary we are about to save and
+			 * a later ROLLBACK TO SAVEPOINT would revert them along with this
+			 * subtransaction's own -- discarding work that was never rolled
+			 * back.  (Concretely: savepoint s1, insert, release s1, savepoint
+			 * s2, insert, rollback to s2 would also undo the s1 insert.)
+			 *
+			 * This must run BEFORE the boundary is saved below, so the
+			 * flushed batch sits strictly below it.
+			 */
+			XactUndoFlushPending();
+
+			/*
+			 * A new subtransaction is starting.  Push an entry onto the
+			 * dynamically-grown stack, extending it if needed.
+			 */
+			XactUndo.subxact_depth++;
+			EnsureSubxactStackCapacity();
+			{
+				XactUndoSubTransactionState *s = CURRENT_SUBXACT();
+
+				s->nestingLevel = mySubid;
+				for (i = 0; i < NUndoPersistenceLevels; ++i)
+				{
+					s->start_location[i] = InvalidUndoRecPtr;
+					/* Save parent's last_batch_lsn for restore on abort */
+					s->last_batch_lsn[i] = XactUndo.last_batch_lsn[i];
+				}
+			}
+			break;
+
+		case SUBXACT_EVENT_COMMIT_SUB:
+
+			/*
+			 * Subtransaction is committing. Merge its UNDO state into parent.
+			 */
+			AtSubCommit_XactUndo(mySubid);
+			break;
+
+		case SUBXACT_EVENT_ABORT_SUB:
+
+			/*
+			 * Subtransaction is aborting. Apply UNDO and clean up.
+			 */
+			AtSubAbort_XactUndo(mySubid);
+			break;
+
+		case SUBXACT_EVENT_PRE_COMMIT_SUB:
+			/* Nothing to do at pre-commit */
+			break;
+	}
+}
+
+/*
+ * EnsureSubxactStackCapacity
+ *		Ensure the subxact_stack has room for the current depth.
+ *
+ * If the stack hasn't been allocated yet, allocate it with the initial
+ * capacity.  If we've exceeded the current capacity, double it.
+ * The stack lives in TopMemoryContext so it persists across transactions
+ * within the same backend.
+ */
+static void
+EnsureSubxactStackCapacity(void)
+{
+	if (XactUndo.subxact_stack == NULL)
+	{
+		/* First-time allocation */
+		XactUndo.subxact_capacity = INITIAL_SUBXACT_CAPACITY;
+		XactUndo.subxact_stack = (XactUndoSubTransactionState *)
+			MemoryContextAllocZero(TopMemoryContext,
+								   XactUndo.subxact_capacity *
+								   sizeof(XactUndoSubTransactionState));
+	}
+	else if (XactUndo.subxact_depth >= XactUndo.subxact_capacity)
+	{
+		int			new_capacity = XactUndo.subxact_capacity * 2;
+
+		XactUndo.subxact_stack = (XactUndoSubTransactionState *)
+			repalloc(XactUndo.subxact_stack,
+					 new_capacity * sizeof(XactUndoSubTransactionState));
+		/* Zero the newly-allocated portion */
+		memset(&XactUndo.subxact_stack[XactUndo.subxact_capacity], 0,
+			   (new_capacity - XactUndo.subxact_capacity) *
+			   sizeof(XactUndoSubTransactionState));
+		XactUndo.subxact_capacity = new_capacity;
+	}
+}
+
+/*
+ * ResetXactUndo
+ *		Reset all backend-private undo state for the next transaction.
+ */
+static void
+ResetXactUndo(void)
+{
+	int			i;
+
+	XactUndo.has_undo = false;
+	XactUndo.subxact_depth = 0;
+
+	/*
+	 * Drop any pending-batch reference.  Every caller of this function has
+	 * already flushed (commit/abort/prepare choke points), so a non-NULL
+	 * value here would mean records were lost; the record sets themselves are
+	 * freed or reparented by the caller, so the pointer must not survive
+	 * either way.
+	 */
+	XactUndo.pending_uset = NULL;
+
+	/*
+	 * The subxact_stack allocation persists across transactions (it's in
+	 * TopMemoryContext).  We just reset the depth and initialize slot 0.
+	 */
+	if (XactUndo.subxact_stack != NULL)
+	{
+		XactUndo.subxact_stack[0].nestingLevel = 1;
+		for (i = 0; i < NUndoPersistenceLevels; i++)
+		{
+			XactUndo.subxact_stack[0].start_location[i] = InvalidUndoRecPtr;
+			XactUndo.subxact_stack[0].last_batch_lsn[i] = InvalidXLogRecPtr;
+		}
+	}
+
+	for (i = 0; i < NUndoPersistenceLevels; i++)
+	{
+		XactUndo.record_set[i] = NULL;
+		XactUndo.last_location[i] = InvalidUndoRecPtr;
+		XactUndo.last_batch_lsn[i] = InvalidXLogRecPtr;
+	}
+}
+
+/*
+ * CollapseXactUndoSubTransactions
+ *		Collapse all subtransaction state into the top level.
+ */
+static void
+CollapseXactUndoSubTransactions(void)
+{
+	while (XactUndo.subxact_depth > 0)
+	{
+		/*
+		 * Merge current level into parent by calling AtSubCommit_XactUndo
+		 * with the current level's nestingLevel.
+		 */
+		AtSubCommit_XactUndo(
+							 XactUndo.subxact_stack[XactUndo.subxact_depth].nestingLevel);
+	}
+}
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 47f55121574..71e46e96091 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -960,6 +960,7 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 			break;
 		default:
 			(void) heap_reloptions(relkind, reloptions, true);
+			break;
 	}
 
 	if (stmt->ofTypename)
diff --git a/src/backend/postmaster/bgworker.c b/src/backend/postmaster/bgworker.c
index 4aef037d5ae..5f5c61c1031 100644
--- a/src/backend/postmaster/bgworker.c
+++ b/src/backend/postmaster/bgworker.c
@@ -13,6 +13,7 @@
 #include "postgres.h"
 
 #include "access/parallel.h"
+#include "access/undoworker.h"
 #include "commands/repack.h"
 #include "libpq/pqsignal.h"
 #include "miscadmin.h"
@@ -166,6 +167,10 @@ static const struct
 	{
 		.fn_name = "DataChecksumsWorkerMain",
 		.fn_addr = DataChecksumsWorkerMain
+	},
+	{
+		.fn_name = "UndoWorkerMain",
+		.fn_addr = UndoWorkerMain
 	}
 };
 
diff --git a/src/backend/storage/buffer/bufmgr.c b/src/backend/storage/buffer/bufmgr.c
index c17fc3590f0..d124a856328 100644
--- a/src/backend/storage/buffer/bufmgr.c
+++ b/src/backend/storage/buffer/bufmgr.c
@@ -5905,6 +5905,79 @@ UnlockBuffers(void)
 	}
 }
 
+/*
+ * BufferLockReleaseAll -- release any buffer content locks still held by this
+ * backend.
+ *
+ * Normally, buffer content locks are released as part of resource-owner
+ * cleanup (see ResOwnerReleaseBuffer, which calls BufferLockUnlock on any
+ * entry whose data.lockmode != BUFFER_LOCK_UNLOCK).  However, that cleanup
+ * runs in ResourceOwnerRelease(RESOURCE_RELEASE_LOCKS), which is *after*
+ * AtAbort_XactUndo() in AbortTransaction().  Inline UNDO application may
+ * itself take buffer content locks, and BufferLockAcquire asserts that no
+ * lock is already held.  If a statement errors out while holding a share
+ * or exclusive content lock, that assert would trip on any re-lock during
+ * UNDO application.
+ *
+ * This helper releases such stragglers early, before inline UNDO runs.
+ * It mirrors the still-locked branch of ResOwnerReleaseBuffer (matching
+ * the HOLD_INTERRUPTS/BufferLockUnlock discipline).  Callers must be in
+ * an error-recovery path -- releasing content locks under a live statement
+ * would violate correctness.
+ *
+ * The subsequent ResourceOwnerRelease pass at RESOURCE_RELEASE_LOCKS still
+ * runs, but ResOwnerReleaseBuffer only calls BufferLockUnlock when
+ * data.lockmode != BUFFER_LOCK_UNLOCK, so already-released buffers are
+ * skipped -- no double-unlock.
+ */
+void
+BufferLockReleaseAll(void)
+{
+	PrivateRefCountEntry *res;
+	int			i;
+
+	/* Walk the small array first (fast path, covers the usual case). */
+	for (i = 0; i < REFCOUNT_ARRAY_ENTRIES; i++)
+	{
+		Buffer		buffer = PrivateRefCountArrayKeys[i];
+
+		if (buffer == InvalidBuffer)
+			continue;
+
+		res = &PrivateRefCountArray[i];
+		if (res->data.lockmode == BUFFER_LOCK_UNLOCK)
+			continue;
+
+		/* Local buffers do not have content locks. */
+		if (BufferIsLocal(buffer))
+			continue;
+
+		HOLD_INTERRUPTS();		/* matched by RESUME_INTERRUPTS in
+								 * BufferLockUnlock */
+		BufferLockUnlock(buffer, GetBufferDescriptor(buffer - 1));
+	}
+
+	/* Then the overflow hash, if any. */
+	if (PrivateRefCountOverflowed)
+	{
+		refcount_iterator iter;
+
+		refcount_start_iterate(PrivateRefCountHash, &iter);
+		while ((res = refcount_iterate(PrivateRefCountHash, &iter)) != NULL)
+		{
+			Buffer		buffer = res->buffer;
+
+			if (res->data.lockmode == BUFFER_LOCK_UNLOCK)
+				continue;
+			if (BufferIsLocal(buffer))
+				continue;
+
+			HOLD_INTERRUPTS();
+			BufferLockUnlock(buffer, GetBufferDescriptor(buffer - 1));
+		}
+	}
+}
+
 /*
  * Acquire the buffer content lock in the specified mode
  *
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 1512d2df196..613984a301c 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1187,6 +1187,7 @@ ProcessUtilitySlow(ParseState *pstate,
 																validnsps,
 																true,
 																false);
+
 							(void) heap_reloptions(RELKIND_TOASTVALUE,
 												   toast_options,
 												   true);
diff --git a/src/backend/utils/activity/wait_event_names.txt b/src/backend/utils/activity/wait_event_names.txt
index 3d366fd1114..4bbc967728b 100644
--- a/src/backend/utils/activity/wait_event_names.txt
+++ b/src/backend/utils/activity/wait_event_names.txt
@@ -69,6 +69,8 @@ WAL_RECEIVER_MAIN	"Waiting in main loop of WAL receiver process."
 WAL_SENDER_MAIN	"Waiting in main loop of WAL sender process."
 WAL_SUMMARIZER_WAL	"Waiting in WAL summarizer for more WAL to be generated."
 WAL_WRITER_MAIN	"Waiting in main loop of WAL writer process."
+UNDO_FLUSH_MAIN	"Waiting in main loop of UNDO flush writer process."
+UNDO_WORKER_MAIN	"Waiting in main loop of UNDO discard worker process."
 
 ABI_compatibility:
 
@@ -167,6 +169,7 @@ WAL_RECEIVER_UPSTREAM_CATCHUP	"Waiting for upstream server WAL flush position to
 WAL_RECEIVER_WAIT_START	"Waiting for startup process to send initial data for streaming replication."
 WAL_SUMMARY_READY	"Waiting for a new WAL summary to be generated."
 XACT_GROUP_UPDATE	"Waiting for the group leader to update transaction status at transaction end."
+UNDO_FLUSH_SYNC	"Waiting for UNDO flush writer to sync UNDO data to disk."
 
 ABI_compatibility:
 
@@ -419,6 +422,7 @@ XactSLRU	"Waiting to access the transaction status SLRU cache."
 ParallelVacuumDSA	"Waiting for parallel vacuum dynamic shared memory allocation."
 AioUringCompletion	"Waiting for another process to complete IO via io_uring."
 ShmemIndex	"Waiting to find or allocate space in shared memory."
+UndoLog	"Waiting to access or modify UNDO log metadata."
 
 # No "ABI_compatibility" region here as WaitEventLWLock has its own C code.
 
diff --git a/src/backend/utils/init/postinit.c b/src/backend/utils/init/postinit.c
index 8b6ea195eca..7a0522ae859 100644
--- a/src/backend/utils/init/postinit.c
+++ b/src/backend/utils/init/postinit.c
@@ -21,6 +21,7 @@
 
 #include "access/genam.h"
 #include "access/heapam.h"
+#include "access/undo.h"
 #include "access/htup_details.h"
 #include "access/session.h"
 #include "access/tableam.h"
@@ -857,6 +858,9 @@ InitPostgres(const char *in_dbname, Oid dboid,
 	InitCatalogCache();
 	InitPlanCache();
 
+	/* Initialize per-backend undo subsystem state */
+	InitializeUndo();
+
 	/* Initialize portal manager */
 	EnablePortalManager();
 
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index 1d0d73074a1..39c32122e84 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -1097,6 +1097,7 @@
   options => 'file_extend_method_options',
 },
 
+
 { name => 'from_collapse_limit', type => 'int', context => 'PGC_USERSET', group => 'QUERY_TUNING_OTHER',
   short_desc => 'Sets the FROM-list size beyond which subqueries are not collapsed.',
   long_desc => 'The planner will merge subqueries into upper queries if the resulting FROM list would have no more than this many items.',
@@ -3296,6 +3297,45 @@
   boot_val => 'false',
 },
 
+{ name => 'undo_batch_record_limit', type => 'int', context => 'PGC_SIGHUP', group => 'RESOURCES_MEM',
+  short_desc => 'Sets the UNDO write buffer flush threshold in number of records.',
+  long_desc => 'When the UNDO write buffer accumulates this many records, it inserts a new XLOG_UNDO_BATCH WAL record. Larger values reduce the number of WAL reads during rollback of large transactions but hold the WAL insertion lock longer per batch.',
+  variable => 'undo_batch_record_limit',
+  boot_val => '1000',
+  min => '100',
+  max => '100000',
+},
+
+{ name => 'undo_batch_size_kb', type => 'int', context => 'PGC_SIGHUP', group => 'RESOURCES_MEM',
+  short_desc => 'Sets the UNDO write buffer flush threshold in kilobytes.',
+  long_desc => 'When the UNDO write buffer accumulates this many kilobytes, it inserts a new XLOG_UNDO_BATCH WAL record. (This does not flush WAL to disk; that is controlled by synchronous_commit.) Larger values reduce the number of WAL reads during rollback of large transactions but hold the WAL insertion lock longer per batch.',
+  flags => 'GUC_UNIT_KB',
+  variable => 'undo_batch_size_kb',
+  boot_val => '256',
+  min => '64',
+  max => '4096',
+},
+
+{ name => 'undo_max_wal_retention_size', type => 'int', context => 'PGC_SIGHUP', group => 'WAL_SETTINGS',
+  short_desc => 'Maximum WAL retained for UNDO rollback before warning.',
+  long_desc => 'If the WAL retained because of in-flight UNDO exceeds this size (in MB), a WARNING is logged. 0 disables the check.',
+  flags => 'GUC_UNIT_MB',
+  variable => 'undo_max_wal_retention_size',
+  boot_val => '0',
+  min => '0',
+  max => 'INT_MAX / 2',
+},
+
+{ name => 'undo_worker_naptime', type => 'int', context => 'PGC_SIGHUP', group => 'VACUUM_AUTOVACUUM',
+  short_desc => 'Time to sleep between runs of the UNDO discard worker.',
+  long_desc => 'The UNDO discard worker wakes up periodically to discard old UNDO records.',
+  flags => 'GUC_UNIT_MS',
+  variable => 'undo_worker_naptime',
+  boot_val => '10000',
+  min => '1',
+  max => 'INT_MAX',
+},
+
 { name => 'unix_socket_directories', type => 'string', context => 'PGC_POSTMASTER', group => 'CONN_AUTH_SETTINGS',
   short_desc => 'Sets the directories where Unix-domain sockets will be created.',
   flags => 'GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY',
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 342aaeef59a..3b3b08c051f 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -34,6 +34,8 @@
 #include "access/slru.h"
 #include "access/toast_compression.h"
 #include "access/twophase.h"
+#include "access/undolog.h"
+#include "access/xactundo.h"
 #include "access/xlog_internal.h"
 #include "access/xlogprefetcher.h"
 #include "access/xlogrecovery.h"
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index 01f98de8a9f..4c4bf765805 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -914,6 +914,13 @@
                                         # (change requires restart)
 #recovery_init_sync_method = fsync      # fsync, syncfs (Linux 5.8+)
 
+# - UNDO -
+
+#undo_max_wal_retention_size = 0        # MB; 0 = unlimited UNDO WAL retention
+#undo_worker_naptime = 10s              # time between UNDO discard worker runs
+#undo_batch_size_kb = 256               # KB per UNDO batch flush (64-4096); reload
+#undo_batch_record_limit = 1000         # records per UNDO batch flush (100-100000); reload
+
 
 #------------------------------------------------------------------------------
 # CONFIG FILE INCLUDES
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 69171d6b859..42668b8188a 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -17407,6 +17407,7 @@ dumpTableSchema(Archive *fout, const TableInfo *tbinfo)
 			if (nonemptyReloptions(tbinfo->reloptions))
 			{
 				addcomma = true;
+				/* Emits all heap reloptions */
 				appendReloptionsArrayAH(q, tbinfo->reloptions, "", fout);
 			}
 			if (nonemptyReloptions(tbinfo->toast_reloptions))
diff --git a/src/bin/pg_waldump/rmgrdesc.c b/src/bin/pg_waldump/rmgrdesc.c
index 931ab8b979e..8570f17916f 100644
--- a/src/bin/pg_waldump/rmgrdesc.c
+++ b/src/bin/pg_waldump/rmgrdesc.c
@@ -20,6 +20,7 @@
 #include "access/nbtxlog.h"
 #include "access/rmgr.h"
 #include "access/spgxlog.h"
+#include "access/undo_xlog.h"
 #include "access/xact.h"
 #include "access/xlog_internal.h"
 #include "catalog/storage_xlog.h"
diff --git a/src/bin/pg_waldump/t/001_basic.pl b/src/bin/pg_waldump/t/001_basic.pl
index 7b33efc6299..fbeb19ee4a4 100644
--- a/src/bin/pg_waldump/t/001_basic.pl
+++ b/src/bin/pg_waldump/t/001_basic.pl
@@ -96,7 +96,8 @@ CommitTs
 ReplicationOrigin
 Generic
 LogicalMessage
-XLOG2$/,
+XLOG2
+Undo$/,
 	'rmgr list');
 
 
diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h
index 9e35961fb9e..4df736025bd 100644
--- a/src/include/access/heapam.h
+++ b/src/include/access/heapam.h
@@ -543,4 +543,5 @@ heap_execute_freeze_tuple(HeapTupleHeader tuple, HeapTupleFreeze *frz)
 	tuple->t_infomask2 = frz->t_infomask2;
 }
 
+
 #endif							/* HEAPAM_H */
diff --git a/src/include/access/rmgrlist.h b/src/include/access/rmgrlist.h
index ae32ef16d67..046ba45cb25 100644
--- a/src/include/access/rmgrlist.h
+++ b/src/include/access/rmgrlist.h
@@ -48,3 +48,4 @@ PG_RMGR(RM_REPLORIGIN_ID, "ReplicationOrigin", replorigin_redo, replorigin_desc,
 PG_RMGR(RM_GENERIC_ID, "Generic", generic_redo, generic_desc, generic_identify, NULL, NULL, generic_mask, NULL)
 PG_RMGR(RM_LOGICALMSG_ID, "LogicalMessage", logicalmsg_redo, logicalmsg_desc, logicalmsg_identify, NULL, NULL, NULL, logicalmsg_decode)
 PG_RMGR(RM_XLOG2_ID, "XLOG2", xlog2_redo, xlog2_desc, xlog2_identify, NULL, NULL, NULL, xlog2_decode)
+PG_RMGR(RM_UNDO_ID, "Undo", undo_redo, undo_desc, undo_identify, NULL, NULL, NULL, NULL)
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index ea3f2a6be99..2efe18b0421 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -324,6 +324,15 @@ typedef struct TableAmRoutine
 	/* this must be set to T_TableAmRoutine */
 	NodeTag		type;
 
+	/*
+	 * am_supports_undo: true if this AM records UNDO for its own table
+	 * changes and relies on applying it to roll back an aborted transaction,
+	 * rather than on visibility rules alone as heap does.  The records are
+	 * opaque to the UNDO core; the AM's own UNDO resource manager interprets
+	 * them and its page format.  Heap leaves this false.
+	 */
+	bool		am_supports_undo;
+
 
 	/* ------------------------------------------------------------------------
 	 * Slot related callbacks.
diff --git a/src/include/access/twophase.h b/src/include/access/twophase.h
index 1d2ff42c9b7..3714ea62b33 100644
--- a/src/include/access/twophase.h
+++ b/src/include/access/twophase.h
@@ -48,6 +48,7 @@ extern GlobalTransaction MarkAsPreparing(FullTransactionId fxid, const char *gid
 extern void StartPrepare(GlobalTransaction gxact);
 extern void EndPrepare(GlobalTransaction gxact);
 extern bool StandbyTransactionIdIsPrepared(TransactionId xid);
+extern bool RecoveryTransactionIdIsPrepared(TransactionId xid);
 
 extern TransactionId PrescanPreparedTransactions(TransactionId **xids_p,
 												 int *nxids_p);
@@ -72,4 +73,6 @@ extern bool LookupGXactBySubid(Oid subid);
 
 extern TransactionId TwoPhaseGetOldestXidInCommit(void);
 
+extern XLogRecPtr TwoPhaseGetOldestUndoBatchLSN(void);
+
 #endif							/* TWOPHASE_H */
diff --git a/src/include/access/undo.h b/src/include/access/undo.h
new file mode 100644
index 00000000000..efbed31546a
--- /dev/null
+++ b/src/include/access/undo.h
@@ -0,0 +1,50 @@
+/*-------------------------------------------------------------------------
+ *
+ * undo.h
+ *	  Common undo layer interface
+ *
+ * The undo subsystem consists of several logically separate subsystems
+ * that work together:
+ *
+ *   undolog.c       - Undo log file management and space allocation
+ *   undorecord.c    - Record format, serialization, and UndoRecordSet
+ *   xactundo.c      - Per-transaction record set management
+ *   undoapply.c     - Physical undo application during rollback
+ *   undoworker.c    - Background discard worker
+ *   undo_xlog.c     - WAL redo routines
+ *
+ * This header provides the unified entry points for shared memory
+ * initialization and startup/shutdown coordination across all undo
+ * subsystems.  The design follows the EDB undo-record-set branch
+ * pattern where UndoShmemSize()/UndoShmemInit() aggregate the
+ * requirements of all subsystems.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/access/undo.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef UNDO_H
+#define UNDO_H
+
+#include "access/undodefs.h"
+#include "utils/palloc.h"
+
+/*
+ * Unified shared memory initialization.
+ *
+ * UndoShmemSize() computes the total shared memory needed by all undo
+ * subsystems.  UndoShmemInit() initializes all undo shared memory
+ * structures.  These are called from ipci.c during postmaster startup.
+ */
+extern void UndoShmemInit(void);
+
+/* Per-backend initialization */
+extern void InitializeUndo(void);
+
+/* Memory context for undo-related allocations */
+extern MemoryContext UndoContext;
+
+#endif							/* UNDO_H */
diff --git a/src/include/access/undo_xlog.h b/src/include/access/undo_xlog.h
new file mode 100644
index 00000000000..ca403a377e6
--- /dev/null
+++ b/src/include/access/undo_xlog.h
@@ -0,0 +1,284 @@
+/*-------------------------------------------------------------------------
+ *
+ * undo_xlog.h
+ *	  UNDO resource manager WAL record definitions
+ *
+ * This file contains the WAL record format definitions for UNDO log
+ * operations. These records are logged by the RM_UNDO_ID resource manager.
+ *
+ * Record types:
+ *   XLOG_UNDO_ALLOCATE       - Log UNDO space allocation
+ *   XLOG_UNDO_DISCARD        - Log UNDO record discard
+ *   XLOG_UNDO_EXTEND         - Log UNDO log file extension
+ *   XLOG_UNDO_APPLY_RECORD   - CLR: Log physical UNDO application to a page
+ *
+ * The XLOG_UNDO_APPLY_RECORD type is a Compensation Log Record (CLR).
+ * CLRs record the fact that an UNDO operation was applied to a page
+ * during transaction rollback.  This ensures crash safety: if we crash
+ * during rollback, the already-applied UNDO operations are preserved
+ * via WAL replay of the CLR's full page image.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/access/undo_xlog.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef UNDO_XLOG_H
+#define UNDO_XLOG_H
+
+#include "access/transam.h"
+#include "access/xlogdefs.h"
+#include "access/xlogreader.h"
+#include "lib/stringinfo.h"
+#include "storage/block.h"
+#include "storage/off.h"
+#include "storage/relfilelocator.h"
+
+/*
+ * UndoRecPtr type definition.  We use undodefs.h which is lightweight
+ * and can be included in both frontend and backend code.  If undodefs.h
+ * has already been included (via undolog.h or directly), this is a no-op.
+ */
+#include "access/undodefs.h"
+
+/*
+ * WAL record types for UNDO operations
+ *
+ * These are the info codes for UNDO WAL records. The low 4 bits are used
+ * for operation type, leaving the upper 4 bits for flags.
+ */
+#define XLOG_UNDO_ALLOCATE			0x00	/* Allocate UNDO log space
+											 * (legacy) */
+#define XLOG_UNDO_DISCARD			0x10	/* Discard old UNDO records */
+#define XLOG_UNDO_EXTEND			0x20	/* Extend UNDO log file (legacy) */
+#define XLOG_UNDO_APPLY_RECORD		0x30	/* CLR: UNDO applied to page */
+#define XLOG_UNDO_ROTATE			0x40	/* Seal old log, activate new
+											 * (legacy) */
+#define XLOG_UNDO_PAGE_WRITE		0x50	/* Write UNDO data to a page
+											 * (legacy) */
+#define XLOG_UNDO_BATCH				0x60	/* Batched UNDO records in WAL */
+
+/*
+ * xl_undo_allocate - WAL record for UNDO space allocation
+ *
+ * Logged when a backend allocates space in an UNDO log for writing
+ * UNDO records. This ensures crash recovery can reconstruct the
+ * insert pointer state.
+ */
+typedef struct xl_undo_allocate
+{
+	UndoRecPtr	start_ptr;		/* Starting position of allocation */
+	uint32		length;			/* Length of allocation in bytes */
+	TransactionId xid;			/* Transaction that allocated this space */
+	uint32		log_number;		/* Log number (extracted from start_ptr) */
+} xl_undo_allocate;
+
+#define SizeOfUndoAllocate	(offsetof(xl_undo_allocate, log_number) + sizeof(uint32))
+
+/*
+ * xl_undo_discard - WAL record for UNDO discard operation
+ *
+ * Logged when the UNDO worker discards old UNDO records that are no
+ * longer needed by any active transaction. This allows space to be
+ * reclaimed.
+ */
+typedef struct xl_undo_discard
+{
+	UndoRecPtr	discard_ptr;	/* New discard pointer (oldest still needed) */
+	uint32		log_number;		/* Which log is being discarded */
+	TransactionId oldest_xid;	/* Oldest XID still needing UNDO */
+} xl_undo_discard;
+
+#define SizeOfUndoDiscard	(offsetof(xl_undo_discard, oldest_xid) + sizeof(TransactionId))
+
+/*
+ * xl_undo_extend - WAL record for UNDO log file extension
+ *
+ * Logged when an UNDO log file is extended to accommodate more UNDO
+ * records. This ensures the file size is correctly restored during
+ * crash recovery.
+ */
+typedef struct xl_undo_extend
+{
+	uint32		log_number;		/* Which log is being extended */
+	uint64		new_size;		/* New size of log file in bytes */
+} xl_undo_extend;
+
+#define SizeOfUndoExtend	(offsetof(xl_undo_extend, new_size) + sizeof(uint64))
+
+/*
+ * xl_undo_apply - CLR for physical UNDO application (physiological)
+ *
+ * This is a Compensation Log Record (CLR) generated when an UNDO record
+ * is physically applied to a table or index page during transaction rollback.
+ * No UNDO consumer in this series modifies pages (FILEOPS reverses
+ * filesystem operations), so nothing here writes one yet; the record and its
+ * redo are the engine's interface for consumers that do.
+ *
+ * Physiological CLR approach:
+ *   Instead of storing a full 8KB page image (REGBUF_FORCE_IMAGE), we log
+ *   just the operation and its data.  During redo, we re-apply the exact
+ *   same page modification.  This reduces WAL volume from ~8KB to
+ *   ~100-500 bytes per CLR.
+ *
+ *   For operations that only change LP state (INSERT undo, HOT_UPDATE kill),
+ *   no additional data is needed -- the metadata in xl_undo_apply suffices.
+ *
+ *   For operations that restore tuple data (DELETE/UPDATE/INPLACE undo),
+ *   the tuple data follows the fixed header as registered buffer data.
+ *
+ *   For full page image operations (DEDUP undo), REGBUF_FORCE_IMAGE is
+ *   still used since the entire page is being replaced.
+ *
+ * CLR flags (in clr_flags):
+ *   UNDO_CLR_HAS_TUPLE    - Before-image bytes follow in block data
+ *   UNDO_CLR_LP_DEAD      - Mark line pointer LP_DEAD (index INSERT undo)
+ */
+
+/* CLR operation flags */
+#define UNDO_CLR_HAS_TUPLE		0x0001	/* Before-image in block data */
+#define UNDO_CLR_LP_DEAD		0x0004	/* Mark target LP_DEAD */
+
+typedef struct xl_undo_apply
+{
+	UndoRecPtr	urec_ptr;		/* UNDO record pointer that was applied */
+	TransactionId xid;			/* Transaction being rolled back */
+	RelFileLocator target_locator;	/* Target relation file locator */
+	BlockNumber target_block;	/* Target block number */
+	OffsetNumber target_offset; /* Target item offset within page */
+	uint16		operation_type; /* UNDO subtype (HEAP_UNDO_INSERT, etc.) */
+	uint16		clr_flags;		/* UNDO_CLR_* flags */
+	uint32		tuple_len;		/* Restored tuple length (0 if no tuple) */
+} xl_undo_apply;
+
+#define SizeOfUndoApply	(offsetof(xl_undo_apply, tuple_len) + sizeof(uint32))
+
+/*
+ * xl_undo_page_write - WAL record for UNDO page data write
+ *
+ * Logged when UNDO data is written to a shared-buffer-managed page.
+ * The actual data follows the record header and is also registered
+ * via XLogRegisterBufData as buffer-specific data (block reference 0).
+ *
+ * During redo, the data is memcpy'd into the page at page_offset.
+ * If a full page image was stored (REGBUF_STANDARD enables FPI after
+ * checkpoints), XLogReadBufferForRedo restores it automatically and
+ * no additional replay is needed.
+ */
+typedef struct xl_undo_page_write
+{
+	uint32		page_offset;	/* Offset within the page to write at */
+	uint32		data_len;		/* Length of data written */
+} xl_undo_page_write;
+
+#define SizeOfUndoPageWrite	(offsetof(xl_undo_page_write, data_len) + sizeof(uint32))
+
+/*
+ * Rotation trigger reasons for XLOG_UNDO_ROTATE records
+ */
+#define UNDO_ROTATE_CAPACITY	0x01	/* Rotated due to capacity threshold */
+#define UNDO_ROTATE_CHECKPOINT	0x02	/* Rotated at checkpoint boundary */
+#define UNDO_ROTATE_PRESSURE	0x03	/* Rotated under allocation pressure */
+#define UNDO_ROTATE_MANUAL		0x04	/* Rotated on request (unused) */
+
+/*
+ * xl_undo_rotate - WAL record for UNDO log segment rotation
+ *
+ * Logged when the active UNDO log is sealed and a new one is activated.
+ * During recovery, the old log is marked SEALED and the new log is
+ * marked ACTIVE, restoring the correct lifecycle state.
+ */
+typedef struct xl_undo_rotate
+{
+	uint32		old_log_number; /* Log being sealed (0 if first log) */
+	UndoRecPtr	old_seal_ptr;	/* Insert pointer at seal time */
+	uint32		new_log_number; /* Newly activated log */
+	uint8		trigger;		/* UNDO_ROTATE_* reason */
+} xl_undo_rotate;
+
+#define SizeOfUndoRotate	(offsetof(xl_undo_rotate, trigger) + sizeof(uint8))
+
+/*
+ * xl_undo_batch - WAL record for batched UNDO data (XLOG_UNDO_BATCH)
+ *
+ * This record type replaces the old pwrite()-to-segment-file path.
+ * All UNDO records for a batch are serialized into a single WAL record.
+ * The batch payload contains concatenated UndoRecordHeader+payload pairs
+ * in their exact serialized format.
+ *
+ * The chain_prev field links this batch to the previous batch for the
+ * same transaction.  During rollback, the UNDO chain is walked backward
+ * by reading WAL records at successive chain_prev LSNs.
+ *
+ * Coalescing: The existing UndoRecordSet mechanism batches records
+ * (flush at 256KB or 1000 records).  This batch becomes one WAL record.
+ * A 1000-row INSERT produces ~1 WAL record containing 1000 UNDO records.
+ */
+typedef struct xl_undo_batch
+{
+	TransactionId xid;			/* Owning transaction */
+	XLogRecPtr	chain_prev;		/* LSN of previous batch for this xact
+								 * (InvalidXLogRecPtr if first batch) */
+	uint32		nrecords;		/* Number of UNDO records in batch */
+	uint32		total_len;		/* Total bytes of serialized UNDO data */
+	Oid			primary_reloid; /* Relation OID (optimization for
+								 * single-relation batches) */
+	UndoPersistenceLevel persistence;	/* Persistence level of this batch */
+	/* Followed by total_len bytes of serialized UndoRecordHeader+payload */
+} xl_undo_batch;
+
+#define SizeOfUndoBatch (offsetof(xl_undo_batch, persistence) + sizeof(UndoPersistenceLevel))
+
+/*
+ * xl_undo_chain_state - UNDO chain state for prepared transactions
+ *
+ * Saved in the two-phase state file during PREPARE TRANSACTION, so the
+ * UNDO chain can be restored during COMMIT/ROLLBACK PREPARED.
+ */
+typedef struct xl_undo_chain_state
+{
+	UndoRecPtr	firstUndoPtr;	/* First UNDO record in transaction chain */
+	UndoRecPtr	currentUndoPtr; /* Most recent UNDO record in chain */
+} xl_undo_chain_state;
+
+/* Function declarations for WAL operations */
+extern void undo_redo(XLogReaderState *record);
+extern void undo_desc(StringInfo buf, XLogReaderState *record);
+extern const char *undo_identify(uint8 info);
+
+/*
+ * UNDO batch reading from WAL for rollback and recovery.
+ *
+ * UndoReadBatchFromWAL reads a single XLOG_UNDO_BATCH record at the
+ * given LSN and returns the header plus a pointer to the payload data.
+ * The caller must pfree the returned data when done.
+ */
+typedef struct UndoBatchData
+{
+	xl_undo_batch header;		/* Batch header */
+	char	   *payload;		/* Serialized UNDO records (palloc'd) */
+	Size		payload_len;	/* Length of payload */
+} UndoBatchData;
+
+extern bool UndoValidateBatchLSN(XLogRecPtr batch_lsn);
+extern UndoBatchData *UndoReadBatchFromWAL(XLogRecPtr batch_lsn);
+extern void UndoFreeBatchData(UndoBatchData *batch);
+
+/*
+ * Recovery UNDO phase support.
+ *
+ * During WAL redo, XLOG_UNDO_BATCH records are tracked so that after
+ * redo completes, incomplete transactions can be identified and their
+ * UNDO chains walked for rollback.
+ */
+extern void UndoRecoveryTrackBatch(TransactionId xid, XLogRecPtr batch_lsn,
+								   XLogRecPtr chain_prev,
+								   UndoPersistenceLevel persistence);
+extern void UndoRecoveryRemoveXid(TransactionId xid);
+extern bool UndoRecoveryNeeded(void);
+extern void PerformUndoRecovery(void);
+extern void FlushDeferredUndoXacts(void);
+
+#endif							/* UNDO_XLOG_H */
diff --git a/src/include/access/undodefs.h b/src/include/access/undodefs.h
new file mode 100644
index 00000000000..b21915bff10
--- /dev/null
+++ b/src/include/access/undodefs.h
@@ -0,0 +1,56 @@
+/*-------------------------------------------------------------------------
+ *
+ * undodefs.h
+ *
+ * Basic definitions for PostgreSQL undo layer. These are separated into
+ * their own header file to avoid including more things than necessary
+ * into widely-used headers like xact.h.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/access/undodefs.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef UNDODEFS_H
+#define UNDODEFS_H
+
+/* The type used to identify an undo log and position within it. */
+typedef uint64 UndoRecPtr;
+
+/* The type used for undo record lengths. */
+typedef uint16 UndoRecordSize;
+
+/* Type for offsets within undo logs */
+typedef uint64 UndoLogOffset;
+
+/* Type for numbering undo logs. */
+typedef int UndoLogNumber;
+
+/* Special value for undo record pointer which indicates that it is invalid. */
+#define	InvalidUndoRecPtr	((UndoRecPtr) 0)
+
+/*
+ * UndoRecPtrIsValid
+ *		True iff undoRecPtr is valid.
+ */
+#define UndoRecPtrIsValid(undoRecPtr) \
+	((bool) ((UndoRecPtr) (undoRecPtr) != InvalidUndoRecPtr))
+
+/* Persistence levels as small integers that can be used as array indexes. */
+typedef enum
+{
+	UNDOPERSISTENCE_PERMANENT = 0,
+	UNDOPERSISTENCE_UNLOGGED = 1,
+	UNDOPERSISTENCE_TEMP = 2
+} UndoPersistenceLevel;
+
+/* Number of supported persistence levels for undo. */
+#define NUndoPersistenceLevels 3
+
+/* Opaque types. */
+struct UndoRecordSet;
+typedef struct UndoRecordSet UndoRecordSet;
+
+#endif
diff --git a/src/include/access/undolog.h b/src/include/access/undolog.h
new file mode 100644
index 00000000000..964ff4ceaf2
--- /dev/null
+++ b/src/include/access/undolog.h
@@ -0,0 +1,202 @@
+/*-------------------------------------------------------------------------
+ *
+ * undolog.h
+ *	  PostgreSQL UNDO log manager -- WAL-integrated version
+ *
+ * With UNDO-in-WAL, UNDO records are stored in the standard WAL stream
+ * as XLOG_UNDO_BATCH records.  The separate base/undo/ segment files
+ * and direct I/O path have been removed.  This header retains:
+ *
+ * - UndoRecPtr encoding macros (still used for addressing)
+ * - Shared memory structures (UndoLogControl, UndoLogSharedData)
+ * - GUC parameter declarations
+ * - Functions for shmem init, discard, and checkpoint
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/access/undolog.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef UNDOLOG_H
+#define UNDOLOG_H
+
+#include "access/transam.h"
+#include "access/undodefs.h"
+#include "access/xlogdefs.h"
+#include "datatype/timestamp.h"
+#include "port/atomics.h"
+#include "port/pg_crc32c.h"
+#include "storage/lwlock.h"
+#include "storage/shmem.h"
+
+/*
+ * UndoRecPtr: 64-bit pointer to UNDO record
+ *
+ * Format:
+ *   Bits 0-39:  Offset within log (40 bits = 1TB per log)
+ *   Bits 40-63: Log number (24 bits = 16M logs)
+ *
+ * The actual UndoRecPtr typedef and InvalidUndoRecPtr are in undodefs.h
+ * to avoid circular include dependencies.
+ */
+
+/* Extract log number and offset from UndoRecPtr */
+#define UndoRecPtrGetLogNo(ptr) ((uint32) (((uint64) (ptr)) >> 40))
+#define UndoRecPtrGetOffset(ptr) (((uint64) (ptr)) & 0xFFFFFFFFFFULL)
+
+/* Construct UndoRecPtr from log number and offset */
+#define MakeUndoRecPtr(logno, offset) \
+	((((uint64) (logno)) << 40) | ((uint64) (offset)))
+
+/*
+ * Legacy define -- no longer used (UNDO records are in WAL, not segment
+ * files).  Retained for any code that still references it at compile time.
+ */
+#define UNDO_LOG_SEGMENT_SIZE (1024 * 1024 * 1024)
+
+/* Maximum number of concurrent UNDO logs */
+#define MAX_UNDO_LOGS 100
+
+/*
+ * UndoLogState: Lifecycle state of an UNDO log slot
+ *
+ * With UNDO-in-WAL, the segment lifecycle is simplified -- these states
+ * are retained for shared memory structure compatibility but the
+ * ACTIVE->SEALED->DISCARDABLE rotation no longer occurs.
+ */
+typedef enum UndoLogState
+{
+	UNDO_LOG_FREE = 0,			/* Slot available */
+	UNDO_LOG_ACTIVE,			/* Accepting writes */
+	UNDO_LOG_SEALED,			/* No more writes */
+	UNDO_LOG_DISCARDABLE		/* All records discarded */
+} UndoLogState;
+
+/*
+ * UndoLogControl: Shared memory control structure for one UNDO log
+ */
+typedef struct UndoLogControl
+{
+	uint32		log_number;		/* Log number */
+	pg_atomic_uint64 insert_ptr;	/* Next insertion point (atomic) */
+	UndoRecPtr	discard_ptr;	/* Can discard older than this */
+	TransactionId oldest_xid;	/* Oldest transaction needing this log */
+	LWLock		lock;			/* Protects metadata (NOT insert_ptr) */
+	bool		in_use;			/* Is this log slot active? */
+	UndoLogState state;			/* Current lifecycle state */
+	pg_atomic_uint64 seal_ptr;	/* insert_ptr frozen at seal time */
+	TimestampTz sealed_time;	/* When this log was sealed */
+} UndoLogControl;
+
+#ifndef PG_HAVE_ATOMIC_U64_SIMULATION
+StaticAssertDecl(offsetof(UndoLogControl, insert_ptr) % 8 == 0,
+				 "UndoLogControl.insert_ptr must be eight-byte aligned");
+StaticAssertDecl(offsetof(UndoLogControl, seal_ptr) % 8 == 0,
+				 "UndoLogControl.seal_ptr must be eight-byte aligned");
+StaticAssertDecl(sizeof(UndoLogControl) % 8 == 0,
+				 "UndoLogControl array elements must be eight-byte aligned");
+#endif
+
+/*
+ * UndoLogSharedData: Shared memory for all UNDO logs
+ *
+ * Note: backend_undo_lsns is a flexible array member; the struct must be
+ * allocated with room for MaxBackends entries.  Use UndoLogShmemSize() to
+ * get the correct allocation size.
+ */
+typedef struct UndoLogSharedData
+{
+	UndoLogControl logs[MAX_UNDO_LOGS];
+	uint32		next_log_number;
+	LWLock		allocation_lock;
+	pg_atomic_uint32 active_log_idx;
+	pg_atomic_uint64 total_allocated;
+	pg_atomic_uint64 total_discarded;
+
+	/*
+	 * UNDO discard horizon: the oldest XLogRecPtr of an XLOG_UNDO_BATCH
+	 * record that is still needed for rollback or index pruning.  WAL
+	 * segments containing data at or after this LSN must be retained. Updated
+	 * by the UNDO discard worker as transactions complete and their UNDO
+	 * records are no longer needed.
+	 */
+	pg_atomic_uint64 undo_discard_horizon;
+
+	/*
+	 * Per-backend first UNDO batch LSN.
+	 *
+	 * Each active backend stores the XLogRecPtr of its first XLOG_UNDO_BATCH
+	 * record here when it writes UNDO data for a transaction.  Cleared at
+	 * commit or abort.  The UNDO discard worker scans this array to find the
+	 * global minimum, which becomes the new undo_discard_horizon, preventing
+	 * WAL recycling past the oldest in-flight UNDO batch.
+	 *
+	 * Indexed by MyProcNumber (0-based, range [0, MaxBackends)).
+	 *
+	 * Must be last field -- UndoLogShmemSize() uses
+	 * offsetof(UndoLogSharedData, backend_undo_lsns).
+	 */
+	pg_atomic_uint64 backend_undo_lsns[FLEXIBLE_ARRAY_MEMBER];
+} UndoLogSharedData;
+
+#ifndef PG_HAVE_ATOMIC_U64_SIMULATION
+StaticAssertDecl(offsetof(UndoLogSharedData, logs) % 8 == 0,
+				 "UndoLogSharedData.logs must be eight-byte aligned");
+StaticAssertDecl(offsetof(UndoLogSharedData, total_allocated) % 8 == 0,
+				 "UndoLogSharedData.total_allocated must be eight-byte aligned");
+StaticAssertDecl(offsetof(UndoLogSharedData, total_discarded) % 8 == 0,
+				 "UndoLogSharedData.total_discarded must be eight-byte aligned");
+StaticAssertDecl(offsetof(UndoLogSharedData, undo_discard_horizon) % 8 == 0,
+				 "UndoLogSharedData.undo_discard_horizon must be eight-byte aligned");
+StaticAssertDecl(offsetof(UndoLogSharedData, backend_undo_lsns) % 8 == 0,
+				 "UndoLogSharedData.backend_undo_lsns must be eight-byte aligned");
+#endif
+
+StaticAssertDecl(sizeof(XLogRecPtr) == sizeof(uint64),
+				 "XLogRecPtr must be 64 bits for UNDO per-backend atomic LSN slots to be correct");
+
+/* Global shared memory pointer (set during startup) */
+extern UndoLogSharedData *UndoLogShared;
+
+/* GUC parameters */
+/*
+ * Note: UNDO records are embedded in WAL (no separate segment files).
+ * UNDO_LOG_SEGMENT_SIZE and MAX_UNDO_LOGS are legacy defines retained
+ * for compile-time compatibility.
+ */
+extern int	undo_worker_naptime;
+extern int	undo_max_wal_retention_size;
+extern int	undo_batch_size_kb;
+extern int	undo_batch_record_limit;
+
+/*
+ * Shared memory initialization
+ */
+extern Size UndoLogShmemSize(void);
+extern void UndoLogShmemRequest(void);
+extern void UndoLogShmemInit(void);
+
+/*
+ * Discard, retention, and checkpoint
+ */
+extern void UndoLogDiscard(UndoRecPtr oldest_needed);
+extern void CheckPointUndoLog(void);
+
+/* WAL retention for UNDO: get/set the discard horizon */
+extern void UndoSetDiscardHorizon(XLogRecPtr horizon);
+
+/* Per-backend UNDO batch LSN registration for WAL retention */
+extern void UndoRegisterBatchLSN(XLogRecPtr batch_lsn);
+extern void UndoClearBatchLSN(void);
+extern XLogRecPtr UndoGetOldestBatchLSN(void);
+
+/*
+ * No-op stubs: UNDO-in-WAL has no segment files to extend, rotate or delete.
+ */
+extern void ExtendUndoLogFile(uint32 log_number, uint64 new_size);
+extern void UndoLogSealAndRotate(uint8 trigger);
+extern void UndoLogDeleteSegmentFile(uint32 log_number);
+
+#endif							/* UNDOLOG_H */
diff --git a/src/include/access/undorecord.h b/src/include/access/undorecord.h
new file mode 100644
index 00000000000..ed5b1e04df7
--- /dev/null
+++ b/src/include/access/undorecord.h
@@ -0,0 +1,218 @@
+/*-------------------------------------------------------------------------
+ *
+ * undorecord.h
+ *	  UNDO record format and insertion API
+ *
+ * This file defines the generic UNDO record format that can be used by
+ * any access method or subsystem.  UNDO records are AM-agnostic: each
+ * record carries an RM ID (urec_rmid) that identifies the resource
+ * manager responsible for interpreting and applying the record.
+ *
+ * Design principles:
+ * - Physical: UNDO stores opaque payload data for direct restore
+ * - Generic: Usable by any AM or subsystem
+ * - Compact: Variable-length format to minimize space
+ * - Chained: Batches form backward chains via xl_undo_batch.chain_prev
+ *   (see access/undo/README; the urec_prev field below is not a reader)
+ * - Batch-oriented: API encourages batching for performance
+ * - AM-agnostic: No AM-specific types in the generic header or API
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/access/undorecord.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef UNDORECORD_H
+#define UNDORECORD_H
+
+#include "access/undodefs.h"
+#include "access/undolog.h"
+#include "access/xlogdefs.h"
+#include "storage/block.h"
+#include "storage/itemptr.h"
+
+/*
+ * UNDO record info flags
+ *
+ * These flags provide additional metadata about the UNDO record.
+ * The lower byte is reserved for generic flags; the upper byte is
+ * available for RM-specific use.
+ */
+#define UNDO_INFO_HAS_PAYLOAD	0x01	/* Record contains opaque payload */
+#define UNDO_INFO_XID_VALID		0x08	/* urec_xid is valid */
+
+/*
+ * UndoRecordHeader - Fixed header for all UNDO records
+ *
+ * Every UNDO record starts with this header, followed by an optional
+ * opaque payload whose interpretation is RM-specific.
+ *
+ * The urec_rmid field identifies which resource manager owns this record.
+ * The urec_info field carries RM-specific subtype/flags (e.g., an in-place
+ * update AM uses it to distinguish INSERT vs DELETE vs UPDATE).
+ *
+ * Size: 40 bytes (optimized for alignment)
+ */
+typedef struct UndoRecordHeader
+{
+	uint8		urec_rmid;		/* UNDO RM ID */
+	uint8		urec_flags;		/* Generic flags (UNDO_INFO_*) */
+	uint16		urec_info;		/* RM-specific subtype and flags */
+	uint32		urec_len;		/* Total length including header + payload */
+
+	TransactionId urec_xid;		/* Transaction that created this */
+
+	/*
+	 * Intended as a per-record back-link, but NOT read by the apply path: the
+	 * real chain is between BATCHES, via xl_undo_batch.chain_prev, and order
+	 * within a batch is positional (undoapply.c collects record offsets, then
+	 * walks them in reverse).  Written by the record builders for format
+	 * stability; do not add a reader without first reading the chain model in
+	 * access/undo/README.
+	 */
+	UndoRecPtr	urec_prev;		/* Previous UNDO for same xact (chain) */
+
+	Oid			urec_reloid;	/* Relation OID (InvalidOid if N/A) */
+
+	/*
+	 * Payload length: size of the RM-specific opaque data that follows the
+	 * header.  Interpretation is entirely RM-specific.
+	 */
+	uint32		urec_payload_len;
+
+	/* Followed by variable-length RM-specific payload */
+} UndoRecordHeader;
+
+#define SizeOfUndoRecordHeader	(offsetof(UndoRecordHeader, urec_payload_len) + sizeof(uint32))
+
+/*
+ * Access macros for payload data following the header
+ *
+ * The payload immediately follows the fixed header in the serialized
+ * record.  Its interpretation is entirely RM-specific.
+ */
+#define UndoRecGetPayload(header) \
+	((char *)(header) + SizeOfUndoRecordHeader)
+
+/*
+ * UndoRecordSetChunkHeader - Header at the start of each chunk.
+ *
+ * When an UndoRecordSet spans multiple undo logs (rare, since each log
+ * is up to 1TB), the data is organized into chunks, each with a header
+ * that records the chunk size and a back-pointer to the previous chunk.
+ * This design follows the EDB undo-record-set branch architecture.
+ */
+typedef struct UndoRecordSetChunkHeader
+{
+	UndoLogOffset size;
+	UndoRecPtr	previous_chunk;
+	uint8		type;
+} UndoRecordSetChunkHeader;
+
+#define SizeOfUndoRecordSetChunkHeader \
+	(offsetof(UndoRecordSetChunkHeader, type) + sizeof(uint8))
+
+/*
+ * Possible undo record set types.
+ */
+typedef enum UndoRecordSetType
+{
+	URST_INVALID = 0,			/* Placeholder when there's no record set. */
+	URST_TRANSACTION = 'T',		/* Normal xact undo; apply on abort. */
+	URST_MULTI = 'M',			/* Informational undo. */
+	URST_EPHEMERAL = 'E'		/* Ephemeral data for testing purposes. */
+} UndoRecordSetType;
+
+/*
+ * UndoRecordSet - Batch container for UNDO records
+ *
+ * This structure accumulates multiple UNDO records before writing them
+ * to the UNDO log in a single operation. This improves performance by
+ * reducing the number of I/O operations and lock acquisitions.
+ *
+ * The records are serialized into a contiguous buffer that grows
+ * dynamically. The design follows the EDB undo-record-set branch
+ * architecture with chunk-based organization and per-persistence-level
+ * separation.
+ */
+typedef struct UndoRecordSet
+{
+	TransactionId xid;			/* Transaction ID for all records */
+	UndoRecPtr	prev_undo_ptr;	/* Previous UNDO pointer in chain (legacy) */
+	UndoPersistenceLevel persistence;	/* Persistence level of this set */
+	UndoRecordSetType type;		/* Record set type */
+
+	int			nrecords;		/* Number of records in set */
+
+	/*
+	 * Dynamic buffer for serialized records. Grows as needed; no fixed
+	 * maximum. This replaces the old fixed-capacity max_records array.
+	 */
+	char	   *buffer;			/* Serialized record buffer */
+	Size		buffer_size;	/* Current buffer size */
+	Size		buffer_capacity;	/* Allocated buffer capacity */
+
+	/*
+	 * WAL-based UNDO chain tracking.  When UNDO records are written to WAL
+	 * via XLOG_UNDO_BATCH, last_batch_lsn tracks the LSN of the most recent
+	 * batch for this record set.  This is used as the chain_prev link when
+	 * the next batch is written.
+	 */
+	XLogRecPtr	last_batch_lsn; /* LSN of last XLOG_UNDO_BATCH record */
+
+	MemoryContext mctx;			/* Memory context for allocations */
+} UndoRecordSet;
+
+/*
+ * Public API for UNDO record management
+ */
+
+/* Create/destroy/reset UNDO record sets */
+extern UndoRecordSet *UndoRecordSetCreate(TransactionId xid,
+										  UndoRecPtr prev_undo_ptr);
+extern void UndoRecordSetFree(UndoRecordSet *uset);
+extern void UndoRecordSetReset(UndoRecordSet *uset);
+extern void UndoRecordSetResetCache(void);
+
+/* Add records to a set - generic payload API */
+extern void UndoRecordAddPayload(UndoRecordSet *uset,
+								 uint8 rmid,
+								 uint16 info,
+								 Oid reloid,
+								 const char *payload,
+								 Size payload_len);
+
+/* Add records with scatter-gather payload (avoids intermediate buffer) */
+extern void UndoRecordAddPayloadParts(UndoRecordSet *uset,
+									  uint8 rmid,
+									  uint16 info,
+									  Oid reloid,
+									  const char *part1,
+									  Size part1_len,
+									  const char *part2,
+									  Size part2_len);
+
+/* Insert the accumulated records into UNDO log */
+extern UndoRecPtr UndoRecordSetInsert(UndoRecordSet *uset);
+
+/* WAL batch management for deferred UNDO allocation logging */
+extern void UndoWalBatchFlush(void);
+extern void UndoWalBatchReset(void);
+
+/* Utility functions for record manipulation */
+extern Size UndoRecordGetPayloadSize(Size payload_len);
+
+/* Statistics and debugging */
+extern Size UndoRecordSetGetSize(UndoRecordSet *uset);
+
+/* Records currently accumulated in the set (0 after a flush/reset). */
+extern int	UndoRecordSetGetNumRecords(UndoRecordSet *uset);
+
+/* UNDO application during rollback */
+extern bool ApplyUndoChainFromWAL(XLogRecPtr last_batch_lsn);
+extern bool ApplyUndoChainFromWALBounded(XLogRecPtr last_batch_lsn,
+										 XLogRecPtr stop_at_lsn);
+
+#endif							/* UNDORECORD_H */
diff --git a/src/include/access/undormgr.h b/src/include/access/undormgr.h
new file mode 100644
index 00000000000..52eb1bb78b8
--- /dev/null
+++ b/src/include/access/undormgr.h
@@ -0,0 +1,115 @@
+/*-------------------------------------------------------------------------
+ *
+ * undormgr.h
+ *	  UNDO resource manager dispatch definitions
+ *
+ * This module provides a dispatch mechanism for UNDO record application,
+ * analogous to the WAL resource manager (rmgr) system.  Each access method
+ * or subsystem that writes UNDO records registers an UndoRmgrData entry
+ * with callbacks for applying UNDO records and describing them for debugging.
+ *
+ * The generic UNDO infrastructure (undoapply.c) dispatches to the appropriate
+ * RM callback based on the urec_rmid field in the UNDO record header.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/access/undormgr.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef UNDORMGR_H
+#define UNDORMGR_H
+
+#include "postgres_ext.h"
+#include "access/undodefs.h"
+#include "access/xlogdefs.h"
+#include "lib/stringinfo.h"
+
+/*
+ * UNDO Resource Manager IDs
+ *
+ * Each AM or subsystem that writes UNDO records is assigned a unique ID.
+ * This ID is stored in the urec_rmid field of every UNDO record header,
+ * enabling the generic UNDO infrastructure to dispatch to the correct
+ * apply callback during rollback.
+ *
+ * The core defines only UNDO_RMID_INVALID.  Each consumer defines its own
+ * UNDO_RMID_* constant in its own header (the same commit that registers it
+ * in access/undormgrlist.h), so this core header names no specific AM or
+ * subsystem.  The ID number space is a shared resource: IDs must be unique
+ * and stable across releases (they are stamped into WAL-durable UNDO
+ * records), so a new consumer takes the next free value in
+ * [1, MAX_UNDO_RMGRS) and never reuses one.
+ */
+#define UNDO_RMID_INVALID	0
+
+#define MAX_UNDO_RMGRS		256
+
+/*
+ * UndoApplyResult - Return value from undo apply callbacks
+ */
+typedef enum UndoApplyResult
+{
+	UNDO_APPLY_SUCCESS = 0,		/* Successfully applied */
+	UNDO_APPLY_SKIPPED,			/* Skipped (e.g., relation dropped) */
+	UNDO_APPLY_ERROR			/* Error during application */
+} UndoApplyResult;
+
+/*
+ * UndoRmgrData - Resource manager registration entry
+ *
+ * Each UNDO RM provides:
+ *   rm_name:  Human-readable name for debugging/logging
+ *   rm_undo:  Apply one UNDO record (rollback callback)
+ *   rm_desc:  Describe an UNDO record for debugging output
+ *
+ * The rm_undo callback receives:
+ *   - rmid:        The RM ID (for verification)
+ *   - info:        RM-specific subtype/flags from urec_info
+ *   - xid:         Transaction being rolled back
+ *   - reloid:      Target relation OID (may be InvalidOid for non-relation ops)
+ *   - payload:     RM-specific opaque payload data
+ *   - payload_len: Length of payload
+ *   - urec_ptr:    Position of this record in UNDO log (for CLR generation)
+ *
+ * The callback is responsible for:
+ *   - Opening the relation (if applicable)
+ *   - Locking and modifying the target page
+ *   - Generating a CLR WAL record
+ *   - Releasing all locks and buffers
+ */
+typedef UndoApplyResult (*UndoRmgrApplyFunc) (uint8 rmid,
+											  uint16 info,
+											  TransactionId xid,
+											  Oid reloid,
+											  const char *payload,
+											  Size payload_len,
+											  UndoRecPtr urec_ptr);
+
+typedef void (*UndoRmgrDescFunc) (StringInfo buf,
+								  uint8 rmid,
+								  uint16 info,
+								  const char *payload,
+								  Size payload_len);
+
+typedef struct UndoRmgrData
+{
+	const char *rm_name;		/* Human-readable name */
+	UndoRmgrApplyFunc rm_undo;	/* Apply callback */
+	UndoRmgrDescFunc rm_desc;	/* Describe callback */
+} UndoRmgrData;
+
+/* Global registration table */
+extern const UndoRmgrData *UndoRmgrs[MAX_UNDO_RMGRS];
+
+/* Registration function (called during _PG_init or startup) */
+extern void RegisterUndoRmgr(uint8 rmid, const UndoRmgrData *rmgr);
+
+/* Lookup function */
+extern const UndoRmgrData *GetUndoRmgr(uint8 rmid);
+
+/* Initialization */
+extern void InitUndoRmgrs(void);
+
+#endif							/* UNDORMGR_H */
diff --git a/src/include/access/undormgrlist.h b/src/include/access/undormgrlist.h
new file mode 100644
index 00000000000..4ab60e21418
--- /dev/null
+++ b/src/include/access/undormgrlist.h
@@ -0,0 +1,33 @@
+/*-------------------------------------------------------------------------
+ *
+ * undormgrlist.h
+ *
+ * List of *UndoRmgrInit() initialization calls for built-in UNDO resource
+ * managers.  Kept in its own source file, mirroring the pattern used by
+ * storage/subsystemlist.h for ShmemCallbacks registration, so that the
+ * generic UNDO core (undo.c) can register every built-in resource manager
+ * without knowing any of their names at compile time: undo.c only expands
+ * this list through a macro it controls, and the extern declarations are
+ * generated the same way (see access/undormgrs.h).
+ *
+ * Each access method or subsystem that writes UNDO records adds itself here
+ * when it is compiled in.  This file is edited by whichever commit
+ * introduces a new UNDO-writing consumer; undo.c itself is never touched to
+ * add a new consumer.
+ *
+ * UNDO_RMGR_INIT is defined by the caller depending on how the list is used.
+ *
+ * No built-in resource managers exist yet at this point in the UNDO
+ * subsystem's history; this file starts empty and grows as each consumer
+ * (an index AM, a table AM, or a subsystem that writes UNDO records) is
+ * compiled in and adds itself here.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/access/undormgrlist.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+/* there is deliberately not an #ifndef UNDORMGRLIST_H here */
diff --git a/src/include/access/undormgrs.h b/src/include/access/undormgrs.h
new file mode 100644
index 00000000000..2a399f78dfa
--- /dev/null
+++ b/src/include/access/undormgrs.h
@@ -0,0 +1,28 @@
+/*-------------------------------------------------------------------------
+ *
+ * undormgrs.h
+ *	  Provide extern declarations for all the built-in UNDO resource
+ *	  manager *Init() functions.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/access/undormgrs.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef UNDORMGRS_H
+#define UNDORMGRS_H
+
+/*
+ * Extern declarations of all the built-in *UndoRmgrInit() functions.
+ *
+ * The actual list is in undormgrlist.h, so that the same list can be used
+ * for other purposes (e.g. undo.c's RegisterUndoRmgrs()).
+ */
+#define UNDO_RMGR_INIT(initfunc) \
+	extern void initfunc(void);
+#include "access/undormgrlist.h"
+#undef UNDO_RMGR_INIT
+
+#endif							/* UNDORMGRS_H */
diff --git a/src/include/access/undostats.h b/src/include/access/undostats.h
new file mode 100644
index 00000000000..65f5ca886da
--- /dev/null
+++ b/src/include/access/undostats.h
@@ -0,0 +1,62 @@
+/*-------------------------------------------------------------------------
+ *
+ * undostats.h
+ *	  UNDO log statistics collection and reporting
+ *
+ * Provides monitoring and observability for the UNDO subsystem,
+ * including per-log statistics and buffer cache statistics.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/access/undostats.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef UNDOSTATS_H
+#define UNDOSTATS_H
+
+#include "access/undolog.h"
+#include "fmgr.h"
+
+/*
+ * UndoLogStat - Per-log statistics snapshot
+ *
+ * Point-in-time snapshot of a single UNDO log's state.
+ */
+typedef struct UndoLogStat
+{
+	uint32		log_number;		/* UNDO log number */
+	UndoRecPtr	insert_ptr;		/* Current insert pointer */
+	UndoRecPtr	discard_ptr;	/* Current discard pointer */
+	TransactionId oldest_xid;	/* Oldest transaction in this log */
+	uint64		size_bytes;		/* Active size (insert - discard) */
+	UndoLogState state;			/* Current lifecycle state */
+} UndoLogStat;
+
+/*
+ * UndoBufferStat - UNDO buffer cache statistics
+ *
+ * Aggregate statistics from the UNDO buffer cache.
+ */
+typedef struct UndoBufferStat
+{
+	int			num_buffers;	/* Number of buffer slots */
+	uint64		cache_hits;		/* Total cache hits */
+	uint64		cache_misses;	/* Total cache misses */
+	uint64		cache_evictions;	/* Total evictions */
+	uint64		cache_writes;	/* Total dirty buffer writes */
+} UndoBufferStat;
+
+/* Functions for collecting statistics */
+extern int	GetUndoLogStats(UndoLogStat *stats, int max_stats);
+extern void GetUndoBufferStats(UndoBufferStat *stats);
+
+/*
+ * The SQL-callable functions in undostats.c are built-in (pg_proc.dat), so
+ * utils/fmgrprotos.h declares them.  Do not declare them here or mark them
+ * with PG_FUNCTION_INFO_V1: on Windows a second declaration with different
+ * linkage fails to compile.
+ */
+
+#endif							/* UNDOSTATS_H */
diff --git a/src/include/access/undoworker.h b/src/include/access/undoworker.h
new file mode 100644
index 00000000000..c3be4c9d6e0
--- /dev/null
+++ b/src/include/access/undoworker.h
@@ -0,0 +1,70 @@
+/*-------------------------------------------------------------------------
+ *
+ * undoworker.h
+ *	  UNDO worker background process
+ *
+ * The UNDO worker is a background process that periodically scans active
+ * transactions and discards UNDO records that are no longer needed.
+ * This reclaims space in UNDO logs.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/access/undoworker.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef UNDOWORKER_H
+#define UNDOWORKER_H
+
+#include "access/transam.h"
+#include "access/undolog.h"
+#include "fmgr.h"
+#include "storage/lwlock.h"
+#include "storage/procnumber.h"
+#include "storage/shmem.h"
+
+/*
+ * UndoWorkerShmemData - Shared memory for UNDO worker coordination
+ *
+ * This structure tracks the state of UNDO discard operations and
+ * coordinates between the worker and other backends.
+ */
+typedef struct UndoWorkerShmemData
+{
+	LWLock		lock;			/* Protects this structure */
+
+	pg_atomic_uint64 last_discard_time; /* Last discard operation time */
+	TransactionId oldest_xid_checked;	/* Last XID used for discard */
+	UndoRecPtr	last_discard_ptr;	/* Last UNDO pointer discarded */
+
+	int			naptime_ms;		/* Current sleep time in ms */
+	bool		shutdown_requested; /* Worker should exit */
+
+	/* Rotation coordination fields */
+	ProcNumber	worker_proc;	/* For latch-based wakeup */
+	pg_atomic_uint32 sealed_log_count;	/* Number of SEALED logs pending */
+} UndoWorkerShmemData;
+
+#ifndef PG_HAVE_ATOMIC_U64_SIMULATION
+StaticAssertDecl(offsetof(UndoWorkerShmemData, last_discard_time) % 8 == 0,
+				 "UndoWorkerShmemData.last_discard_time must be eight-byte aligned");
+#endif
+
+/* GUC parameters */
+extern int	undo_worker_naptime;
+
+/* Shared memory functions */
+extern Size UndoWorkerShmemSize(void);
+extern void UndoWorkerShmemRequest(void);
+extern void UndoWorkerShmemInit(void);
+
+/* Worker lifecycle functions */
+pg_noreturn extern void UndoWorkerMain(Datum main_arg);
+extern void UndoWorkerRegister(void);
+
+/* Utility functions */
+extern TransactionId UndoWorkerGetOldestXid(void);
+extern void WakeUndoDiscardWorker(void);
+
+#endif							/* UNDOWORKER_H */
diff --git a/src/include/access/xact.h b/src/include/access/xact.h
index a8cbdf247c8..8341d2015d3 100644
--- a/src/include/access/xact.h
+++ b/src/include/access/xact.h
@@ -138,6 +138,24 @@ typedef enum
 
 typedef void (*XactCallback) (XactEvent event, void *arg);
 
+/*
+ * PendingPhysOps{Do,PostPrepare,AtSubCommit,AtSubAbort}_hook: mirror the
+ * matching core smgr{DoPendingDeletes,PostPrepare,AtSubCommit,AtSubAbort}
+ * calls in xact.c.  A subsystem that manages pending structural filesystem
+ * operations needs the SAME commit/abort-sequencing position
+ * as smgr's own pending-deletes housekeeping -- e.g.
+ * PendingPhysOpsDo_hook(true) is placed immediately after
+ * smgrDoPendingDeletes(true), which core's own comment there documents as
+ * "best done after releasing relcache and buffer pins... this ordering is
+ * definitely critical during abort" -- a physical-file-cleanup ordering
+ * requirement no ordinary XactCallback/SubXactCallback event exposes.  NULL
+ * is a valid no-op for a build with no such subsystem compiled in.
+ */
+extern void (*PendingPhysOpsDo_hook) (bool isCommit);
+extern void (*PendingPhysOpsPostPrepare_hook) (void);
+extern void (*PendingPhysOpsAtSubCommit_hook) (void);
+extern void (*PendingPhysOpsAtSubAbort_hook) (void);
+
 typedef enum
 {
 	SUBXACT_EVENT_START_SUB,
@@ -368,8 +386,28 @@ typedef struct xl_xact_prepare
 	uint16		gidlen;			/* length of the GID - GID follows the header */
 	XLogRecPtr	origin_lsn;		/* lsn of this record at origin node */
 	TimestampTz origin_timestamp;	/* time of prepare at origin node */
+
+	/*
+	 * UNDO chain head LSN per persistence level (3 == NUndoPersistenceLevels
+	 * from undodefs.h; hardcoded here to keep xact.h free of UNDO headers).
+	 * If NUndoPersistenceLevels changes, this array must be updated and both
+	 * XLOG_PAGE_MAGIC (xlog_internal.h) and TWOPHASE_MAGIC (twophase.c) must
+	 * be bumped.  See StaticAssertDecl in xactundo.c for compile-time guard.
+	 */
+	XLogRecPtr	last_batch_lsn[3];
 } xl_xact_prepare;
 
+#define SizeOfXactPrepare sizeof(xl_xact_prepare)
+
+/*
+ * Verify xl_xact_prepare contains the UNDO last_batch_lsn field.  This struct
+ * is written into WAL as part of XLOG_XACT_PREPARE records, and into 2PC
+ * state files via TwoPhaseFileHeader.  Any layout change requires bumping both
+ * XLOG_PAGE_MAGIC (xlog_internal.h) and TWOPHASE_MAGIC (twophase.c).
+ */
+StaticAssertDecl(offsetof(xl_xact_prepare, last_batch_lsn) > 0,
+				 "xl_xact_prepare must contain last_batch_lsn for UNDO WAL compat");
+
 /*
  * Commit/Abort records in the above form are a bit verbose to parse, so
  * there's a deconstructed versions generated by ParseCommit/AbortRecord() for
@@ -439,6 +477,8 @@ typedef struct xl_xact_parsed_abort
  */
 extern bool IsTransactionState(void);
 extern bool IsAbortedTransactionBlockState(void);
+extern int	EnterInlineUndoApplyState(void);
+extern void LeaveInlineUndoApplyState(int saved);
 extern TransactionId GetTopTransactionId(void);
 extern TransactionId GetTopTransactionIdIfAny(void);
 extern TransactionId GetCurrentTransactionId(void);
@@ -535,4 +575,8 @@ extern void EnterParallelMode(void);
 extern void ExitParallelMode(void);
 extern bool IsInParallelMode(void);
 
+/* UNDO chain management */
+extern void SetCurrentTransactionUndoRecPtr(uint64 undo_ptr);
+extern uint64 GetCurrentTransactionUndoRecPtr(void);
+
 #endif							/* XACT_H */
diff --git a/src/include/access/xactundo.h b/src/include/access/xactundo.h
new file mode 100644
index 00000000000..09be0110cec
--- /dev/null
+++ b/src/include/access/xactundo.h
@@ -0,0 +1,119 @@
+/*-------------------------------------------------------------------------
+ *
+ * xactundo.h
+ *	  Transaction-level undo management
+ *
+ * This module manages per-transaction undo record sets. It maintains
+ * up to NUndoPersistenceLevels (3) record sets per transaction -- one
+ * for each persistence level (permanent, unlogged, temporary). This
+ * design follows the EDB undo-record-set branch architecture where
+ * undo records for different persistence levels are kept separate.
+ *
+ * Code that wants to write transactional undo should interface with
+ * these functions rather than manipulating UndoRecordSet directly.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/access/xactundo.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef XACTUNDO_H
+#define XACTUNDO_H
+
+#include "access/undodefs.h"
+#include "access/undorecord.h"
+#include "access/xlogdefs.h"
+
+/*
+ * XactUndoContext - Context for a single undo insertion within a transaction.
+ *
+ * Created by PrepareXactUndoData(), consumed by InsertXactUndoData()
+ * and cleaned up by CleanupXactUndoInsertion(). The plevel tracks which
+ * persistence-level record set this insertion belongs to.
+ */
+typedef struct XactUndoContext
+{
+	UndoPersistenceLevel plevel;
+	UndoRecordSet *uset;		/* borrowed reference, do not free */
+} XactUndoContext;
+
+/* Shared memory initialization */
+extern Size XactUndoShmemSize(void);
+extern void XactUndoShmemInit(void);
+
+/* Per-backend initialization */
+extern void InitializeXactUndo(void);
+
+/*
+ * Undo insertion API for any AM or subsystem.
+ *
+ * PrepareXactUndoData: Find or create the appropriate per-persistence-level
+ *   UndoRecordSet for the current transaction and prepare it for a new
+ *   record. Returns the UndoRecPtr where the record will be written.
+ *
+ *   Parameters are AM-agnostic: the caller provides an RM ID, RM-specific
+ *   info flags, a relation OID, and an opaque payload.
+ *
+ * InsertXactUndoData: Actually write the record data into the undo log.
+ *
+ * CleanupXactUndoInsertion: Release any resources held by the context.
+ */
+extern UndoRecPtr PrepareXactUndoData(XactUndoContext *ctx,
+									  char persistence,
+									  uint8 rmid,
+									  uint16 info,
+									  Oid reloid,
+									  const char *payload,
+									  Size payload_len);
+extern void InsertXactUndoData(XactUndoContext *ctx);
+extern void CleanupXactUndoInsertion(XactUndoContext *ctx);
+
+/*
+ * DeferXactUndoData: accumulate this record instead of writing it now.
+ *
+ * The alternative to InsertXactUndoData() for callers that emit many small
+ * records per statement (the index AMs: one per index entry).  The record is
+ * already in ctx->uset's buffer after PrepareXactUndoData[Parts]; deferring
+ * simply declines to flush that buffer, so the next record lands in the same
+ * batch and all of them share one XLOG_UNDO_BATCH record instead of getting one
+ * each.  This is what the UndoRecordSet was built for -- see the batching note
+ * at the top of undoinsert.c.
+ *
+ * Deferral is only safe because every path that can consume the chain flushes
+ * first: XactUndoFlushPending() is called at the top of AtAbort_XactUndo(),
+ * AtSubAbort_XactUndo(), AtCommit_XactUndo() and AtPrepare_XactUndo(), and on
+ * crossing the size/record thresholds below.  A deferred record is therefore
+ * never the reason a rollback finds nothing to apply.
+ *
+ * Returns true if the record is still pending (nothing written), false if the
+ * threshold forced an immediate flush.  Callers may ignore the result.
+ */
+extern bool DeferXactUndoData(XactUndoContext *ctx);
+
+/*
+ * XactUndoFlushPending: write any deferred records as one batch.
+ *
+ * Safe to call when nothing is pending (no-op).  Must NOT be called from a
+ * critical section: it emits a WAL record.
+ */
+extern void XactUndoFlushPending(void);
+
+/* True if any deferred record is waiting to be written. */
+extern bool XactUndoHasPendingData(void);
+
+/* Transaction lifecycle hooks */
+extern void AtCommit_XactUndo(void);
+extern void AtAbort_XactUndo(void);
+extern void AtPrepare_XactUndo(void);
+extern void AtSubCommit_XactUndo(int level);
+extern void AtSubAbort_XactUndo(int level);
+extern void AtProcExit_XactUndo(void);
+
+extern bool XactUndoHasUnrecoverableUndo(void);
+
+/* Undo chain traversal for rollback */
+extern XLogRecPtr GetCurrentXactLastBatchLSN(UndoPersistenceLevel plevel);
+
+#endif							/* XACTUNDO_H */
diff --git a/src/include/access/xlog_internal.h b/src/include/access/xlog_internal.h
index bf609c3c703..949bfd496c8 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 0xD122	/* can be used as WAL version indicator */
+#define XLOG_PAGE_MAGIC 0xD12E	/* can be used as WAL version indicator */
 
 typedef struct XLogPageHeaderData
 {
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 9b78fcad414..24e17d4a810 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12783,4 +12783,26 @@
   proname => 'hashoid8extended', prorettype => 'int8',
   proargtypes => 'oid8 int8', prosrc => 'hashoid8extended' },
 
+# UNDO subsystem statistics and maintenance
+{ oid => '9410', descr => 'statistics: per-log UNDO storage and discard progress',
+  proname => 'pg_stat_get_undo_logs', prorows => '128', proretset => 't',
+  provolatile => 'v', proparallel => 'r', prorettype => 'record',
+  proargtypes => '',
+  proallargtypes => '{int4,int8,int8,int8,xid,text}',
+  proargmodes => '{o,o,o,o,o,o}',
+  proargnames => '{log_number,insert_offset,discard_offset,size_bytes,oldest_xid,state}',
+  prosrc => 'pg_stat_get_undo_logs' },
+{ oid => '9411', descr => 'statistics: UNDO buffer cache hit/miss/eviction',
+  proname => 'pg_stat_get_undo_buffers', proisstrict => 'f',
+  provolatile => 'v', proparallel => 'r', prorettype => 'record',
+  proargtypes => '',
+  proallargtypes => '{int4,int8,int8,int8,int8,float4}',
+  proargmodes => '{o,o,o,o,o,o}',
+  proargnames => '{num_buffers,cache_hits,cache_misses,cache_evictions,cache_writes,hit_ratio}',
+  prosrc => 'pg_stat_get_undo_buffers' },
+{ oid => '9412', descr => 'force discard of reclaimable UNDO records',
+  proname => 'pg_undo_force_discard', provolatile => 'v', proparallel => 'r',
+  prorettype => 'int4', proargtypes => 'bool',
+  prosrc => 'pg_undo_force_discard' },
+
 ]
diff --git a/src/include/storage/bufmgr.h b/src/include/storage/bufmgr.h
index 6837b35fc6d..1b96907ef21 100644
--- a/src/include/storage/bufmgr.h
+++ b/src/include/storage/bufmgr.h
@@ -321,6 +321,7 @@ extern bool BufferBeginSetHintBits(Buffer buffer);
 extern void BufferFinishSetHintBits(Buffer buffer, bool mark_dirty, bool buffer_std);
 
 extern void UnlockBuffers(void);
+extern void BufferLockReleaseAll(void);
 extern void UnlockBuffer(Buffer buffer);
 extern void LockBufferInternal(Buffer buffer, BufferLockMode mode);
 
diff --git a/src/include/storage/lwlocklist.h b/src/include/storage/lwlocklist.h
index 8d858be9927..16d501c944b 100644
--- a/src/include/storage/lwlocklist.h
+++ b/src/include/storage/lwlocklist.h
@@ -141,3 +141,4 @@ PG_LWLOCKTRANCHE(XACT_SLRU, XactSLRU)
 PG_LWLOCKTRANCHE(PARALLEL_VACUUM_DSA, ParallelVacuumDSA)
 PG_LWLOCKTRANCHE(AIO_URING_COMPLETION, AioUringCompletion)
 PG_LWLOCKTRANCHE(SHMEM_INDEX, ShmemIndex)
+PG_LWLOCKTRANCHE(UNDO_LOG, UndoLog)
diff --git a/src/include/storage/subsystemlist.h b/src/include/storage/subsystemlist.h
index 9ad619080be..9b995f9bad7 100644
--- a/src/include/storage/subsystemlist.h
+++ b/src/include/storage/subsystemlist.h
@@ -88,3 +88,6 @@ PG_SHMEM_SUBSYSTEM(DataChecksumsShmemCallbacks)
 
 /* AIO subsystem. This delegates to the method-specific callbacks */
 PG_SHMEM_SUBSYSTEM(AioShmemCallbacks)
+
+/* UNDO subsystem */
+PG_SHMEM_SUBSYSTEM(UndoShmemCallbacks)
diff --git a/src/tools/pginclude/headerscheck b/src/tools/pginclude/headerscheck
index 785d6f867ad..29c63abfb2b 100755
--- a/src/tools/pginclude/headerscheck
+++ b/src/tools/pginclude/headerscheck
@@ -129,6 +129,7 @@ do
 	# These files are not meant to be included standalone, because
 	# they contain lists that might have multiple use-cases.
 	test "$f" = src/include/access/rmgrlist.h && continue
+	test "$f" = src/include/access/undormgrlist.h && continue
 	test "$f" = src/include/parser/kwlist.h && continue
 	test "$f" = src/include/postmaster/proctypelist.h && continue
 	test "$f" = src/include/regex/regerrs.h && continue
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 553162c0810..336028f7d8d 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -616,6 +616,7 @@ DBState
 DCHCacheEntry
 DEADLOCK_INFO
 DECountItem
+DeferredUndoXact
 DH
 DIR
 DNSServiceErrorType
@@ -3305,6 +3306,27 @@ UINT
 ULARGE_INTEGER
 ULONG
 ULONG_PTR
+UndoApplyResult
+UndoBatchData
+UndoBufferStat
+UndoLogControl
+UndoLogNumber
+UndoLogOffset
+UndoLogSharedData
+UndoLogStat
+UndoLogState
+UndoPersistenceLevel
+UndoRecordHeader
+UndoRecordSet
+UndoRecordSetChunkHeader
+UndoRecordSetType
+UndoRecordSize
+UndoRecoveryEntry
+UndoRecPtr
+UndoRmgrApplyFunc
+UndoRmgrData
+UndoRmgrDescFunc
+UndoWorkerShmemData
 UV
 UVersionInfo
 UnicodeNormalizationForm
@@ -3485,6 +3507,17 @@ X509_NAME_ENTRY
 X509_STORE
 X509_STORE_CTX
 X86FeatureId
+XactUndoContext
+XactUndoData
+XactUndoSubTransactionState
+xl_undo_allocate
+xl_undo_apply
+xl_undo_batch
+xl_undo_chain_state
+xl_undo_discard
+xl_undo_extend
+xl_undo_page_write
+xl_undo_rotate
 XLTW_Oper
 XLogCtlData
 XLogCtlInsert
-- 
2.54.0

