diff --git a/.github/workflows/fork-ocr-model-check.yml b/.github/workflows/fork-ocr-model-check.yml
new file mode 100644
index 0000000000000..b85a990c66fd7
--- /dev/null
+++ b/.github/workflows/fork-ocr-model-check.yml
@@ -0,0 +1,18 @@
+# Weekly check for a newer Claude Opus on Bedrock; opens/updates one issue here
+# when OCR_BEDROCK_MODEL is behind. Notify-only (GITHUB_TOKEN cannot write
+# Actions variables). Logic lives in gburd/ci-workflows.
+
+name: OCR model self-check
+
+on:
+ schedule:
+ - cron: '0 12 * * 1' # Mondays 12:00 UTC
+ workflow_dispatch:
+
+jobs:
+ check:
+ uses: gburd/ci-workflows/.github/workflows/ocr-model-check.yml@v1
+ with:
+ aws_role_arn: ${{ vars.AWS_ROLE_ARN }}
+ aws_region: ${{ vars.AWS_REGION }}
+ bedrock_model: ${{ vars.OCR_BEDROCK_MODEL }}
diff --git a/.github/workflows/fork-ocr-review.yml b/.github/workflows/fork-ocr-review.yml
new file mode 100644
index 0000000000000..ae938754339b4
--- /dev/null
+++ b/.github/workflows/fork-ocr-review.yml
@@ -0,0 +1,43 @@
+# AI PR review (Claude Opus on Bedrock). Logic and review rules live in
+# gburd/ci-workflows so this fork's master carries a stub instead of ~1000
+# lines that have to be rebased onto upstream forever.
+#
+# Needs repo variables AWS_ROLE_ARN, AWS_REGION, OCR_BEDROCK_MODEL. Auth is
+# GitHub OIDC; no static AWS keys. See the ci-workflows README.
+
+name: OCR AI Review
+
+on:
+ pull_request:
+ # Note: no draft filter — drafts are reviewed too.
+ types: [opened, synchronize, reopened, ready_for_review]
+ issue_comment:
+ types: [created]
+ workflow_dispatch:
+ inputs:
+ pr_number:
+ description: 'PR number to review'
+ required: true
+ type: number
+
+# One review per PR; cancel superseded runs to save Bedrock spend.
+concurrency:
+ group: ocr-review-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
+ cancel-in-progress: true
+
+jobs:
+ review:
+ # Comment events only when the comment is on a PR and starts with the
+ # trigger keyword; PR events and manual dispatch always.
+ if: |
+ github.event_name == 'pull_request' ||
+ github.event_name == 'workflow_dispatch' ||
+ (github.event_name == 'issue_comment' && github.event.issue.pull_request &&
+ (startsWith(github.event.comment.body, '/open-code-review') ||
+ startsWith(github.event.comment.body, '@open-code-review') ||
+ startsWith(github.event.comment.body, '/pg-history')))
+ uses: gburd/ci-workflows/.github/workflows/ocr-review.yml@v1
+ with:
+ aws_role_arn: ${{ vars.AWS_ROLE_ARN }}
+ aws_region: ${{ vars.AWS_REGION }}
+ bedrock_model: ${{ vars.OCR_BEDROCK_MODEL }}
diff --git a/.github/workflows/fork-sync-upstream.yml b/.github/workflows/fork-sync-upstream.yml
new file mode 100644
index 0000000000000..022b3101b5a9d
--- /dev/null
+++ b/.github/workflows/fork-sync-upstream.yml
@@ -0,0 +1,48 @@
+# Keep this fork's master rebased on postgres/postgres master, carrying only
+# the fork-owned CI stubs (.github/workflows/fork-*.yml). The workflows they
+# call, and the OCR review rules, live in gburd/ci-workflows.
+#
+# Requires the SYNC_TOKEN secret: a PAT with repo+workflow scope. The default
+# GITHUB_TOKEN is not allowed to push commits that touch .github/workflows/,
+# and every commit we carry does.
+
+name: Sync from upstream
+
+on:
+ schedule:
+ - cron: '17 */3 * * *'
+ workflow_dispatch:
+
+concurrency:
+ group: fork-sync-upstream
+
+permissions:
+ contents: read
+
+jobs:
+ sync:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.SYNC_TOKEN }}
+
+ - name: Rebase master onto upstream
+ run: |
+ set -euo pipefail
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git remote add upstream https://github.com/postgres/postgres.git
+ git fetch --no-tags upstream master
+
+ # Master carries fork CI only. Anything else means work landed here
+ # that belongs on a branch; force-rebasing it on a timer would be wrong.
+ if git diff --name-only upstream/master...HEAD |
+ grep -v '^\.github/workflows/fork-'; then
+ echo "::error::master carries non-CI changes (listed above); move them to a branch"
+ exit 1
+ fi
+
+ git rebase upstream/master
+ git push --force-with-lease
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 0797dcf96da3a..7800bf9944b7a 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -49,6 +49,7 @@
+
diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml
index 2101442c90fcb..0940a557ffa2e 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;
®ress;
diff --git a/doc/src/sgml/undo.sgml b/doc/src/sgml/undo.sgml
new file mode 100644
index 0000000000000..ff4bd22f0ab2d
--- /dev/null
+++ b/doc/src/sgml/undo.sgml
@@ -0,0 +1,160 @@
+
+
+
+ UNDO Logging
+
+
+ UNDO logging
+
+
+
+ PostgreSQL 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.
+
+
+
+ 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
+ heap table access method does not, and ordinary heap and
+ index changes carry no UNDO overhead. The one user of UNDO in
+ PostgreSQL today is transactional filesystem
+ operations (FILEOPS), which make the directory and file changes done by
+ CREATE DATABASE, ALTER DATABASE SET
+ TABLESPACE, CREATE TABLESPACE and
+ DROP TABLESPACE roll back with the transaction.
+
+
+
+ Transactional Filesystem Operations
+
+
+ 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 ROLLBACK 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.
+
+
+
+ FILEOPS writes WAL and UNDO only when is
+ replica or higher. At minimal it
+ writes neither, and a crash in the middle of a transaction can leave a
+ partly done filesystem change behind, as without FILEOPS.
+
+
+
+
+ Crash Recovery and Replication
+
+
+ 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; ROLLBACK PREPARED applies its UNDO
+ records later, and COMMIT PREPARED discards them.
+
+
+
+ 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
+ WARNING 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.
+
+
+
+ A physical standby replays the WAL record of each filesystem change.
+
+
+
+
+ Configuration Parameters
+
+
+
+ undo_worker_naptime (integer)
+
+
+ Time in milliseconds between runs of the UNDO discard worker.
+ Default: 10000 (10 seconds).
+
+
+
+
+
+ undo_max_wal_retention_size (integer)
+
+
+ If the WAL held back because of in-progress UNDO exceeds this size, in
+ megabytes, the discard worker logs a WARNING.
+ 0, the default, disables the check.
+
+
+
+
+
+ undo_batch_size_kb (integer)
+ undo_batch_record_limit (integer)
+
+
+ A transaction's UNDO records are collected in memory and written as one
+ WAL record when this many kilobytes (default 256) or
+ this many records (default 1000) have accumulated,
+ and at the end of the transaction.
+
+
+
+
+
+
+
+ UNDO Space Management
+
+
+ UNDO records live in the WAL stream as XLOG_UNDO_BATCH
+ 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.
+
+
+
+
+ Monitoring
+
+
+ The server log shows UNDO being applied during crash recovery:
+
+
+
+LOG: UNDO recovery: rolling back transaction 742
+LOG: UNDO recovery complete: 1 transactions rolled back, 3 records applied
+
+
+
+ and, if some records could not be applied:
+
+
+
+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.
+
+
+
+ pg_waldump shows UNDO records with
+ and the filesystem changes themselves with
+ .
+
+
+
+
diff --git a/meson.build b/meson.build
index 3238c849e1bcd..935de4e5fc344 100644
--- a/meson.build
+++ b/meson.build
@@ -1586,6 +1586,17 @@ endif
+###############################################################
+# Library: FILEOPS
+###############################################################
+
+# FILEOPS transactional filesystem operations (WAL rmgr RM_FILEOPS_ID) is
+# always built. Mirrors USE_FILEOPS in src/Makefile.global.in: without it the
+# rmgrlist.h entry is compiled out and every RM_FILEOPS_ID reference fails to
+# build.
+cdata.set('USE_FILEOPS', 1)
+
+
###############################################################
# Library: selinux
###############################################################
diff --git a/src/Makefile.global.in b/src/Makefile.global.in
index 38914773b837b..b20d0065ce90e 100644
--- a/src/Makefile.global.in
+++ b/src/Makefile.global.in
@@ -256,6 +256,11 @@ PG_SYSROOT = @PG_SYSROOT@
override CPPFLAGS += $(ICU_CFLAGS) $(LIBNUMA_CFLAGS) $(LIBURING_CFLAGS)
+# FILEOPS transactional filesystem operations (WAL rmgr RM_FILEOPS_ID) is
+# always built.
+USE_FILEOPS = 1
+override CPPFLAGS += -DUSE_FILEOPS
+
ifdef PGXS
override CPPFLAGS := -I$(includedir_server) -I$(includedir_internal) $(CPPFLAGS)
else # not PGXS
diff --git a/src/backend/access/Makefile b/src/backend/access/Makefile
index e88d72ea0397d..5778d0d7f8225 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 5fd18de74f92b..d569ac4e6e32a 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 cd95eec37f148..5b0d8922ea272 100644
--- a/src/backend/access/rmgrdesc/Makefile
+++ b/src/backend/access/rmgrdesc/Makefile
@@ -13,6 +13,7 @@ OBJS = \
clogdesc.o \
committsdesc.o \
dbasedesc.o \
+ fileopsdesc.o \
genericdesc.o \
gindesc.o \
gistdesc.o \
@@ -29,6 +30,7 @@ OBJS = \
spgdesc.o \
standbydesc.o \
tblspcdesc.o \
+ undodesc.o \
xactdesc.o \
xlogdesc.o
diff --git a/src/backend/access/rmgrdesc/fileopsdesc.c b/src/backend/access/rmgrdesc/fileopsdesc.c
new file mode 100644
index 0000000000000..d13891729ee1a
--- /dev/null
+++ b/src/backend/access/rmgrdesc/fileopsdesc.c
@@ -0,0 +1,219 @@
+/*-------------------------------------------------------------------------
+ *
+ * fileopsdesc.c
+ * rmgr descriptor routines for storage/file/fileops.c
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ * src/backend/access/rmgrdesc/fileopsdesc.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "storage/fileops.h"
+
+void
+fileops_desc(StringInfo buf, XLogReaderState *record)
+{
+ char *data = XLogRecGetData(record);
+ uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK;
+
+ switch (info)
+ {
+ case XLOG_FILEOPS_CREATE:
+ {
+ xl_fileops_create *xlrec = (xl_fileops_create *) data;
+ const char *path = data + SizeOfFileOpsCreate;
+
+ appendStringInfo(buf, "create \"%s\" flags 0x%x mode 0%o",
+ path, xlrec->flags, xlrec->mode);
+ }
+ break;
+
+ case XLOG_FILEOPS_WRITE:
+ {
+ xl_fileops_write *xlrec = (xl_fileops_write *) data;
+ const char *path = data + SizeOfFileOpsWrite;
+
+ appendStringInfo(buf, "write \"%s\" offset %lld len %u",
+ path, (long long) xlrec->offset, xlrec->len);
+ }
+ break;
+
+ case XLOG_FILEOPS_RENAME:
+ {
+ xl_fileops_rename *xlrec = (xl_fileops_rename *) data;
+ const char *oldpath = data + SizeOfFileOpsRename;
+ const char *newpath = oldpath + xlrec->oldpath_len;
+
+ appendStringInfo(buf, "rename \"%s\" to \"%s\"",
+ oldpath, newpath);
+ }
+ break;
+
+ case XLOG_FILEOPS_DELETE:
+ {
+ xl_fileops_delete *xlrec = (xl_fileops_delete *) data;
+ const char *path = data + SizeOfFileOpsDelete;
+
+ appendStringInfo(buf, "delete \"%s\" at_%s",
+ path,
+ xlrec->at_commit ? "commit" : "abort");
+ }
+ break;
+
+ case XLOG_FILEOPS_SYMLINK:
+ {
+ xl_fileops_symlink *xlrec = (xl_fileops_symlink *) data;
+ const char *target = data + SizeOfFileOpsSymlink;
+ const char *linkpath = target + xlrec->target_len;
+
+ appendStringInfo(buf, "symlink \"%s\" -> \"%s\"",
+ linkpath, target);
+ }
+ break;
+
+ case XLOG_FILEOPS_LINK:
+ {
+ xl_fileops_link *xlrec = (xl_fileops_link *) data;
+ const char *oldpath = data + SizeOfFileOpsLink;
+ const char *newpath = oldpath + xlrec->oldpath_len;
+
+ appendStringInfo(buf, "link \"%s\" -> \"%s\"",
+ newpath, oldpath);
+ }
+ break;
+
+ case XLOG_FILEOPS_MKDIR:
+ {
+ xl_fileops_mkdir *xlrec = (xl_fileops_mkdir *) data;
+ const char *path = data + SizeOfFileOpsMkdir;
+
+ appendStringInfo(buf, "mkdir \"%s\" mode 0%o",
+ path, (unsigned int) xlrec->mode);
+ }
+ break;
+
+ case XLOG_FILEOPS_RMDIR:
+ {
+ xl_fileops_rmdir *xlrec = (xl_fileops_rmdir *) data;
+ const char *path = data + SizeOfFileOpsRmdir;
+
+ appendStringInfo(buf, "rmdir \"%s\" at_%s",
+ path,
+ xlrec->at_commit ? "commit" : "abort");
+ }
+ break;
+
+ case XLOG_FILEOPS_CHMOD:
+ {
+ xl_fileops_chmod *xlrec = (xl_fileops_chmod *) data;
+ const char *path = data + SizeOfFileOpsChmod;
+
+ appendStringInfo(buf, "chmod \"%s\" mode 0%o",
+ path, (unsigned int) xlrec->mode);
+ }
+ break;
+
+ case XLOG_FILEOPS_CHOWN:
+ {
+ xl_fileops_chown *xlrec = (xl_fileops_chown *) data;
+ const char *path = data + SizeOfFileOpsChown;
+
+ appendStringInfo(buf, "chown \"%s\" uid %d gid %d",
+ path, (int) xlrec->uid, (int) xlrec->gid);
+ }
+ break;
+
+ case XLOG_FILEOPS_TRUNCATE:
+ {
+ xl_fileops_truncate *xlrec = (xl_fileops_truncate *) data;
+ const char *path = data + SizeOfFileOpsTruncate;
+
+ appendStringInfo(buf, "truncate \"%s\" to %lld bytes",
+ path, (long long) xlrec->length);
+ }
+ break;
+
+ case XLOG_FILEOPS_SETXATTR:
+ {
+ xl_fileops_setxattr *xlrec = (xl_fileops_setxattr *) data;
+ const char *path = data + SizeOfFileOpsSetxattr;
+ const char *name = path + xlrec->path_len;
+
+ appendStringInfo(buf, "setxattr \"%s\" name \"%s\" len %u",
+ path, name, xlrec->value_len);
+ }
+ break;
+
+ case XLOG_FILEOPS_REMOVEXATTR:
+ {
+ xl_fileops_removexattr *xlrec =
+ (xl_fileops_removexattr *) data;
+ const char *path = data + SizeOfFileOpsRemovexattr;
+ const char *name = path + xlrec->path_len;
+
+ appendStringInfo(buf, "removexattr \"%s\" name \"%s\"",
+ path, name);
+ }
+ break;
+
+ default:
+ appendStringInfo(buf, "unknown fileops op code %u", info);
+ break;
+ }
+}
+
+const char *
+fileops_identify(uint8 info)
+{
+ const char *id = NULL;
+
+ switch (info & ~XLR_INFO_MASK)
+ {
+ case XLOG_FILEOPS_CREATE:
+ id = "CREATE";
+ break;
+ case XLOG_FILEOPS_DELETE:
+ id = "DELETE";
+ break;
+ case XLOG_FILEOPS_RENAME:
+ id = "RENAME";
+ break;
+ case XLOG_FILEOPS_WRITE:
+ id = "WRITE";
+ break;
+ case XLOG_FILEOPS_TRUNCATE:
+ id = "TRUNCATE";
+ break;
+ case XLOG_FILEOPS_CHMOD:
+ id = "CHMOD";
+ break;
+ case XLOG_FILEOPS_CHOWN:
+ id = "CHOWN";
+ break;
+ case XLOG_FILEOPS_MKDIR:
+ id = "MKDIR";
+ break;
+ case XLOG_FILEOPS_RMDIR:
+ id = "RMDIR";
+ break;
+ case XLOG_FILEOPS_SYMLINK:
+ id = "SYMLINK";
+ break;
+ case XLOG_FILEOPS_LINK:
+ id = "LINK";
+ break;
+ case XLOG_FILEOPS_SETXATTR:
+ id = "SETXATTR";
+ break;
+ case XLOG_FILEOPS_REMOVEXATTR:
+ id = "REMOVEXATTR";
+ break;
+ }
+
+ return id;
+}
diff --git a/src/backend/access/rmgrdesc/meson.build b/src/backend/access/rmgrdesc/meson.build
index d9000ccd9fd10..c8af1ce82dd69 100644
--- a/src/backend/access/rmgrdesc/meson.build
+++ b/src/backend/access/rmgrdesc/meson.build
@@ -22,8 +22,15 @@ rmgr_desc_sources = files(
'spgdesc.c',
'standbydesc.c',
'tblspcdesc.c',
+ 'undodesc.c',
'xactdesc.c',
'xlogdesc.c',
)
+# FILEOPS is always built (USE_FILEOPS); rmgrlist.h registers
+# fileops_desc/fileops_identify, so fileopsdesc.c must always be compiled or
+# pg_waldump fails to link. The autoconf build picks this up via its
+# *desc*.c wildcard; meson needs it listed explicitly.
+rmgr_desc_sources += files('fileopsdesc.c')
+
backend_sources += rmgr_desc_sources
diff --git a/src/backend/access/rmgrdesc/undodesc.c b/src/backend/access/rmgrdesc/undodesc.c
new file mode 100644
index 0000000000000..d4817684ad471
--- /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 4fda03a3cfcc6..7441cf2a716b9 100644
--- a/src/backend/access/transam/rmgr.c
+++ b/src/backend/access/transam/rmgr.c
@@ -6,6 +6,7 @@
* src/backend/access/transam/rmgr.c
*/
#include "postgres.h"
+#include "storage/fileops.h"
#include "access/rmgr.h"
#include "access/xlog_internal.h"
@@ -40,6 +41,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 d89afb093209c..55c553339d692 100644
--- a/src/backend/access/transam/twophase.c
+++ b/src/backend/access/transam/twophase.c
@@ -77,6 +77,8 @@
#include
#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"
@@ -98,6 +100,7 @@
#include "replication/origin.h"
#include "replication/syncrep.h"
#include "storage/fd.h"
+#include "storage/fileops.h"
#include "storage/ipc.h"
#include "storage/md.h"
#include "storage/predicate.h"
@@ -148,6 +151,15 @@ int max_prepared_xacts = 0;
* twophase.h
*/
+/*
+ * FILEOPS 2PC prepare hook (fileops.c). Declared locally by prototype to
+ * avoid pulling storage/fileops.h into this file. Serializes this
+ * transaction's pending file operations into the 2PC state file so
+ * COMMIT/ROLLBACK PREPARED -- possibly from another backend after a crash --
+ * can perform the deferred commit-time ops or the abort-time filesystem undo.
+ */
+extern void FileOpsAtPrepare(void);
+
typedef struct GlobalTransactionData
{
GlobalTransaction next; /* list link for free list */
@@ -163,6 +175,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 +516,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 +1003,20 @@ 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 a 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.
+ *
+ * Bumped again to 0x57F94536: FILEOPS now registers TWOPHASE_RM_FILEOPS_ID
+ * records so deferred commit-time file operations (RENAME/DELETE/RMDIR/RMTREE)
+ * and abort-time filesystem undo survive PREPARE. This adds a new 2PC record
+ * type (not a header field), but a bump still keeps a mixed-version cluster
+ * from replaying a state file whose record stream it does not understand.
*/
-#define TWOPHASE_MAGIC 0x57F94534 /* format identifier */
+#define TWOPHASE_MAGIC 0x57F94536 /* format identifier */
typedef xl_xact_prepare TwoPhaseFileHeader;
@@ -1102,6 +1138,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);
@@ -1155,6 +1195,13 @@ EndPrepare(GlobalTransaction gxact)
StateFileChunk *record;
bool replorigin;
+ /*
+ * FILEOPS: persist this transaction's pending file operations into the
+ * 2PC state file, so COMMIT PREPARED can run them after a crash. Must
+ * run before the END sentinel while the record chain is still open. A no-op if this transaction registered no file operations.
+ */
+ FileOpsAtPrepare();
+
/* Add the end sentinel to the list of 2PC records */
RegisterTwoPhaseRecord(TWOPHASE_RM_END_ID, 0,
NULL, 0);
@@ -1499,6 +1546,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 +1674,7 @@ FinishPreparedTransaction(const char *gid, bool isCommit)
hdr->ninvalmsgs, invalmsgs,
hdr->initfileinval, gid);
else
+ {
RecordTransactionAbortPrepared(xid,
hdr->nsubxacts, children,
hdr->nabortrels, abortrels,
@@ -1593,6 +1682,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 +2282,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 +2763,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 +3045,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/twophase_rmgr.c b/src/backend/access/transam/twophase_rmgr.c
index fae254c6e2364..687e77f7e6743 100644
--- a/src/backend/access/transam/twophase_rmgr.c
+++ b/src/backend/access/transam/twophase_rmgr.c
@@ -19,6 +19,14 @@
#include "pgstat.h"
#include "storage/lock.h"
#include "storage/predicate.h"
+#include "storage/fileops.h"
+
+/*
+ * FILEOPS 2PC callbacks (fileops.c). Declared locally by prototype to avoid
+ * pulling storage/fileops.h in here; the signature is the generic
+ * TwoPhaseCallback. Postcommit performs the deferred commit-time file ops;
+ * postabort performs the abort-time filesystem undo.
+ */
const TwoPhaseCallback twophase_recover_callbacks[TWOPHASE_RM_MAX_ID + 1] =
@@ -27,7 +35,8 @@ const TwoPhaseCallback twophase_recover_callbacks[TWOPHASE_RM_MAX_ID + 1] =
lock_twophase_recover, /* Lock */
NULL, /* pgstat */
multixact_twophase_recover, /* MultiXact */
- predicatelock_twophase_recover /* PredicateLock */
+ predicatelock_twophase_recover, /* PredicateLock */
+ NULL /* FILEOPS (post-commit/abort only) */
};
const TwoPhaseCallback twophase_postcommit_callbacks[TWOPHASE_RM_MAX_ID + 1] =
@@ -36,7 +45,8 @@ const TwoPhaseCallback twophase_postcommit_callbacks[TWOPHASE_RM_MAX_ID + 1] =
lock_twophase_postcommit, /* Lock */
pgstat_twophase_postcommit, /* pgstat */
multixact_twophase_postcommit, /* MultiXact */
- NULL /* PredicateLock */
+ NULL, /* PredicateLock */
+ fileops_twophase_postcommit /* FILEOPS */
};
const TwoPhaseCallback twophase_postabort_callbacks[TWOPHASE_RM_MAX_ID + 1] =
@@ -45,7 +55,8 @@ const TwoPhaseCallback twophase_postabort_callbacks[TWOPHASE_RM_MAX_ID + 1] =
lock_twophase_postabort, /* Lock */
pgstat_twophase_postabort, /* pgstat */
multixact_twophase_postabort, /* MultiXact */
- NULL /* PredicateLock */
+ NULL, /* PredicateLock */
+ fileops_twophase_postabort /* FILEOPS */
};
const TwoPhaseCallback twophase_standby_recover_callbacks[TWOPHASE_RM_MAX_ID + 1] =
@@ -54,5 +65,6 @@ const TwoPhaseCallback twophase_standby_recover_callbacks[TWOPHASE_RM_MAX_ID + 1
lock_twophase_standby_recover, /* Lock */
NULL, /* pgstat */
NULL, /* MultiXact */
- NULL /* PredicateLock */
+ NULL, /* PredicateLock */
+ NULL /* FILEOPS */
};
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index 49e6493966739..7b0ef34508b33 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 8636ad9ab2c7b..bf13a7f637781 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 52ac6d58423d8..069f25de50923 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 0000000000000..ba565c07028a7
--- /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 0000000000000..361d72c3f9de2
--- /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 ;
+
+### 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 0000000000000..dd67f28e5d46c
--- /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 0000000000000..2136b9baea8c7
--- /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 0000000000000..6ff7db84f0de5
--- /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 0000000000000..437ec4804e877
--- /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 0000000000000..47ceb92877aea
--- /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 0000000000000..26b6271501b85
--- /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 0000000000000..bdd3bdbbc8127
--- /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 0000000000000..851ef61ae8d4f
--- /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 0000000000000..603fe6e642c8f
--- /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 0000000000000..ab878c7c23042
--- /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
+#include
+
+#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 0000000000000..47a4a6fdbce40
--- /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/dbcommands.c b/src/backend/commands/dbcommands.c
index 7e3fc59eafdda..99549a79e74d3 100644
--- a/src/backend/commands/dbcommands.c
+++ b/src/backend/commands/dbcommands.c
@@ -56,6 +56,7 @@
#include "replication/slot.h"
#include "storage/copydir.h"
#include "storage/fd.h"
+#include "storage/fileops.h"
#include "storage/ipc.h"
#include "storage/lmgr.h"
#include "storage/md.h"
@@ -473,55 +474,91 @@ CreateDirAndVersionFile(char *dbpath, Oid dbid, Oid tsid, bool isRedo)
nbytes = strlen(PG_MAJORVERSION) + 1;
/* Create database directory. */
- if (MakePGDirectory(dbpath) < 0)
+ if (!isRedo)
{
- /* Failure other than already exists or not in WAL replay? */
- if (errno != EEXIST || !isRedo)
- ereport(ERROR,
- (errcode_for_file_access(),
- errmsg("could not create directory \"%s\": %m", dbpath)));
+ FileOpsMkdir(dbpath, pg_dir_create_mode);
+ }
+ else
+ {
+ if (MakePGDirectory(dbpath) < 0)
+ {
+ /* Failure other than already exists or not in WAL replay? */
+ if (errno != EEXIST)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not create directory \"%s\": %m", dbpath)));
+ }
}
/*
- * Create PG_VERSION file in the database path. If the file already
- * exists and we are in WAL replay then try again to open it in write
- * mode.
+ * Create PG_VERSION file in the database path.
*/
snprintf(versionfile, sizeof(versionfile), "%s/%s", dbpath, "PG_VERSION");
- fd = OpenTransientFile(versionfile, O_WRONLY | O_CREAT | O_EXCL | PG_BINARY);
- if (fd < 0 && errno == EEXIST && isRedo)
- fd = OpenTransientFile(versionfile, O_WRONLY | O_TRUNC | PG_BINARY);
+ if (!isRedo)
+ {
+ /*
+ * Use FileOpsCreate + FileOpsWrite for transactional version file
+ * creation with crash-safe rollback support.
+ */
+ fd = FileOpsCreate(versionfile, O_WRONLY | PG_BINARY,
+ pg_file_create_mode, true);
+ pgstat_report_wait_start(WAIT_EVENT_VERSION_FILE_WRITE);
+ errno = 0;
+ if ((int) write(fd, buf, nbytes) != nbytes)
+ {
+ if (errno == 0)
+ errno = ENOSPC;
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not write to file \"%s\": %m", versionfile)));
+ }
+ pgstat_report_wait_end();
- if (fd < 0)
- ereport(ERROR,
- (errcode_for_file_access(),
- errmsg("could not create file \"%s\": %m", versionfile)));
+ pgstat_report_wait_start(WAIT_EVENT_VERSION_FILE_SYNC);
+ if (pg_fsync(fd) != 0)
+ ereport(data_sync_elevel(ERROR),
+ (errcode_for_file_access(),
+ errmsg("could not fsync file \"%s\": %m", versionfile)));
+ fsync_fname(dbpath, true);
+ pgstat_report_wait_end();
- /* Write PG_MAJORVERSION in the PG_VERSION file. */
- pgstat_report_wait_start(WAIT_EVENT_VERSION_FILE_WRITE);
- errno = 0;
- if (write(fd, buf, nbytes) != nbytes)
- {
- /* If write didn't set errno, assume problem is no disk space. */
- if (errno == 0)
- errno = ENOSPC;
- ereport(ERROR,
- (errcode_for_file_access(),
- errmsg("could not write to file \"%s\": %m", versionfile)));
+ CloseTransientFile(fd);
}
- pgstat_report_wait_end();
+ else
+ {
+ /* WAL replay: use raw file operations */
+ fd = OpenTransientFile(versionfile, O_WRONLY | O_CREAT | O_EXCL | PG_BINARY);
+ if (fd < 0 && errno == EEXIST)
+ fd = OpenTransientFile(versionfile, O_WRONLY | O_TRUNC | PG_BINARY);
- pgstat_report_wait_start(WAIT_EVENT_VERSION_FILE_SYNC);
- if (pg_fsync(fd) != 0)
- ereport(data_sync_elevel(ERROR),
- (errcode_for_file_access(),
- errmsg("could not fsync file \"%s\": %m", versionfile)));
- fsync_fname(dbpath, true);
- pgstat_report_wait_end();
+ if (fd < 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not create file \"%s\": %m", versionfile)));
- /* Close the version file. */
- CloseTransientFile(fd);
+ pgstat_report_wait_start(WAIT_EVENT_VERSION_FILE_WRITE);
+ errno = 0;
+ if ((int) write(fd, buf, nbytes) != nbytes)
+ {
+ if (errno == 0)
+ errno = ENOSPC;
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not write to file \"%s\": %m", versionfile)));
+ }
+ pgstat_report_wait_end();
+
+ pgstat_report_wait_start(WAIT_EVENT_VERSION_FILE_SYNC);
+ if (pg_fsync(fd) != 0)
+ ereport(data_sync_elevel(ERROR),
+ (errcode_for_file_access(),
+ errmsg("could not fsync file \"%s\": %m", versionfile)));
+ fsync_fname(dbpath, true);
+ pgstat_report_wait_end();
+
+ CloseTransientFile(fd);
+ }
/* If we are not in WAL replay then write the WAL. */
if (!isRedo)
@@ -633,9 +670,11 @@ CreateDatabaseUsingFileCopy(Oid src_dboid, Oid dst_dboid, Oid src_tsid,
/*
* Copy this subdirectory to the new location
*
- * We don't need to copy subdirectories
+ * We don't need to copy subdirectories. Register the destination for
+ * delete-on-abort so a transaction abort recursively removes the
+ * partial copy.
*/
- copydir(srcpath, dstpath, false);
+ copydir(srcpath, dstpath, false, true);
/* Record the filesystem change in XLOG */
{
@@ -2221,8 +2260,18 @@ movedb(const char *dbname, const char *tblspcname)
FreeDir(dstdir);
/*
- * The directory exists but is empty. We must remove it before using
+ * The directory exists but is empty. We must remove it before using
* the copydir function.
+ *
+ * NB: this rmdir is deliberately NOT routed through FILEOPS. The
+ * destination tablespace path is not yet referenced by any catalog
+ * row (the pg_database.dattablespace update happens later, and is
+ * what binds dst_dbpath to the database on commit), so an abort that
+ * leaves dst_dbpath missing does not orphan any catalog state -- the
+ * database remains in src_tblspcoid. copydir() below will recreate
+ * dst_dbpath via MakePGDirectory in its first action and registers
+ * the destination tree for delete-on-abort, so the partial-copy
+ * window is covered.
*/
if (rmdir(dst_dbpath) != 0)
elog(ERROR, "could not remove directory \"%s\": %m",
@@ -2245,9 +2294,13 @@ movedb(const char *dbname, const char *tblspcname)
bool new_record_repl[Natts_pg_database] = {0};
/*
- * Copy files from the old tablespace to the new one
+ * Copy files from the old tablespace to the new one. Register the
+ * destination for delete-on-abort so a transaction abort recursively
+ * removes the partial copy (closing the long-standing crash-asymmetry
+ * with the source tree, which is preserved until commit by movedb's
+ * post-commit cleanup).
*/
- copydir(src_dbpath, dst_dbpath, false);
+ copydir(src_dbpath, dst_dbpath, false, true);
/*
* Record the filesystem change in XLOG
@@ -3407,9 +3460,12 @@ dbase_redo(XLogReaderState *record)
/*
* Copy this subdirectory to the new location
*
- * We don't need to copy subdirectories
+ * We don't need to copy subdirectories. Pass false for
+ * register_for_abort_cleanup: this is a WAL replay path with no
+ * surrounding transaction; the redo handler is itself the recovery
+ * action.
*/
- copydir(src_path, dst_path, false);
+ copydir(src_path, dst_path, false, false);
pfree(src_path);
pfree(dst_path);
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 47f5512157498..71e46e96091e2 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/commands/tablespace.c b/src/backend/commands/tablespace.c
index 7c40176b7b9d2..0ba99f635afc3 100644
--- a/src/backend/commands/tablespace.c
+++ b/src/backend/commands/tablespace.c
@@ -70,6 +70,7 @@
#include "miscadmin.h"
#include "postmaster/bgwriter.h"
#include "storage/fd.h"
+#include "storage/fileops.h"
#include "storage/lmgr.h"
#include "storage/lwlock.h"
#include "storage/procsignal.h"
@@ -600,11 +601,18 @@ create_tablespace_directories(const char *location, const Oid tablespaceoid)
if (in_place)
{
- if (MakePGDirectory(linkloc) < 0 && errno != EEXIST)
- ereport(ERROR,
- (errcode_for_file_access(),
- errmsg("could not create directory \"%s\": %m",
- linkloc)));
+ if (!InRecovery)
+ {
+ FileOpsMkdir(linkloc, pg_dir_create_mode);
+ }
+ else
+ {
+ if (MakePGDirectory(linkloc) < 0 && errno != EEXIST)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not create directory \"%s\": %m",
+ linkloc)));
+ }
}
location_with_version_dir = psprintf("%s/%s", in_place ? linkloc : location,
@@ -615,20 +623,36 @@ create_tablespace_directories(const char *location, const Oid tablespaceoid)
* it doesn't exist or has the wrong owner. Not needed for in-place mode,
* because in that case we created the directory with the desired
* permissions.
+ *
+ * Outside recovery, route through FileOpsChmod so a transaction abort
+ * restores the prior mode via the FILEOPS UNDO record. In recovery
+ * (where there is no surrounding transaction) issue chmod directly. On
+ * Windows, chmod is partial (only the read-only bit is meaningful);
+ * FileOpsChmod handles platform differences.
*/
- if (!in_place && chmod(location, pg_dir_create_mode) != 0)
+ if (!in_place)
{
- if (errno == ENOENT)
- ereport(ERROR,
- (errcode(ERRCODE_UNDEFINED_FILE),
- errmsg("directory \"%s\" does not exist", location),
- InRecovery ? errhint("Create this directory for the tablespace before "
- "restarting the server.") : 0));
+ int rc;
+
+ if (InRecovery)
+ rc = chmod(location, pg_dir_create_mode);
else
- ereport(ERROR,
- (errcode_for_file_access(),
- errmsg("could not set permissions on directory \"%s\": %m",
- location)));
+ rc = FileOpsChmod(location, pg_dir_create_mode);
+
+ if (rc != 0)
+ {
+ if (errno == ENOENT)
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_FILE),
+ errmsg("directory \"%s\" does not exist", location),
+ InRecovery ? errhint("Create this directory for the tablespace before "
+ "restarting the server.") : 0));
+ else
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not set permissions on directory \"%s\": %m",
+ location)));
+ }
}
/*
@@ -645,6 +669,8 @@ create_tablespace_directories(const char *location, const Oid tablespaceoid)
(errcode_for_file_access(),
errmsg("could not stat directory \"%s\": %m",
location_with_version_dir)));
+ else if (!InRecovery)
+ FileOpsMkdir(location_with_version_dir, pg_dir_create_mode);
else if (MakePGDirectory(location_with_version_dir) < 0)
ereport(ERROR,
(errcode_for_file_access(),
@@ -671,11 +697,16 @@ create_tablespace_directories(const char *location, const Oid tablespaceoid)
/*
* Create the symlink under PGDATA
*/
- if (!in_place && symlink(location, linkloc) < 0)
- ereport(ERROR,
- (errcode_for_file_access(),
- errmsg("could not create symbolic link \"%s\": %m",
- linkloc)));
+ if (!in_place)
+ {
+ if (!InRecovery)
+ FileOpsSymlink(location, linkloc);
+ else if (symlink(location, linkloc) < 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not create symbolic link \"%s\": %m",
+ linkloc)));
+ }
pfree(linkloc);
pfree(location_with_version_dir);
@@ -773,27 +804,50 @@ destroy_tablespace_directories(Oid tablespaceoid, bool redo)
return false;
}
- /* remove empty directory */
- if (rmdir(subfile) < 0)
- ereport(redo ? LOG : ERROR,
- (errcode_for_file_access(),
- errmsg("could not remove directory \"%s\": %m",
- subfile)));
+ /*
+ * Remove empty directory. Outside recovery, defer to commit time so
+ * a transaction abort leaves the per-DB tablespace subdirectory
+ * intact. In recovery, issue rmdir directly because the redo handler
+ * is itself the recovery action.
+ */
+ if (redo)
+ {
+ if (rmdir(subfile) < 0)
+ ereport(LOG,
+ (errcode_for_file_access(),
+ errmsg("could not remove directory \"%s\": %m",
+ subfile)));
+ }
+ else
+ {
+ FileOpsRmdir(subfile, true /* at_commit */ );
+ }
pfree(subfile);
}
FreeDir(dirdesc);
- /* remove version directory */
- if (rmdir(linkloc_with_version_dir) < 0)
+ /*
+ * Remove version directory. Same forward / redo split as above: defer to
+ * commit outside recovery so abort restores the version dir; issue rmdir
+ * directly during redo.
+ */
+ if (redo)
{
- ereport(redo ? LOG : ERROR,
- (errcode_for_file_access(),
- errmsg("could not remove directory \"%s\": %m",
- linkloc_with_version_dir)));
- pfree(linkloc_with_version_dir);
- return false;
+ if (rmdir(linkloc_with_version_dir) < 0)
+ {
+ ereport(LOG,
+ (errcode_for_file_access(),
+ errmsg("could not remove directory \"%s\": %m",
+ linkloc_with_version_dir)));
+ pfree(linkloc_with_version_dir);
+ return false;
+ }
+ }
+ else
+ {
+ FileOpsRmdir(linkloc_with_version_dir, true /* at_commit */ );
}
/*
@@ -819,26 +873,53 @@ destroy_tablespace_directories(Oid tablespaceoid, bool redo)
}
else if (S_ISDIR(st.st_mode))
{
- if (rmdir(linkloc) < 0)
+ /*
+ * Junction-style entry (Windows) or stale directory (any platform).
+ * In redo, remove now; outside redo, defer to commit so abort
+ * restores the entry.
+ */
+ if (redo)
{
- int saved_errno = errno;
+ if (rmdir(linkloc) < 0)
+ {
+ int saved_errno = errno;
- ereport(redo ? LOG : (saved_errno == ENOENT ? WARNING : ERROR),
- (errcode_for_file_access(),
- errmsg("could not remove directory \"%s\": %m",
- linkloc)));
+ ereport(saved_errno == ENOENT ? LOG : LOG,
+ (errcode_for_file_access(),
+ errmsg("could not remove directory \"%s\": %m",
+ linkloc)));
+ }
+ }
+ else
+ {
+ FileOpsRmdir(linkloc, true /* at_commit */ );
}
}
else if (S_ISLNK(st.st_mode))
{
- if (unlink(linkloc) < 0)
+ /*
+ * Tablespace symlink under PGDATA/pg_tblspc/. Symmetric with
+ * FileOpsSymlink on the create path: defer the unlink to commit so
+ * abort restores the symlink, closing the long-standing
+ * crash-asymmetry between CREATE TABLESPACE and DROP TABLESPACE. In
+ * redo, issue unlink directly because the redo handler is itself the
+ * recovery action.
+ */
+ if (redo)
{
- int saved_errno = errno;
+ if (unlink(linkloc) < 0)
+ {
+ int saved_errno = errno;
- ereport(redo ? LOG : (saved_errno == ENOENT ? WARNING : ERROR),
- (errcode_for_file_access(),
- errmsg("could not remove symbolic link \"%s\": %m",
- linkloc)));
+ ereport(saved_errno == ENOENT ? LOG : LOG,
+ (errcode_for_file_access(),
+ errmsg("could not remove symbolic link \"%s\": %m",
+ linkloc)));
+ }
+ }
+ else
+ {
+ FileOpsDelete(linkloc, true /* at_commit */ );
}
}
else
diff --git a/src/backend/postmaster/bgworker.c b/src/backend/postmaster/bgworker.c
index 4aef037d5aed5..5f5c61c10319a 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 c17fc3590f0cd..d124a8563283f 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/storage/file/Makefile b/src/backend/storage/file/Makefile
index 660ac51807e79..8bcfafec7cf7c 100644
--- a/src/backend/storage/file/Makefile
+++ b/src/backend/storage/file/Makefile
@@ -16,6 +16,8 @@ OBJS = \
buffile.o \
copydir.o \
fd.o \
+ fileops.o \
+ fileops_undo.o \
fileset.o \
reinit.o \
sharedfileset.o
diff --git a/src/backend/storage/file/README b/src/backend/storage/file/README
new file mode 100644
index 0000000000000..233db9bbbf366
--- /dev/null
+++ b/src/backend/storage/file/README
@@ -0,0 +1,201 @@
+src/backend/storage/file/README
+
+Transactional File Operations (FILEOPS)
+=======================================
+
+This directory's fileops.c implements FILEOPS: a set of filesystem
+operations that participate in PostgreSQL transactions. An ordinary
+unlink() or rename() issued in the middle of a transaction is not covered by
+WAL and is not reversed if the transaction aborts or the server crashes.
+FILEOPS closes that gap for the filesystem mutations the server performs
+outside the buffer manager.
+
+FILEOPS is NOT used for relation files. Creating and dropping tables,
+indexes, TRUNCATE, and CREATE/DROP DATABASE|TABLESPACE at the catalog/smgr
+level go through smgr and catalog/storage.c, which have zero FileOps calls.
+The only in-core callers of the FileOps* API are:
+
+ - dbcommands.c (CREATE/ALTER/DROP DATABASE: directory + PG_VERSION,
+ ALTER DATABASE ... SET TABLESPACE)
+ - tablespace.c (CREATE/DROP TABLESPACE: the per-tablespace directory
+ and symlink; the LOCATION paths are absolute by design)
+ - copydir.c (the destination tree of a directory copy)
+
+
+Two op classes: constructive (immediate) vs destructive (deferred)
+------------------------------------------------------------------
+
+CONSTRUCTIVE operations run IMMEDIATELY when the API is called, so the file
+or directory can be used within the same transaction, and each registers an
+abort-time undo action that reverses it if the transaction rolls back:
+
+ FileOpsCreate -- create() now; UNDO/pending-delete on abort
+ FileOpsMkdir -- mkdir() now; UNDO rmdir on abort
+ FileOpsChmod -- chmod() now; UNDO restore old mode on abort
+ FileOpsChown -- chown() now; UNDO restore old uid/gid on abort
+ FileOpsTruncate -- ftruncate() now; UNDO restore old length on abort
+ FileOpsSymlink -- symlink() now; UNDO unlink on abort
+ FileOpsLink -- link() now; UNDO unlink on abort
+ FileOpsWrite -- pwrite() now (content, WAL-logged; no UNDO)
+ FileOpsSetXattr / FileOpsRemoveXattr -- now; UNDO restore old value on abort
+
+DESTRUCTIVE operations are DEFERRED: the API only records a PendingFileOp,
+and the filesystem is touched at transaction end:
+
+ FileOpsRename -- deferred, always at_commit
+ FileOpsDelete -- deferred; at_commit chooses commit vs abort
+ FileOpsRmdir / FileOpsRmdirRecursive -- deferred; at_commit
+ FileOpsRmtree -- deferred; walked at execute time (copydir abort cleanup)
+
+FileOpsDoPendingOps(isCommit) runs the pending list at transaction end: the
+at_commit=true ops fire on commit, the at_commit=false ops fire on abort.
+
+
+When FILEOPS is durable: only at wal_level >= replica
+-----------------------------------------------------
+
+FILEOPS writes WAL and UNDO records ONLY when XLogIsNeeded() is true, i.e.
+wal_level >= replica. At wal_level = minimal it emits no WAL and no UNDO:
+
+ - There is NO crash safety at minimal. The immediate constructive ops
+ mutate the filesystem with no WAL record to redo, and the only reversal
+ for a crash-mid-transaction is the in-memory pendingFileOps list, which
+ does not survive a crash.
+
+ - There is NO 2PC safety at minimal. With nothing serialized, a PREPARE'd
+ transaction's file operations cannot be reproduced after a crash.
+
+Every FILEOPS + crash-recovery + 2PC guarantee below assumes wal_level >=
+replica, which is what any deployment relying on those guarantees must set.
+
+
+Revert and recovery model (NO fileops_cleanup)
+----------------------------------------------
+
+There is NO fileops_cleanup() function. The RM_FILEOPS_ID WAL resource
+manager registers only rm_redo/rm_desc/rm_identify; its rm_startup and
+rm_cleanup slots are NULL (see access/rmgrlist.h). Reversal rides two
+mechanisms, both on the COMMON WAL stream:
+
+ 1. Live-transaction abort (in-memory). ROLLBACK runs
+ FileOpsDoPendingOps(false), executing each constructive op's registered
+ abort-time undo (delete the created file, restore the old
+ mode/length/xattr, rmdir the created directory, ...). No WAL needed;
+ the in-process list has the state.
+
+ 2. Crash recovery (common-WAL UNDO records). Each constructive op
+ also prepares an UNDO record (PrepareXactUndoData(UNDO_RMID_FILEOPS,...))
+ and inserts it (InsertXactUndoData) in the same critical section as its
+ forward WAL record. On crash, a loser transaction's UNDO chain is
+ applied by the cluster-wide UNDO engine's recovery pass
+ (PerformUndoRecovery, at end of redo); the apply side is
+ fileops_undo_apply() in fileops_undo.c, registered through the UNDO rmgr
+ registry (FileopsUndoRmgrInit), NOT through a WAL rm_cleanup callback.
+
+Deferred DESTRUCTIVE ops emit their forward WAL record at commit-EXECUTION
+time, inside FileOpsDoPendingOps() when the op actually happens on the
+primary, so a physical standby reproduces it by redo. Their redo handlers
+are idempotent (delete of an already-absent file, rename whose source is
+gone, rmdir of an already-absent directory all count as success). Only the
+commit path is logged; an at_commit=false delete/rmdir run during abort emits
+no WAL, because a standby must never apply an aborted transaction's effect.
+
+Two deliberate exceptions:
+
+ RMTREE (FileOpsRmtree) writes no WAL and has no redo. Its only core caller
+ (copydir.c) registers it at_commit=false, so it fires only on ABORT to
+ clean up a partially-copied tree; aborts are never replayed as committed,
+ so it cannot diverge a standby.
+
+ SYMLINK redo is an intentional no-op. FileOpsSymlink is used only for
+ tablespace symlinks; standby/crash-recovery replay of those is owned by
+ RM_TBLSPC, which applies the recovery-time layout remap.
+
+
+Two-phase commit
+----------------
+
+PREPARE TRANSACTION serializes the transaction's pending FileOps into the
+2PC state file so they survive to COMMIT/ROLLBACK PREPARED, including from a
+different backend after a crash between PREPARE and the final decision:
+
+ - FileOpsAtPrepare(), called from EndPrepare() between StartPrepare() and
+ the END sentinel, writes one TWOPHASE_RM_FILEOPS_ID record per pending
+ at_commit=true op into the durable 2PC state.
+
+ - The FILEOPS 2PC post-commit callback performs those deferred commit-time
+ ops (rename/delete/rmdir/rmtree), reusing the same per-op executor as
+ FileOpsDoPendingOps(). COMMIT PREPARED thus applies them; a crash before
+ it re-drives them from the durable 2PC state.
+
+ - The abort-time ops (at_commit=false, the immediate constructive ops' undo)
+ are NOT serialized here. They are reversed on ROLLBACK PREPARED by the
+ constructive op's cluster-wide UNDO record, which
+ FinishPreparedTransaction() applies synchronously. Duplicating them in
+ the 2PC record stream would revert them twice. The FILEOPS post-abort
+ callback is therefore effectively a no-op.
+
+ - PostPrepare_FileOps() then discards this backend's in-memory list; the
+ durable 2PC state (for commit-time ops) and the UNDO chain (for abort-time
+ reversal), not this backend, drive the eventual work.
+
+Before this, PostPrepare_FileOps() freed the pending list without recording
+it, so deferred commit-time ops were lost across PREPARE. Adding the
+TWOPHASE_RM_FILEOPS_ID record type bumped TWOPHASE_MAGIC (twophase.c).
+
+
+How to use the API
+------------------
+
+The public API is declared in src/include/storage/fileops.h. Call these in
+place of the raw libc/syscall equivalents whenever the operation must be
+transactional.
+
+ register_delete (FileOpsCreate). When true, the newly created file is
+ registered for deletion if the transaction aborts.
+
+ at_commit (FileOpsDelete, FileOpsRmdir, FileOpsRmdirRecursive,
+ FileOpsRmtree). at_commit = true removes on commit (dropping state the
+ transaction is logically deleting); at_commit = false removes only on abort
+ (cleaning up something the transaction just created, e.g. a copy tree).
+
+FileOpsRmtree defers its tree walk to execute time (removing whatever ended
+up on disk by commit/abort), where FileOpsRmdirRecursive enumerates contents
+at registration time.
+
+The API hides platform differences: PG_O_DIRECT for unbuffered I/O,
+pg_fsync() for the correct per-platform flush, fsync_parent_path() for
+directory durability on Unix, and durable_rename()/durable_unlink() for
+crash-safe rename and unlink ordering.
+
+
+Transaction lifecycle integration
+----------------------------------
+
+The deferred operations are driven by AM-neutral hooks that xact.c calls
+(wired to the FileOps* functions in fileops_undo.c):
+
+ FileOpsDoPendingOps(isCommit) -- executes the pending list at end of xact.
+ AtSubCommit_FileOps() -- reparents a subxact's pending ops.
+ AtSubAbort_FileOps() -- runs a subxact's abort-time ops.
+ FileOpsAtPrepare() -- serializes pending ops into 2PC state.
+ PostPrepare_FileOps() -- discards the in-memory list after PREPARE.
+ FileOpsCancelPendingDelete() -- removes a registered delete entry.
+ FileOpsSync() -- forces durability of a path immediately.
+
+
+WAL replay
+----------
+
+fileops_redo() dispatches each XLOG_FILEOPS_* record to the matching action
+during recovery; fileops_desc()/fileops_identify() render records for
+pg_waldump. Because the redo handler is itself the recovery action, code
+paths that run under WAL replay (the "redo" arms in dbcommands.c,
+tablespace.c, and copydir.c) call the raw syscalls directly rather than
+re-entering the FileOps* API.
+
+Tests: src/test/recovery/t/062_fileops_recovery.pl (crash recovery),
+063_fileops_2pc.pl (constructive-op 2PC via chmod),
+064_fileops_standby_divergence.pl (physical-standby convergence),
+061_fileops_2pc_deferred.pl (deferred commit-time ops through PREPARE +
+crash), and the test_fileops module's regression test.
diff --git a/src/backend/storage/file/copydir.c b/src/backend/storage/file/copydir.c
index ee42c796f773a..3c716c84bb0e3 100644
--- a/src/backend/storage/file/copydir.c
+++ b/src/backend/storage/file/copydir.c
@@ -24,11 +24,13 @@
#include
#include
+#include "access/xlog.h"
#include "common/file_utils.h"
#include "miscadmin.h"
#include "pgstat.h"
#include "storage/copydir.h"
#include "storage/fd.h"
+#include "storage/fileops.h"
#include "utils/wait_event.h"
/* GUCs */
@@ -44,9 +46,17 @@ static void clone_file(const char *fromfile, const char *tofile);
*
* This function uses the file_copy_method GUC. New uses of this function must
* be documented in doc/src/sgml/config.sgml.
+ *
+ * If register_for_abort_cleanup is true and we are not in WAL replay, the
+ * destination tree is registered with FILEOPS so that a transaction abort
+ * recursively removes the partial copy. This closes the long-standing
+ * crash-asymmetry where CREATE DATABASE FILE_COPY and ALTER DATABASE SET
+ * TABLESPACE leave orphan trees behind on rollback. Recovery callers pass
+ * false because they are themselves the recovery action.
*/
void
-copydir(const char *fromdir, const char *todir, bool recurse)
+copydir(const char *fromdir, const char *todir, bool recurse,
+ bool register_for_abort_cleanup)
{
DIR *xldir;
struct dirent *xlde;
@@ -58,6 +68,16 @@ copydir(const char *fromdir, const char *todir, bool recurse)
(errcode_for_file_access(),
errmsg("could not create directory \"%s\": %m", todir)));
+ /*
+ * Register the destination tree for delete-on-abort BEFORE we start
+ * copying. If the surrounding transaction aborts (or this function
+ * itself errors out), FILEOPS will recursively unlink the partial copy.
+ * Skip this in WAL replay: the redo handler IS the recovery action and
+ * has no surrounding transaction context.
+ */
+ if (register_for_abort_cleanup && !RecoveryInProgress())
+ FileOpsRmtree(todir, false /* at_commit -> at abort */ );
+
xldir = AllocateDir(fromdir);
while ((xlde = ReadDir(xldir, fromdir)) != NULL)
@@ -78,9 +98,16 @@ copydir(const char *fromdir, const char *todir, bool recurse)
if (xlde_type == PGFILETYPE_DIR)
{
- /* recurse to handle subdirectories */
+ /*
+ * Recurse to handle subdirectories. Pass false for
+ * register_for_abort_cleanup: the parent call (if any) already
+ * registered the top-level destination tree, and
+ * FileOpsRmdirRecursive walks the tree at abort time, so
+ * registering subdirectories separately would only duplicate
+ * work.
+ */
if (recurse)
- copydir(fromfile, tofile, true);
+ copydir(fromfile, tofile, true, false);
}
else if (xlde_type == PGFILETYPE_REG)
{
diff --git a/src/backend/storage/file/fileops.c b/src/backend/storage/file/fileops.c
new file mode 100644
index 0000000000000..aab81341a964a
--- /dev/null
+++ b/src/backend/storage/file/fileops.c
@@ -0,0 +1,2561 @@
+/*-------------------------------------------------------------------------
+ *
+ * fileops.c
+ * Transactional file operations with WAL logging
+ *
+ * This module provides transactional filesystem operations that integrate
+ * with PostgreSQL's WAL and transaction management. It is NOT used for
+ * relation files (smgr/catalog handle those); the only in-core callers are
+ * dbcommands.c, tablespace.c, and copydir.c.
+ *
+ * Two op classes:
+ * - CONSTRUCTIVE ops (create, mkdir, chmod, chown, truncate, symlink, link,
+ * write, setxattr) run IMMEDIATELY, and each registers an abort-time undo
+ * action (a common-WAL UNDO record plus, where relevant, a pending op)
+ * that reverses it on rollback.
+ * - DESTRUCTIVE ops (rename, delete, rmdir, rmtree) are DEFERRED: a
+ * PendingFileOp is recorded now and the filesystem is touched at
+ * transaction end by FileOpsDoPendingOps().
+ *
+ * WAL and UNDO are written only when XLogIsNeeded() (wal_level >= replica);
+ * at wal_level = minimal there is no crash or 2PC safety.
+ *
+ * The deferred-operations pattern works as follows:
+ * 1. A destructive API call records a PendingFileOp entry in a linked list
+ * 2. At commit/abort time, FileOpsDoPendingOps() executes or discards
+ * the pending operations based on transaction outcome (a commit-time
+ * destructive op emits its WAL record at execution time)
+ * 3. At PREPARE, FileOpsAtPrepare() serializes the pending ops into the
+ * 2PC state file so COMMIT/ROLLBACK PREPARED can perform them
+ *
+ * Subtransaction support:
+ * - At subtransaction commit, entries are reassigned to the parent level
+ * - At subtransaction abort, abort-time actions execute immediately
+ *
+ * Platform-specific handling:
+ * - O_DIRECT: Uses PG_O_DIRECT abstraction (Linux native O_DIRECT,
+ * macOS F_NOCACHE via fcntl, Windows FILE_FLAG_NO_BUFFERING)
+ * - fsync: Uses pg_fsync() which selects the appropriate mechanism
+ * (Linux fdatasync, macOS F_FULLFSYNC, Windows FlushFileBuffers,
+ * BSD fsync)
+ * - Directory sync: Uses fsync_fname()/fsync_parent_path() which
+ * handle directory fsync on Unix platforms (not needed on Windows)
+ * - Durable operations: Uses durable_rename()/durable_unlink() which
+ * ensure operations persist across crashes via proper fsync ordering
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ * src/backend/storage/file/fileops.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include
+#include
+#include
+#ifdef HAVE_SYS_FCNTL_H
+#include
+#endif
+
+/*
+ * ENODATA is Linux-specific; FreeBSD and other BSDs don't define it.
+ * When removing extended attributes, ENODATA means "attribute does not
+ * exist" -- equivalent to ENOATTR on BSDs.
+ */
+#ifndef ENODATA
+#ifdef ENOATTR
+#define ENODATA ENOATTR
+#else
+#define ENODATA ENOENT
+#endif
+#endif
+
+#include "access/fileops_xlog.h"
+#include "access/rmgr.h"
+#include "access/twophase_rmgr.h"
+#include "access/undolog.h"
+#include "access/undorecord.h"
+#include "access/undormgr.h"
+#include "access/xact.h"
+#include "access/xactundo.h"
+#include "access/xlog.h"
+#include "access/xloginsert.h"
+#include "catalog/pg_class.h"
+#include "miscadmin.h"
+#include "port/pg_xattr.h"
+#include "storage/fd.h"
+#include "storage/fileops.h"
+#include "utils/injection_point.h"
+#include "utils/memutils.h"
+
+
+/* Head of the pending file operations linked list */
+static PendingFileOp *pendingFileOps = NULL;
+
+static void FileOpsExecOne(PendingFileOp *pending, bool isCommit,
+ XLogRecPtr *max_lsn);
+
+/*
+ * fileops_fsync_parent -- fsync the parent directory of a file path
+ *
+ * This ensures that directory entry changes (create, delete, rename)
+ * are durable. On Windows, directory fsync is not needed because NTFS
+ * journals directory entries; fsync_fname_ext() handles this by being
+ * a no-op for directories on Windows.
+ */
+static void
+fileops_fsync_parent(const char *fname, int elevel)
+{
+ char parentpath[MAXPGPATH];
+ char *sep;
+
+ strlcpy(parentpath, fname, MAXPGPATH);
+
+ sep = strrchr(parentpath, '/');
+ if (sep != NULL)
+ {
+ /* Got a path component, fsync the directory portion */
+ if (sep == parentpath)
+ parentpath[1] = '\0'; /* root directory */
+ else
+ *sep = '\0';
+
+ fsync_fname_ext(parentpath, true, true, elevel);
+ }
+}
+
+/*
+ * AddPendingFileOp - Add a new pending file operation to the list
+ *
+ * All fields are deep-copied into TopMemoryContext to survive
+ * until transaction end, following the PendingRelDelete pattern.
+ */
+static void
+AddPendingFileOp(PendingFileOpType type, const char *path,
+ const char *newpath, off_t length, bool at_commit)
+{
+ PendingFileOp *pending;
+ MemoryContext oldcxt;
+
+ oldcxt = MemoryContextSwitchTo(TopMemoryContext);
+
+ pending = (PendingFileOp *) palloc(sizeof(PendingFileOp));
+ pending->type = type;
+ pending->path = pstrdup(path);
+ pending->newpath = newpath ? pstrdup(newpath) : NULL;
+ pending->length = length;
+ pending->data = NULL;
+ pending->data_len = 0;
+ pending->at_commit = at_commit;
+ pending->nestLevel = GetCurrentTransactionNestLevel();
+ pending->next = pendingFileOps;
+ pendingFileOps = pending;
+
+ MemoryContextSwitchTo(oldcxt);
+}
+
+/*
+ * AddPendingFileOpWithData - Add a pending file operation with extra data
+ *
+ * Like AddPendingFileOp but also stores arbitrary data (e.g., original
+ * xattr value for restore on abort).
+ */
+static void
+AddPendingFileOpWithData(PendingFileOpType type, const char *path,
+ const char *newpath, off_t length,
+ const void *data, size_t data_len,
+ bool at_commit)
+{
+ PendingFileOp *pending;
+ MemoryContext oldcxt;
+
+ oldcxt = MemoryContextSwitchTo(TopMemoryContext);
+
+ pending = (PendingFileOp *) palloc(sizeof(PendingFileOp));
+ pending->type = type;
+ pending->path = pstrdup(path);
+ pending->newpath = newpath ? pstrdup(newpath) : NULL;
+ pending->length = length;
+ if (data && data_len > 0)
+ {
+ pending->data = palloc(data_len);
+ memcpy(pending->data, data, data_len);
+ pending->data_len = data_len;
+ }
+ else
+ {
+ pending->data = NULL;
+ pending->data_len = 0;
+ }
+ pending->at_commit = at_commit;
+ pending->nestLevel = GetCurrentTransactionNestLevel();
+ pending->next = pendingFileOps;
+ pendingFileOps = pending;
+
+ MemoryContextSwitchTo(oldcxt);
+}
+
+/*
+ * FreePendingFileOp - Free a pending file operation entry
+ */
+static void
+FreePendingFileOp(PendingFileOp *pending)
+{
+ if (pending->path)
+ pfree(pending->path);
+ if (pending->newpath)
+ pfree(pending->newpath);
+ if (pending->data)
+ pfree(pending->data);
+ pfree(pending);
+}
+
+/*
+ * FileOpsCancelPendingDelete - Cancel a pending file deletion
+ *
+ * This removes matching DELETE entries from the pendingFileOps list.
+ * It is called by RelationPreserveStorage() to ensure that when a
+ * relation's storage is preserved (e.g., during index reuse in ALTER TABLE),
+ * the corresponding FileOps DELETE entry is also cancelled, preventing
+ * FileOpsDoPendingOps from deleting the file at commit time.
+ */
+void
+FileOpsCancelPendingDelete(const char *path, bool at_commit)
+{
+ PendingFileOp *pending;
+ PendingFileOp *prev;
+ PendingFileOp *next;
+
+ prev = NULL;
+ for (pending = pendingFileOps; pending != NULL; pending = next)
+ {
+ next = pending->next;
+ if (pending->type == PENDING_FILEOP_DELETE &&
+ pending->at_commit == at_commit &&
+ strcmp(pending->path, path) == 0)
+ {
+ /* unlink and free list entry */
+ if (prev)
+ prev->next = next;
+ else
+ pendingFileOps = next;
+ FreePendingFileOp(pending);
+ /* prev does not change */
+ }
+ else
+ {
+ prev = pending;
+ }
+ }
+}
+
+/*
+ * FileOpsSync - Ensure a file's data is durably written to disk
+ *
+ * This is a convenience wrapper around fsync_fname() that uses the
+ * platform-appropriate sync mechanism:
+ * - Linux: fdatasync() (only flushes data, not metadata unless needed)
+ * - macOS: fcntl(F_FULLFSYNC) (flushes disk write cache)
+ * - FreeBSD: fsync()
+ * - Windows: FlushFileBuffers()
+ *
+ * An ERROR is raised if the sync fails.
+ */
+/*
+ * FileOpsCreate - Create a file within a transaction
+ *
+ * Creates the file immediately (so it can be used within the transaction)
+ * and logs the creation to WAL. If register_delete is true, the file will
+ * be deleted if the transaction aborts.
+ *
+ * Platform handling:
+ * - Linux/FreeBSD: O_DIRECT passed directly to open()
+ * - macOS: F_NOCACHE fcntl applied after open()
+ * - Windows: FILE_FLAG_NO_BUFFERING (handled by port layer)
+ *
+ * Returns the file descriptor on success, or -1 on failure.
+ */
+int
+FileOpsCreate(const char *path, int flags, mode_t mode, bool register_delete)
+{
+ int fd;
+ bool need_wal = XLogIsNeeded();
+ xl_fileops_create xlrec;
+ int pathlen = 0;
+ char undo_payload[sizeof(FileopsUndoCreate) + MAXPGPATH];
+ XactUndoContext undo_ctx;
+
+ Assert(!IsInParallelMode());
+
+ /*
+ * Write-ahead ordering: the WAL record (and UNDO record) must be durably
+ * on disk BEFORE the file is physically created. Once that is true, the
+ * create+fsync below is contractually obligated to happen -- a failure at
+ * that point would leave a durable record of a creation that never
+ * occurred, so it runs inside a critical section and any failure is
+ * promoted to PANIC by the elog machinery (see errfinish() in elog.c).
+ * This mirrors RelationTruncate() in catalog/storage.c and the (already
+ * correct) FileOpsTruncate() below.
+ */
+ if (need_wal)
+ {
+ FileopsUndoCreate *hdr = (FileopsUndoCreate *) undo_payload;
+
+ xlrec.flags = flags;
+ xlrec.mode = mode;
+ xlrec.register_delete = register_delete;
+
+ pathlen = (int) strlen(path) + 1;
+
+ /*
+ * The path length is stored in a uint16 (hdr->path_len) and the
+ * payload buffer is sized for MAXPGPATH. Paths are MAXPGPATH-bounded
+ * everywhere upstream, so this is defensive, but a
+ * filesystem-mutation WAL record must never silently truncate an
+ * over-long path into a uint16 -- fail loudly instead.
+ */
+ if (pathlen > MAXPGPATH)
+ ereport(ERROR,
+ (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED),
+ errmsg("file operation path length %d exceeds maximum %d",
+ pathlen, MAXPGPATH)));
+
+ hdr->path_len = (uint16) pathlen;
+ memcpy(undo_payload + sizeof(FileopsUndoCreate), path, pathlen);
+
+ /*
+ * PrepareXactUndoData() may palloc (first UNDO record of this
+ * persistence level in the transaction allocates an UndoRecordSet),
+ * so it must run before the critical section; palloc is forbidden
+ * inside one.
+ */
+ PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT,
+ UNDO_RMID_FILEOPS, FILEOPS_UNDO_CREATE,
+ InvalidOid, undo_payload,
+ sizeof(FileopsUndoCreate) + pathlen);
+
+ /*
+ * Load the injection point cache entry before the critical section;
+ * looking it up can palloc, which is forbidden once we
+ * START_CRIT_SECTION.
+ */
+ INJECTION_POINT_LOAD("fileops-create-after-wal-flush");
+ }
+
+ if (need_wal)
+ START_CRIT_SECTION();
+
+ if (need_wal)
+ {
+ XLogRecPtr lsn;
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsCreate);
+ XLogRegisterData(path, pathlen);
+ lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_CREATE);
+
+ InsertXactUndoData(&undo_ctx);
+ CleanupXactUndoInsertion(&undo_ctx);
+
+ XLogFlush(lsn);
+
+ INJECTION_POINT_CACHED("fileops-create-after-wal-flush", NULL);
+ }
+
+ fd = OpenTransientFilePerm(path, flags | O_CREAT, mode);
+ if (fd < 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not create file \"%s\": %m", path)));
+
+ if (enableFsync)
+ {
+ if (pg_fsync(fd) != 0)
+ ereport(data_sync_elevel(ERROR),
+ (errcode_for_file_access(),
+ errmsg("could not fsync file \"%s\": %m", path)));
+ fileops_fsync_parent(path, data_sync_elevel(ERROR));
+ }
+
+ if (need_wal)
+ END_CRIT_SECTION();
+
+ if (register_delete)
+ AddPendingFileOp(PENDING_FILEOP_DELETE, path, NULL, 0, false);
+
+ return fd;
+}
+
+/*
+ * FileOpsDelete - Schedule a file deletion within a transaction
+ *
+ * The file is not deleted immediately. Instead, the deletion is deferred
+ * to transaction commit (if at_commit is true) or abort (if false).
+ * On Unix: unlink() with parent dir fsync.
+ * On Windows: pgunlink() with retry on EACCES.
+ */
+void
+FileOpsDelete(const char *path, bool at_commit)
+{
+ Assert(!IsInParallelMode());
+
+ /*
+ * no WAL is emitted at registration time. The deferred delete is
+ * WAL-logged in FileOpsDoPendingOps() at commit-execution time, when it
+ * actually happens on the primary, so a physical standby reproduces it
+ * via redo. Registration-time logging would be a no-op on redo (the
+ * standby never runs FileOpsDoPendingOps()) and, for at_commit=false
+ * delete-on-abort registrations, would wrongly apply an aborted
+ * transaction's effect on the standby.
+ */
+ AddPendingFileOp(PENDING_FILEOP_DELETE, path, NULL, 0, at_commit);
+}
+
+/*
+ * FileOpsRename - Rename a file within a transaction
+ *
+ * The rename is deferred to commit time. Uses durable_rename() internally
+ * which handles all platform differences:
+ * - Unix: rename() with fsync of both old and new parent dirs
+ * - Windows: MoveFileEx(MOVEFILE_REPLACE_EXISTING) with retry
+ */
+int
+FileOpsRename(const char *oldpath, const char *newpath)
+{
+ Assert(!IsInParallelMode());
+
+ /*
+ * WAL is emitted at commit-execution time in FileOpsDoPendingOps(), not
+ * at registration, so the standby reproduces the rename via redo.
+ */
+ AddPendingFileOp(PENDING_FILEOP_RENAME, oldpath, newpath, 0, true);
+
+ return 0;
+}
+
+/*
+ * FileOpsWrite - Write data to a file at a specific offset
+ *
+ * Immediate execution, WAL-logged for crash recovery replay.
+ * Uses pwrite() on POSIX. On Windows: SetFilePointerEx + WriteFile.
+ *
+ * Returns 0 on success, -1 on failure.
+ */
+int
+FileOpsWrite(const char *path, off_t offset, const void *data, uint32 len)
+{
+ int fd;
+ ssize_t nbytes;
+ bool need_wal = XLogIsNeeded();
+
+ Assert(!IsInParallelMode());
+
+ fd = OpenTransientFile(path, O_RDWR | PG_BINARY);
+ if (fd < 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not open file \"%s\" for writing: %m", path)));
+
+ /*
+ * Write-ahead ordering: WAL must be durable before the physical write
+ * that it describes. There is no UNDO record here (unlike the other
+ * Category-A operations) because FileOpsWrite has no "original value"
+ * concept to restore on abort -- callers that need rollback protection
+ * pair it with FileOpsCreate's delete-on-abort or FileOpsTruncate. Once
+ * the WAL record is flushed, the write+fsync below is contractually
+ * obligated to happen; a failure at that point is escalated to PANIC by
+ * the surrounding critical section (see errfinish() in elog.c), matching
+ * FileOpsTruncate() below and RelationTruncate() in catalog/storage.c.
+ */
+ if (need_wal)
+ {
+ xl_fileops_write xlrec;
+ int pathlen;
+ XLogRecPtr lsn;
+
+ pathlen = (int) strlen(path) + 1;
+
+ xlrec.offset = offset;
+ xlrec.len = len;
+ xlrec.path_len = (uint16) pathlen;
+
+ START_CRIT_SECTION();
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsWrite);
+ XLogRegisterData(path, pathlen);
+ XLogRegisterData(data, len);
+ lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_WRITE);
+
+ XLogFlush(lsn);
+ }
+
+ nbytes = pg_pwrite(fd, data, len, offset);
+ if (nbytes != (ssize_t) len)
+ {
+ int save_errno = errno;
+
+ CloseTransientFile(fd);
+ errno = save_errno;
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not write %u bytes to file \"%s\" at offset %lld: %m",
+ len, path, (long long) offset)));
+ }
+
+ if (enableFsync && pg_fsync(fd) != 0)
+ {
+ int save_errno = errno;
+
+ CloseTransientFile(fd);
+ errno = save_errno;
+ ereport(data_sync_elevel(ERROR),
+ (errcode_for_file_access(),
+ errmsg("could not fsync file \"%s\" after write: %m", path)));
+ }
+
+ if (need_wal)
+ END_CRIT_SECTION();
+
+ if (CloseTransientFile(fd) != 0)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not close file \"%s\": %m", path)));
+
+ return 0;
+}
+
+/*
+ * FileOpsTruncate - Truncate a file within a transaction
+ *
+ * Executed immediately and WAL-logged. Uses ftruncate() on POSIX,
+ * SetEndOfFile() on Windows. File is fsynced after truncation.
+ */
+void
+FileOpsTruncate(const char *path, off_t length)
+{
+ int fd;
+ struct stat st;
+ off_t orig_length = 0;
+ bool need_wal = XLogIsNeeded();
+
+ Assert(!IsInParallelMode());
+
+ /* Capture original length for UNDO before truncation */
+ if (stat(path, &st) == 0)
+ orig_length = st.st_size;
+
+ if (need_wal)
+ {
+ xl_fileops_truncate xlrec;
+ int pathlen;
+ XactUndoContext undo_ctx;
+ char payload[sizeof(FileopsUndoTruncate) + MAXPGPATH];
+ FileopsUndoTruncate *hdr = (FileopsUndoTruncate *) payload;
+ XLogRecPtr lsn;
+
+ xlrec.length = length;
+ xlrec.orig_length = orig_length;
+ pathlen = (int) strlen(path) + 1;
+
+ hdr->orig_length = orig_length;
+ hdr->path_len = (uint16) pathlen;
+ memcpy(payload + sizeof(FileopsUndoTruncate), path, pathlen);
+
+ /*
+ * PrepareXactUndoData() may palloc, so it must happen before the
+ * critical section below (palloc is forbidden inside one).
+ */
+ PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT,
+ UNDO_RMID_FILEOPS, FILEOPS_UNDO_TRUNCATE,
+ InvalidOid, payload,
+ sizeof(FileopsUndoTruncate) + pathlen);
+
+ /*
+ * Once the WAL/UNDO record is durably flushed below, the physical
+ * ftruncate()+fsync() is contractually obligated to happen: a
+ * durably-recorded truncation that never occurs on disk would leave
+ * recovery/rollback with nothing to reconcile against. Wrap the
+ * whole WAL-insert-through-syscall sequence in a critical section so
+ * that any failure past this point is promoted to PANIC by the elog
+ * machinery (errfinish() in elog.c), instead of silently diverging
+ * via an ordinary transaction abort. This retrofits the same
+ * protection RelationTruncate() already has in catalog/storage.c,
+ * which this function was previously missing.
+ */
+ START_CRIT_SECTION();
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsTruncate);
+ XLogRegisterData(path, pathlen);
+ lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_TRUNCATE);
+
+ InsertXactUndoData(&undo_ctx);
+ CleanupXactUndoInsertion(&undo_ctx);
+
+ /*
+ * Flush the redo and UNDO records to durable storage before mutating
+ * the file below. ftruncate()+pg_fsync() makes the physical change
+ * durable immediately; if we crashed after that but before the WAL
+ * reached disk, recovery would have no record of the operation and no
+ * UNDO batch to reverse it on rollback. This mirrors the write-ahead
+ * ordering in RelationTruncate() (see catalog/storage.c). Flush the
+ * LSN XLogInsert() itself returned, not a separately-fetched "current
+ * insert position" -- under concurrency the latter can point into the
+ * middle of a record another backend has reserved but not finished
+ * copying, which makes XLogFlush() fail with "xlog flush request is
+ * not satisfied".
+ */
+ XLogFlush(lsn);
+ }
+
+ fd = OpenTransientFile(path, O_RDWR | PG_BINARY);
+ if (fd < 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not open file \"%s\" for truncation: %m", path)));
+
+ if (ftruncate(fd, length) < 0)
+ {
+ int save_errno = errno;
+
+ CloseTransientFile(fd);
+ errno = save_errno;
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not truncate file \"%s\" to %lld bytes: %m",
+ path, (long long) length)));
+ }
+
+ if (enableFsync && pg_fsync(fd) != 0)
+ {
+ int save_errno = errno;
+
+ CloseTransientFile(fd);
+ errno = save_errno;
+ ereport(data_sync_elevel(ERROR),
+ (errcode_for_file_access(),
+ errmsg("could not fsync file \"%s\" after truncation: %m",
+ path)));
+ }
+
+ if (need_wal)
+ END_CRIT_SECTION();
+
+ if (CloseTransientFile(fd) != 0)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not close file \"%s\": %m", path)));
+
+ /*
+ * Register abort-time pending op to restore original size on rollback.
+ * This provides immediate rollback without waiting for the background
+ * UNDO worker (which is deferred due to BumpContext limitations).
+ */
+ AddPendingFileOp(PENDING_FILEOP_TRUNCATE, path, NULL, orig_length, false);
+}
+
+/*
+ * FileOpsChmod - Change file permissions within a transaction
+ *
+ * Immediate execution, WAL-logged.
+ * On POSIX: chmod(). On Windows: _chmod() with limited mode bits
+ * (only _S_IREAD/_S_IWRITE; no group/other). Logs WARNING for
+ * unsupported mode bits on Windows.
+ *
+ * Returns 0 on success, -1 on failure with errno left set so the caller
+ * can distinguish ENOENT from other errors. No WAL, UNDO record, or
+ * pending op is registered on the failure path because the on-disk mode
+ * was not changed.
+ *
+ * Design note (write-ordering vs. tolerable-failure contract): WAL/UNDO
+ * must be durable before chmod() runs, but tablespace.c's caller relies on
+ * getting a plain -1/ENOENT back (not a crash) when the target directory
+ * does not exist -- a real caller-observed case, not a hypothetical one.
+ * We resolve the conflict by reusing the stat() this function already
+ * needs (to capture the original mode for UNDO) as a preflight: if the
+ * path cannot be stat()ed, chmod() would fail the same way, so we return
+ * -1 with stat()'s errno before writing anything to WAL. This is not a
+ * "trial run" of the mutating syscall itself (no double-mutation hazard),
+ * and existence does not flip in the narrow window between this stat()
+ * and the chmod() below for paths FileOps callers own/manage. Once that
+ * preflight passes, the WAL/UNDO record becomes durable and chmod() is
+ * contractually obligated to happen: any failure past that point (a
+ * genuine race, read-only remount, immutable-flag, etc.) is escalated to
+ * PANIC by the surrounding critical section, per errfinish() in elog.c.
+ */
+int
+FileOpsChmod(const char *path, mode_t mode)
+{
+ struct stat st;
+ mode_t orig_mode;
+ bool need_wal;
+
+ Assert(!IsInParallelMode());
+
+ /* Capture original mode for UNDO before chmod, and preflight ENOENT */
+ if (stat(path, &st) != 0)
+ return -1; /* leave errno set by stat() for the caller */
+ orig_mode = st.st_mode & 07777;
+
+ need_wal = XLogIsNeeded();
+
+ if (need_wal)
+ {
+ xl_fileops_chmod xlrec;
+ int pathlen;
+ XactUndoContext undo_ctx;
+ char payload[sizeof(FileopsUndoChmod) + MAXPGPATH];
+ FileopsUndoChmod *hdr = (FileopsUndoChmod *) payload;
+ XLogRecPtr lsn;
+
+ xlrec.mode = mode;
+ xlrec.orig_mode = orig_mode;
+ pathlen = (int) strlen(path) + 1;
+
+ hdr->orig_mode = orig_mode;
+ hdr->path_len = (uint16) pathlen;
+ memcpy(payload + sizeof(FileopsUndoChmod), path, pathlen);
+
+ /* PrepareXactUndoData() may palloc; must run before the crit section */
+ PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT,
+ UNDO_RMID_FILEOPS, FILEOPS_UNDO_CHMOD,
+ InvalidOid, payload,
+ sizeof(FileopsUndoChmod) + pathlen);
+
+ /*
+ * Load the injection point cache entry before the critical section;
+ * looking it up can palloc, which is forbidden once we
+ * START_CRIT_SECTION.
+ */
+ INJECTION_POINT_LOAD("fileops-chmod-after-wal-flush");
+
+ START_CRIT_SECTION();
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsChmod);
+ XLogRegisterData(path, pathlen);
+ lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_CHMOD);
+
+ InsertXactUndoData(&undo_ctx);
+ CleanupXactUndoInsertion(&undo_ctx);
+
+ XLogFlush(lsn);
+
+ INJECTION_POINT_CACHED("fileops-chmod-after-wal-flush", NULL);
+ }
+
+ if (chmod(path, mode) < 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not chmod file \"%s\": %m", path)));
+
+ if (need_wal)
+ END_CRIT_SECTION();
+
+ /*
+ * Register abort-time pending op to restore original mode on rollback.
+ * Store mode in the length field (mode_t fits in off_t).
+ */
+ AddPendingFileOp(PENDING_FILEOP_CHMOD, path, NULL, (off_t) orig_mode, false);
+
+ return 0;
+}
+
+/*
+ * FileOpsChown - Change file ownership within a transaction
+ *
+ * Immediate execution, WAL-logged.
+ * On POSIX: chown(). On Windows: no-op with WARNING (Windows uses
+ * ACLs for ownership, not uid/gid).
+ *
+ * Returns 0 on success.
+ */
+int
+FileOpsChown(const char *path, uid_t uid, gid_t gid)
+{
+ struct stat st;
+ uid_t orig_uid = (uid_t) -1;
+ gid_t orig_gid = (gid_t) -1;
+ bool need_wal = XLogIsNeeded();
+
+ Assert(!IsInParallelMode());
+
+ /* Capture original ownership for UNDO before chown */
+ if (stat(path, &st) == 0)
+ {
+ orig_uid = st.st_uid;
+ orig_gid = st.st_gid;
+ }
+
+ /*
+ * Write-ahead ordering: WAL/UNDO must be durable before the physical
+ * chown(). Once durable, chown() is contractually obligated to happen; a
+ * failure past that point is escalated to PANIC by the surrounding
+ * critical section (see errfinish() in elog.c), matching
+ * FileOpsTruncate() above and RelationTruncate() in catalog/storage.c.
+ */
+ if (need_wal)
+ {
+ xl_fileops_chown xlrec;
+ int pathlen;
+ XactUndoContext undo_ctx;
+ char payload[sizeof(FileopsUndoChown) + MAXPGPATH];
+ FileopsUndoChown *hdr = (FileopsUndoChown *) payload;
+ XLogRecPtr lsn;
+
+ xlrec.uid = uid;
+ xlrec.gid = gid;
+ xlrec.orig_uid = orig_uid;
+ xlrec.orig_gid = orig_gid;
+ pathlen = (int) strlen(path) + 1;
+
+ hdr->orig_uid = orig_uid;
+ hdr->orig_gid = orig_gid;
+ hdr->path_len = (uint16) pathlen;
+ memcpy(payload + sizeof(FileopsUndoChown), path, pathlen);
+
+ /* PrepareXactUndoData() may palloc; must run before the crit section */
+ PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT,
+ UNDO_RMID_FILEOPS, FILEOPS_UNDO_CHOWN,
+ InvalidOid, payload,
+ sizeof(FileopsUndoChown) + pathlen);
+
+ START_CRIT_SECTION();
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsChown);
+ XLogRegisterData(path, pathlen);
+ lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_CHOWN);
+
+ InsertXactUndoData(&undo_ctx);
+ CleanupXactUndoInsertion(&undo_ctx);
+
+ XLogFlush(lsn);
+ }
+
+#ifndef WIN32
+ if (chown(path, uid, gid) < 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not chown file \"%s\" to %d:%d: %m",
+ path, (int) uid, (int) gid)));
+#else
+ ereport(WARNING,
+ (errmsg("chown is not supported on Windows, skipping for \"%s\"",
+ path)));
+#endif
+
+ if (need_wal)
+ END_CRIT_SECTION();
+
+ return 0;
+}
+
+void
+FileOpsSync(const char *path)
+{
+ fsync_fname(path, false);
+}
+
+/*
+ * FileOpsMkdir - Create a directory within a transaction
+ *
+ * Immediate execution. Optionally registers rmdir-on-abort.
+ * Uses MakePGDirectory() pattern (mkdir with pg_dir_create_mode).
+ * On Windows: _mkdir() (no mode parameter, permissions from parent).
+ *
+ * Returns 0 on success.
+ */
+int
+FileOpsMkdir(const char *path, mode_t mode)
+{
+ bool need_wal = XLogIsNeeded();
+
+ Assert(!IsInParallelMode());
+
+ /*
+ * Write-ahead ordering: WAL/UNDO must be durable before the physical
+ * mkdir()+parent-dir-fsync. Once durable, the mkdir is contractually
+ * obligated to happen; a failure past that point is escalated to PANIC by
+ * the surrounding critical section (see errfinish() in elog.c), matching
+ * FileOpsTruncate() above and RelationTruncate() in catalog/storage.c.
+ */
+ if (need_wal)
+ {
+ xl_fileops_mkdir xlrec;
+ int pathlen;
+ XactUndoContext undo_ctx;
+ char payload[sizeof(FileopsUndoMkdir) + MAXPGPATH];
+ FileopsUndoMkdir *hdr = (FileopsUndoMkdir *) payload;
+ XLogRecPtr lsn;
+
+ xlrec.mode = mode;
+ pathlen = (int) strlen(path) + 1;
+
+ hdr->path_len = (uint16) pathlen;
+ memcpy(payload + sizeof(FileopsUndoMkdir), path, pathlen);
+
+ /* PrepareXactUndoData() may palloc; must run before the crit section */
+ PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT,
+ UNDO_RMID_FILEOPS, FILEOPS_UNDO_MKDIR,
+ InvalidOid, payload,
+ sizeof(FileopsUndoMkdir) + pathlen);
+
+ START_CRIT_SECTION();
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsMkdir);
+ XLogRegisterData(path, pathlen);
+ lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_MKDIR);
+
+ InsertXactUndoData(&undo_ctx);
+ CleanupXactUndoInsertion(&undo_ctx);
+
+ XLogFlush(lsn);
+ }
+
+ if (MakePGDirectory(path) < 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not create directory \"%s\": %m", path)));
+
+ if (enableFsync)
+ fileops_fsync_parent(path, data_sync_elevel(ERROR));
+
+ if (need_wal)
+ END_CRIT_SECTION();
+
+ /* Register rmdir-on-abort so directory is cleaned up on rollback */
+ AddPendingFileOp(PENDING_FILEOP_RMDIR, path, NULL, 0, false);
+
+ return 0;
+}
+
+/*
+ * FileOpsRmdir - Remove a directory within a transaction
+ *
+ * Deferred to commit time (like DELETE). Uses rmdir() on all platforms.
+ * On Windows: _rmdir().
+ */
+void
+FileOpsRmdir(const char *path, bool at_commit)
+{
+ Assert(!IsInParallelMode());
+
+ /*
+ * WAL is emitted at commit-execution time in FileOpsDoPendingOps(), not
+ * at registration, so the standby reproduces the rmdir via redo.
+ */
+ AddPendingFileOp(PENDING_FILEOP_RMDIR, path, NULL, 0, at_commit);
+}
+
+/*
+ * FileOpsRmtree - Schedule a recursive directory removal at commit/abort time.
+ *
+ * Unlike FileOpsRmdirRecursive (which enumerates the tree at registration
+ * time and queues per-entry FILEOPS_DELETE/RMDIR ops), this enqueues a
+ * single PENDING_FILEOP_RMTREE that defers the tree walk to execute time.
+ * That is the right semantic for callers that register the destination of
+ * an in-progress copy: the directory is empty (or nonexistent) when the
+ * registration happens, but contains files by the time of abort.
+ *
+ * No WAL is written because rmtree is not a directly recoverable
+ * operation; the abort case relies on the surrounding DDL having already
+ * recorded enough state to make the partial tree disposable.
+ */
+void
+FileOpsRmtree(const char *path, bool at_commit)
+{
+ Assert(!IsInParallelMode());
+ AddPendingFileOp(PENDING_FILEOP_RMTREE, path, NULL, 0, at_commit);
+}
+
+/*
+ * FileOpsRmdirRecursive - Schedule recursive directory removal.
+ *
+ * Enumerates directory tree and schedules:
+ * - FileOpsDelete() for each file (at_commit)
+ * - FileOpsRmdir() for each subdirectory (at_commit, leaf-first order)
+ *
+ * Permissive: ENOENT is logged but not an error.
+ */
+void
+FileOpsRmdirRecursive(const char *path, bool at_commit)
+{
+ DIR *dir;
+ struct dirent *de;
+
+ dir = AllocateDir(path);
+ if (dir == NULL)
+ {
+ if (errno == ENOENT)
+ {
+ elog(LOG, "FileOpsRmdirRecursive: directory \"%s\" does not exist",
+ path);
+ return;
+ }
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not open directory \"%s\": %m", path)));
+ }
+
+ while ((de = ReadDirExtended(dir, path, LOG)) != NULL)
+ {
+ char subpath[MAXPGPATH];
+ struct stat st;
+
+ if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0)
+ continue;
+
+ snprintf(subpath, MAXPGPATH, "%s/%s", path, de->d_name);
+ if (lstat(subpath, &st) == 0)
+ {
+ if (S_ISDIR(st.st_mode))
+ FileOpsRmdirRecursive(subpath, at_commit);
+ else
+ FileOpsDelete(subpath, at_commit);
+ }
+ }
+ FreeDir(dir);
+ FileOpsRmdir(path, at_commit);
+}
+
+/*
+ * FileOpsSymlink - Create a symbolic link within a transaction
+ *
+ * Immediate execution. Registers delete-on-abort for cleanup.
+ * On POSIX: symlink(). On Windows: pgsymlink() which creates NTFS
+ * junction points via DeviceIoControl(). Note: junction points only
+ * work for directories on Windows.
+ *
+ * Returns 0 on success.
+ */
+int
+FileOpsSymlink(const char *target, const char *linkpath)
+{
+ bool need_wal = XLogIsNeeded();
+
+ Assert(!IsInParallelMode());
+
+ /*
+ * Write-ahead ordering: WAL/UNDO must be durable before the physical
+ * symlink()+parent-dir-fsync. Once durable, the symlink is contractually
+ * obligated to happen; a failure past that point is escalated to PANIC by
+ * the surrounding critical section (see errfinish() in elog.c), matching
+ * FileOpsTruncate() above and RelationTruncate() in catalog/storage.c.
+ */
+ if (need_wal)
+ {
+ xl_fileops_symlink xlrec;
+ int targetlen;
+ int linkpathlen;
+ XactUndoContext undo_ctx;
+ char payload[sizeof(FileopsUndoSymlink) + MAXPGPATH];
+ FileopsUndoSymlink *hdr = (FileopsUndoSymlink *) payload;
+ XLogRecPtr lsn;
+
+ targetlen = (int) strlen(target) + 1;
+ linkpathlen = (int) strlen(linkpath) + 1;
+ xlrec.target_len = (uint16) targetlen;
+
+ hdr->linkpath_len = (uint16) linkpathlen;
+ memcpy(payload + sizeof(FileopsUndoSymlink), linkpath, linkpathlen);
+
+ /* PrepareXactUndoData() may palloc; must run before the crit section */
+ PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT,
+ UNDO_RMID_FILEOPS, FILEOPS_UNDO_SYMLINK,
+ InvalidOid, payload,
+ sizeof(FileopsUndoSymlink) + linkpathlen);
+
+ START_CRIT_SECTION();
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsSymlink);
+ XLogRegisterData(target, targetlen);
+ XLogRegisterData(linkpath, linkpathlen);
+ lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_SYMLINK);
+
+ InsertXactUndoData(&undo_ctx);
+ CleanupXactUndoInsertion(&undo_ctx);
+
+ XLogFlush(lsn);
+ }
+
+ if (symlink(target, linkpath) < 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not create symbolic link \"%s\" -> \"%s\": %m",
+ linkpath, target)));
+
+ if (enableFsync)
+ fileops_fsync_parent(linkpath, data_sync_elevel(ERROR));
+
+ if (need_wal)
+ END_CRIT_SECTION();
+
+ /* Register delete-on-abort to clean up the symlink on rollback */
+ AddPendingFileOp(PENDING_FILEOP_DELETE, linkpath, NULL, 0, false);
+
+ return 0;
+}
+
+/*
+ * FileOpsLink - Create a hard link within a transaction
+ *
+ * Immediate execution. Registers delete-on-abort for cleanup.
+ * On POSIX: link(). On Windows: CreateHardLinkA() (NTFS only).
+ *
+ * Returns 0 on success.
+ */
+int
+FileOpsLink(const char *oldpath, const char *newpath)
+{
+ bool need_wal = XLogIsNeeded();
+
+ Assert(!IsInParallelMode());
+
+ /*
+ * Write-ahead ordering: WAL/UNDO must be durable before the physical
+ * link()+parent-dir-fsync. Once durable, the link is contractually
+ * obligated to happen; a failure past that point is escalated to PANIC by
+ * the surrounding critical section (see errfinish() in elog.c), matching
+ * FileOpsTruncate() above and RelationTruncate() in catalog/storage.c.
+ */
+ if (need_wal)
+ {
+ xl_fileops_link xlrec;
+ int oldpathlen;
+ int newpathlen;
+ XactUndoContext undo_ctx;
+ char payload[sizeof(FileopsUndoLink) + MAXPGPATH];
+ FileopsUndoLink *hdr = (FileopsUndoLink *) payload;
+ XLogRecPtr lsn;
+
+ oldpathlen = (int) strlen(oldpath) + 1;
+ newpathlen = (int) strlen(newpath) + 1;
+ xlrec.oldpath_len = (uint16) oldpathlen;
+
+ hdr->newpath_len = (uint16) newpathlen;
+ memcpy(payload + sizeof(FileopsUndoLink), newpath, newpathlen);
+
+ /* PrepareXactUndoData() may palloc; must run before the crit section */
+ PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT,
+ UNDO_RMID_FILEOPS, FILEOPS_UNDO_LINK,
+ InvalidOid, payload,
+ sizeof(FileopsUndoLink) + newpathlen);
+
+ START_CRIT_SECTION();
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsLink);
+ XLogRegisterData(oldpath, oldpathlen);
+ XLogRegisterData(newpath, newpathlen);
+ lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_LINK);
+
+ InsertXactUndoData(&undo_ctx);
+ CleanupXactUndoInsertion(&undo_ctx);
+
+ XLogFlush(lsn);
+ }
+
+ if (link(oldpath, newpath) < 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not create hard link \"%s\" -> \"%s\": %m",
+ newpath, oldpath)));
+
+ if (enableFsync)
+ fileops_fsync_parent(newpath, data_sync_elevel(ERROR));
+
+ if (need_wal)
+ END_CRIT_SECTION();
+
+ /* Register delete-on-abort to clean up the link on rollback */
+ AddPendingFileOp(PENDING_FILEOP_DELETE, newpath, NULL, 0, false);
+
+ return 0;
+}
+
+/*
+ * FileOpsSetXattr - Set an extended attribute on a file
+ *
+ * Immediate execution, WAL-logged.
+ * Uses pg_setxattr() portability layer which handles:
+ * - Linux: setxattr()
+ * - macOS: setxattr() with extra options parameter
+ * - FreeBSD: extattr_set_file()
+ * - Windows: NTFS Alternate Data Streams
+ * - Other: returns ENOTSUP
+ *
+ * Returns 0 on success, -1 if extended attributes are not supported on
+ * this filesystem/platform (errno left as ENOTSUP/EOPNOTSUPP).
+ *
+ * Design note (write-ordering vs. tolerable-failure conflict): the analysis
+ * that preceded this fix flagged that WAL-before-syscall conflicts with the
+ * old "return -1 on ENOTSUP/EOPNOTSUPP/EPERM/EACCES" contract, since a
+ * durable WAL/UNDO record asserting a xattr change that never happened is
+ * exactly the bug being fixed. We resolve it by splitting the old tolerable
+ * set into two buckets:
+ *
+ * - ENOTSUP/EOPNOTSUPP ("this filesystem doesn't implement xattrs at
+ * all") is a whole-filesystem capability question, not a per-call race:
+ * it cannot flip between a preflight check and the real syscall a few
+ * instructions later. The pg_getxattr() probe below (already needed to
+ * capture the original value for UNDO) surfaces this same errno for the
+ * same reason before any WAL is written, so we check it there and
+ * return -1 pre-WAL -- no new syscall, no trial run of the mutating
+ * operation itself.
+ *
+ * - EPERM/EACCES ("security policy denies this specific write") is a
+ * genuine permission decision that CAN legitimately differ between a
+ * read probe and a write attempt (e.g. SELinux, immutable flag), so it
+ * cannot be preflighted without attempting the mutating syscall itself
+ * (which the prior analysis correctly flagged as fragile/racy and not
+ * attempted here). grep of the tree found zero production callers of
+ * FileOpsSetXattr; the only consumer (test_fileops) exists to probe
+ * whole-filesystem xattr support, which the ENOTSUP/EOPNOTSUPP preflight
+ * above already covers. We therefore treat a post-WAL EPERM/EACCES the
+ * same as any other post-WAL syscall failure: it escalates to PANIC via
+ * the surrounding critical section (errfinish() in elog.c), because a
+ * durable WAL/UNDO record with no corresponding physical change is the
+ * load-bearing invariant this whole fix protects. If a real caller ever
+ * needs graceful EPERM/EACCES handling, that is a signal this decision
+ * needs revisiting (see FILEOPS-ANALYSIS-UNIMPLEMENTED.md point 3).
+ */
+int
+FileOpsSetXattr(const char *path, const char *name,
+ const void *value, size_t len)
+{
+ bool had_value = false;
+ char *orig_value = NULL;
+ ssize_t orig_value_len = 0;
+ bool need_wal;
+ struct stat st;
+
+ Assert(!IsInParallelMode());
+
+ /*
+ * Preflight existence check: FileOpsSetXattr on a nonexistent path is a
+ * caller error (ENOENT was never in the tolerable set below), and must be
+ * caught here, before any WAL is written -- otherwise it would be caught
+ * by the mutating syscall itself AFTER WAL is durable, escalating an
+ * ordinary caller mistake to PANIC.
+ */
+ if (stat(path, &st) != 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not stat file \"%s\": %m", path)));
+
+ /*
+ * Capture existing xattr value before overwriting. This is needed by the
+ * abort-time pending op (clean rollback) regardless of wal_level, so it
+ * must run unconditionally -- not gated on XLogIsNeeded() like the
+ * WAL/UNDO emission below. If the attribute doesn't exist, had_value
+ * stays false.
+ *
+ * This call doubles as the ENOTSUP/EOPNOTSUPP capability preflight
+ * described above: on every supported platform, a filesystem/inode that
+ * doesn't implement extended attributes fails an attribute-value probe
+ * the same way it would fail a set, regardless of whether the named
+ * attribute happens to exist.
+ */
+ {
+ ssize_t vlen;
+
+ vlen = pg_getxattr(path, name, NULL, 0);
+ if (vlen > 0)
+ {
+ orig_value = (char *) palloc(vlen);
+ orig_value_len = pg_getxattr(path, name, orig_value, vlen);
+ if (orig_value_len >= 0)
+ had_value = true;
+ else
+ {
+ pfree(orig_value);
+ orig_value = NULL;
+ orig_value_len = 0;
+ }
+ }
+ else if (vlen == 0)
+ {
+ /* Attribute exists but has zero-length value */
+ had_value = true;
+ }
+ else if (errno == ENOTSUP || errno == EOPNOTSUPP)
+ {
+ /*
+ * Whole-filesystem capability failure; safe to preflight (see
+ * above)
+ */
+ return -1;
+ }
+
+ /*
+ * else: ENODATA/PG_ENOATTR (attribute absent) or other;
+ * had_value=false
+ */
+ }
+
+ need_wal = XLogIsNeeded();
+
+ /*
+ * Write-ahead ordering: WAL/UNDO must be durable before the physical
+ * setxattr(). Once durable, setxattr() is contractually obligated to
+ * succeed; any failure past this point (including EPERM/EACCES, per the
+ * design note above) is escalated to PANIC by the surrounding critical
+ * section.
+ */
+ if (need_wal)
+ {
+ xl_fileops_setxattr xlrec;
+ int pathlen;
+ int namelen;
+ XactUndoContext undo_ctx;
+ Size payload_len;
+ char *payload;
+ FileopsUndoSetxattr *hdr;
+ XLogRecPtr lsn;
+
+ pathlen = (int) strlen(path) + 1;
+ namelen = (int) strlen(name) + 1;
+
+ xlrec.path_len = (uint16) pathlen;
+ xlrec.name_len = (uint16) namelen;
+ xlrec.value_len = (uint32) len;
+ xlrec.had_value = had_value;
+ xlrec.orig_value_len = (uint32) (had_value ? orig_value_len : 0);
+
+ payload_len = sizeof(FileopsUndoSetxattr) + pathlen + namelen +
+ (had_value ? orig_value_len : 0);
+
+ /*
+ * PrepareXactUndoData() may palloc; this palloc must run before the
+ * crit section too.
+ */
+ payload = (char *) palloc(payload_len);
+ hdr = (FileopsUndoSetxattr *) payload;
+
+ hdr->path_len = (uint16) pathlen;
+ hdr->name_len = (uint16) namelen;
+ hdr->orig_value_len = (uint32) (had_value ? orig_value_len : 0);
+ hdr->had_value = had_value;
+
+ memcpy(payload + sizeof(FileopsUndoSetxattr), path, pathlen);
+ memcpy(payload + sizeof(FileopsUndoSetxattr) + pathlen, name, namelen);
+ if (had_value && orig_value_len > 0)
+ memcpy(payload + sizeof(FileopsUndoSetxattr) + pathlen + namelen,
+ orig_value, orig_value_len);
+
+ PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT,
+ UNDO_RMID_FILEOPS, FILEOPS_UNDO_SETXATTR,
+ InvalidOid, payload, payload_len);
+
+ START_CRIT_SECTION();
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsSetxattr);
+ XLogRegisterData(path, pathlen);
+ XLogRegisterData(name, namelen);
+ XLogRegisterData(value, (uint32) len);
+ if (had_value && orig_value_len > 0)
+ XLogRegisterData(orig_value, (uint32) orig_value_len);
+ lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_SETXATTR);
+
+ InsertXactUndoData(&undo_ctx);
+ CleanupXactUndoInsertion(&undo_ctx);
+ pfree(payload);
+
+ XLogFlush(lsn);
+ }
+
+ if (pg_setxattr(path, name, value, len) < 0)
+ {
+ if (orig_value)
+ pfree(orig_value);
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not set extended attribute \"%s\" on \"%s\": %m",
+ name, path)));
+ }
+
+ if (need_wal)
+ END_CRIT_SECTION();
+
+ /*
+ * Register abort-time pending op to restore/remove xattr on rollback. If
+ * had_value: restore original value. If !had_value: remove the xattr.
+ */
+ if (had_value)
+ AddPendingFileOpWithData(PENDING_FILEOP_SETXATTR, path, name,
+ 0, orig_value, (size_t) orig_value_len, false);
+ else
+ AddPendingFileOp(PENDING_FILEOP_REMOVEXATTR, path, name, 0, false);
+
+ if (orig_value)
+ pfree(orig_value);
+
+ return 0;
+}
+
+/*
+ * FileOpsRemoveXattr - Remove an extended attribute from a file
+ *
+ * Immediate execution, WAL-logged.
+ * Uses pg_removexattr() portability layer.
+ *
+ * Returns 0 on success, -1 if extended attributes are not supported on
+ * this filesystem/platform (errno left as ENOTSUP/EOPNOTSUPP).
+ *
+ * Design note: see FileOpsSetXattr's design note above for the general
+ * reasoning (write-ahead ordering vs. the old tolerable-failure contract).
+ * REMOVEXATTR's tolerable set is narrower than SETXATTR's: only
+ * ENOTSUP/EOPNOTSUPP (whole-filesystem incapability, preflighted the same
+ * way, via the pg_getxattr() probe already needed to save the original
+ * value for UNDO) and "attribute does not exist" (PG_ENOATTR/ENODATA).
+ * The latter is not a permission decision like SETXATTR's EPERM/EACCES --
+ * it is symmetric between a read probe and the write attempt (if
+ * pg_getxattr() just reported the attribute absent, pg_removexattr() will
+ * fail the same way for the same reason microseconds later), and it is
+ * information we already captured for the UNDO decision, not a new trial
+ * run of the mutating syscall. We therefore tolerate exactly that one
+ * errno post-WAL, inside the critical section, and PANIC on anything else,
+ * matching fileops_redo()'s own existing tolerance for this same errno.
+ */
+int
+FileOpsRemoveXattr(const char *path, const char *name)
+{
+ char *saved_value = NULL;
+ ssize_t saved_value_len = 0;
+ bool need_wal;
+
+ Assert(!IsInParallelMode());
+
+ /*
+ * Capture existing xattr value before removal. This is needed by the
+ * abort-time pending op (clean rollback) regardless of wal_level, so it
+ * must run unconditionally -- not gated on XLogIsNeeded() like the
+ * WAL/UNDO emission below. We need it to restore the attribute on
+ * rollback. It also doubles as the ENOTSUP/EOPNOTSUPP capability
+ * preflight (see design note above): a filesystem/inode without xattr
+ * support fails this probe the same way it would fail a removal.
+ */
+ {
+ ssize_t vlen;
+
+ vlen = pg_getxattr(path, name, NULL, 0);
+ if (vlen > 0)
+ {
+ saved_value = (char *) palloc(vlen);
+ saved_value_len = pg_getxattr(path, name, saved_value, vlen);
+ if (saved_value_len < 0)
+ {
+ pfree(saved_value);
+ saved_value = NULL;
+ saved_value_len = 0;
+ }
+ }
+ else if (vlen == 0)
+ {
+ /* Attribute exists but has zero-length value */
+ saved_value_len = 0;
+ saved_value = (char *) palloc(1); /* non-NULL marker */
+ }
+ else if (errno == ENOTSUP || errno == EOPNOTSUPP)
+ {
+ /*
+ * Whole-filesystem capability failure; safe to preflight (see
+ * above)
+ */
+ return -1;
+ }
+
+ /*
+ * else: ENODATA/PG_ENOATTR (attribute absent) or other;
+ * saved_value=NULL
+ */
+ }
+
+ need_wal = XLogIsNeeded();
+
+ /*
+ * Write-ahead ordering: WAL (and UNDO, if there was a value to restore)
+ * must be durable before the physical removal. Once durable,
+ * pg_removexattr() is contractually obligated to either succeed or fail
+ * with the already-anticipated PG_ENOATTR (see design note); any other
+ * failure escalates to PANIC via the surrounding critical section.
+ */
+ if (need_wal)
+ {
+ xl_fileops_removexattr xlrec;
+ int pathlen;
+ int namelen;
+ XactUndoContext undo_ctx;
+ Size payload_len = 0;
+ char *payload = NULL;
+ bool had_value = (saved_value != NULL);
+ XLogRecPtr lsn;
+
+ pathlen = (int) strlen(path) + 1;
+ namelen = (int) strlen(name) + 1;
+
+ xlrec.path_len = (uint16) pathlen;
+ xlrec.name_len = (uint16) namelen;
+ xlrec.had_value = had_value;
+ xlrec.orig_value_len = (uint32) (had_value ? saved_value_len : 0);
+
+ if (saved_value != NULL)
+ {
+ FileopsUndoRemovexattr *hdr;
+
+ payload_len = sizeof(FileopsUndoRemovexattr) + pathlen + namelen +
+ saved_value_len;
+
+ /*
+ * PrepareXactUndoData() may palloc; must run before the crit
+ * section, so this palloc does too.
+ */
+ payload = (char *) palloc(payload_len);
+ hdr = (FileopsUndoRemovexattr *) payload;
+
+ hdr->path_len = (uint16) pathlen;
+ hdr->name_len = (uint16) namelen;
+ hdr->value_len = (uint32) saved_value_len;
+
+ memcpy(payload + sizeof(FileopsUndoRemovexattr), path, pathlen);
+ memcpy(payload + sizeof(FileopsUndoRemovexattr) + pathlen,
+ name, namelen);
+ if (saved_value_len > 0)
+ memcpy(payload + sizeof(FileopsUndoRemovexattr) + pathlen + namelen,
+ saved_value, saved_value_len);
+
+ PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT,
+ UNDO_RMID_FILEOPS, FILEOPS_UNDO_REMOVEXATTR,
+ InvalidOid, payload, payload_len);
+ }
+
+ START_CRIT_SECTION();
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsRemovexattr);
+ XLogRegisterData(path, pathlen);
+ XLogRegisterData(name, namelen);
+ if (had_value && saved_value_len > 0)
+ XLogRegisterData(saved_value, (uint32) saved_value_len);
+ lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_REMOVEXATTR);
+
+ if (saved_value != NULL)
+ {
+ InsertXactUndoData(&undo_ctx);
+ CleanupXactUndoInsertion(&undo_ctx);
+ pfree(payload);
+ }
+
+ XLogFlush(lsn);
+ }
+
+ if (pg_removexattr(path, name) < 0 && errno != PG_ENOATTR)
+ {
+ if (saved_value)
+ pfree(saved_value);
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not remove extended attribute \"%s\" from \"%s\": %m",
+ name, path)));
+ }
+
+ if (need_wal)
+ END_CRIT_SECTION();
+
+ /*
+ * Register abort-time pending op to restore the removed xattr on
+ * rollback. Use SETXATTR type: on abort, set the xattr back to its saved
+ * value.
+ */
+ if (saved_value != NULL)
+ AddPendingFileOpWithData(PENDING_FILEOP_SETXATTR, path, name,
+ 0, saved_value, (size_t) saved_value_len, false);
+
+ if (saved_value)
+ pfree(saved_value);
+
+ return 0;
+}
+
+/*
+ * FileOpsDoPendingOps - Execute pending file operations at transaction end
+ *
+ * At commit, operations with at_commit=true are executed.
+ * At abort, operations with at_commit=false are executed.
+ *
+ * This is called from xact.c at transaction commit/abort, analogous
+ * to smgrDoPendingDeletes().
+ */
+void
+FileOpsDoPendingOps(bool isCommit)
+{
+ int nestLevel = GetCurrentTransactionNestLevel();
+ PendingFileOp *pending;
+ PendingFileOp *prev;
+ PendingFileOp *next;
+ PendingFileOp *exec_head = NULL;
+ PendingFileOp *exec_tail = NULL;
+ XLogRecPtr max_lsn = InvalidXLogRecPtr;
+
+ /*
+ * Pass 1: detach the entries belonging to this (or an inner) nesting
+ * level from the pending list. Those matching the transaction outcome
+ * are queued for execution; the rest are discarded. Outer-level entries
+ * stay on the list for a later call. The queue is built in pending-list
+ * order, which is newest-first because AddPendingFileOp prepends.
+ */
+ prev = NULL;
+ for (pending = pendingFileOps; pending != NULL; pending = next)
+ {
+ next = pending->next;
+
+ if (pending->nestLevel < nestLevel)
+ {
+ /* outer-level entries should not be processed yet */
+ prev = pending;
+ continue;
+ }
+
+ /* unlink from list first, so we don't retry on failure */
+ if (prev)
+ prev->next = next;
+ else
+ pendingFileOps = next;
+
+ if (pending->at_commit == isCommit)
+ {
+ /*
+ * queue for execution, preserving pending-list (newest-first)
+ * order
+ */
+ pending->next = NULL;
+ if (exec_tail != NULL)
+ exec_tail->next = pending;
+ else
+ exec_head = pending;
+ exec_tail = pending;
+ }
+ else
+ FreePendingFileOp(pending);
+ }
+
+ /*
+ * Commit-time ops must run in registration order (oldest-first) so that
+ * nested directory removals proceed deepest-first. The pending list is
+ * LIFO, so the queue collected above is newest-first; reverse it for
+ * commit. Abort-time ops must keep LIFO order, which the collected queue
+ * already preserves, so they are left as-is.
+ */
+ if (isCommit)
+ {
+ PendingFileOp *rev = NULL;
+
+ while (exec_head != NULL)
+ {
+ next = exec_head->next;
+ exec_head->next = rev;
+ rev = exec_head;
+ exec_head = next;
+ }
+ exec_head = rev;
+ }
+
+ /* Commit record written, nothing executed yet (for tests) */
+ if (isCommit && exec_head != NULL)
+ INJECTION_POINT("fileops-commit-before-pending-ops", NULL);
+
+ /* Pass 2: execute the queued ops in order, freeing each when done. */
+ for (pending = exec_head; pending != NULL; pending = next)
+ {
+ next = pending->next;
+
+ FileOpsExecOne(pending, isCommit, &max_lsn);
+
+ FreePendingFileOp(pending);
+ }
+
+ /*
+ * Each destructive commit op above XLogFlush()es its own record BEFORE
+ * performing the filesystem mutation (WAL-before-mutation), so no primary
+ * FS change can outrun its WAL record. This trailing flush is therefore
+ * redundant, kept only as a no-op safety backstop (max_lsn is already
+ * durable). Only the commit path ever sets max_lsn.
+ */
+ if (!XLogRecPtrIsInvalid(max_lsn))
+ XLogFlush(max_lsn);
+}
+
+/*
+ * FileOpsExecOne - Execute one pending file operation
+ *
+ * Performs a single deferred op (commit- or abort-time) exactly as it would
+ * run in an ordinary transaction end. Shared by FileOpsDoPendingOps() and by
+ * the 2PC post-commit/post-abort callbacks, so an op prepared with PREPARE
+ * TRANSACTION runs identically to one that never left this backend.
+ *
+ * *max_lsn accumulates the highest WAL LSN written by a commit-time op so the
+ * caller can flush it. Each destructive commit op also flushes its own record
+ * before mutating the filesystem (WAL-before-mutation), so *max_lsn is only a
+ * backstop.
+ */
+static void
+FileOpsExecOne(PendingFileOp *pending, bool isCommit, XLogRecPtr *max_lsn)
+{
+ {
+ switch (pending->type)
+ {
+ case PENDING_FILEOP_CREATE:
+ /* Creates are executed immediately, nothing to do */
+ break;
+
+ case PENDING_FILEOP_RENAME:
+
+ /*
+ * log-at-commit-execution. Emit the WAL record now, when the
+ * rename actually happens on the primary, so a physical
+ * standby reproduces it via redo (which is now a real,
+ * idempotent handler). WAL-before-op preserves write-ahead
+ * ordering; the commit record that follows orders this after
+ * the transaction's other WAL. Only commit-time execution is
+ * logged: an abort never reaches a PENDING_FILEOP_RENAME
+ * (rename is always at_commit).
+ */
+ if (isCommit && XLogIsNeeded())
+ {
+ xl_fileops_rename xlrec;
+ int oldpathlen = (int) strlen(pending->path) + 1;
+ int newpathlen = (int) strlen(pending->newpath) + 1;
+
+ xlrec.oldpath_len = (uint16) oldpathlen;
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsRename);
+ XLogRegisterData(pending->path, oldpathlen);
+ XLogRegisterData(pending->newpath, newpathlen);
+ *max_lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_RENAME);
+
+ /*
+ * WAL-before-mutation durability: flush this record
+ * BEFORE touching the filesystem. A crash after the
+ * rename but before a batched flush would mutate the
+ * primary's FS without the record ever reaching the
+ * standby, diverging the two. Mirrors the create/write
+ * paths that flush inside their crit section before
+ * mutating.
+ */
+ XLogFlush(*max_lsn);
+ }
+ (void) durable_rename(pending->path, pending->newpath,
+ WARNING);
+ break;
+
+ case PENDING_FILEOP_DELETE:
+
+ /*
+ * log-at-commit-execution, but ONLY for the commit path. A
+ * delete-on-abort registration (at_commit=false) executes
+ * here during abort; that must NOT be WAL-logged, because a
+ * standby must never apply an aborted transaction's
+ * filesystem effect.
+ */
+ if (isCommit && XLogIsNeeded())
+ {
+ xl_fileops_delete xlrec;
+ int pathlen = (int) strlen(pending->path) + 1;
+
+ xlrec.at_commit = true;
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsDelete);
+ XLogRegisterData(pending->path, pathlen);
+ *max_lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_DELETE);
+
+ /* WAL-before-mutation durability (see RENAME above). */
+ XLogFlush(*max_lsn);
+ }
+ if (unlink(pending->path) < 0)
+ {
+ if (errno != ENOENT)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not remove file \"%s\": %m",
+ pending->path)));
+ }
+ else
+ {
+ if (enableFsync)
+ fileops_fsync_parent(pending->path, WARNING);
+ }
+ break;
+
+ case PENDING_FILEOP_RMDIR:
+
+ /*
+ * log-at-commit-execution, commit path only (see the DELETE
+ * case above for the abort-path rationale).
+ */
+ if (isCommit && XLogIsNeeded())
+ {
+ xl_fileops_rmdir xlrec;
+ int pathlen = (int) strlen(pending->path) + 1;
+
+ xlrec.at_commit = true;
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsRmdir);
+ XLogRegisterData(pending->path, pathlen);
+ *max_lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_RMDIR);
+
+ /* WAL-before-mutation durability (see RENAME above). */
+ XLogFlush(*max_lsn);
+ }
+ if (rmdir(pending->path) < 0)
+ {
+ if (errno != ENOENT)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not remove directory \"%s\": %m",
+ pending->path)));
+ }
+ else
+ {
+ if (enableFsync)
+ fileops_fsync_parent(pending->path, WARNING);
+ }
+ break;
+
+ case PENDING_FILEOP_RMTREE:
+
+ /*
+ * Recursive removal. We defer the actual tree walk to
+ * execute time so that a copy operation that registered the
+ * destination tree at copy-start can have the abort path
+ * remove whatever ended up on disk by the time of abort.
+ * Enumerating at registration time would only see the empty
+ * destination directory created moments earlier.
+ */
+ if (!rmtree(pending->path, true))
+ ereport(WARNING,
+ (errmsg("could not remove directory tree \"%s\"",
+ pending->path)));
+ else if (enableFsync)
+ fileops_fsync_parent(pending->path, WARNING);
+ break;
+
+ case PENDING_FILEOP_TRUNCATE:
+ {
+ /*
+ * Restore original file size on abort. The original
+ * length is stored in pending->length.
+ */
+ int trunc_fd;
+
+ trunc_fd = OpenTransientFile(pending->path,
+ O_RDWR | PG_BINARY);
+ if (trunc_fd >= 0)
+ {
+ if (ftruncate(trunc_fd, pending->length) < 0)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not restore file \"%s\" to original size %lld: %m",
+ pending->path,
+ (long long) pending->length)));
+ else if (enableFsync && pg_fsync(trunc_fd) != 0)
+ ereport(data_sync_elevel(ERROR),
+ (errcode_for_file_access(),
+ errmsg("could not fsync file \"%s\" after restoring size: %m",
+ pending->path)));
+ CloseTransientFile(trunc_fd);
+ }
+ else
+ {
+ if (errno != ENOENT)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not open file \"%s\" to restore size: %m",
+ pending->path)));
+ }
+ }
+ break;
+
+ case PENDING_FILEOP_CHMOD:
+ {
+ /*
+ * Restore original file mode on abort. The original mode
+ * is stored in pending->length (cast from mode_t).
+ */
+ mode_t restore_mode = (mode_t) pending->length;
+
+ if (chmod(pending->path, restore_mode) < 0)
+ {
+ if (errno != ENOENT)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not restore file \"%s\" to original mode 0%o: %m",
+ pending->path,
+ (unsigned int) restore_mode)));
+ }
+ }
+ break;
+
+ case PENDING_FILEOP_SETXATTR:
+ {
+ /*
+ * Restore an xattr to its original value on abort. path =
+ * file, newpath = attr name, data = orig value.
+ */
+ if (pending->data && pending->data_len > 0)
+ {
+ if (pg_setxattr(pending->path, pending->newpath,
+ pending->data, pending->data_len) < 0)
+ {
+ if (errno != ENOENT)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not restore extended attribute \"%s\" on \"%s\": %m",
+ pending->newpath,
+ pending->path)));
+ }
+ }
+ else
+ {
+ /* Zero-length original value */
+ if (pg_setxattr(pending->path, pending->newpath,
+ "", 0) < 0)
+ {
+ if (errno != ENOENT)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not restore extended attribute \"%s\" on \"%s\": %m",
+ pending->newpath,
+ pending->path)));
+ }
+ }
+ }
+ break;
+
+ case PENDING_FILEOP_REMOVEXATTR:
+ {
+ /*
+ * Remove an xattr on abort (attribute was newly created
+ * in the aborted transaction).
+ */
+ if (pg_removexattr(pending->path, pending->newpath) < 0)
+ {
+ /*
+ * "Attribute already absent" is success for a
+ * remove-on-abort (idempotent, like our unlink/rmdir
+ * redo). Use PG_ENOATTR, the portable
+ * absent-attribute errno: on Linux that is ENODATA,
+ * on macOS/BSD ENOATTR -- the raw ENODATA/ENOENT
+ * check missed the macOS case and warned spuriously
+ * (sec3 portability bug).
+ */
+ if (errno != ENOENT && errno != PG_ENOATTR)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not remove extended attribute \"%s\" from \"%s\": %m",
+ pending->newpath,
+ pending->path)));
+ }
+ }
+ break;
+
+ default:
+ break;
+ }
+ }
+}
+
+/*
+ * AtSubCommit_FileOps - Handle subtransaction commit
+ *
+ * Reassign all pending ops from the current nesting level to the parent.
+ */
+void
+AtSubCommit_FileOps(void)
+{
+ int nestLevel = GetCurrentTransactionNestLevel();
+ PendingFileOp *pending;
+
+ for (pending = pendingFileOps; pending != NULL; pending = pending->next)
+ {
+ if (pending->nestLevel >= nestLevel)
+ pending->nestLevel = nestLevel - 1;
+ }
+}
+
+/*
+ * AtSubAbort_FileOps - Handle subtransaction abort
+ *
+ * Execute abort-time actions for the current nesting level immediately.
+ */
+void
+AtSubAbort_FileOps(void)
+{
+ FileOpsDoPendingOps(false);
+}
+
+/*
+ * PostPrepare_FileOps - Clean up after PREPARE TRANSACTION
+ *
+ * FileOpsAtPrepare() (called from EndPrepare, before the 2PC END sentinel)
+ * has already serialized every pending file operation into the two-phase
+ * state file, so this backend can now discard its in-memory list. The ops
+ * are performed later by the FILEOPS 2PC post-commit/post-abort callbacks
+ * (fileops_twophase_postcommit/postabort), possibly from a different backend
+ * after a crash, driven from the durable 2PC state.
+ */
+void
+PostPrepare_FileOps(void)
+{
+ PendingFileOp *pending;
+ PendingFileOp *next;
+
+ for (pending = pendingFileOps; pending != NULL; pending = next)
+ {
+ next = pending->next;
+ pendingFileOps = next;
+ FreePendingFileOp(pending);
+ }
+}
+
+/*
+ * Serialized form of a PendingFileOp for the 2PC state file.
+ *
+ * A fixed header followed by three variable-length blobs (path, newpath,
+ * data), in that order. Self-describing so a crash-recovered COMMIT/ROLLBACK
+ * PREPARED -- run by any backend -- can rebuild an equivalent PendingFileOp
+ * and hand it to FileOpsExecOne(). Only the fields FileOpsExecOne() reads
+ * are carried; nestLevel is irrelevant once prepared.
+ */
+typedef struct FileOpsTwoPhaseRecord
+{
+ int type; /* PendingFileOpType */
+ bool at_commit; /* run at COMMIT PREPARED vs ROLLBACK PREPARED */
+ off_t length; /* truncate length / write offset / mode */
+ uint32 path_len; /* bytes, including NUL, 0 if none */
+ uint32 newpath_len; /* bytes, including NUL, 0 if none */
+ uint32 data_len; /* bytes, 0 if none */
+ /* followed by path, newpath, data */
+} FileOpsTwoPhaseRecord;
+
+/*
+ * FileOpsAtPrepare - Serialize deferred commit-time file ops into 2PC state
+ *
+ * Called from EndPrepare() between StartPrepare() and the END sentinel. Each
+ * pending at_commit=true op (RENAME/DELETE/RMDIR/RMTREE) is written as one
+ * TWOPHASE_RM_FILEOPS_ID record. This is what makes those deferred
+ * commit-time ops survive PREPARE for COMMIT PREPARED across a crash between
+ * PREPARE and the final decision.
+ *
+ * Abort-time ops (at_commit=false) are intentionally NOT serialized: they are
+ * the immediate constructive ops' undo, already covered for ROLLBACK PREPARED
+ * by the cluster-wide UNDO chain, which FinishPreparedTransaction() applies
+ * synchronously.
+ */
+void
+FileOpsAtPrepare(void)
+{
+ PendingFileOp *pending;
+
+ for (pending = pendingFileOps; pending != NULL; pending = pending->next)
+ {
+ FileOpsTwoPhaseRecord rec;
+ char *buf;
+ uint32 path_len;
+ uint32 newpath_len;
+ uint32 data_len;
+ uint32 total;
+ char *p;
+
+ /*
+ * Only the deferred commit-time ops (at_commit=true: RENAME/DELETE/
+ * RMDIR/RMTREE) need to be carried in the 2PC state: they have no
+ * other durable representation, so without this they were silently
+ * lost at PREPARE. The abort-time ops (at_commit=false, registered
+ * by the immediate constructive ops) are ALREADY covered for ROLLBACK
+ * PREPARED by the constructive op's cluster-wide UNDO record, which
+ * FinishPreparedTransaction() applies. Serializing them here too
+ * would revert them twice, so skip them.
+ */
+ if (!pending->at_commit)
+ continue;
+
+ path_len = pending->path ? (uint32) strlen(pending->path) + 1 : 0;
+ newpath_len = pending->newpath ? (uint32) strlen(pending->newpath) + 1 : 0;
+ data_len = (pending->data && pending->data_len > 0) ?
+ (uint32) pending->data_len : 0;
+
+ rec.type = (int) pending->type;
+ rec.at_commit = pending->at_commit;
+ rec.length = pending->length;
+ rec.path_len = path_len;
+ rec.newpath_len = newpath_len;
+ rec.data_len = data_len;
+
+ total = sizeof(FileOpsTwoPhaseRecord) + path_len + newpath_len + data_len;
+ buf = palloc(total);
+ memcpy(buf, &rec, sizeof(FileOpsTwoPhaseRecord));
+ p = buf + sizeof(FileOpsTwoPhaseRecord);
+ if (path_len > 0)
+ {
+ memcpy(p, pending->path, path_len);
+ p += path_len;
+ }
+ if (newpath_len > 0)
+ {
+ memcpy(p, pending->newpath, newpath_len);
+ p += newpath_len;
+ }
+ if (data_len > 0)
+ memcpy(p, pending->data, data_len);
+
+ RegisterTwoPhaseRecord(TWOPHASE_RM_FILEOPS_ID, 0, buf, total);
+ pfree(buf);
+ }
+}
+
+/*
+ * fileops_twophase_do - Shared body for the FILEOPS 2PC callbacks
+ *
+ * Rebuild a PendingFileOp from the serialized 2PC record and, if it matches
+ * the transaction outcome, perform it exactly as FileOpsDoPendingOps() would.
+ * A commit-time op emits its own WAL (idempotent redo on a standby); the
+ * trailing flush makes any accumulated LSN durable.
+ */
+static void
+fileops_twophase_do(bool isCommit, void *recdata, uint32 len)
+{
+ FileOpsTwoPhaseRecord rec;
+ PendingFileOp pending;
+ const char *p;
+ XLogRecPtr max_lsn = InvalidXLogRecPtr;
+
+ if (len < sizeof(FileOpsTwoPhaseRecord))
+ {
+ elog(WARNING, "FILEOPS 2PC record too short (%u bytes)", len);
+ return;
+ }
+ memcpy(&rec, recdata, sizeof(FileOpsTwoPhaseRecord));
+
+ /* Only ops matching this outcome run; the rest are simply discarded. */
+ if (rec.at_commit != isCommit)
+ return;
+
+ memset(&pending, 0, sizeof(pending));
+ pending.type = (PendingFileOpType) rec.type;
+ pending.at_commit = rec.at_commit;
+ pending.length = rec.length;
+
+ p = (const char *) recdata + sizeof(FileOpsTwoPhaseRecord);
+ if (rec.path_len > 0)
+ {
+ pending.path = (char *) p;
+ p += rec.path_len;
+ }
+ if (rec.newpath_len > 0)
+ {
+ pending.newpath = (char *) p;
+ p += rec.newpath_len;
+ }
+ if (rec.data_len > 0)
+ {
+ pending.data = (void *) p;
+ pending.data_len = rec.data_len;
+ }
+
+ FileOpsExecOne(&pending, isCommit, &max_lsn);
+
+ if (!XLogRecPtrIsInvalid(max_lsn))
+ XLogFlush(max_lsn);
+}
+
+/*
+ * fileops_twophase_postcommit - COMMIT PREPARED callback (perform at_commit ops)
+ */
+void
+fileops_twophase_postcommit(FullTransactionId fxid, uint16 info,
+ void *recdata, uint32 len)
+{
+ fileops_twophase_do(true, recdata, len);
+}
+
+/*
+ * fileops_twophase_postabort - ROLLBACK PREPARED callback
+ *
+ * A no-op in practice: only at_commit=true ops are serialized at PREPARE, and
+ * those are not performed on abort. Kept for symmetry and to defensively
+ * discard any at_commit=true record on the abort path.
+ */
+void
+fileops_twophase_postabort(FullTransactionId fxid, uint16 info,
+ void *recdata, uint32 len)
+{
+ fileops_twophase_do(false, recdata, len);
+}
+
+/*
+ * fileops_redo - WAL redo function for FILEOPS records
+ *
+ * Replay file operations during crash recovery or standby apply.
+ * Each operation type has its own redo handler added in separate commits.
+ */
+void
+fileops_redo(XLogReaderState *record)
+{
+ uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK;
+ char *data = XLogRecGetData(record);
+
+ switch (info)
+ {
+ case XLOG_FILEOPS_CREATE:
+ {
+ xl_fileops_create *xlrec = (xl_fileops_create *) data;
+ const char *path = data + SizeOfFileOpsCreate;
+ int fd;
+
+ fd = BasicOpenFilePerm(path,
+ (xlrec->flags & ~PG_O_DIRECT) | O_CREAT,
+ xlrec->mode);
+ if (fd < 0)
+ {
+ if (errno == ENOENT)
+ {
+ char parentpath[MAXPGPATH];
+ char *sep;
+
+ strlcpy(parentpath, path, MAXPGPATH);
+ sep = strrchr(parentpath, '/');
+ if (sep != NULL)
+ {
+ *sep = '\0';
+ if (MakePGDirectory(parentpath) < 0 &&
+ errno != EEXIST)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not create directory \"%s\" during WAL replay: %m",
+ parentpath)));
+ }
+
+ fd = BasicOpenFilePerm(path,
+ (xlrec->flags & ~PG_O_DIRECT) | O_CREAT,
+ xlrec->mode);
+ }
+
+ if (fd < 0 && errno != EEXIST)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not create file \"%s\" during WAL replay: %m",
+ path)));
+ }
+
+ if (fd >= 0)
+ {
+ if (enableFsync && pg_fsync(fd) != 0)
+ ereport(data_sync_elevel(ERROR),
+ (errcode_for_file_access(),
+ errmsg("could not fsync file \"%s\" during WAL replay: %m",
+ path)));
+ close(fd);
+ if (enableFsync)
+ fileops_fsync_parent(path, data_sync_elevel(ERROR));
+ }
+ }
+ break;
+
+ case XLOG_FILEOPS_WRITE:
+ {
+ xl_fileops_write *xlrec = (xl_fileops_write *) data;
+ const char *path = data + SizeOfFileOpsWrite;
+ const char *wdata = path + xlrec->path_len;
+ int fd;
+
+ fd = BasicOpenFile(path, O_RDWR | PG_BINARY);
+ if (fd < 0)
+ {
+ if (errno != ENOENT)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not open file \"%s\" for write during WAL replay: %m",
+ path)));
+ }
+ else
+ {
+ if (pg_pwrite(fd, wdata, xlrec->len, xlrec->offset) !=
+ (ssize_t) xlrec->len)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not write to file \"%s\" during WAL replay: %m",
+ path)));
+ else if (enableFsync && pg_fsync(fd) != 0)
+ ereport(data_sync_elevel(ERROR),
+ (errcode_for_file_access(),
+ errmsg("could not fsync file \"%s\" during WAL replay: %m",
+ path)));
+ close(fd);
+ }
+ }
+ break;
+
+ case XLOG_FILEOPS_RENAME:
+
+ /*
+ * idempotent redo of a deferred rename executed at commit on the
+ * primary. The record carries oldpath then newpath. A standby
+ * (or a re-replay after a primary crash mid-commit) may find the
+ * rename already applied: source gone. In that case treat it as
+ * success rather than failing on a missing source -- this is what
+ * makes redo converge whether the crash happened before or after
+ * the physical rename.
+ */
+ {
+ xl_fileops_rename *xlrec = (xl_fileops_rename *) data;
+ const char *oldpath = data + SizeOfFileOpsRename;
+ const char *newpath = oldpath + xlrec->oldpath_len;
+
+ if (rename(oldpath, newpath) < 0)
+ {
+ /*
+ * ENOENT on the source means the rename already took
+ * effect (or the source never existed on this standby);
+ * either way the desired end state -- newpath present,
+ * oldpath absent -- is what we want, so it is not an
+ * error.
+ */
+ if (errno != ENOENT)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not rename file \"%s\" to \"%s\" during WAL replay: %m",
+ oldpath, newpath)));
+ }
+ else if (enableFsync)
+ {
+ fileops_fsync_parent(newpath, data_sync_elevel(ERROR));
+ fileops_fsync_parent(oldpath, data_sync_elevel(ERROR));
+ }
+ }
+ break;
+
+ case XLOG_FILEOPS_DELETE:
+
+ /*
+ * idempotent redo of a deferred delete executed at commit on the
+ * primary. An already-absent file (ENOENT) means the delete
+ * already took effect -- treat as success. Only commit-path
+ * deletes are ever logged (see FileOpsDoPendingOps), so redo of
+ * an aborted transaction's delete-on-abort can never occur here.
+ */
+ {
+ xl_fileops_delete *xlrec pg_attribute_unused() =
+ (xl_fileops_delete *) data;
+ const char *path = data + SizeOfFileOpsDelete;
+
+ if (unlink(path) < 0)
+ {
+ if (errno != ENOENT)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not remove file \"%s\" during WAL replay: %m",
+ path)));
+ }
+ else if (enableFsync)
+ fileops_fsync_parent(path, data_sync_elevel(ERROR));
+ }
+ break;
+
+ case XLOG_FILEOPS_SYMLINK:
+
+ /*
+ * sec2: intentional no-op during redo.
+ *
+ * FileOpsSymlink() has exactly one caller,
+ * create_tablespace_directories() in tablespace.c, and it is used
+ * ONLY for tablespace symlinks under $PGDATA/pg_tblspc. Standby
+ * (and primary crash-recovery) replay of those symlinks is OWNED
+ * by RM_TBLSPC: tblspc_redo() -> create_tablespace_directories()
+ * runs with InRecovery set, first removing any stale symlink
+ * (remove_tablespace_symlink) and then re-creating it with the
+ * recovery-time layout logic that honors the standby's own
+ * tablespace layout / allow_in_place_tablespaces / tablespace_map
+ * remap.
+ *
+ * Replaying the FILEOPS record here would be redundant (RM_TBLSPC
+ * already creates the link) and, worse, actively wrong on a
+ * remapped standby: it would unlink the correctly-remapped link
+ * and re-point it at the primary's LITERAL absolute target, which
+ * may not exist or may belong to a different location on the
+ * standby. So FILEOPS defers tablespace-symlink replay to
+ * RM_TBLSPC and does nothing here.
+ *
+ * If FileOpsSymlink ever gains a non-tablespace caller, this must
+ * be revisited to replay those (and only those) symlinks; that
+ * would require distinguishing the two cases in the record.
+ */
+ break;
+
+ case XLOG_FILEOPS_LINK:
+ {
+ xl_fileops_link *xlrec = (xl_fileops_link *) data;
+ const char *oldpath = data + SizeOfFileOpsLink;
+ const char *newpath = oldpath + xlrec->oldpath_len;
+
+ /* Remove existing link first for idempotent redo */
+ unlink(newpath);
+ if (link(oldpath, newpath) < 0 && errno != EEXIST)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not create hard link \"%s\" during WAL replay: %m",
+ newpath)));
+ else if (enableFsync)
+ fileops_fsync_parent(newpath, data_sync_elevel(ERROR));
+ }
+ break;
+
+ case XLOG_FILEOPS_MKDIR:
+ {
+ xl_fileops_mkdir *xlrec pg_attribute_unused() =
+ (xl_fileops_mkdir *) data;
+ const char *path = data + SizeOfFileOpsMkdir;
+
+ if (MakePGDirectory(path) < 0 && errno != EEXIST)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not create directory \"%s\" during WAL replay: %m",
+ path)));
+ else if (enableFsync)
+ fileops_fsync_parent(path, data_sync_elevel(ERROR));
+ }
+ break;
+
+ case XLOG_FILEOPS_RMDIR:
+
+ /*
+ * idempotent redo of a deferred rmdir executed at commit on the
+ * primary. An already-absent directory (ENOENT) means the rmdir
+ * already took effect -- treat as success.
+ */
+ {
+ xl_fileops_rmdir *xlrec pg_attribute_unused() =
+ (xl_fileops_rmdir *) data;
+ const char *path = data + SizeOfFileOpsRmdir;
+
+ if (rmdir(path) < 0)
+ {
+ if (errno != ENOENT)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not remove directory \"%s\" during WAL replay: %m",
+ path)));
+ }
+ else if (enableFsync)
+ fileops_fsync_parent(path, data_sync_elevel(ERROR));
+ }
+ break;
+
+ case XLOG_FILEOPS_CHMOD:
+ {
+ xl_fileops_chmod *xlrec = (xl_fileops_chmod *) data;
+ const char *path = data + SizeOfFileOpsChmod;
+
+ if (chmod(path, xlrec->mode) < 0 && errno != ENOENT)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not chmod file \"%s\" during WAL replay: %m",
+ path)));
+ }
+ break;
+
+ case XLOG_FILEOPS_CHOWN:
+ {
+#ifndef WIN32
+ xl_fileops_chown *xlrec = (xl_fileops_chown *) data;
+ const char *path = data + SizeOfFileOpsChown;
+
+ if (chown(path, xlrec->uid, xlrec->gid) < 0 &&
+ errno != ENOENT)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not chown file \"%s\" during WAL replay: %m",
+ path)));
+#endif
+ }
+ break;
+
+ case XLOG_FILEOPS_TRUNCATE:
+ {
+ xl_fileops_truncate *xlrec = (xl_fileops_truncate *) data;
+ const char *path = data + SizeOfFileOpsTruncate;
+ int fd;
+
+ XLogFlush(record->EndRecPtr);
+
+ fd = BasicOpenFile(path, O_RDWR | PG_BINARY);
+ if (fd < 0)
+ {
+ if (errno != ENOENT)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not open file \"%s\" for truncation during WAL replay: %m",
+ path)));
+ }
+ else
+ {
+ if (ftruncate(fd, xlrec->length) < 0)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not truncate file \"%s\" to %lld bytes during WAL replay: %m",
+ path, (long long) xlrec->length)));
+ else if (enableFsync && pg_fsync(fd) != 0)
+ ereport(data_sync_elevel(ERROR),
+ (errcode_for_file_access(),
+ errmsg("could not fsync file \"%s\" during WAL replay: %m",
+ path)));
+ close(fd);
+ }
+ }
+ break;
+
+ case XLOG_FILEOPS_SETXATTR:
+ {
+ xl_fileops_setxattr *xlrec = (xl_fileops_setxattr *) data;
+ const char *path = data + SizeOfFileOpsSetxattr;
+ const char *name = path + xlrec->path_len;
+ const void *value = name + xlrec->name_len;
+
+ if (pg_setxattr(path, name, value, xlrec->value_len) < 0 &&
+ errno != ENOTSUP && errno != ENOENT)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not set extended attribute \"%s\" on \"%s\" during WAL replay: %m",
+ name, path)));
+ }
+ break;
+
+ case XLOG_FILEOPS_REMOVEXATTR:
+ {
+ xl_fileops_removexattr *xlrec =
+ (xl_fileops_removexattr *) data;
+ const char *path = data + SizeOfFileOpsRemovexattr;
+ const char *name = path + xlrec->path_len;
+
+ if (pg_removexattr(path, name) < 0 &&
+ errno != ENOTSUP && errno != ENOENT &&
+ errno != PG_ENOATTR)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not remove extended attribute \"%s\" from \"%s\" during WAL replay: %m",
+ name, path)));
+ }
+ break;
+
+ default:
+ elog(PANIC, "fileops_redo: unknown op code %u", info);
+ break;
+ }
+}
diff --git a/src/backend/storage/file/fileops_undo.c b/src/backend/storage/file/fileops_undo.c
new file mode 100644
index 0000000000000..3afdb0b03a80a
--- /dev/null
+++ b/src/backend/storage/file/fileops_undo.c
@@ -0,0 +1,431 @@
+/*-------------------------------------------------------------------------
+ *
+ * fileops_undo.c
+ * FILEOPS UNDO resource manager
+ *
+ * This module implements the UNDO apply callbacks for transactional file
+ * operations. UNDO covers structural filesystem operations (file existence,
+ * permissions, directory structure). File content recovery uses traditional
+ * WAL (via FileOpsWrite), not FILEOPS UNDO.
+ *
+ * On transaction abort, each FILEOPS UNDO record reverses the structural
+ * change made by the corresponding FileOps* function:
+ * - CREATE: unlink the created file
+ * - RENAME: rename back to the original path
+ * - TRUNCATE: ftruncate to original length
+ * - CHMOD: chmod to original mode
+ * - CHOWN: chown to original uid/gid
+ * - MKDIR: rmdir the created directory
+ * - SYMLINK: unlink the symlink
+ * - LINK: unlink the hard link
+ * - SETXATTR: restore/remove the original xattr
+ * - REMOVEXATTR: restore the original xattr value
+ *
+ * DELETE and RMDIR are handled by PendingFileOps (deferred execution)
+ * and do not need UNDO records since they execute only at commit time.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ * src/backend/storage/file/fileops_undo.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include
+#include
+
+#include "access/undormgr.h"
+#include "access/xact.h"
+#include "port/pg_xattr.h"
+#include "storage/fd.h"
+#include "storage/fileops.h"
+#include "utils/injection_point.h"
+
+/*
+ * UNDO subtype constants and payload structures are defined in fileops.h,
+ * shared between this file (apply side) and fileops.c (insert side).
+ */
+
+/* Forward declarations */
+static UndoApplyResult fileops_undo_apply(uint8 rmid, uint16 info,
+ TransactionId xid, Oid reloid,
+ const char *payload, Size payload_len,
+ UndoRecPtr urec_ptr);
+static void fileops_undo_desc(StringInfo buf, uint8 rmid, uint16 info,
+ const char *payload, Size payload_len);
+
+/*
+ * Report an xattr restore that failed, and return UNDO_APPLY_ERROR so the
+ * caller counts it as a record that could not be applied. Like the other
+ * subtypes, a file that no longer exists is not a failure.
+ */
+static UndoApplyResult
+fileops_undo_xattr_failed(const char *op, const char *path, const char *name)
+{
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("FILEOPS UNDO %s: could not restore attribute \"%s\" of \"%s\": %m",
+ op, name, path)));
+ return UNDO_APPLY_ERROR;
+}
+
+/* The FILEOPS UNDO RM registration entry */
+static const UndoRmgrData fileops_undo_rmgr = {
+ .rm_name = "fileops",
+ .rm_undo = fileops_undo_apply,
+ .rm_desc = fileops_undo_desc,
+};
+
+/*
+ * FileopsUndoRmgrInit - Register the FILEOPS UNDO resource manager
+ */
+void
+FileopsUndoRmgrInit(void)
+{
+ RegisterUndoRmgr(UNDO_RMID_FILEOPS, &fileops_undo_rmgr);
+
+ /*
+ * Wire FILEOPS's pending-structural-file-operation callbacks into the
+ * AM-neutral hooks in xact.c (see access/xact.h for why xact.c cannot
+ * call these by name directly).
+ */
+ PendingPhysOpsDo_hook = FileOpsDoPendingOps;
+ PendingPhysOpsPostPrepare_hook = PostPrepare_FileOps;
+ PendingPhysOpsAtSubCommit_hook = AtSubCommit_FileOps;
+ PendingPhysOpsAtSubAbort_hook = AtSubAbort_FileOps;
+}
+
+/*
+ * fileops_undo_apply - Apply a single FILEOPS UNDO record
+ *
+ * Reverses the structural filesystem operation.
+ */
+static UndoApplyResult
+fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid,
+ const char *payload, Size payload_len, UndoRecPtr urec_ptr)
+{
+ UndoApplyResult result = UNDO_APPLY_SUCCESS;
+
+ Assert(rmid == UNDO_RMID_FILEOPS);
+
+ INJECTION_POINT("fileops-undo-apply-begin", NULL);
+
+ switch (info)
+ {
+ case FILEOPS_UNDO_CREATE:
+ {
+ FileopsUndoCreate hdr;
+ const char *path;
+
+ if (payload_len < sizeof(FileopsUndoCreate))
+ return UNDO_APPLY_ERROR;
+ memcpy(&hdr, payload, sizeof(FileopsUndoCreate));
+ path = payload + sizeof(FileopsUndoCreate);
+
+ if (unlink(path) < 0 && errno != ENOENT)
+ {
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("FILEOPS UNDO CREATE: could not unlink \"%s\": %m",
+ path)));
+ result = UNDO_APPLY_ERROR;
+ }
+ else
+ ereport(DEBUG2,
+ (errmsg("FILEOPS UNDO CREATE: unlinked \"%s\"",
+ path)));
+ }
+ break;
+
+ case FILEOPS_UNDO_RENAME:
+ {
+ FileopsUndoRename hdr;
+ const char *oldpath;
+ const char *newpath;
+
+ if (payload_len < sizeof(FileopsUndoRename))
+ return UNDO_APPLY_ERROR;
+ memcpy(&hdr, payload, sizeof(FileopsUndoRename));
+ oldpath = payload + sizeof(FileopsUndoRename);
+ newpath = oldpath + hdr.oldpath_len;
+
+ /* Reverse the rename: newpath -> oldpath */
+ if (rename(newpath, oldpath) < 0)
+ {
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("FILEOPS UNDO RENAME: could not rename \"%s\" back to \"%s\": %m",
+ newpath, oldpath)));
+ result = UNDO_APPLY_ERROR;
+ }
+ else
+ ereport(DEBUG2,
+ (errmsg("FILEOPS UNDO RENAME: renamed \"%s\" back to \"%s\"",
+ newpath, oldpath)));
+ }
+ break;
+
+ case FILEOPS_UNDO_TRUNCATE:
+ {
+ FileopsUndoTruncate hdr;
+ const char *path;
+ int fd;
+
+ if (payload_len < sizeof(FileopsUndoTruncate))
+ return UNDO_APPLY_ERROR;
+ memcpy(&hdr, payload, sizeof(FileopsUndoTruncate));
+ path = payload + sizeof(FileopsUndoTruncate);
+
+ fd = BasicOpenFile(path, O_RDWR | PG_BINARY);
+ if (fd < 0)
+ {
+ if (errno != ENOENT)
+ {
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("FILEOPS UNDO TRUNCATE: could not open \"%s\": %m",
+ path)));
+ result = UNDO_APPLY_ERROR;
+ }
+ }
+ else
+ {
+ if (ftruncate(fd, hdr.orig_length) < 0)
+ {
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("FILEOPS UNDO TRUNCATE: could not restore length of \"%s\": %m",
+ path)));
+ result = UNDO_APPLY_ERROR;
+ }
+ close(fd);
+ }
+ }
+ break;
+
+ case FILEOPS_UNDO_CHMOD:
+ {
+ FileopsUndoChmod hdr;
+ const char *path;
+
+ if (payload_len < sizeof(FileopsUndoChmod))
+ return UNDO_APPLY_ERROR;
+ memcpy(&hdr, payload, sizeof(FileopsUndoChmod));
+ path = payload + sizeof(FileopsUndoChmod);
+
+ if (chmod(path, hdr.orig_mode) < 0 && errno != ENOENT)
+ {
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("FILEOPS UNDO CHMOD: could not restore mode of \"%s\": %m",
+ path)));
+ result = UNDO_APPLY_ERROR;
+ }
+ }
+ break;
+
+ case FILEOPS_UNDO_CHOWN:
+ {
+#ifndef WIN32
+ FileopsUndoChown hdr;
+ const char *path;
+
+ if (payload_len < sizeof(FileopsUndoChown))
+ return UNDO_APPLY_ERROR;
+ memcpy(&hdr, payload, sizeof(FileopsUndoChown));
+ path = payload + sizeof(FileopsUndoChown);
+
+ if (chown(path, hdr.orig_uid, hdr.orig_gid) < 0 &&
+ errno != ENOENT)
+ {
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("FILEOPS UNDO CHOWN: could not restore ownership of \"%s\": %m",
+ path)));
+ result = UNDO_APPLY_ERROR;
+ }
+#endif
+ }
+ break;
+
+ case FILEOPS_UNDO_MKDIR:
+ {
+ FileopsUndoMkdir hdr;
+ const char *path;
+
+ if (payload_len < sizeof(FileopsUndoMkdir))
+ return UNDO_APPLY_ERROR;
+ memcpy(&hdr, payload, sizeof(FileopsUndoMkdir));
+ path = payload + sizeof(FileopsUndoMkdir);
+
+ if (rmdir(path) < 0 && errno != ENOENT)
+ {
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("FILEOPS UNDO MKDIR: could not rmdir \"%s\": %m",
+ path)));
+ result = UNDO_APPLY_ERROR;
+ }
+ }
+ break;
+
+ case FILEOPS_UNDO_SYMLINK:
+ {
+ FileopsUndoSymlink hdr;
+ const char *linkpath;
+
+ if (payload_len < sizeof(FileopsUndoSymlink))
+ return UNDO_APPLY_ERROR;
+ memcpy(&hdr, payload, sizeof(FileopsUndoSymlink));
+ linkpath = payload + sizeof(FileopsUndoSymlink);
+
+ if (unlink(linkpath) < 0 && errno != ENOENT)
+ {
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("FILEOPS UNDO SYMLINK: could not unlink \"%s\": %m",
+ linkpath)));
+ result = UNDO_APPLY_ERROR;
+ }
+ }
+ break;
+
+ case FILEOPS_UNDO_LINK:
+ {
+ FileopsUndoLink hdr;
+ const char *newpath;
+
+ if (payload_len < sizeof(FileopsUndoLink))
+ return UNDO_APPLY_ERROR;
+ memcpy(&hdr, payload, sizeof(FileopsUndoLink));
+ newpath = payload + sizeof(FileopsUndoLink);
+
+ if (unlink(newpath) < 0 && errno != ENOENT)
+ {
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("FILEOPS UNDO LINK: could not unlink \"%s\": %m",
+ newpath)));
+ result = UNDO_APPLY_ERROR;
+ }
+ }
+ break;
+
+ case FILEOPS_UNDO_SETXATTR:
+ {
+ FileopsUndoSetxattr hdr;
+ const char *path;
+ const char *name;
+
+ if (payload_len < sizeof(FileopsUndoSetxattr))
+ return UNDO_APPLY_ERROR;
+ memcpy(&hdr, payload, sizeof(FileopsUndoSetxattr));
+ path = payload + sizeof(FileopsUndoSetxattr);
+ name = path + hdr.path_len;
+
+ if (hdr.had_value)
+ {
+ /* Restore original value */
+ const void *orig_value = name + hdr.name_len;
+
+ if (pg_setxattr(path, name, orig_value,
+ hdr.orig_value_len) < 0 &&
+ errno != ENOENT)
+ result = fileops_undo_xattr_failed("SETXATTR", path,
+ name);
+ }
+ else
+ {
+ /* xattr didn't exist before, remove it */
+ if (pg_removexattr(path, name) < 0 &&
+ errno != ENOENT && errno != PG_ENOATTR)
+ result = fileops_undo_xattr_failed("SETXATTR", path,
+ name);
+ }
+ }
+ break;
+
+ case FILEOPS_UNDO_REMOVEXATTR:
+ {
+ FileopsUndoRemovexattr hdr;
+ const char *path;
+ const char *name;
+ const void *value;
+
+ if (payload_len < sizeof(FileopsUndoRemovexattr))
+ return UNDO_APPLY_ERROR;
+ memcpy(&hdr, payload, sizeof(FileopsUndoRemovexattr));
+ path = payload + sizeof(FileopsUndoRemovexattr);
+ name = path + hdr.path_len;
+ value = name + hdr.name_len;
+
+ /* Restore the removed xattr */
+ if (pg_setxattr(path, name, value, hdr.value_len) < 0 &&
+ errno != ENOENT)
+ result = fileops_undo_xattr_failed("REMOVEXATTR", path,
+ name);
+ }
+ break;
+
+ default:
+ ereport(WARNING,
+ (errmsg("FILEOPS UNDO: unknown subtype %u", info)));
+ return UNDO_APPLY_ERROR;
+ }
+
+ INJECTION_POINT("fileops-undo-apply-end", NULL);
+
+ return result;
+}
+
+/*
+ * fileops_undo_desc - Describe a FILEOPS UNDO record for debugging
+ */
+static void
+fileops_undo_desc(StringInfo buf, uint8 rmid, uint16 info,
+ const char *payload, Size payload_len)
+{
+ const char *opname;
+
+ switch (info)
+ {
+ case FILEOPS_UNDO_CREATE:
+ opname = "CREATE";
+ break;
+ case FILEOPS_UNDO_RENAME:
+ opname = "RENAME";
+ break;
+ case FILEOPS_UNDO_TRUNCATE:
+ opname = "TRUNCATE";
+ break;
+ case FILEOPS_UNDO_CHMOD:
+ opname = "CHMOD";
+ break;
+ case FILEOPS_UNDO_CHOWN:
+ opname = "CHOWN";
+ break;
+ case FILEOPS_UNDO_MKDIR:
+ opname = "MKDIR";
+ break;
+ case FILEOPS_UNDO_SYMLINK:
+ opname = "SYMLINK";
+ break;
+ case FILEOPS_UNDO_LINK:
+ opname = "LINK";
+ break;
+ case FILEOPS_UNDO_SETXATTR:
+ opname = "SETXATTR";
+ break;
+ case FILEOPS_UNDO_REMOVEXATTR:
+ opname = "REMOVEXATTR";
+ break;
+ default:
+ opname = "UNKNOWN";
+ break;
+ }
+
+ appendStringInfo(buf, "fileops %s", opname);
+}
diff --git a/src/backend/storage/file/meson.build b/src/backend/storage/file/meson.build
index 795402589b0b9..7e487324c5f3e 100644
--- a/src/backend/storage/file/meson.build
+++ b/src/backend/storage/file/meson.build
@@ -4,6 +4,8 @@ backend_sources += files(
'buffile.c',
'copydir.c',
'fd.c',
+ 'fileops.c',
+ 'fileops_undo.c',
'fileset.c',
'reinit.c',
'sharedfileset.c',
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 1512d2df196e6..613984a301ce2 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 3d366fd11141d..4bbc967728b18 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 8b6ea195ecade..7a0522ae859d8 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 1d0d73074a1b2..39c32122e8472 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 342aaeef59afd..3b3b08c051f20 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 01f98de8a9ffc..4c4bf765805e0 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 69171d6b8591b..42668b8188a3f 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 931ab8b979e23..ed27a243febb7 100644
--- a/src/bin/pg_waldump/rmgrdesc.c
+++ b/src/bin/pg_waldump/rmgrdesc.c
@@ -17,9 +17,11 @@
#include "access/hash_xlog.h"
#include "access/heapam_xlog.h"
#include "access/multixact.h"
+#include "access/fileops_xlog.h"
#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 7b33efc62999f..ded5e37feda65 100644
--- a/src/bin/pg_waldump/t/001_basic.pl
+++ b/src/bin/pg_waldump/t/001_basic.pl
@@ -96,7 +96,9 @@
ReplicationOrigin
Generic
LogicalMessage
-XLOG2$/,
+XLOG2
+Undo
+FileOps$/,
'rmgr list');
diff --git a/src/include/access/fileops_xlog.h b/src/include/access/fileops_xlog.h
new file mode 100644
index 0000000000000..b3ece9a268691
--- /dev/null
+++ b/src/include/access/fileops_xlog.h
@@ -0,0 +1,40 @@
+/*-------------------------------------------------------------------------
+ *
+ * fileops_xlog.h
+ * Transactional file operations XLOG resource manager definitions
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ * src/include/access/fileops_xlog.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef FILEOPS_XLOG_H
+#define FILEOPS_XLOG_H
+
+#include "access/xlogreader.h"
+#include "lib/stringinfo.h"
+
+/* XLOG stuff - all record types defined upfront for WAL compatibility */
+#define XLOG_FILEOPS_CREATE 0x00
+#define XLOG_FILEOPS_DELETE 0x10
+#define XLOG_FILEOPS_RENAME 0x20
+#define XLOG_FILEOPS_WRITE 0x30
+#define XLOG_FILEOPS_TRUNCATE 0x40
+#define XLOG_FILEOPS_CHMOD 0x50
+#define XLOG_FILEOPS_CHOWN 0x60
+#define XLOG_FILEOPS_MKDIR 0x70
+#define XLOG_FILEOPS_RMDIR 0x80
+#define XLOG_FILEOPS_SYMLINK 0x90
+#define XLOG_FILEOPS_LINK 0xA0
+#define XLOG_FILEOPS_SETXATTR 0xB0
+#define XLOG_FILEOPS_REMOVEXATTR 0xC0
+
+/* Resource manager functions */
+extern void fileops_redo(XLogReaderState *record);
+extern void fileops_desc(StringInfo buf, XLogReaderState *record);
+extern const char *fileops_identify(uint8 info);
+
+#endif /* FILEOPS_XLOG_H */
diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h
index 9e35961fb9e19..4df736025bd45 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 ae32ef16d67b6..a34394a1e233e 100644
--- a/src/include/access/rmgrlist.h
+++ b/src/include/access/rmgrlist.h
@@ -48,3 +48,7 @@ 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)
+#ifdef USE_FILEOPS
+PG_RMGR(RM_FILEOPS_ID, "FileOps", fileops_redo, fileops_desc, fileops_identify, NULL, NULL, NULL, NULL)
+#endif
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index ea3f2a6be9907..2efe18b042189 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 1d2ff42c9b72f..3714ea62b3385 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/twophase_rmgr.h b/src/include/access/twophase_rmgr.h
index 8927f369c39b5..37be3c7b2900c 100644
--- a/src/include/access/twophase_rmgr.h
+++ b/src/include/access/twophase_rmgr.h
@@ -28,7 +28,8 @@ typedef uint8 TwoPhaseRmgrId;
#define TWOPHASE_RM_PGSTAT_ID 2
#define TWOPHASE_RM_MULTIXACT_ID 3
#define TWOPHASE_RM_PREDICATELOCK_ID 4
-#define TWOPHASE_RM_MAX_ID TWOPHASE_RM_PREDICATELOCK_ID
+#define TWOPHASE_RM_FILEOPS_ID 5
+#define TWOPHASE_RM_MAX_ID TWOPHASE_RM_FILEOPS_ID
extern PGDLLIMPORT const TwoPhaseCallback twophase_recover_callbacks[];
extern PGDLLIMPORT const TwoPhaseCallback twophase_postcommit_callbacks[];
@@ -39,4 +40,10 @@ extern PGDLLIMPORT const TwoPhaseCallback twophase_standby_recover_callbacks[];
extern void RegisterTwoPhaseRecord(TwoPhaseRmgrId rmid, uint16 info,
const void *data, uint32 len);
+/* FILEOPS two-phase commit callbacks (defined in storage/file/fileops.c) */
+extern void fileops_twophase_postcommit(FullTransactionId fxid, uint16 info,
+ void *recdata, uint32 len);
+extern void fileops_twophase_postabort(FullTransactionId fxid, uint16 info,
+ void *recdata, uint32 len);
+
#endif /* TWOPHASE_RMGR_H */
diff --git a/src/include/access/undo.h b/src/include/access/undo.h
new file mode 100644
index 0000000000000..efbed31546a30
--- /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 0000000000000..ca403a377e670
--- /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 0000000000000..b21915bff1004
--- /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 0000000000000..964ff4ceaf215
--- /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 0000000000000..ed5b1e04df74a
--- /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 0000000000000..52eb1bb78b8d1
--- /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 0000000000000..a9f8ec5afd730
--- /dev/null
+++ b/src/include/access/undormgrlist.h
@@ -0,0 +1,36 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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 */
+
+/* FILEOPS: transactional filesystem operations UNDO rmgr (UNDO_RMID_FILEOPS=2) */
+UNDO_RMGR_INIT(FileopsUndoRmgrInit)
diff --git a/src/include/access/undormgrs.h b/src/include/access/undormgrs.h
new file mode 100644
index 0000000000000..2a399f78dfab2
--- /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 0000000000000..65f5ca886da43
--- /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 0000000000000..c3be4c9d6e003
--- /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 a8cbdf247c866..8341d2015d393 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 0000000000000..09be0110cec30
--- /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 bf609c3c703d9..949bfd496c81c 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 9b78fcad414d8..24e17d4a810e5 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/port/pg_xattr.h b/src/include/port/pg_xattr.h
new file mode 100644
index 0000000000000..7159fc27a26a1
--- /dev/null
+++ b/src/include/port/pg_xattr.h
@@ -0,0 +1,40 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_xattr.h
+ * Cross-platform extended attribute abstraction
+ *
+ * Provides pg_setxattr() and pg_removexattr() that work across:
+ * - Linux: setxattr/removexattr
+ * - macOS: setxattr/removexattr (extra options param)
+ * - FreeBSD: extattr_set_file/extattr_delete_file
+ * - Windows: NTFS Alternate Data Streams
+ * - Fallback: returns ENOTSUP with WARNING
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/include/port/pg_xattr.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_XATTR_H
+#define PG_XATTR_H
+
+/*
+ * ENODATA is Linux-specific. FreeBSD/macOS use ENOATTR for "attribute not
+ * found". Provide a portable PG_ENOATTR so callers don't need #ifdefs.
+ */
+#if defined(ENOATTR)
+#define PG_ENOATTR ENOATTR
+#elif defined(ENODATA)
+#define PG_ENOATTR ENODATA
+#else
+#define PG_ENOATTR ENOENT /* last-resort fallback */
+#endif
+
+extern int pg_setxattr(const char *path, const char *name,
+ const void *value, size_t size);
+extern ssize_t pg_getxattr(const char *path, const char *name,
+ void *value, size_t size);
+extern int pg_removexattr(const char *path, const char *name);
+
+#endif /* PG_XATTR_H */
diff --git a/src/include/storage/bufmgr.h b/src/include/storage/bufmgr.h
index 6837b35fc6d0b..1b96907ef211c 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/copydir.h b/src/include/storage/copydir.h
index 40849c2ef9947..31faa51cd6af7 100644
--- a/src/include/storage/copydir.h
+++ b/src/include/storage/copydir.h
@@ -22,7 +22,19 @@ typedef enum FileCopyMethod
/* GUC parameters */
extern PGDLLIMPORT int file_copy_method;
-extern void copydir(const char *fromdir, const char *todir, bool recurse);
+/*
+ * copydir(): copy a directory tree.
+ *
+ * If register_for_abort_cleanup is true, the destination tree is registered
+ * with FILEOPS for delete-on-abort. Forward-path callers in transactional
+ * commands (CREATE DATABASE STRATEGY=FILE_COPY, ALTER DATABASE SET
+ * TABLESPACE) should pass true so that a transaction abort removes the
+ * partial copy. Recovery callers (XLOG_DBASE_CREATE_FILE_COPY redo) must
+ * pass false because there is no surrounding transaction context and the
+ * redo handler is itself the recovery action.
+ */
+extern void copydir(const char *fromdir, const char *todir, bool recurse,
+ bool register_for_abort_cleanup);
extern void copy_file(const char *fromfile, const char *tofile);
#endif /* COPYDIR_H */
diff --git a/src/include/storage/fileops.h b/src/include/storage/fileops.h
new file mode 100644
index 0000000000000..e8ab366acd5b6
--- /dev/null
+++ b/src/include/storage/fileops.h
@@ -0,0 +1,463 @@
+/*-------------------------------------------------------------------------
+ *
+ * fileops.h
+ * Transactional file operations API
+ *
+ * This module provides transactional filesystem operations that are
+ * WAL-logged and integrated with PostgreSQL's transaction management.
+ * File operations are deferred until transaction commit/abort, ensuring
+ * atomicity with the rest of the transaction.
+ *
+ * The RM_FILEOPS_ID resource manager handles WAL replay for these
+ * operations, ensuring correct behavior during crash recovery and
+ * standby replay.
+ *
+ * The operation set follows the Berkeley DB fileops.src model: each
+ * filesystem operation is a composable unit with its own WAL record
+ * type, redo handler, and descriptor.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/storage/fileops.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef FILEOPS_H
+#define FILEOPS_H
+
+#include "access/xlogreader.h"
+#include "lib/stringinfo.h"
+
+/*
+ * WAL record types for FILEOPS operations.
+ *
+ * The high 4 bits of the info byte are used for record type,
+ * leaving the low bits for flags (following PostgreSQL convention).
+ *
+ * Following the Berkeley DB fileops.src model, each filesystem
+ * operation has its own WAL record type for independent redo.
+ */
+#define XLOG_FILEOPS_CREATE 0x00
+#define XLOG_FILEOPS_DELETE 0x10
+#define XLOG_FILEOPS_RENAME 0x20
+#define XLOG_FILEOPS_WRITE 0x30
+#define XLOG_FILEOPS_TRUNCATE 0x40
+#define XLOG_FILEOPS_CHMOD 0x50
+#define XLOG_FILEOPS_CHOWN 0x60
+#define XLOG_FILEOPS_MKDIR 0x70
+#define XLOG_FILEOPS_RMDIR 0x80
+#define XLOG_FILEOPS_SYMLINK 0x90
+#define XLOG_FILEOPS_LINK 0xA0
+#define XLOG_FILEOPS_SETXATTR 0xB0
+#define XLOG_FILEOPS_REMOVEXATTR 0xC0
+
+/*
+ * PendingFileOp - Deferred file operation entry
+ *
+ * File operations are collected in a linked list during a transaction
+ * and executed at commit or abort time. This follows the same pattern
+ * used by PendingRelDelete in catalog/storage.c.
+ */
+typedef enum PendingFileOpType
+{
+ PENDING_FILEOP_CREATE,
+ PENDING_FILEOP_DELETE,
+ PENDING_FILEOP_RENAME,
+ PENDING_FILEOP_WRITE,
+ PENDING_FILEOP_TRUNCATE,
+ PENDING_FILEOP_CHMOD,
+ PENDING_FILEOP_CHOWN,
+ PENDING_FILEOP_MKDIR,
+ PENDING_FILEOP_RMDIR,
+ PENDING_FILEOP_RMTREE, /* recursive removal at execute time */
+ PENDING_FILEOP_SYMLINK,
+ PENDING_FILEOP_LINK,
+ PENDING_FILEOP_SETXATTR,
+ PENDING_FILEOP_REMOVEXATTR
+} PendingFileOpType;
+
+typedef struct PendingFileOp
+{
+ PendingFileOpType type; /* operation type */
+ char *path; /* primary file path */
+ char *newpath; /* new path (RENAME/SYMLINK/LINK), or xattr
+ * name */
+ off_t length; /* truncation length, write offset, or mode */
+ void *data; /* generic data (e.g., original xattr value) */
+ size_t data_len; /* length of data */
+ bool at_commit; /* execute at commit (true) or abort (false) */
+ int nestLevel; /* transaction nesting level */
+ struct PendingFileOp *next; /* linked list link */
+} PendingFileOp;
+
+/*
+ * Public API for transactional file operations
+ *
+ * These functions handle platform-specific differences automatically:
+ * - O_DIRECT: PG_O_DIRECT (Linux/FreeBSD native, macOS F_NOCACHE,
+ * Windows FILE_FLAG_NO_BUFFERING)
+ * - fsync: pg_fsync() (Linux fdatasync, macOS F_FULLFSYNC,
+ * BSD fsync, Windows FlushFileBuffers)
+ * - Directory sync: fsync_parent_path() (Unix only, no-op on Windows)
+ * - Durable ops: durable_rename()/durable_unlink() with proper
+ * fsync ordering for crash safety
+ *
+ * Operation-specific API functions are declared below their WAL
+ * record structures in subsequent sections.
+ */
+
+/* Utility functions */
+extern void FileOpsCancelPendingDelete(const char *path, bool at_commit);
+extern void FileOpsSync(const char *path);
+
+/* Transaction lifecycle hooks */
+extern void FileOpsDoPendingOps(bool isCommit);
+extern void AtSubCommit_FileOps(void);
+extern void AtSubAbort_FileOps(void);
+extern void PostPrepare_FileOps(void);
+extern void FileOpsAtPrepare(void);
+
+/*
+ * xl_fileops_create - WAL record for file creation
+ *
+ * Records that a file was created within a transaction. If the transaction
+ * aborts, the file will be deleted. The path is stored as variable-length
+ * data following the fixed header.
+ */
+typedef struct xl_fileops_create
+{
+ int flags; /* open flags used for creation */
+ mode_t mode; /* file permission mode */
+ bool register_delete; /* register for delete-on-abort */
+ /* variable-length path follows */
+} xl_fileops_create;
+
+#define SizeOfFileOpsCreate (offsetof(xl_fileops_create, register_delete) + sizeof(bool))
+
+/* File creation API */
+extern int FileOpsCreate(const char *path, int flags, mode_t mode,
+ bool register_delete);
+
+/*
+ * xl_fileops_delete - WAL record for file deletion
+ *
+ * Records that a file deletion was requested. The at_commit flag indicates
+ * whether the deletion should happen at commit (true) or was registered
+ * as a delete-on-abort from a prior create (false).
+ */
+typedef struct xl_fileops_delete
+{
+ bool at_commit; /* true = delete at commit, false = at abort */
+ /* variable-length path follows */
+} xl_fileops_delete;
+
+#define SizeOfFileOpsDelete (offsetof(xl_fileops_delete, at_commit) + sizeof(bool))
+
+/* File deletion API */
+extern void FileOpsDelete(const char *path, bool at_commit);
+
+/*
+ * xl_fileops_rename - WAL record for file rename
+ *
+ * Records that a file was renamed. Both old and new paths are stored
+ * as variable-length data: oldpath_len bytes of old path, then the
+ * new path follows.
+ */
+typedef struct xl_fileops_rename
+{
+ uint16 oldpath_len; /* length of old path (including NUL) */
+ /* variable-length old path follows, then new path */
+} xl_fileops_rename;
+
+#define SizeOfFileOpsRename (offsetof(xl_fileops_rename, oldpath_len) + sizeof(uint16))
+
+/* File rename API */
+extern int FileOpsRename(const char *oldpath, const char *newpath);
+
+/*
+ * xl_fileops_write - WAL record for file write at offset
+ *
+ * Records that data was written to a file at a specific offset.
+ * The path and data are stored as variable-length data following
+ * the fixed header.
+ */
+typedef struct xl_fileops_write
+{
+ off_t offset; /* write offset in file */
+ uint32 len; /* data length */
+ uint16 path_len; /* length of path (including NUL) */
+ /* variable-length path follows, then data */
+} xl_fileops_write;
+
+#define SizeOfFileOpsWrite (offsetof(xl_fileops_write, path_len) + sizeof(uint16))
+
+/* File write API */
+extern int FileOpsWrite(const char *path, off_t offset,
+ const void *data, uint32 len);
+
+/*
+ * xl_fileops_truncate - WAL record for file truncation
+ */
+typedef struct xl_fileops_truncate
+{
+ off_t length; /* new file length */
+ off_t orig_length; /* pre-truncate length (crash-revert
+ * before-state) */
+ /* variable-length path follows */
+} xl_fileops_truncate;
+
+#define SizeOfFileOpsTruncate (offsetof(xl_fileops_truncate, orig_length) + sizeof(off_t))
+
+/* File truncation API */
+extern void FileOpsTruncate(const char *path, off_t length);
+
+/*
+ * xl_fileops_chmod - WAL record for file permission change
+ */
+typedef struct xl_fileops_chmod
+{
+ mode_t mode; /* new permission mode */
+ mode_t orig_mode; /* pre-chmod mode (crash-revert before-state) */
+ /* variable-length path follows */
+} xl_fileops_chmod;
+
+#define SizeOfFileOpsChmod (offsetof(xl_fileops_chmod, orig_mode) + sizeof(mode_t))
+
+/*
+ * xl_fileops_chown - WAL record for file ownership change
+ */
+typedef struct xl_fileops_chown
+{
+ uid_t uid; /* new owner user id */
+ gid_t gid; /* new owner group id */
+ uid_t orig_uid; /* pre-chown uid (crash-revert before-state) */
+ gid_t orig_gid; /* pre-chown gid (crash-revert before-state) */
+ /* variable-length path follows */
+} xl_fileops_chown;
+
+#define SizeOfFileOpsChown (offsetof(xl_fileops_chown, orig_gid) + sizeof(gid_t))
+
+/* File metadata API */
+extern int FileOpsChmod(const char *path, mode_t mode);
+extern int FileOpsChown(const char *path, uid_t uid, gid_t gid);
+
+/*
+ * xl_fileops_mkdir - WAL record for directory creation
+ */
+typedef struct xl_fileops_mkdir
+{
+ mode_t mode; /* directory permission mode */
+ /* variable-length path follows */
+} xl_fileops_mkdir;
+
+#define SizeOfFileOpsMkdir (offsetof(xl_fileops_mkdir, mode) + sizeof(mode_t))
+
+/*
+ * xl_fileops_rmdir - WAL record for directory removal
+ */
+typedef struct xl_fileops_rmdir
+{
+ bool at_commit; /* true = rmdir at commit, false = at abort */
+ /* variable-length path follows */
+} xl_fileops_rmdir;
+
+#define SizeOfFileOpsRmdir (offsetof(xl_fileops_rmdir, at_commit) + sizeof(bool))
+
+/* Directory lifecycle API */
+extern int FileOpsMkdir(const char *path, mode_t mode);
+extern void FileOpsRmdir(const char *path, bool at_commit);
+extern void FileOpsRmdirRecursive(const char *path, bool at_commit);
+
+/*
+ * FileOpsRmtree - schedule deferred recursive directory removal.
+ *
+ * Use this when the destination tree is empty (or nonexistent) at
+ * registration time but is expected to contain files by the time of
+ * commit or abort -- e.g., when registering the destination of an
+ * in-progress copydir() for delete-on-abort. Unlike
+ * FileOpsRmdirRecursive, the tree walk is deferred to execute time,
+ * so whatever ended up on disk gets removed.
+ */
+extern void FileOpsRmtree(const char *path, bool at_commit);
+
+/*
+ * xl_fileops_symlink - WAL record for symbolic link creation
+ */
+typedef struct xl_fileops_symlink
+{
+ uint16 target_len; /* length of target (including NUL) */
+ /* variable-length target follows, then linkpath */
+} xl_fileops_symlink;
+
+#define SizeOfFileOpsSymlink (offsetof(xl_fileops_symlink, target_len) + sizeof(uint16))
+
+/*
+ * xl_fileops_link - WAL record for hard link creation
+ */
+typedef struct xl_fileops_link
+{
+ uint16 oldpath_len; /* length of old path (including NUL) */
+ /* variable-length old path follows, then new path */
+} xl_fileops_link;
+
+#define SizeOfFileOpsLink (offsetof(xl_fileops_link, oldpath_len) + sizeof(uint16))
+
+/* Link operations API */
+extern int FileOpsSymlink(const char *target, const char *linkpath);
+extern int FileOpsLink(const char *oldpath, const char *newpath);
+
+/*
+ * xl_fileops_setxattr - WAL record for setting an extended attribute
+ */
+typedef struct xl_fileops_setxattr
+{
+ uint16 name_len; /* attribute name length (including NUL) */
+ uint32 value_len; /* attribute value length */
+ uint16 path_len; /* file path length (including NUL) */
+ uint32 orig_value_len; /* pre-set value length (crash-revert) */
+ bool had_value; /* true if xattr existed before this set */
+ /* variable-length: path, name, value, orig_value (if had_value) */
+} xl_fileops_setxattr;
+
+#define SizeOfFileOpsSetxattr (offsetof(xl_fileops_setxattr, had_value) + sizeof(bool))
+
+/*
+ * xl_fileops_removexattr - WAL record for removing an extended attribute
+ */
+typedef struct xl_fileops_removexattr
+{
+ uint16 name_len; /* attribute name length (including NUL) */
+ uint16 path_len; /* file path length (including NUL) */
+ uint32 orig_value_len; /* removed value length (crash-revert); 0 =
+ * none */
+ bool had_value; /* true if xattr existed before this remove */
+ /* variable-length: path, name, orig_value (if had_value) */
+} xl_fileops_removexattr;
+
+#define SizeOfFileOpsRemovexattr (offsetof(xl_fileops_removexattr, had_value) + sizeof(bool))
+
+/* Extended attribute API */
+extern int FileOpsSetXattr(const char *path, const char *name,
+ const void *value, size_t len);
+extern int FileOpsRemoveXattr(const char *path, const char *name);
+
+/* WAL redo and descriptor functions */
+extern void fileops_redo(XLogReaderState *record);
+extern void fileops_desc(StringInfo buf, XLogReaderState *record);
+extern const char *fileops_identify(uint8 info);
+
+/* FILEOPS UNDO RM (fileops_undo.c) */
+extern void FileopsUndoRmgrInit(void);
+
+/*
+ * UNDO resource-manager ID for FILEOPS (stored in urec_rmid). Defined here,
+ * with FILEOPS, rather than in the generic access/undormgr.h, so the UNDO
+ * core names no specific consumer. See access/undormgr.h for the shared,
+ * WAL-durable ID number space.
+ */
+#define UNDO_RMID_FILEOPS 2
+
+/* FILEOPS UNDO subtypes (stored in urec_info) */
+#define FILEOPS_UNDO_CREATE 0x0001
+#define FILEOPS_UNDO_RENAME 0x0002
+#define FILEOPS_UNDO_TRUNCATE 0x0003
+#define FILEOPS_UNDO_CHMOD 0x0004
+#define FILEOPS_UNDO_CHOWN 0x0005
+#define FILEOPS_UNDO_MKDIR 0x0006
+#define FILEOPS_UNDO_SYMLINK 0x0007
+#define FILEOPS_UNDO_LINK 0x0008
+#define FILEOPS_UNDO_SETXATTR 0x0009
+#define FILEOPS_UNDO_REMOVEXATTR 0x000A
+
+/*
+ * FILEOPS UNDO payload structures
+ *
+ * These structs define the on-disk layout of UNDO record payloads for each
+ * FILEOPS operation. Variable-length paths and data follow the fixed header.
+ */
+
+/* CREATE undo payload: just the path to unlink */
+typedef struct FileopsUndoCreate
+{
+ uint16 path_len; /* including NUL */
+ /* followed by path */
+} FileopsUndoCreate;
+
+/* RENAME undo payload: oldpath and newpath for reverse rename */
+typedef struct FileopsUndoRename
+{
+ uint16 oldpath_len;
+ uint16 newpath_len;
+ /* followed by oldpath, then newpath */
+} FileopsUndoRename;
+
+/* TRUNCATE undo payload: path + original length */
+typedef struct FileopsUndoTruncate
+{
+ off_t orig_length;
+ uint16 path_len;
+ /* followed by path */
+} FileopsUndoTruncate;
+
+/* CHMOD undo payload: path + original mode */
+typedef struct FileopsUndoChmod
+{
+ mode_t orig_mode;
+ uint16 path_len;
+ /* followed by path */
+} FileopsUndoChmod;
+
+/* CHOWN undo payload: path + original uid/gid */
+typedef struct FileopsUndoChown
+{
+ uid_t orig_uid;
+ gid_t orig_gid;
+ uint16 path_len;
+ /* followed by path */
+} FileopsUndoChown;
+
+/* MKDIR undo payload: just the path to rmdir */
+typedef struct FileopsUndoMkdir
+{
+ uint16 path_len;
+ /* followed by path */
+} FileopsUndoMkdir;
+
+/* SYMLINK undo payload: just the linkpath to unlink */
+typedef struct FileopsUndoSymlink
+{
+ uint16 linkpath_len;
+ /* followed by linkpath */
+} FileopsUndoSymlink;
+
+/* LINK undo payload: just the newpath to unlink */
+typedef struct FileopsUndoLink
+{
+ uint16 newpath_len;
+ /* followed by newpath */
+} FileopsUndoLink;
+
+/* SETXATTR undo payload: path + name + original value (or empty if new) */
+typedef struct FileopsUndoSetxattr
+{
+ uint16 path_len;
+ uint16 name_len;
+ uint32 orig_value_len; /* 0 if xattr didn't exist before */
+ bool had_value; /* true if xattr existed before setxattr */
+ /* followed by path, name, original value (if had_value) */
+} FileopsUndoSetxattr;
+
+/* REMOVEXATTR undo payload: path + name + removed value */
+typedef struct FileopsUndoRemovexattr
+{
+ uint16 path_len;
+ uint16 name_len;
+ uint32 value_len;
+ /* followed by path, name, value */
+} FileopsUndoRemovexattr;
+
+/* Recursive directory removal */
+extern void FileOpsRmdirRecursive(const char *path, bool at_commit);
+
+#endif /* FILEOPS_H */
diff --git a/src/include/storage/lwlocklist.h b/src/include/storage/lwlocklist.h
index 8d858be9927f6..16d501c944b65 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 9ad619080be22..9b995f9bad799 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/port/Makefile b/src/port/Makefile
index 7e9b58776529a..39e1388227adc 100644
--- a/src/port/Makefile
+++ b/src/port/Makefile
@@ -51,6 +51,7 @@ OBJS = \
pg_popcount_aarch64.o \
pg_popcount_x86.o \
pg_strong_random.o \
+ pg_xattr.o \
pgcheckdir.o \
pgmkdirp.o \
pgsleep.o \
diff --git a/src/port/meson.build b/src/port/meson.build
index 922b3f646768d..20e1ca0043110 100644
--- a/src/port/meson.build
+++ b/src/port/meson.build
@@ -14,6 +14,7 @@ pgport_sources = [
'pg_popcount_aarch64.c',
'pg_popcount_x86.c',
'pg_strong_random.c',
+ 'pg_xattr.c',
'pgcheckdir.c',
'pgmkdirp.c',
'pgsleep.c',
diff --git a/src/port/pg_xattr.c b/src/port/pg_xattr.c
new file mode 100644
index 0000000000000..58a3d422817f7
--- /dev/null
+++ b/src/port/pg_xattr.c
@@ -0,0 +1,217 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_xattr.c
+ * Cross-platform extended attribute abstraction
+ *
+ * Platform detection uses compiler-defined macros rather than
+ * configure-time checks, avoiding meson.build/configure.ac changes.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ * src/port/pg_xattr.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "c.h"
+
+#include
+#include "port/pg_xattr.h"
+
+/*
+ * Platform detection via compiler macros.
+ * Linux and macOS both provide but with different APIs.
+ * FreeBSD uses . Windows uses NTFS Alternate Data Streams.
+ */
+#if defined(__linux__) || defined(__APPLE__)
+#include
+#define PG_HAVE_XATTR 1
+#elif defined(__FreeBSD__)
+#include
+#include
+#define PG_HAVE_EXTATTR 1
+#elif defined(WIN32)
+#define PG_HAVE_ADS 1
+#endif
+
+#if defined(PG_HAVE_ADS)
+/*
+ * Set errno for a failed operation on the stream "path:name". A missing
+ * stream fails with ERROR_FILE_NOT_FOUND, like a missing file; report it as
+ * PG_ENOATTR when the file itself exists, as the other platforms do.
+ */
+static void
+pg_ads_set_errno(const char *path, DWORD err)
+{
+ _dosmaperr(err);
+ if (errno == ENOENT && GetFileAttributesA(path) != INVALID_FILE_ATTRIBUTES)
+ errno = PG_ENOATTR;
+}
+#endif
+
+/*
+ * pg_setxattr - Set an extended attribute on a file
+ *
+ * Returns 0 on success, -1 on failure (errno set).
+ */
+int
+pg_setxattr(const char *path, const char *name,
+ const void *value, size_t size)
+{
+#if defined(PG_HAVE_XATTR)
+#if defined(__APPLE__)
+ return setxattr(path, name, value, size, 0, 0);
+#else
+ return setxattr(path, name, value, size, 0);
+#endif
+
+#elif defined(PG_HAVE_EXTATTR)
+ ssize_t ret;
+
+ ret = extattr_set_file(path, EXTATTR_NAMESPACE_USER,
+ name, value, size);
+ return (ret >= 0) ? 0 : -1;
+
+#elif defined(PG_HAVE_ADS)
+ char ads_path[MAXPGPATH];
+ HANDLE hFile;
+ DWORD written;
+
+ snprintf(ads_path, sizeof(ads_path), "%s:%s", path, name);
+
+ hFile = CreateFileA(ads_path, GENERIC_WRITE, 0, NULL,
+ CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
+ if (hFile == INVALID_HANDLE_VALUE)
+ {
+ _dosmaperr(GetLastError());
+ return -1;
+ }
+
+ if (!WriteFile(hFile, value, (DWORD) size, &written, NULL) ||
+ written != (DWORD) size)
+ {
+ _dosmaperr(GetLastError());
+ CloseHandle(hFile);
+ return -1;
+ }
+
+ CloseHandle(hFile);
+ return 0;
+
+#else
+ /* Unsupported platform: succeed in WAL but no-op locally */
+ (void) path;
+ (void) name;
+ (void) value;
+ (void) size;
+ errno = ENOTSUP;
+ return -1;
+#endif
+}
+
+/*
+ * pg_getxattr - Get an extended attribute value from a file
+ *
+ * Returns the number of bytes placed in value on success,
+ * or -1 on failure (errno set). If value is NULL or size is 0,
+ * returns the size of the attribute value without reading it.
+ */
+ssize_t
+pg_getxattr(const char *path, const char *name,
+ void *value, size_t size)
+{
+#if defined(PG_HAVE_XATTR)
+#if defined(__APPLE__)
+ return getxattr(path, name, value, size, 0, 0);
+#else
+ return getxattr(path, name, value, size);
+#endif
+
+#elif defined(PG_HAVE_EXTATTR)
+ return extattr_get_file(path, EXTATTR_NAMESPACE_USER,
+ name, value, size);
+
+#elif defined(PG_HAVE_ADS)
+ char ads_path[MAXPGPATH];
+ HANDLE hFile;
+ DWORD bytesRead;
+ LARGE_INTEGER fileSize;
+
+ snprintf(ads_path, sizeof(ads_path), "%s:%s", path, name);
+
+ hFile = CreateFileA(ads_path, GENERIC_READ, FILE_SHARE_READ, NULL,
+ OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
+ if (hFile == INVALID_HANDLE_VALUE)
+ {
+ pg_ads_set_errno(path, GetLastError());
+ return -1;
+ }
+
+ if (!GetFileSizeEx(hFile, &fileSize))
+ {
+ _dosmaperr(GetLastError());
+ CloseHandle(hFile);
+ return -1;
+ }
+
+ if (value == NULL || size == 0)
+ {
+ CloseHandle(hFile);
+ return (ssize_t) fileSize.QuadPart;
+ }
+
+ if (!ReadFile(hFile, value, (DWORD) size, &bytesRead, NULL))
+ {
+ _dosmaperr(GetLastError());
+ CloseHandle(hFile);
+ return -1;
+ }
+
+ CloseHandle(hFile);
+ return (ssize_t) bytesRead;
+
+#else
+ (void) path;
+ (void) name;
+ (void) value;
+ (void) size;
+ errno = ENOTSUP;
+ return -1;
+#endif
+}
+
+/*
+ * pg_removexattr - Remove an extended attribute from a file
+ *
+ * Returns 0 on success, -1 on failure (errno set).
+ */
+int
+pg_removexattr(const char *path, const char *name)
+{
+#if defined(PG_HAVE_XATTR)
+#if defined(__APPLE__)
+ return removexattr(path, name, 0);
+#else
+ return removexattr(path, name);
+#endif
+
+#elif defined(PG_HAVE_EXTATTR)
+ return extattr_delete_file(path, EXTATTR_NAMESPACE_USER, name);
+
+#elif defined(PG_HAVE_ADS)
+ char ads_path[MAXPGPATH];
+
+ snprintf(ads_path, sizeof(ads_path), "%s:%s", path, name);
+ if (DeleteFileA(ads_path))
+ return 0;
+
+ pg_ads_set_errno(path, GetLastError());
+ return -1;
+
+#else
+ (void) path;
+ (void) name;
+ errno = ENOTSUP;
+ return -1;
+#endif
+}
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 6c1504ac9323a..f16d803fa2c85 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -32,6 +32,7 @@ SUBDIRS = \
test_escape \
test_extensible \
test_extensions \
+ test_fileops \
test_ginpostinglist \
test_int128 \
test_integerset \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index ef3cdb8faf9c9..49cc09d041b6f 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -34,6 +34,7 @@ subdir('test_dsm_registry')
subdir('test_escape')
subdir('test_extensible')
subdir('test_extensions')
+subdir('test_fileops')
subdir('test_ginpostinglist')
subdir('test_int128')
subdir('test_integerset')
diff --git a/src/test/modules/test_fileops/Makefile b/src/test/modules/test_fileops/Makefile
new file mode 100644
index 0000000000000..69100c7527288
--- /dev/null
+++ b/src/test/modules/test_fileops/Makefile
@@ -0,0 +1,19 @@
+MODULE_big = test_fileops
+OBJS = test_fileops.o
+EXTENSION = test_fileops
+DATA = test_fileops--1.0.sql
+PGFILEDESC = "test_fileops - test transactional file operations"
+REGRESS = test_fileops
+TAP_TESTS = 1
+EXTRA_INSTALL = src/test/modules/injection_points
+export enable_injection_points
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_fileops
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/test_fileops/expected/test_fileops.out b/src/test/modules/test_fileops/expected/test_fileops.out
new file mode 100644
index 0000000000000..b529cfe3bb29f
--- /dev/null
+++ b/src/test/modules/test_fileops/expected/test_fileops.out
@@ -0,0 +1,413 @@
+--
+-- Tests for FILEOPS UNDO rollback of direct file operations.
+--
+-- This test module provides SQL-callable wrappers around FileOps C functions
+-- that have no DDL-level callers, enabling direct testing of UNDO rollback
+-- for: FileOpsTruncate, FileOpsChmod, FileOpsLink, FileOpsSetXattr,
+-- FileOpsRemoveXattr.
+--
+-- REQUIRES: UNDO subsystem (always active)
+--
+-- These are POSIX file semantics: modes, hard links that do not share the
+-- target's attributes, extended attributes. Windows has none of them (chmod
+-- only toggles the read-only attribute, which a hard link shares), so the test
+-- does not run there; the TAP tests cover the operations FILEOPS supports on
+-- Windows.
+SELECT version() ~ '(Visual C\+\+|mingw32|msvc|windows)' AS skip_test \gset
+\if :skip_test
+\quit
+\endif
+CREATE EXTENSION test_fileops;
+-- ================================================================
+-- Setup: create test files in the data directory
+-- ================================================================
+SELECT test_fileops_create_tempfile('test_fileops_a.dat') AS filepath_a \gset
+SELECT test_fileops_create_tempfile('test_fileops_b.dat') AS filepath_b \gset
+-- Record initial state (umask may affect mode)
+SELECT test_fileops_file_size(:'filepath_a') AS initial_size;
+ initial_size
+--------------
+ 1024
+(1 row)
+
+SELECT test_fileops_get_mode(:'filepath_a') AS initial_mode;
+ initial_mode
+--------------
+ 384
+(1 row)
+
+-- ================================================================
+-- Test 1: FileOpsTruncate UNDO - rollback restores original size
+-- ================================================================
+BEGIN;
+SELECT test_fileops_truncate(:'filepath_a', 256);
+ test_fileops_truncate
+-----------------------
+
+(1 row)
+
+SELECT test_fileops_file_size(:'filepath_a') AS size_during_txn;
+ size_during_txn
+-----------------
+ 256
+(1 row)
+
+ROLLBACK;
+-- After rollback, UNDO should restore original size (1024)
+SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback;
+ size_after_rollback
+---------------------
+ 1024
+(1 row)
+
+-- ================================================================
+-- Test 2: FileOpsTruncate commit - change persists
+-- ================================================================
+BEGIN;
+SELECT test_fileops_truncate(:'filepath_b', 512);
+ test_fileops_truncate
+-----------------------
+
+(1 row)
+
+COMMIT;
+SELECT test_fileops_file_size(:'filepath_b') AS size_after_commit;
+ size_after_commit
+-------------------
+ 512
+(1 row)
+
+-- ================================================================
+-- Test 3: FileOpsChmod UNDO - rollback restores original permissions
+-- ================================================================
+-- Change to a known starting mode first (committed)
+SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 = 420 decimal
+ test_fileops_chmod
+--------------------
+
+(1 row)
+
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_baseline;
+ mode_baseline
+---------------
+ 420
+(1 row)
+
+BEGIN;
+-- Change to 0600 = 384 decimal
+SELECT test_fileops_chmod(:'filepath_a', 384);
+ test_fileops_chmod
+--------------------
+
+(1 row)
+
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_during_txn;
+ mode_during_txn
+-----------------
+ 384
+(1 row)
+
+ROLLBACK;
+-- After rollback, UNDO should restore to 0644 = 420
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback;
+ mode_after_rollback
+---------------------
+ 420
+(1 row)
+
+-- ================================================================
+-- Test 4: FileOpsChmod commit - change persists
+-- ================================================================
+BEGIN;
+SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700 = 448 decimal
+ test_fileops_chmod
+--------------------
+
+(1 row)
+
+COMMIT;
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit;
+ mode_after_commit
+-------------------
+ 448
+(1 row)
+
+-- Restore for subsequent tests
+SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644
+ test_fileops_chmod
+--------------------
+
+(1 row)
+
+-- ================================================================
+-- Test 5: FileOpsLink UNDO - rollback removes the hard link
+-- ================================================================
+SELECT test_fileops_data_dir() || '/test_fileops_link.dat' AS linkpath \gset
+BEGIN;
+SELECT test_fileops_link(:'filepath_a', :'linkpath');
+ test_fileops_link
+-------------------
+
+(1 row)
+
+SELECT test_fileops_file_exists(:'linkpath') AS link_during_txn;
+ link_during_txn
+-----------------
+ t
+(1 row)
+
+ROLLBACK;
+-- After rollback, UNDO should remove the hard link
+SELECT test_fileops_file_exists(:'linkpath') AS link_after_rollback;
+ link_after_rollback
+---------------------
+ f
+(1 row)
+
+-- ================================================================
+-- Test 6: FileOpsLink commit - link persists
+-- ================================================================
+BEGIN;
+SELECT test_fileops_link(:'filepath_a', :'linkpath');
+ test_fileops_link
+-------------------
+
+(1 row)
+
+COMMIT;
+SELECT test_fileops_file_exists(:'linkpath') AS link_after_commit;
+ link_after_commit
+-------------------
+ t
+(1 row)
+
+-- ================================================================
+-- Test 7: FileOpsTruncate in subtransaction - ROLLBACK TO undoes it
+-- ================================================================
+-- Ensure file is 1024 bytes
+SELECT test_fileops_file_size(:'filepath_a') AS size_before_test7;
+ size_before_test7
+-------------------
+ 1024
+(1 row)
+
+BEGIN;
+-- Outer truncate (should persist after commit)
+SELECT test_fileops_truncate(:'filepath_a', 800);
+ test_fileops_truncate
+-----------------------
+
+(1 row)
+
+SAVEPOINT sp1;
+-- Inner truncate (should be rolled back)
+SELECT test_fileops_truncate(:'filepath_a', 100);
+ test_fileops_truncate
+-----------------------
+
+(1 row)
+
+SELECT test_fileops_file_size(:'filepath_a') AS size_in_savepoint;
+ size_in_savepoint
+-------------------
+ 100
+(1 row)
+
+ROLLBACK TO sp1;
+-- After ROLLBACK TO, should be back to 800 (the outer truncate value)
+SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback_to;
+ size_after_rollback_to
+------------------------
+ 800
+(1 row)
+
+COMMIT;
+-- After commit, the outer truncate to 800 should persist
+SELECT test_fileops_file_size(:'filepath_a') AS size_after_commit;
+ size_after_commit
+-------------------
+ 800
+(1 row)
+
+-- ================================================================
+-- Test 8: FileOpsChmod in subtransaction - ROLLBACK TO restores mode
+-- ================================================================
+-- Set a known mode baseline
+SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644
+ test_fileops_chmod
+--------------------
+
+(1 row)
+
+BEGIN;
+-- Outer chmod (persists)
+SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700
+ test_fileops_chmod
+--------------------
+
+(1 row)
+
+SAVEPOINT sp1;
+-- Inner chmod (rolled back)
+SELECT test_fileops_chmod(:'filepath_a', 256); -- 0400
+ test_fileops_chmod
+--------------------
+
+(1 row)
+
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_in_savepoint;
+ mode_in_savepoint
+-------------------
+ 256
+(1 row)
+
+ROLLBACK TO sp1;
+-- After ROLLBACK TO, should be back to 0700 = 448
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback_to;
+ mode_after_rollback_to
+------------------------
+ 448
+(1 row)
+
+COMMIT;
+-- After commit, outer chmod (0700 = 448) persists
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit;
+ mode_after_commit
+-------------------
+ 448
+(1 row)
+
+-- ================================================================
+-- Test 9: FileOpsLink in subtransaction - ROLLBACK TO removes link
+-- ================================================================
+SELECT test_fileops_data_dir() || '/test_fileops_link2.dat' AS linkpath2 \gset
+BEGIN;
+SAVEPOINT sp1;
+SELECT test_fileops_link(:'filepath_a', :'linkpath2');
+ test_fileops_link
+-------------------
+
+(1 row)
+
+SELECT test_fileops_file_exists(:'linkpath2') AS link2_in_savepoint;
+ link2_in_savepoint
+--------------------
+ t
+(1 row)
+
+ROLLBACK TO sp1;
+-- Link should be removed by UNDO
+SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_rollback_to;
+ link2_after_rollback_to
+-------------------------
+ f
+(1 row)
+
+COMMIT;
+SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_commit;
+ link2_after_commit
+--------------------
+ f
+(1 row)
+
+-- ================================================================
+-- Test 10: FileOpsSetXattr / FileOpsRemoveXattr UNDO
+-- (skipped on platforms without xattr support)
+-- ================================================================
+-- Try to set an xattr; returns false if unsupported (ENOTSUP/EPERM/EACCES)
+SELECT test_fileops_setxattr(:'filepath_a', 'user.test_key', 'initial_value')
+ AS xattr_supported \gset
+\if :xattr_supported
+-- Test 10a: SetXattr UNDO - rollback removes newly-set xattr
+BEGIN;
+SELECT test_fileops_setxattr(:'filepath_a', 'user.rollback_test', 'will_vanish');
+ test_fileops_setxattr
+-----------------------
+ t
+(1 row)
+
+SELECT test_fileops_getxattr(:'filepath_a', 'user.rollback_test') AS xattr_during_txn;
+ xattr_during_txn
+------------------
+ will_vanish
+(1 row)
+
+ROLLBACK;
+SELECT test_fileops_getxattr(:'filepath_a', 'user.rollback_test') AS xattr_after_rollback;
+ xattr_after_rollback
+----------------------
+
+(1 row)
+
+-- Test 10b: SetXattr overwrite UNDO - rollback restores old value
+BEGIN;
+SELECT test_fileops_setxattr(:'filepath_a', 'user.test_key', 'overwritten');
+ test_fileops_setxattr
+-----------------------
+ t
+(1 row)
+
+SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_overwritten;
+ xattr_overwritten
+-------------------
+ overwritten
+(1 row)
+
+ROLLBACK;
+SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_restored;
+ xattr_restored
+----------------
+ initial_value
+(1 row)
+
+-- Test 10c: RemoveXattr UNDO - rollback restores removed xattr
+BEGIN;
+SELECT test_fileops_removexattr(:'filepath_a', 'user.test_key');
+ test_fileops_removexattr
+--------------------------
+ t
+(1 row)
+
+SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_after_remove;
+ xattr_after_remove
+--------------------
+
+(1 row)
+
+ROLLBACK;
+SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_after_remove_rollback;
+ xattr_after_remove_rollback
+-----------------------------
+ initial_value
+(1 row)
+
+-- Test 10d: Xattr in subtransaction
+BEGIN;
+SAVEPOINT sp1;
+SELECT test_fileops_setxattr(:'filepath_a', 'user.sub_key', 'sub_value');
+ test_fileops_setxattr
+-----------------------
+ t
+(1 row)
+
+SELECT test_fileops_getxattr(:'filepath_a', 'user.sub_key') AS xattr_in_savepoint;
+ xattr_in_savepoint
+--------------------
+ sub_value
+(1 row)
+
+ROLLBACK TO sp1;
+SELECT test_fileops_getxattr(:'filepath_a', 'user.sub_key') AS xattr_after_sp_rollback;
+ xattr_after_sp_rollback
+-------------------------
+
+(1 row)
+
+COMMIT;
+\else
+SELECT 'xattr tests skipped (not supported on this platform/filesystem)' AS notice;
+\endif
+-- ================================================================
+-- Cleanup
+-- ================================================================
+DROP EXTENSION test_fileops;
diff --git a/src/test/modules/test_fileops/expected/test_fileops_1.out b/src/test/modules/test_fileops/expected/test_fileops_1.out
new file mode 100644
index 0000000000000..41193304cd627
--- /dev/null
+++ b/src/test/modules/test_fileops/expected/test_fileops_1.out
@@ -0,0 +1,332 @@
+--
+-- Tests for FILEOPS UNDO rollback of direct file operations.
+--
+-- This test module provides SQL-callable wrappers around FileOps C functions
+-- that have no DDL-level callers, enabling direct testing of UNDO rollback
+-- for: FileOpsTruncate, FileOpsChmod, FileOpsLink, FileOpsSetXattr,
+-- FileOpsRemoveXattr.
+--
+-- REQUIRES: UNDO subsystem (always active)
+--
+-- These are POSIX file semantics: modes, hard links that do not share the
+-- target's attributes, extended attributes. Windows has none of them (chmod
+-- only toggles the read-only attribute, which a hard link shares), so the test
+-- does not run there; the TAP tests cover the operations FILEOPS supports on
+-- Windows.
+SELECT version() ~ '(Visual C\+\+|mingw32|msvc|windows)' AS skip_test \gset
+\if :skip_test
+\quit
+\endif
+CREATE EXTENSION test_fileops;
+-- ================================================================
+-- Setup: create test files in the data directory
+-- ================================================================
+SELECT test_fileops_create_tempfile('test_fileops_a.dat') AS filepath_a \gset
+SELECT test_fileops_create_tempfile('test_fileops_b.dat') AS filepath_b \gset
+-- Record initial state (umask may affect mode)
+SELECT test_fileops_file_size(:'filepath_a') AS initial_size;
+ initial_size
+--------------
+ 1024
+(1 row)
+
+SELECT test_fileops_get_mode(:'filepath_a') AS initial_mode;
+ initial_mode
+--------------
+ 384
+(1 row)
+
+-- ================================================================
+-- Test 1: FileOpsTruncate UNDO - rollback restores original size
+-- ================================================================
+BEGIN;
+SELECT test_fileops_truncate(:'filepath_a', 256);
+ test_fileops_truncate
+-----------------------
+
+(1 row)
+
+SELECT test_fileops_file_size(:'filepath_a') AS size_during_txn;
+ size_during_txn
+-----------------
+ 256
+(1 row)
+
+ROLLBACK;
+-- After rollback, UNDO should restore original size (1024)
+SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback;
+ size_after_rollback
+---------------------
+ 1024
+(1 row)
+
+-- ================================================================
+-- Test 2: FileOpsTruncate commit - change persists
+-- ================================================================
+BEGIN;
+SELECT test_fileops_truncate(:'filepath_b', 512);
+ test_fileops_truncate
+-----------------------
+
+(1 row)
+
+COMMIT;
+SELECT test_fileops_file_size(:'filepath_b') AS size_after_commit;
+ size_after_commit
+-------------------
+ 512
+(1 row)
+
+-- ================================================================
+-- Test 3: FileOpsChmod UNDO - rollback restores original permissions
+-- ================================================================
+-- Change to a known starting mode first (committed)
+SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 = 420 decimal
+ test_fileops_chmod
+--------------------
+
+(1 row)
+
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_baseline;
+ mode_baseline
+---------------
+ 420
+(1 row)
+
+BEGIN;
+-- Change to 0600 = 384 decimal
+SELECT test_fileops_chmod(:'filepath_a', 384);
+ test_fileops_chmod
+--------------------
+
+(1 row)
+
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_during_txn;
+ mode_during_txn
+-----------------
+ 384
+(1 row)
+
+ROLLBACK;
+-- After rollback, UNDO should restore to 0644 = 420
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback;
+ mode_after_rollback
+---------------------
+ 420
+(1 row)
+
+-- ================================================================
+-- Test 4: FileOpsChmod commit - change persists
+-- ================================================================
+BEGIN;
+SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700 = 448 decimal
+ test_fileops_chmod
+--------------------
+
+(1 row)
+
+COMMIT;
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit;
+ mode_after_commit
+-------------------
+ 448
+(1 row)
+
+-- Restore for subsequent tests
+SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644
+ test_fileops_chmod
+--------------------
+
+(1 row)
+
+-- ================================================================
+-- Test 5: FileOpsLink UNDO - rollback removes the hard link
+-- ================================================================
+SELECT test_fileops_data_dir() || '/test_fileops_link.dat' AS linkpath \gset
+BEGIN;
+SELECT test_fileops_link(:'filepath_a', :'linkpath');
+ test_fileops_link
+-------------------
+
+(1 row)
+
+SELECT test_fileops_file_exists(:'linkpath') AS link_during_txn;
+ link_during_txn
+-----------------
+ t
+(1 row)
+
+ROLLBACK;
+-- After rollback, UNDO should remove the hard link
+SELECT test_fileops_file_exists(:'linkpath') AS link_after_rollback;
+ link_after_rollback
+---------------------
+ f
+(1 row)
+
+-- ================================================================
+-- Test 6: FileOpsLink commit - link persists
+-- ================================================================
+BEGIN;
+SELECT test_fileops_link(:'filepath_a', :'linkpath');
+ test_fileops_link
+-------------------
+
+(1 row)
+
+COMMIT;
+SELECT test_fileops_file_exists(:'linkpath') AS link_after_commit;
+ link_after_commit
+-------------------
+ t
+(1 row)
+
+-- ================================================================
+-- Test 7: FileOpsTruncate in subtransaction - ROLLBACK TO undoes it
+-- ================================================================
+-- Ensure file is 1024 bytes
+SELECT test_fileops_file_size(:'filepath_a') AS size_before_test7;
+ size_before_test7
+-------------------
+ 1024
+(1 row)
+
+BEGIN;
+-- Outer truncate (should persist after commit)
+SELECT test_fileops_truncate(:'filepath_a', 800);
+ test_fileops_truncate
+-----------------------
+
+(1 row)
+
+SAVEPOINT sp1;
+-- Inner truncate (should be rolled back)
+SELECT test_fileops_truncate(:'filepath_a', 100);
+ test_fileops_truncate
+-----------------------
+
+(1 row)
+
+SELECT test_fileops_file_size(:'filepath_a') AS size_in_savepoint;
+ size_in_savepoint
+-------------------
+ 100
+(1 row)
+
+ROLLBACK TO sp1;
+-- After ROLLBACK TO, should be back to 800 (the outer truncate value)
+SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback_to;
+ size_after_rollback_to
+------------------------
+ 800
+(1 row)
+
+COMMIT;
+-- After commit, the outer truncate to 800 should persist
+SELECT test_fileops_file_size(:'filepath_a') AS size_after_commit;
+ size_after_commit
+-------------------
+ 800
+(1 row)
+
+-- ================================================================
+-- Test 8: FileOpsChmod in subtransaction - ROLLBACK TO restores mode
+-- ================================================================
+-- Set a known mode baseline
+SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644
+ test_fileops_chmod
+--------------------
+
+(1 row)
+
+BEGIN;
+-- Outer chmod (persists)
+SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700
+ test_fileops_chmod
+--------------------
+
+(1 row)
+
+SAVEPOINT sp1;
+-- Inner chmod (rolled back)
+SELECT test_fileops_chmod(:'filepath_a', 256); -- 0400
+ test_fileops_chmod
+--------------------
+
+(1 row)
+
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_in_savepoint;
+ mode_in_savepoint
+-------------------
+ 256
+(1 row)
+
+ROLLBACK TO sp1;
+-- After ROLLBACK TO, should be back to 0700 = 448
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback_to;
+ mode_after_rollback_to
+------------------------
+ 448
+(1 row)
+
+COMMIT;
+-- After commit, outer chmod (0700 = 448) persists
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit;
+ mode_after_commit
+-------------------
+ 448
+(1 row)
+
+-- ================================================================
+-- Test 9: FileOpsLink in subtransaction - ROLLBACK TO removes link
+-- ================================================================
+SELECT test_fileops_data_dir() || '/test_fileops_link2.dat' AS linkpath2 \gset
+BEGIN;
+SAVEPOINT sp1;
+SELECT test_fileops_link(:'filepath_a', :'linkpath2');
+ test_fileops_link
+-------------------
+
+(1 row)
+
+SELECT test_fileops_file_exists(:'linkpath2') AS link2_in_savepoint;
+ link2_in_savepoint
+--------------------
+ t
+(1 row)
+
+ROLLBACK TO sp1;
+-- Link should be removed by UNDO
+SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_rollback_to;
+ link2_after_rollback_to
+-------------------------
+ f
+(1 row)
+
+COMMIT;
+SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_commit;
+ link2_after_commit
+--------------------
+ f
+(1 row)
+
+-- ================================================================
+-- Test 10: FileOpsSetXattr / FileOpsRemoveXattr UNDO
+-- (skipped on platforms without xattr support)
+-- ================================================================
+-- Try to set an xattr; returns false if unsupported (ENOTSUP/EPERM/EACCES)
+SELECT test_fileops_setxattr(:'filepath_a', 'user.test_key', 'initial_value')
+ AS xattr_supported \gset
+\if :xattr_supported
+\else
+SELECT 'xattr tests skipped (not supported on this platform/filesystem)' AS notice;
+ notice
+-----------------------------------------------------------------
+ xattr tests skipped (not supported on this platform/filesystem)
+(1 row)
+
+\endif
+-- ================================================================
+-- Cleanup
+-- ================================================================
+DROP EXTENSION test_fileops;
diff --git a/src/test/modules/test_fileops/expected/test_fileops_2.out b/src/test/modules/test_fileops/expected/test_fileops_2.out
new file mode 100644
index 0000000000000..d25aa81d54bab
--- /dev/null
+++ b/src/test/modules/test_fileops/expected/test_fileops_2.out
@@ -0,0 +1,18 @@
+--
+-- Tests for FILEOPS UNDO rollback of direct file operations.
+--
+-- This test module provides SQL-callable wrappers around FileOps C functions
+-- that have no DDL-level callers, enabling direct testing of UNDO rollback
+-- for: FileOpsTruncate, FileOpsChmod, FileOpsLink, FileOpsSetXattr,
+-- FileOpsRemoveXattr.
+--
+-- REQUIRES: UNDO subsystem (always active)
+--
+-- These are POSIX file semantics: modes, hard links that do not share the
+-- target's attributes, extended attributes. Windows has none of them (chmod
+-- only toggles the read-only attribute, which a hard link shares), so the test
+-- does not run there; the TAP tests cover the operations FILEOPS supports on
+-- Windows.
+SELECT version() ~ '(Visual C\+\+|mingw32|msvc|windows)' AS skip_test \gset
+\if :skip_test
+\quit
diff --git a/src/test/modules/test_fileops/meson.build b/src/test/modules/test_fileops/meson.build
new file mode 100644
index 0000000000000..adda18e624e6c
--- /dev/null
+++ b/src/test/modules/test_fileops/meson.build
@@ -0,0 +1,44 @@
+# Copyright (c) 2024-2026, PostgreSQL Global Development Group
+
+test_fileops_sources = files(
+ 'test_fileops.c',
+)
+
+if host_system == 'windows'
+ test_fileops_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+ '--NAME', 'test_fileops',
+ '--FILEDESC', 'test_fileops - regression testing of FILEOPS subsystem',])
+endif
+
+test_fileops = shared_module('test_fileops',
+ test_fileops_sources,
+ kwargs: pg_test_mod_args,
+)
+test_install_libs += test_fileops
+
+test_install_data += files(
+ 'test_fileops.control',
+ 'test_fileops--1.0.sql',
+)
+
+tests += {
+ 'name': 'test_fileops',
+ 'sd': meson.current_source_dir(),
+ 'bd': meson.current_build_dir(),
+ 'regress': {
+ 'sql': [
+ 'test_fileops',
+ ],
+ },
+ 'tap': {
+ 'env': {
+ 'enable_injection_points': get_option('injection_points') ? 'yes' : 'no',
+ },
+ 'tests': [
+ 't/001_fileops_apply_failure.pl',
+ 't/007_fileops_commit_crash.pl',
+ 't/023_fileops_inline_abort_ctx.pl',
+ 't/024_fileops_recovery_apply_failure.pl',
+ ],
+ },
+}
diff --git a/src/test/modules/test_fileops/sql/test_fileops.sql b/src/test/modules/test_fileops/sql/test_fileops.sql
new file mode 100644
index 0000000000000..ef1a10523d10f
--- /dev/null
+++ b/src/test/modules/test_fileops/sql/test_fileops.sql
@@ -0,0 +1,229 @@
+--
+-- Tests for FILEOPS UNDO rollback of direct file operations.
+--
+-- This test module provides SQL-callable wrappers around FileOps C functions
+-- that have no DDL-level callers, enabling direct testing of UNDO rollback
+-- for: FileOpsTruncate, FileOpsChmod, FileOpsLink, FileOpsSetXattr,
+-- FileOpsRemoveXattr.
+--
+-- REQUIRES: UNDO subsystem (always active)
+--
+-- These are POSIX file semantics: modes, hard links that do not share the
+-- target's attributes, extended attributes. Windows has none of them (chmod
+-- only toggles the read-only attribute, which a hard link shares), so the test
+-- does not run there; the TAP tests cover the operations FILEOPS supports on
+-- Windows.
+SELECT version() ~ '(Visual C\+\+|mingw32|msvc|windows)' AS skip_test \gset
+\if :skip_test
+\quit
+\endif
+
+CREATE EXTENSION test_fileops;
+
+-- ================================================================
+-- Setup: create test files in the data directory
+-- ================================================================
+
+SELECT test_fileops_create_tempfile('test_fileops_a.dat') AS filepath_a \gset
+SELECT test_fileops_create_tempfile('test_fileops_b.dat') AS filepath_b \gset
+
+-- Record initial state (umask may affect mode)
+SELECT test_fileops_file_size(:'filepath_a') AS initial_size;
+SELECT test_fileops_get_mode(:'filepath_a') AS initial_mode;
+
+-- ================================================================
+-- Test 1: FileOpsTruncate UNDO - rollback restores original size
+-- ================================================================
+
+BEGIN;
+SELECT test_fileops_truncate(:'filepath_a', 256);
+SELECT test_fileops_file_size(:'filepath_a') AS size_during_txn;
+ROLLBACK;
+
+-- After rollback, UNDO should restore original size (1024)
+SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback;
+
+-- ================================================================
+-- Test 2: FileOpsTruncate commit - change persists
+-- ================================================================
+
+BEGIN;
+SELECT test_fileops_truncate(:'filepath_b', 512);
+COMMIT;
+
+SELECT test_fileops_file_size(:'filepath_b') AS size_after_commit;
+
+-- ================================================================
+-- Test 3: FileOpsChmod UNDO - rollback restores original permissions
+-- ================================================================
+
+-- Change to a known starting mode first (committed)
+SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 = 420 decimal
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_baseline;
+
+BEGIN;
+-- Change to 0600 = 384 decimal
+SELECT test_fileops_chmod(:'filepath_a', 384);
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_during_txn;
+ROLLBACK;
+
+-- After rollback, UNDO should restore to 0644 = 420
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback;
+
+-- ================================================================
+-- Test 4: FileOpsChmod commit - change persists
+-- ================================================================
+
+BEGIN;
+SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700 = 448 decimal
+COMMIT;
+
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit;
+
+-- Restore for subsequent tests
+SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644
+
+-- ================================================================
+-- Test 5: FileOpsLink UNDO - rollback removes the hard link
+-- ================================================================
+
+SELECT test_fileops_data_dir() || '/test_fileops_link.dat' AS linkpath \gset
+
+BEGIN;
+SELECT test_fileops_link(:'filepath_a', :'linkpath');
+SELECT test_fileops_file_exists(:'linkpath') AS link_during_txn;
+ROLLBACK;
+
+-- After rollback, UNDO should remove the hard link
+SELECT test_fileops_file_exists(:'linkpath') AS link_after_rollback;
+
+-- ================================================================
+-- Test 6: FileOpsLink commit - link persists
+-- ================================================================
+
+BEGIN;
+SELECT test_fileops_link(:'filepath_a', :'linkpath');
+COMMIT;
+
+SELECT test_fileops_file_exists(:'linkpath') AS link_after_commit;
+
+-- ================================================================
+-- Test 7: FileOpsTruncate in subtransaction - ROLLBACK TO undoes it
+-- ================================================================
+
+-- Ensure file is 1024 bytes
+SELECT test_fileops_file_size(:'filepath_a') AS size_before_test7;
+
+BEGIN;
+-- Outer truncate (should persist after commit)
+SELECT test_fileops_truncate(:'filepath_a', 800);
+
+SAVEPOINT sp1;
+-- Inner truncate (should be rolled back)
+SELECT test_fileops_truncate(:'filepath_a', 100);
+SELECT test_fileops_file_size(:'filepath_a') AS size_in_savepoint;
+ROLLBACK TO sp1;
+
+-- After ROLLBACK TO, should be back to 800 (the outer truncate value)
+SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback_to;
+COMMIT;
+
+-- After commit, the outer truncate to 800 should persist
+SELECT test_fileops_file_size(:'filepath_a') AS size_after_commit;
+
+-- ================================================================
+-- Test 8: FileOpsChmod in subtransaction - ROLLBACK TO restores mode
+-- ================================================================
+
+-- Set a known mode baseline
+SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644
+
+BEGIN;
+-- Outer chmod (persists)
+SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700
+
+SAVEPOINT sp1;
+-- Inner chmod (rolled back)
+SELECT test_fileops_chmod(:'filepath_a', 256); -- 0400
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_in_savepoint;
+ROLLBACK TO sp1;
+
+-- After ROLLBACK TO, should be back to 0700 = 448
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback_to;
+COMMIT;
+
+-- After commit, outer chmod (0700 = 448) persists
+SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit;
+
+-- ================================================================
+-- Test 9: FileOpsLink in subtransaction - ROLLBACK TO removes link
+-- ================================================================
+
+SELECT test_fileops_data_dir() || '/test_fileops_link2.dat' AS linkpath2 \gset
+
+BEGIN;
+SAVEPOINT sp1;
+SELECT test_fileops_link(:'filepath_a', :'linkpath2');
+SELECT test_fileops_file_exists(:'linkpath2') AS link2_in_savepoint;
+ROLLBACK TO sp1;
+
+-- Link should be removed by UNDO
+SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_rollback_to;
+COMMIT;
+
+SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_commit;
+
+-- ================================================================
+-- Test 10: FileOpsSetXattr / FileOpsRemoveXattr UNDO
+-- (skipped on platforms without xattr support)
+-- ================================================================
+
+-- Try to set an xattr; returns false if unsupported (ENOTSUP/EPERM/EACCES)
+SELECT test_fileops_setxattr(:'filepath_a', 'user.test_key', 'initial_value')
+ AS xattr_supported \gset
+
+\if :xattr_supported
+
+-- Test 10a: SetXattr UNDO - rollback removes newly-set xattr
+BEGIN;
+SELECT test_fileops_setxattr(:'filepath_a', 'user.rollback_test', 'will_vanish');
+SELECT test_fileops_getxattr(:'filepath_a', 'user.rollback_test') AS xattr_during_txn;
+ROLLBACK;
+
+SELECT test_fileops_getxattr(:'filepath_a', 'user.rollback_test') AS xattr_after_rollback;
+
+-- Test 10b: SetXattr overwrite UNDO - rollback restores old value
+BEGIN;
+SELECT test_fileops_setxattr(:'filepath_a', 'user.test_key', 'overwritten');
+SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_overwritten;
+ROLLBACK;
+
+SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_restored;
+
+-- Test 10c: RemoveXattr UNDO - rollback restores removed xattr
+BEGIN;
+SELECT test_fileops_removexattr(:'filepath_a', 'user.test_key');
+SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_after_remove;
+ROLLBACK;
+
+SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_after_remove_rollback;
+
+-- Test 10d: Xattr in subtransaction
+BEGIN;
+SAVEPOINT sp1;
+SELECT test_fileops_setxattr(:'filepath_a', 'user.sub_key', 'sub_value');
+SELECT test_fileops_getxattr(:'filepath_a', 'user.sub_key') AS xattr_in_savepoint;
+ROLLBACK TO sp1;
+
+SELECT test_fileops_getxattr(:'filepath_a', 'user.sub_key') AS xattr_after_sp_rollback;
+COMMIT;
+
+\else
+SELECT 'xattr tests skipped (not supported on this platform/filesystem)' AS notice;
+\endif
+
+-- ================================================================
+-- Cleanup
+-- ================================================================
+
+DROP EXTENSION test_fileops;
diff --git a/src/test/modules/test_fileops/t/001_fileops_apply_failure.pl b/src/test/modules/test_fileops/t/001_fileops_apply_failure.pl
new file mode 100644
index 0000000000000..d3a80282118bd
--- /dev/null
+++ b/src/test/modules/test_fileops/t/001_fileops_apply_failure.pl
@@ -0,0 +1,42 @@
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# A FILEOPS UNDO record that cannot be applied must be reported as a failure
+# instead of being treated as rolled back.
+#
+# The transaction truncates a file, which is then replaced behind its back by a
+# directory of the same name, so the truncation's UNDO cannot reopen it.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('fileops_apply_failure');
+$node->init;
+# FILEOPS writes UNDO only when WAL is needed.
+$node->append_conf(
+ 'postgresql.conf', q{
+wal_level = replica
+});
+$node->start;
+$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops');
+
+my $path = $node->safe_psql('postgres',
+ "SELECT test_fileops_create_tempfile('fileops_fail.dat')");
+
+my $bg = $node->background_psql('postgres');
+$bg->query_safe('BEGIN');
+$bg->query_safe("SELECT test_fileops_truncate('$path', 10)");
+unlink($path) or die "unlink $path: $!";
+mkdir($path) or die "mkdir $path: $!";
+$bg->query('ROLLBACK');
+$bg->quit;
+
+like(
+ slurp_file($node->logfile),
+ qr/FILEOPS UNDO TRUNCATE: could not open/,
+ 'the failure was reported');
+
+$node->stop;
+done_testing();
diff --git a/src/test/modules/test_fileops/t/007_fileops_commit_crash.pl b/src/test/modules/test_fileops/t/007_fileops_commit_crash.pl
new file mode 100644
index 0000000000000..f40dcd271b05b
--- /dev/null
+++ b/src/test/modules/test_fileops/t/007_fileops_commit_crash.pl
@@ -0,0 +1,95 @@
+# Copyright (c) 2024-2026, PostgreSQL Global Development Group
+
+# A deferred commit-time file operation must survive a crash right after
+# its transaction's commit record: the commit is durable, so the deletion,
+# rename or rmdir it implies must happen too, on restart if not before.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if ($ENV{enable_injection_points} ne 'yes')
+{
+ plan skip_all => 'Injection points not supported by this build';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('fileops_commit_crash');
+$node->init;
+$node->append_conf(
+ 'postgresql.conf', q{
+shared_preload_libraries = 'injection_points'
+wal_level = replica
+});
+$node->start;
+if (!$node->check_extension('injection_points'))
+{
+ plan skip_all => 'Extension injection_points not installed';
+}
+$node->safe_psql('postgres', 'CREATE EXTENSION injection_points');
+$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops');
+my $datadir = $node->data_dir;
+
+# Commit $sql, crash right after the commit record, restart.
+sub commit_and_crash
+{
+ my ($sql) = @_;
+ $node->safe_psql('postgres',
+ "SELECT injection_points_attach('fileops-commit-before-pending-ops', 'wait')"
+ );
+ my $bg = $node->background_psql('postgres', on_error_stop => 0);
+ $bg->query_until(qr/start/, "\\echo start\n$sql\n");
+ $node->wait_for_event('client backend',
+ 'fileops-commit-before-pending-ops');
+ $node->stop('immediate');
+ $bg->{run}->finish;
+ $node->start;
+}
+
+$node->safe_psql('postgres',
+ "SELECT test_fileops_create_tempfile('del.dat')");
+$node->safe_psql('postgres',
+ "SELECT test_fileops_create_tempfile('ren.dat')");
+$node->safe_psql('postgres', "SELECT test_fileops_mkdir('rmd', 448)");
+commit_and_crash(
+ q{BEGIN;
+SELECT test_fileops_delete('del.dat');
+SELECT test_fileops_rename('ren.dat', 'ren2.dat');
+SELECT test_fileops_rmdir('rmd');
+COMMIT;});
+TODO:
+{
+ # The deferred ops are WAL-logged only when executed, after the commit
+ # record, so this crash loses them. Their intent must be durable before
+ # the commit record and executed by commit redo.
+ local $TODO =
+ 'commit-time FILEOPS ops are logged after the commit record';
+ ok(!-e "$datadir/del.dat",
+ 'committed FileOpsDelete happened despite the crash');
+ ok( !-e "$datadir/ren.dat" && -e "$datadir/ren2.dat",
+ 'committed FileOpsRename happened despite the crash');
+ ok(!-e "$datadir/rmd",
+ 'committed FileOpsRmdir happened despite the crash');
+}
+
+# DROP TABLESPACE is safe: its XLOG_TBLSPC_DROP precedes the commit record
+# and its redo removes the directories. (In-place, so they are under
+# pg_tblspc.)
+$node->safe_psql('postgres',
+ 'SET allow_in_place_tablespaces = on; CREATE TABLESPACE ts LOCATION \'\''
+);
+my $tsoid = $node->safe_psql('postgres',
+ "SELECT oid FROM pg_tablespace WHERE spcname = 'ts'");
+ok(-d "$datadir/pg_tblspc/$tsoid", 'tablespace directory exists');
+commit_and_crash('DROP TABLESPACE ts;');
+is( $node->safe_psql(
+ 'postgres', "SELECT count(*) FROM pg_tablespace WHERE spcname = 'ts'"
+ ),
+ '0',
+ 'DROP TABLESPACE committed');
+ok(!-e "$datadir/pg_tblspc/$tsoid",
+ 'committed DROP TABLESPACE removed its directory despite the crash');
+
+$node->stop;
+done_testing();
diff --git a/src/test/modules/test_fileops/t/023_fileops_inline_abort_ctx.pl b/src/test/modules/test_fileops/t/023_fileops_inline_abort_ctx.pl
new file mode 100644
index 0000000000000..edce86837732d
--- /dev/null
+++ b/src/test/modules/test_fileops/t/023_fileops_inline_abort_ctx.pl
@@ -0,0 +1,60 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# AtAbort_XactUndo()'s inline-abort path must restore the memory context and
+# free its working context on every exit, including the early exit taken when
+# the batch LSN does not point at a valid UNDO batch.
+#
+# That exit jumped over the MemoryContextSwitchTo(old_ctx) /
+# MemoryContextDelete(undo_ctx) pair, so the "Inline UNDO Apply" context (a
+# child of TopMemoryContext) leaked on every such abort, and the rest of the
+# abort ran in, and returned into, that context. One leaked context is
+# enough to show it: it survives into the next statement of the same
+# backend, where pg_backend_memory_contexts lists it.
+#
+# The invalid-batch exit is forced with the "inline-undo-invalid-batch"
+# injection point. A FILEOPS chmod puts one UNDO record in the
+# transaction's chain so the inline path runs at all.
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if ($ENV{enable_injection_points} ne 'yes')
+{
+ plan skip_all => 'Injection points not supported by this build';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('fileops_inline_abort_ctx');
+$node->init;
+# FILEOPS writes UNDO only when WAL is needed.
+$node->append_conf('postgresql.conf', 'wal_level = replica');
+$node->start;
+$node->safe_psql('postgres',
+ 'CREATE EXTENSION test_fileops; CREATE EXTENSION injection_points;');
+
+my $path = $node->safe_psql('postgres',
+ "SELECT test_fileops_create_tempfile('inline_abort_ctx.dat')");
+
+# One backend: the leaked context has to be looked for in the backend that
+# aborted, after its transaction has ended.
+my $bg = $node->background_psql('postgres');
+$bg->query_safe('SELECT injection_points_set_local()');
+$bg->query_safe(
+ "SELECT injection_points_attach('inline-undo-invalid-batch', 'error')");
+
+$bg->query_safe('BEGIN');
+$bg->query_safe("SELECT test_fileops_chmod('$path', 384)");
+$bg->query('ROLLBACK');
+
+# The ROLLBACK warns "inline UNDO failed"; query() leaves that on stderr.
+my $leaked = $bg->query(
+ "SELECT count(*) FROM pg_backend_memory_contexts "
+ . "WHERE name = 'Inline UNDO Apply'");
+chomp $leaked;
+is($leaked, '0',
+ 'the inline-abort working context is freed on the invalid-batch exit');
+
+$bg->quit;
+$node->stop;
+done_testing();
diff --git a/src/test/modules/test_fileops/t/024_fileops_recovery_apply_failure.pl b/src/test/modules/test_fileops/t/024_fileops_recovery_apply_failure.pl
new file mode 100644
index 0000000000000..62eb302b20fdb
--- /dev/null
+++ b/src/test/modules/test_fileops/t/024_fileops_recovery_apply_failure.pl
@@ -0,0 +1,59 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# The startup undo phase must not report a crash loser as rolled back when one
+# of its UNDO records could not be applied.
+#
+# A crash loser changed a file's mode through FILEOPS. Before the restart the
+# file's directory loses its search permission, so the inverse chmod() fails
+# with a real EACCES. Nothing retries it, so the mode stays changed; the
+# summary at the end of the undo phase has to say so.
+#
+# Must not run as root (root ignores the directory permission).
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+plan skip_all => 'running as root: EACCES cannot be produced' if $> == 0;
+plan skip_all => 'directory search permission is POSIX-only'
+ if $PostgreSQL::Test::Utils::windows_os;
+
+my $node = PostgreSQL::Test::Cluster->new('fileops_recovery_apply_failure');
+$node->init;
+# FILEOPS writes UNDO only when WAL is needed.
+$node->append_conf('postgresql.conf', "wal_level = replica\nautovacuum = off\n");
+$node->start;
+$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops');
+
+my $dir = PostgreSQL::Test::Utils::tempdir('eacces');
+my $path = "$dir/m.dat";
+open(my $fh, '>', $path) or die "open $path: $!";
+close($fh);
+chmod(0644, $path) or die "chmod $path: $!";
+
+# A chmod in flight when the server crashes: a crash loser.
+my $bg = $node->background_psql('postgres', on_error_stop => 0);
+$bg->query_safe('BEGIN');
+$bg->query_safe("SELECT test_fileops_chmod('$path', 384)"); # 0600
+$node->safe_psql('postgres', 'SELECT pg_switch_wal()');
+my $offset = -s $node->logfile;
+$node->stop('immediate');
+$bg->{run}->finish;
+
+chmod(0600, $dir) or die "chmod $dir: $!";
+$node->start;
+chmod(0700, $dir) or die "chmod $dir: $!";
+
+my $log = slurp_file($node->logfile, $offset);
+like($log, qr/FILEOPS UNDO CHMOD: could not restore mode of .*: Permission denied/,
+ 'the inverse failed with a real EACCES');
+like($log, qr/UNDO recovery complete: 1 transactions processed, 0 records applied, 1 records could not be applied/,
+ 'the undo phase reports the record it could not apply');
+unlike($log, qr/UNDO recovery complete: \d+ transactions rolled back/,
+ 'the loser is not reported as rolled back');
+is(sprintf('%04o', (stat($path))[2] & 07777), '0600',
+ 'the mode change was not rolled back');
+
+$node->stop;
+done_testing();
diff --git a/src/test/modules/test_fileops/test_fileops--1.0.sql b/src/test/modules/test_fileops/test_fileops--1.0.sql
new file mode 100644
index 0000000000000..80545574727a7
--- /dev/null
+++ b/src/test/modules/test_fileops/test_fileops--1.0.sql
@@ -0,0 +1,99 @@
+/* src/test/modules/test_fileops/test_fileops--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_fileops" to load this file. \quit
+
+-- Create a temporary file and return its path
+CREATE FUNCTION test_fileops_create_tempfile(filename text)
+ RETURNS text
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Create a file via FileOpsCreate (exercises the WAL-before-syscall path)
+CREATE FUNCTION test_fileops_create(filepath text, mode int)
+ RETURNS void
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Truncate a file to the given length (uses FileOpsTruncate)
+CREATE FUNCTION test_fileops_truncate(filepath text, length bigint)
+ RETURNS void
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Get file size
+CREATE FUNCTION test_fileops_file_size(filepath text)
+ RETURNS bigint
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Chmod a file (uses FileOpsChmod)
+CREATE FUNCTION test_fileops_chmod(filepath text, mode int)
+ RETURNS void
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Get file permissions (mode)
+CREATE FUNCTION test_fileops_get_mode(filepath text)
+ RETURNS int
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Create a hard link (uses FileOpsLink)
+CREATE FUNCTION test_fileops_link(oldpath text, newpath text)
+ RETURNS void
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Check if a file exists
+CREATE FUNCTION test_fileops_file_exists(filepath text)
+ RETURNS boolean
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Set extended attribute (uses FileOpsSetXattr)
+CREATE FUNCTION test_fileops_setxattr(filepath text, attrname text, attrvalue text)
+ RETURNS boolean
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Get extended attribute value
+CREATE FUNCTION test_fileops_getxattr(filepath text, attrname text)
+ RETURNS text
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Remove extended attribute (uses FileOpsRemoveXattr)
+CREATE FUNCTION test_fileops_removexattr(filepath text, attrname text)
+ RETURNS boolean
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Get the data directory path (for constructing absolute paths)
+CREATE FUNCTION test_fileops_data_dir()
+ RETURNS text
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Deferred delete at commit (uses FileOpsDelete, at_commit=true)
+CREATE FUNCTION test_fileops_delete(filepath text)
+ RETURNS void
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Deferred rename at commit (uses FileOpsRename)
+CREATE FUNCTION test_fileops_rename(oldpath text, newpath text)
+ RETURNS void
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Create a directory (uses FileOpsMkdir)
+CREATE FUNCTION test_fileops_mkdir(path text, mode int)
+ RETURNS void
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Deferred rmdir at commit (uses FileOpsRmdir, at_commit=true)
+CREATE FUNCTION test_fileops_rmdir(path text)
+ RETURNS void
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Write a string at an offset (uses FileOpsWrite)
+CREATE FUNCTION test_fileops_write(filepath text, "offset" bigint, data text)
+ RETURNS void
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Change ownership (uses FileOpsChown)
+CREATE FUNCTION test_fileops_chown(filepath text, uid int, gid int)
+ RETURNS void
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
+
+-- Create a symbolic link (uses FileOpsSymlink)
+CREATE FUNCTION test_fileops_symlink(target text, linkpath text)
+ RETURNS void
+ AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
diff --git a/src/test/modules/test_fileops/test_fileops.c b/src/test/modules/test_fileops/test_fileops.c
new file mode 100644
index 0000000000000..c871d974427e9
--- /dev/null
+++ b/src/test/modules/test_fileops/test_fileops.c
@@ -0,0 +1,471 @@
+/*-------------------------------------------------------------------------
+ *
+ * test_fileops.c
+ * Test module exposing FileOps C API to SQL for regression testing.
+ *
+ * This module provides SQL-callable wrappers around the FileOps functions
+ * that have no DDL-level callers, enabling direct testing of:
+ * - FileOpsTruncate (with UNDO rollback of file size)
+ * - FileOpsChmod (with UNDO rollback of permissions)
+ * - FileOpsLink (with UNDO rollback of hard links)
+ * - FileOpsSetXattr / FileOpsRemoveXattr (with UNDO rollback)
+ *
+ * Copyright (c) 2024-2026, PostgreSQL Global Development Group
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include
+#include
+#include
+
+#include "fmgr.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "port/pg_xattr.h"
+#include "access/xlogdefs.h"
+#include "storage/fd.h"
+#include "storage/fileops.h"
+#include "utils/builtins.h"
+#include "utils/pg_lsn.h"
+
+PG_MODULE_MAGIC;
+
+PG_FUNCTION_INFO_V1(test_fileops_create_tempfile);
+PG_FUNCTION_INFO_V1(test_fileops_create);
+PG_FUNCTION_INFO_V1(test_fileops_truncate);
+PG_FUNCTION_INFO_V1(test_fileops_file_size);
+PG_FUNCTION_INFO_V1(test_fileops_chmod);
+PG_FUNCTION_INFO_V1(test_fileops_get_mode);
+PG_FUNCTION_INFO_V1(test_fileops_link);
+PG_FUNCTION_INFO_V1(test_fileops_file_exists);
+PG_FUNCTION_INFO_V1(test_fileops_setxattr);
+PG_FUNCTION_INFO_V1(test_fileops_getxattr);
+PG_FUNCTION_INFO_V1(test_fileops_removexattr);
+PG_FUNCTION_INFO_V1(test_fileops_data_dir);
+PG_FUNCTION_INFO_V1(test_fileops_write);
+PG_FUNCTION_INFO_V1(test_fileops_chown);
+PG_FUNCTION_INFO_V1(test_fileops_symlink);
+
+PG_FUNCTION_INFO_V1(test_fileops_delete);
+PG_FUNCTION_INFO_V1(test_fileops_rename);
+PG_FUNCTION_INFO_V1(test_fileops_mkdir);
+PG_FUNCTION_INFO_V1(test_fileops_rmdir);
+
+/*
+ * test_fileops_create_tempfile - Create a test file in data directory.
+ *
+ * Creates a file with some content using FileOpsCreate + write, returning
+ * the absolute path. The file is created with 1024 bytes of content.
+ */
+Datum
+test_fileops_create_tempfile(PG_FUNCTION_ARGS)
+{
+ text *filename = PG_GETARG_TEXT_PP(0);
+ char *fname = text_to_cstring(filename);
+ char filepath[MAXPGPATH];
+ int fd;
+ char buf[1024];
+
+ snprintf(filepath, MAXPGPATH, "%s/%s", DataDir, fname);
+
+ /* Use raw create + write so we have a file to manipulate */
+ fd = open(filepath, O_CREAT | O_WRONLY | O_TRUNC | PG_BINARY, 0644);
+ if (fd < 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not create file \"%s\": %m", filepath)));
+
+ /* Fill with 1024 bytes */
+ memset(buf, 'X', sizeof(buf));
+ if (write(fd, buf, sizeof(buf)) != sizeof(buf))
+ {
+ int save_errno = errno;
+
+ close(fd);
+ errno = save_errno;
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not write to file \"%s\": %m", filepath)));
+ }
+ close(fd);
+
+ PG_RETURN_TEXT_P(cstring_to_text(filepath));
+}
+
+/*
+ * test_fileops_create - Create a file using FileOpsCreate.
+ *
+ * Unlike test_fileops_create_tempfile (which uses raw open()/write() to set
+ * up fixture files outside any transactional path), this calls FileOpsCreate
+ * directly so tests can exercise its WAL-before-syscall crash-recovery
+ * behavior.
+ */
+Datum
+test_fileops_create(PG_FUNCTION_ARGS)
+{
+ text *filepath_text = PG_GETARG_TEXT_PP(0);
+ int mode = PG_GETARG_INT32(1);
+ char *filepath = text_to_cstring(filepath_text);
+ int fd;
+
+ fd = FileOpsCreate(filepath, O_WRONLY | PG_BINARY, (mode_t) mode, true);
+ if (fd >= 0)
+ CloseTransientFile(fd);
+
+ pfree(filepath);
+ PG_RETURN_VOID();
+}
+
+/*
+ * test_fileops_truncate - Truncate a file using FileOpsTruncate.
+ *
+ * This exercises the UNDO path: on rollback, the original file size
+ * should be restored.
+ */
+Datum
+test_fileops_truncate(PG_FUNCTION_ARGS)
+{
+ text *filepath_text = PG_GETARG_TEXT_PP(0);
+ int64 length = PG_GETARG_INT64(1);
+ char *filepath = text_to_cstring(filepath_text);
+
+ FileOpsTruncate(filepath, (off_t) length);
+
+ pfree(filepath);
+ PG_RETURN_VOID();
+}
+
+/*
+ * test_fileops_file_size - Get the current size of a file.
+ */
+Datum
+test_fileops_file_size(PG_FUNCTION_ARGS)
+{
+ text *filepath_text = PG_GETARG_TEXT_PP(0);
+ char *filepath = text_to_cstring(filepath_text);
+ struct stat st;
+
+ if (stat(filepath, &st) < 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not stat file \"%s\": %m", filepath)));
+
+ pfree(filepath);
+ PG_RETURN_INT64((int64) st.st_size);
+}
+
+/*
+ * test_fileops_chmod - Change file permissions using FileOpsChmod.
+ *
+ * This exercises the UNDO path: on rollback, the original permissions
+ * should be restored.
+ */
+Datum
+test_fileops_chmod(PG_FUNCTION_ARGS)
+{
+ text *filepath_text = PG_GETARG_TEXT_PP(0);
+ int mode = PG_GETARG_INT32(1);
+ char *filepath = text_to_cstring(filepath_text);
+
+ if (FileOpsChmod(filepath, (mode_t) mode) != 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not chmod file \"%s\": %m", filepath)));
+
+ pfree(filepath);
+ PG_RETURN_VOID();
+}
+
+/*
+ * test_fileops_get_mode - Get file permission bits.
+ */
+Datum
+test_fileops_get_mode(PG_FUNCTION_ARGS)
+{
+ text *filepath_text = PG_GETARG_TEXT_PP(0);
+ char *filepath = text_to_cstring(filepath_text);
+ struct stat st;
+
+ if (stat(filepath, &st) < 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not stat file \"%s\": %m", filepath)));
+
+ pfree(filepath);
+ PG_RETURN_INT32((int32) (st.st_mode & 0777));
+}
+
+/*
+ * test_fileops_link - Create a hard link using FileOpsLink.
+ *
+ * This exercises the UNDO path: on rollback, the link should be removed.
+ */
+Datum
+test_fileops_link(PG_FUNCTION_ARGS)
+{
+ text *oldpath_text = PG_GETARG_TEXT_PP(0);
+ text *newpath_text = PG_GETARG_TEXT_PP(1);
+ char *oldpath = text_to_cstring(oldpath_text);
+ char *newpath = text_to_cstring(newpath_text);
+
+ if (FileOpsLink(oldpath, newpath) != 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not link \"%s\" to \"%s\": %m",
+ oldpath, newpath)));
+
+ pfree(oldpath);
+ pfree(newpath);
+ PG_RETURN_VOID();
+}
+
+/*
+ * test_fileops_file_exists - Check whether a file exists.
+ */
+Datum
+test_fileops_file_exists(PG_FUNCTION_ARGS)
+{
+ text *filepath_text = PG_GETARG_TEXT_PP(0);
+ char *filepath = text_to_cstring(filepath_text);
+ struct stat st;
+ bool exists;
+
+ exists = (stat(filepath, &st) == 0);
+
+ pfree(filepath);
+ PG_RETURN_BOOL(exists);
+}
+
+/*
+ * test_fileops_setxattr - Set an extended attribute using FileOpsSetXattr.
+ *
+ * Returns true if the platform supports xattrs, false if ENOTSUP.
+ * This exercises the UNDO path: on rollback, the original xattr value
+ * (or absence) should be restored.
+ */
+Datum
+test_fileops_setxattr(PG_FUNCTION_ARGS)
+{
+ text *filepath_text = PG_GETARG_TEXT_PP(0);
+ text *name_text = PG_GETARG_TEXT_PP(1);
+ text *value_text = PG_GETARG_TEXT_PP(2);
+ char *filepath = text_to_cstring(filepath_text);
+ char *name = text_to_cstring(name_text);
+ char *value = text_to_cstring(value_text);
+ int ret;
+
+ ret = FileOpsSetXattr(filepath, name, value, strlen(value));
+ if (ret != 0)
+ {
+ if (errno == ENOTSUP || errno == EOPNOTSUPP ||
+ errno == EPERM || errno == EACCES)
+ {
+ pfree(filepath);
+ pfree(name);
+ pfree(value);
+ PG_RETURN_BOOL(false);
+ }
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not set xattr \"%s\" on \"%s\": %m",
+ name, filepath)));
+ }
+
+ pfree(filepath);
+ pfree(name);
+ pfree(value);
+ PG_RETURN_BOOL(true);
+}
+
+/*
+ * test_fileops_getxattr - Get an extended attribute value.
+ *
+ * Returns NULL if the attribute doesn't exist or xattrs are unsupported.
+ */
+Datum
+test_fileops_getxattr(PG_FUNCTION_ARGS)
+{
+ text *filepath_text = PG_GETARG_TEXT_PP(0);
+ text *name_text = PG_GETARG_TEXT_PP(1);
+ char *filepath = text_to_cstring(filepath_text);
+ char *name = text_to_cstring(name_text);
+ char buf[1024];
+ ssize_t len;
+
+ len = pg_getxattr(filepath, name, buf, sizeof(buf) - 1);
+ if (len < 0)
+ {
+ pfree(filepath);
+ pfree(name);
+ PG_RETURN_NULL();
+ }
+
+ buf[len] = '\0';
+
+ pfree(filepath);
+ pfree(name);
+ PG_RETURN_TEXT_P(cstring_to_text(buf));
+}
+
+/*
+ * test_fileops_removexattr - Remove an extended attribute using
+ * FileOpsRemoveXattr.
+ *
+ * Returns true if successful, false if xattrs are unsupported.
+ * This exercises the UNDO path: on rollback, the removed xattr should
+ * be restored.
+ */
+Datum
+test_fileops_removexattr(PG_FUNCTION_ARGS)
+{
+ text *filepath_text = PG_GETARG_TEXT_PP(0);
+ text *name_text = PG_GETARG_TEXT_PP(1);
+ char *filepath = text_to_cstring(filepath_text);
+ char *name = text_to_cstring(name_text);
+ int ret;
+
+ ret = FileOpsRemoveXattr(filepath, name);
+ if (ret != 0)
+ {
+ if (errno == ENOTSUP || errno == EOPNOTSUPP)
+ {
+ pfree(filepath);
+ pfree(name);
+ PG_RETURN_BOOL(false);
+ }
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not remove xattr \"%s\" from \"%s\": %m",
+ name, filepath)));
+ }
+
+ pfree(filepath);
+ pfree(name);
+ PG_RETURN_BOOL(true);
+}
+
+/*
+ * test_fileops_data_dir - Return the data directory path.
+ */
+Datum
+test_fileops_data_dir(PG_FUNCTION_ARGS)
+{
+ PG_RETURN_TEXT_P(cstring_to_text(DataDir));
+}
+
+/*
+ * test_fileops_delete - Deferred delete via FileOpsDelete(at_commit=true).
+ *
+ * Exercises the log-at-commit-execution path: the delete happens (and is
+ * WAL-logged) in FileOpsDoPendingOps() at commit, so a physical standby
+ * reproduces it by replay.
+ */
+Datum
+test_fileops_delete(PG_FUNCTION_ARGS)
+{
+ char *filepath = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+ FileOpsDelete(filepath, true);
+ pfree(filepath);
+ PG_RETURN_VOID();
+}
+
+/*
+ * test_fileops_rename - Deferred rename via FileOpsRename (always at_commit).
+ */
+Datum
+test_fileops_rename(PG_FUNCTION_ARGS)
+{
+ char *oldpath = text_to_cstring(PG_GETARG_TEXT_PP(0));
+ char *newpath = text_to_cstring(PG_GETARG_TEXT_PP(1));
+
+ if (FileOpsRename(oldpath, newpath) != 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not rename \"%s\" to \"%s\": %m",
+ oldpath, newpath)));
+ pfree(oldpath);
+ pfree(newpath);
+ PG_RETURN_VOID();
+}
+
+/*
+ * test_fileops_mkdir - Create a directory via FileOpsMkdir.
+ *
+ * FileOpsMkdir creates the directory immediately and registers an
+ * rmdir-on-abort; on commit the directory persists. Used to set up a
+ * directory the test can then remove with test_fileops_rmdir.
+ */
+Datum
+test_fileops_mkdir(PG_FUNCTION_ARGS)
+{
+ char *path = text_to_cstring(PG_GETARG_TEXT_PP(0));
+ int mode = PG_GETARG_INT32(1);
+
+ if (FileOpsMkdir(path, (mode_t) mode) != 0)
+ ereport(ERROR,
+ (errcode_for_file_access(),
+ errmsg("could not create directory \"%s\": %m", path)));
+ pfree(path);
+ PG_RETURN_VOID();
+}
+
+/*
+ * test_fileops_rmdir - Deferred rmdir via FileOpsRmdir(at_commit=true).
+ */
+Datum
+test_fileops_rmdir(PG_FUNCTION_ARGS)
+{
+ char *path = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+ FileOpsRmdir(path, true);
+ pfree(path);
+ PG_RETURN_VOID();
+}
+
+/*
+ * test_fileops_write - Write a string at an offset via FileOpsWrite.
+ */
+Datum
+test_fileops_write(PG_FUNCTION_ARGS)
+{
+ char *path = text_to_cstring(PG_GETARG_TEXT_PP(0));
+ int64 offset = PG_GETARG_INT64(1);
+ char *data = text_to_cstring(PG_GETARG_TEXT_PP(2));
+
+ (void) FileOpsWrite(path, (off_t) offset, data, (uint32) strlen(data));
+ pfree(path);
+ pfree(data);
+ PG_RETURN_VOID();
+}
+
+/*
+ * test_fileops_chown - Change ownership via FileOpsChown.
+ */
+Datum
+test_fileops_chown(PG_FUNCTION_ARGS)
+{
+ char *path = text_to_cstring(PG_GETARG_TEXT_PP(0));
+
+ (void) FileOpsChown(path, (uid_t) PG_GETARG_INT32(1),
+ (gid_t) PG_GETARG_INT32(2));
+ pfree(path);
+ PG_RETURN_VOID();
+}
+
+/*
+ * test_fileops_symlink - Create a symbolic link via FileOpsSymlink.
+ */
+Datum
+test_fileops_symlink(PG_FUNCTION_ARGS)
+{
+ char *target = text_to_cstring(PG_GETARG_TEXT_PP(0));
+ char *linkpath = text_to_cstring(PG_GETARG_TEXT_PP(1));
+
+ (void) FileOpsSymlink(target, linkpath);
+ pfree(target);
+ pfree(linkpath);
+ PG_RETURN_VOID();
+}
diff --git a/src/test/modules/test_fileops/test_fileops.control b/src/test/modules/test_fileops/test_fileops.control
new file mode 100644
index 0000000000000..1d39ab2a9166b
--- /dev/null
+++ b/src/test/modules/test_fileops/test_fileops.control
@@ -0,0 +1,4 @@
+comment = 'Test module for transactional file operations (FILEOPS)'
+default_version = '1.0'
+module_pathname = '$libdir/test_fileops'
+relocatable = true
diff --git a/src/test/recovery/Makefile b/src/test/recovery/Makefile
index 9c4102b6b2c34..cb169662fb6a9 100644
--- a/src/test/recovery/Makefile
+++ b/src/test/recovery/Makefile
@@ -13,6 +13,7 @@ EXTRA_INSTALL=contrib/pg_prewarm \
contrib/pg_stat_statements \
contrib/test_decoding \
src/test/modules/injection_points \
+ src/test/modules/test_fileops \
src/test/modules/test_wait_lsn
subdir = src/test/recovery
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index b6ac9dd07a7ab..daa07c3856007 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -69,6 +69,11 @@ tests += {
't/058_shutdown_crash_restart.pl',
't/059_remote_apply_status_interval.pl',
't/060_multixact_crashed_updater.pl',
+ 't/064_fileops_standby_divergence.pl',
+ 't/061_fileops_2pc_deferred.pl',
+ 't/062_fileops_recovery.pl',
+ 't/063_fileops_2pc.pl',
+ 't/065_fileops_crash_undo.pl',
],
},
}
diff --git a/src/test/recovery/t/061_fileops_2pc_deferred.pl b/src/test/recovery/t/061_fileops_2pc_deferred.pl
new file mode 100644
index 0000000000000..670cdb9b72058
--- /dev/null
+++ b/src/test/recovery/t/061_fileops_2pc_deferred.pl
@@ -0,0 +1,167 @@
+# Copyright (c) 2024-2026, PostgreSQL Global Development Group
+#
+# Test that DEFERRED commit-time FILEOPS (delete/rename) and abort-time
+# filesystem undo survive PREPARE TRANSACTION across a crash.
+#
+# The bug: PostPrepare_FileOps() used to free the pending-op list without
+# recording it anywhere, so a transaction PREPARE'd with a deferred delete or
+# rename (at_commit=true) lost that op entirely -- COMMIT PREPARED performed
+# nothing -- and an immediate create's abort-time undo was lost for ROLLBACK
+# PREPARED. The fix serializes the pending ops into the 2PC state file at
+# PREPARE (TWOPHASE_RM_FILEOPS_ID records) and performs them in the FILEOPS
+# post-commit / post-abort callbacks, so they survive a crash between PREPARE
+# and the final decision, driven from the durable 2PC state.
+#
+# Unlike 063_fileops_2pc.pl (which only exercises chmod, a constructive op
+# reversed via the cluster-wide UNDO chain), this test drives the deferred
+# commit-time delete/rename path -- the one that was silently dropped.
+#
+# NB: FILEOPS writes WAL/UNDO only when XLogIsNeeded() (wal_level >= replica),
+# so 2PC durability requires wal_level = replica; this test runs there.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('fileops_2pc_deferred');
+$node->init;
+$node->append_conf(
+ "postgresql.conf", qq(
+autovacuum = off
+max_prepared_transactions = 10
+wal_level = replica
+));
+$node->start;
+
+if (!$node->check_extension('test_fileops'))
+{
+ plan skip_all => 'Extension test_fileops not installed';
+}
+$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops');
+
+my $datadir = $node->data_dir;
+
+sub file_exists
+{
+ my ($path) = @_;
+ return $node->safe_psql('postgres',
+ qq{SELECT test_fileops_file_exists('$path')});
+}
+
+# Poll until the file's existence matches $want ('t'/'f').
+sub wait_for_exists
+{
+ my ($path, $want, $desc) = @_;
+ my $ok = $node->poll_query_until('postgres',
+ qq{SELECT test_fileops_file_exists('$path') = '$want'});
+ is($ok, '1', $desc);
+}
+
+# ================================================================
+# Scenario A: COMMIT PREPARED performs the deferred commit-time ops
+# - deferred delete of an existing file
+# - deferred rename of an existing file
+# - immediate create of a new file (register_delete)
+# PREPARE, crash, restart, then COMMIT PREPARED. All three must take effect.
+# ================================================================
+my $del_a = "$datadir/fo2pc_del_a.dat";
+my $ren_a = "$datadir/fo2pc_ren_a.dat";
+my $ren_a2 = "$datadir/fo2pc_ren_a.renamed";
+my $new_a = "$datadir/fo2pc_new_a.dat";
+
+$node->safe_psql('postgres',
+ qq{SELECT test_fileops_create_tempfile('fo2pc_del_a.dat')});
+$node->safe_psql('postgres',
+ qq{SELECT test_fileops_create_tempfile('fo2pc_ren_a.dat')});
+
+is(file_exists($del_a), 't', 'A: delete target exists before txn');
+is(file_exists($ren_a), 't', 'A: rename source exists before txn');
+
+$node->safe_psql(
+ 'postgres', qq(
+BEGIN;
+SELECT test_fileops_delete('$del_a');
+SELECT test_fileops_rename('$ren_a', '$ren_a2');
+SELECT test_fileops_create('$new_a', 384);
+PREPARE TRANSACTION 'fo2pc_commit';
+));
+
+# While prepared, deferred ops have NOT run yet; the immediate create has.
+is(file_exists($del_a), 't',
+ 'A: delete deferred -- file still present while prepared');
+is(file_exists($ren_a), 't',
+ 'A: rename deferred -- source still present while prepared');
+is(file_exists($new_a), 't', 'A: immediate create present while prepared');
+
+# Crash between PREPARE and COMMIT PREPARED.
+$node->stop('immediate');
+$node->start;
+
+is($node->safe_psql('postgres', q{SELECT gid FROM pg_prepared_xacts}),
+ 'fo2pc_commit', 'A: prepared xact survives crash');
+
+$node->safe_psql('postgres', q{COMMIT PREPARED 'fo2pc_commit'});
+
+is(file_exists($del_a), 'f',
+ 'A: COMMIT PREPARED performed the deferred delete');
+is(file_exists($ren_a), 'f',
+ 'A: COMMIT PREPARED performed the rename (source gone)');
+is(file_exists($ren_a2), 't',
+ 'A: COMMIT PREPARED performed the rename (dest present)');
+is(file_exists($new_a), 't',
+ 'A: created file persists after COMMIT PREPARED');
+
+# ================================================================
+# Scenario B: ROLLBACK PREPARED does NOT perform the deferred commit-time
+# ops, and DOES undo the immediate create.
+# PREPARE, crash, restart, then ROLLBACK PREPARED.
+# ================================================================
+my $del_b = "$datadir/fo2pc_del_b.dat";
+my $ren_b = "$datadir/fo2pc_ren_b.dat";
+my $ren_b2 = "$datadir/fo2pc_ren_b.renamed";
+my $new_b = "$datadir/fo2pc_new_b.dat";
+
+$node->safe_psql('postgres',
+ qq{SELECT test_fileops_create_tempfile('fo2pc_del_b.dat')});
+$node->safe_psql('postgres',
+ qq{SELECT test_fileops_create_tempfile('fo2pc_ren_b.dat')});
+
+$node->safe_psql(
+ 'postgres', qq(
+BEGIN;
+SELECT test_fileops_delete('$del_b');
+SELECT test_fileops_rename('$ren_b', '$ren_b2');
+SELECT test_fileops_create('$new_b', 384);
+PREPARE TRANSACTION 'fo2pc_rollback';
+));
+
+is(file_exists($new_b), 't', 'B: immediate create present while prepared');
+
+# Crash between PREPARE and ROLLBACK PREPARED.
+$node->stop('immediate');
+$node->start;
+
+is($node->safe_psql('postgres', q{SELECT gid FROM pg_prepared_xacts}),
+ 'fo2pc_rollback', 'B: prepared xact survives crash');
+
+$node->safe_psql('postgres', q{ROLLBACK PREPARED 'fo2pc_rollback'});
+
+is(file_exists($del_b), 't',
+ 'B: ROLLBACK PREPARED did NOT delete (file still present)');
+is(file_exists($ren_b), 't',
+ 'B: ROLLBACK PREPARED did NOT rename (source still present)');
+is(file_exists($ren_b2), 'f',
+ 'B: ROLLBACK PREPARED did NOT rename (dest absent)');
+# The immediate create is undone by the constructive op's cluster-wide UNDO
+# record on ROLLBACK PREPARED.
+wait_for_exists($new_b, 'f',
+ 'B: ROLLBACK PREPARED undid the immediate create');
+
+# Server healthy at the end.
+is($node->safe_psql('postgres', 'SELECT 1'),
+ '1', 'server operational after all deferred-2PC scenarios');
+
+$node->stop;
+done_testing();
diff --git a/src/test/recovery/t/062_fileops_recovery.pl b/src/test/recovery/t/062_fileops_recovery.pl
new file mode 100644
index 0000000000000..306fc19883aac
--- /dev/null
+++ b/src/test/recovery/t/062_fileops_recovery.pl
@@ -0,0 +1,446 @@
+# Copyright (c) 2024-2026, PostgreSQL Global Development Group
+#
+# Test crash recovery for transactional file operations (FILEOPS).
+#
+# These tests verify that FILEOPS WAL replay correctly handles:
+# - Crash during file creation (with delete-on-abort)
+# - Crash during deferred file deletion
+# - Crash during file operations on standby
+# - Multiple sequential crashes
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('fileops_recovery');
+$node->init;
+$node->append_conf(
+ "postgresql.conf", qq(
+autovacuum = off
+log_min_messages = debug2
+));
+$node->start;
+
+# ================================================================
+# Test 1: CREATE TABLE survives crash
+# ================================================================
+
+$node->safe_psql(
+ "postgres", qq(
+CREATE TABLE fileops_test (id int, data text);
+INSERT INTO fileops_test VALUES (1, 'created_table');
+));
+
+$node->stop('immediate');
+$node->start;
+
+my $result =
+ $node->safe_psql("postgres", "SELECT data FROM fileops_test WHERE id = 1");
+is($result, 'created_table', 'CREATE TABLE survives crash');
+
+# ================================================================
+# Test 2: DROP TABLE is properly handled after crash
+# ================================================================
+
+$node->safe_psql(
+ "postgres", qq(
+CREATE TABLE drop_me (id int);
+INSERT INTO drop_me VALUES (1);
+));
+
+# Get the relfilenode before dropping
+my $relpath =
+ $node->safe_psql("postgres", "SELECT pg_relation_filepath('drop_me')");
+
+$node->safe_psql("postgres", "DROP TABLE drop_me");
+
+$node->stop('immediate');
+$node->start;
+
+# Table should be gone
+my ($ret, $stdout, $stderr) =
+ $node->psql("postgres", "SELECT * FROM drop_me");
+isnt($ret, 0, 'dropped table is gone after crash recovery');
+
+# ================================================================
+# Test 3: Crash during transaction with CREATE TABLE (uncommitted)
+# ================================================================
+
+# This table is committed
+$node->safe_psql(
+ "postgres", qq(
+CREATE TABLE committed_table (id int);
+INSERT INTO committed_table VALUES (42);
+));
+
+# Crash the server
+$node->stop('immediate');
+$node->start;
+
+# Committed table should exist
+$result = $node->safe_psql("postgres", "SELECT id FROM committed_table");
+is($result, '42', 'committed CREATE TABLE survives crash');
+
+# ================================================================
+# Test 4: Multiple CREATE and DROP operations then crash
+# ================================================================
+
+$node->safe_psql(
+ "postgres", qq(
+CREATE TABLE t1 (id int);
+CREATE TABLE t2 (id int);
+CREATE TABLE t3 (id int);
+INSERT INTO t1 VALUES (1);
+INSERT INTO t2 VALUES (2);
+INSERT INTO t3 VALUES (3);
+DROP TABLE t2;
+));
+
+$node->stop('immediate');
+$node->start;
+
+$result = $node->safe_psql("postgres", "SELECT id FROM t1");
+is($result, '1', 't1 survives crash');
+
+($ret, $stdout, $stderr) = $node->psql("postgres", "SELECT * FROM t2");
+isnt($ret, 0, 't2 (dropped) is gone after crash');
+
+$result = $node->safe_psql("postgres", "SELECT id FROM t3");
+is($result, '3', 't3 survives crash');
+
+# ================================================================
+# Test 5: Crash after checkpoint with file operations
+# ================================================================
+
+$node->safe_psql(
+ "postgres", qq(
+DROP TABLE IF EXISTS t1;
+DROP TABLE IF EXISTS t3;
+CREATE TABLE checkpoint_test (id int);
+INSERT INTO checkpoint_test VALUES (1);
+CHECKPOINT;
+INSERT INTO checkpoint_test VALUES (2);
+));
+
+$node->stop('immediate');
+$node->start;
+
+$result =
+ $node->safe_psql("postgres", "SELECT count(*) FROM checkpoint_test");
+is($result, '2', 'data after checkpoint survives crash');
+
+# ================================================================
+# Test 6: Multiple crashes in sequence with file operations
+# ================================================================
+
+$node->safe_psql(
+ "postgres", qq(
+DROP TABLE IF EXISTS checkpoint_test;
+CREATE TABLE multi_crash (id int);
+INSERT INTO multi_crash VALUES (1);
+));
+
+$node->stop('immediate');
+$node->start;
+
+$node->safe_psql(
+ "postgres", qq(
+INSERT INTO multi_crash VALUES (2);
+CREATE TABLE multi_crash_2 (id int);
+INSERT INTO multi_crash_2 VALUES (10);
+));
+
+$node->stop('immediate');
+$node->start;
+
+$result = $node->safe_psql("postgres", "SELECT count(*) FROM multi_crash");
+is($result, '2', 'multi_crash table correct after double crash');
+
+$result = $node->safe_psql("postgres", "SELECT id FROM multi_crash_2");
+is($result, '10', 'multi_crash_2 table correct after double crash');
+
+# ================================================================
+# Test 7: CREATE TABLESPACE survives crash
+# ================================================================
+
+$node->safe_psql(
+ "postgres", qq(
+DROP TABLE IF EXISTS multi_crash;
+DROP TABLE IF EXISTS multi_crash_2;
+SET allow_in_place_tablespaces = on;
+CREATE TABLESPACE fileops_crash_ts LOCATION '';
+CREATE TABLE ts_crash_table (id int) TABLESPACE fileops_crash_ts;
+INSERT INTO ts_crash_table VALUES (99);
+));
+
+$node->stop('immediate');
+$node->start;
+
+$result = $node->safe_psql("postgres", "SELECT id FROM ts_crash_table");
+is($result, '99', 'table in tablespace survives crash');
+
+$result = $node->safe_psql("postgres",
+ "SELECT spcname FROM pg_tablespace WHERE spcname = 'fileops_crash_ts'");
+is($result, 'fileops_crash_ts', 'tablespace survives crash');
+
+# ================================================================
+# Test 8: DROP TABLESPACE completes after crash
+# ================================================================
+
+$node->safe_psql(
+ "postgres", qq(
+DROP TABLE ts_crash_table;
+DROP TABLESPACE fileops_crash_ts;
+));
+
+$node->stop('immediate');
+$node->start;
+
+$result = $node->safe_psql("postgres",
+ "SELECT count(*) FROM pg_tablespace WHERE spcname = 'fileops_crash_ts'");
+is($result, '0', 'dropped tablespace is gone after crash');
+
+# ================================================================
+# Test 9: CREATE DATABASE survives crash
+# ================================================================
+
+$node->safe_psql(
+ "postgres", qq(
+CREATE DATABASE fileops_crash_db;
+));
+
+$node->stop('immediate');
+$node->start;
+
+$result = $node->safe_psql("postgres",
+ "SELECT datname FROM pg_database WHERE datname = 'fileops_crash_db'");
+is($result, 'fileops_crash_db', 'CREATE DATABASE survives crash');
+
+$node->safe_psql("postgres", "DROP DATABASE fileops_crash_db");
+
+# ================================================================
+# Test 10: Standby crash during FILEOPS replay
+# ================================================================
+
+# Set up primary + standby
+my $primary = PostgreSQL::Test::Cluster->new('fileops_primary');
+$primary->init(allows_streaming => 1);
+$primary->append_conf(
+ "postgresql.conf", qq(
+autovacuum = off
+));
+$primary->start;
+$primary->backup('backup');
+
+my $standby = PostgreSQL::Test::Cluster->new('fileops_standby');
+$standby->init_from_backup($primary, 'backup', has_streaming => 1);
+$standby->start;
+
+# Create table on primary and wait for standby to catch up
+$primary->safe_psql(
+ "postgres", qq(
+CREATE TABLE standby_test (id int);
+INSERT INTO standby_test VALUES (1);
+));
+
+$primary->wait_for_catchup($standby);
+
+# Verify on standby
+$result = $standby->safe_psql("postgres", "SELECT id FROM standby_test");
+is($result, '1', 'CREATE TABLE replicated to standby');
+
+# Crash the standby
+$standby->stop('immediate');
+$standby->start;
+
+# Add more data on primary
+$primary->safe_psql(
+ "postgres", qq(
+INSERT INTO standby_test VALUES (2);
+));
+
+$primary->wait_for_catchup($standby);
+
+$result =
+ $standby->safe_psql("postgres", "SELECT count(*) FROM standby_test");
+is($result, '2', 'standby recovers and catches up after crash');
+
+# Clean up primary/standby
+$standby->stop;
+$primary->stop;
+
+# Clean up original node
+$node->stop;
+
+# ================================================================
+# Test 11/12: Crash exactly between WAL-flush and the physical syscall
+# for the write-ordering fix (FileOpsCreate, FileOpsChmod).
+#
+# Before the fix, these two functions ran the physical syscall (and, for
+# Create, the fsync) BEFORE the WAL record reached disk. After the fix,
+# WAL/UNDO is durably flushed first, and only then does the physical
+# syscall run, inside a critical section. These tests use injection
+# points to pause the backend in that exact window -- after XLogFlush(),
+# before open()/chmod() -- then hard-kill the postmaster.
+#
+# The injection point fires from inside the enclosing single-statement
+# implicit transaction, before that transaction's COMMIT record could be
+# written. So crashing there means the transaction is, correctly,
+# recovered as never-committed: WAL redo physically replays the CREATE/
+# CHMOD unconditionally (redo does not know yet whether the surrounding
+# xact committed), and then crash recovery's abort processing applies the
+# UNDO record for that not-committed transaction, reversing the physical
+# change. The end state must be as if the transaction never ran at all --
+# no orphaned file, no leftover mode change -- which is exactly the
+# invariant broken before this fix: with the syscall running before WAL,
+# a crash in this window used to leave a physical create()/chmod() with
+# NO corresponding WAL or UNDO record for recovery to reconcile against
+# (the syscall had already happened, but WAL/UNDO for it had not),
+# leaving a permanently orphaned file or an un-reversible mode change even
+# though the owning transaction never committed. Now WAL/UNDO is always
+# durable before the syscall runs, so recovery always has a consistent
+# decision to make -- keep (if committed) or reverse (if not) -- no
+# matter which side of the syscall the crash lands on.
+# ================================================================
+
+if ($ENV{enable_injection_points} eq 'yes')
+{
+ my $inode = PostgreSQL::Test::Cluster->new('fileops_wal_ordering');
+ $inode->init;
+ $inode->append_conf(
+ 'postgresql.conf', qq(
+shared_preload_libraries = 'injection_points'
+autovacuum = off
+log_min_messages = debug2
+wal_level = replica
+));
+ $inode->start;
+
+ if ( $inode->check_extension('injection_points')
+ && $inode->check_extension('test_fileops'))
+ {
+ $inode->safe_psql('postgres', 'CREATE EXTENSION injection_points');
+ $inode->safe_psql('postgres', 'CREATE EXTENSION test_fileops');
+
+ my $idatadir = $inode->data_dir;
+
+ # --- Test 11: FileOpsCreate -----------------------------------
+ $inode->safe_psql('postgres',
+ q{SELECT injection_points_attach('fileops-create-after-wal-flush', 'wait')}
+ );
+
+ my $create_path = "$idatadir/wal_ordering_create.dat";
+ my $bgpsql = $inode->background_psql('postgres');
+ $bgpsql->query_until(
+ qr/starting_create/,
+ qq(\\echo starting_create
+SELECT test_fileops_create('$create_path', 384);
+\\q
+));
+
+ $inode->wait_for_event('client backend',
+ 'fileops-create-after-wal-flush');
+
+ # The WAL record is durably flushed, but the physical create() has
+ # not run yet -- the file must not exist on disk right now.
+ ok( !-e $create_path,
+ 'Test 11: file does not exist yet at the injection point '
+ . '(WAL durable, physical create() not yet run)');
+
+ # Hard-kill the postmaster from exactly this window. The wrapping
+ # implicit transaction is still in progress (blocked on the
+ # injection point), so it never committed.
+ $inode->stop('immediate');
+ $inode->start;
+
+ # Recovery redoes the physical CREATE from the durable WAL record
+ # unconditionally, then determines the transaction never committed
+ # and applies its UNDO record to reverse the create. Net effect:
+ # the file must NOT exist -- exactly as if the transaction had
+ # never run, with no orphaned state left behind by the crash.
+ ok( !-e $create_path,
+ 'Test 11: FileOpsCreate of a not-committed transaction is '
+ . 'cleanly reversed after a crash mid-critical-section');
+
+ # Server is healthy after the reconciliation.
+ my $result11 = $inode->safe_psql('postgres', 'SELECT 1');
+ is($result11, '1', 'Test 11: server operational after recovery');
+
+ $bgpsql->{run}->finish;
+
+ # --- Test 12: FileOpsChmod -------------------------------------
+ $inode->safe_psql('postgres',
+ qq{SELECT test_fileops_create_tempfile('wal_ordering_chmod.dat')}
+ );
+ my $chmod_path = "$idatadir/wal_ordering_chmod.dat";
+ $inode->safe_psql('postgres',
+ qq{SELECT test_fileops_chmod('$chmod_path', 420)}); # 0644
+ my $mode_before = $inode->safe_psql('postgres',
+ qq{SELECT test_fileops_get_mode('$chmod_path')});
+ # Windows has no POSIX file modes: chmod only toggles read-only.
+ my $posix_modes = !$windows_os;
+ is($mode_before, '420', 'Test 12: baseline mode is 0644')
+ if $posix_modes;
+
+ $inode->safe_psql('postgres',
+ q{SELECT injection_points_attach('fileops-chmod-after-wal-flush', 'wait')}
+ );
+
+ $bgpsql = $inode->background_psql('postgres');
+ $bgpsql->query_until(
+ qr/starting_chmod/,
+ qq(\\echo starting_chmod
+SELECT test_fileops_chmod('$chmod_path', 448); -- 0700
+\\q
+));
+
+ $inode->wait_for_event('client backend',
+ 'fileops-chmod-after-wal-flush');
+
+ # WAL is durably flushed, but chmod() has not run yet -- the mode
+ # on disk must still be the baseline 0644.
+ my $mode_at_injection = $inode->safe_psql('postgres',
+ qq{SELECT test_fileops_get_mode('$chmod_path')});
+ is($mode_at_injection, '420',
+ 'Test 12: mode unchanged on disk at the injection point '
+ . '(WAL durable, physical chmod() not yet run)')
+ if $posix_modes;
+
+ # Hard-kill mid-critical-section, same as Test 11. The chmod's
+ # wrapping implicit transaction never committed.
+ $inode->stop('immediate');
+ $inode->start;
+
+ # Recovery redoes the physical CHMOD unconditionally, then reverses
+ # it via UNDO because the transaction never committed. Net effect:
+ # mode must be back to the pre-transaction baseline (0644), not the
+ # attempted 0700, and not some undefined intermediate state.
+ my $mode_after = $inode->safe_psql('postgres',
+ qq{SELECT test_fileops_get_mode('$chmod_path')});
+ is($mode_after, '420',
+ 'Test 12: FileOpsChmod of a not-committed transaction is '
+ . 'cleanly reversed after a crash mid-critical-section')
+ if $posix_modes;
+
+ # Server is healthy after the reconciliation.
+ my $result12 = $inode->safe_psql('postgres', 'SELECT 1');
+ is($result12, '1', 'Test 12: server operational after recovery');
+
+ $bgpsql->{run}->finish;
+ }
+ else
+ {
+ note
+ 'skipping Test 11/12 (injection_points or test_fileops extension not installed)';
+ }
+
+ $inode->stop;
+}
+else
+{
+ note 'skipping Test 11/12 (injection points not supported by this build)';
+}
+
+done_testing();
diff --git a/src/test/recovery/t/063_fileops_2pc.pl b/src/test/recovery/t/063_fileops_2pc.pl
new file mode 100644
index 0000000000000..b88c8dd59103b
--- /dev/null
+++ b/src/test/recovery/t/063_fileops_2pc.pl
@@ -0,0 +1,149 @@
+# Copyright (c) 2024-2026, PostgreSQL Global Development Group
+#
+# Test two-phase commit (PREPARE TRANSACTION / COMMIT PREPARED / ROLLBACK
+# PREPARED) for transactions that generated cluster-wide FILEOPS UNDO.
+#
+# FILEOPS uses the undo-in-common-WAL mechanism. At PREPARE the permanent
+# UNDO chain-head LSN is durably saved in the 2PC state (xl_xact_prepare /
+# gxact->undo_batch_lsn), and its WAL is retained while the xact stays
+# prepared. ROLLBACK PREPARED (even from a different backend, post-crash)
+# applies that chain, which reverses the filesystem change. COMMIT PREPARED
+# keeps it.
+#
+# Vehicle: the test_fileops extension's SQL wrappers for FileOpsChmod, which
+# are legal inside a transaction block that can then be PREPARE'd.
+#
+# NB: FILEOPS only writes UNDO WAL when XLogIsNeeded() (wal_level >= replica).
+# At wal_level = minimal it relies on the backend-private pending-op list,
+# which is discarded at PREPARE and cannot survive 2PC. This test therefore
+# runs at wal_level = replica, which is what any FILEOPS + 2PC deployment
+# requires.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Every test here is a chmod reversal; Windows has no POSIX file modes.
+plan skip_all => 'POSIX permission semantics required' if $windows_os;
+
+my $node = PostgreSQL::Test::Cluster->new('fileops_2pc');
+$node->init;
+$node->append_conf(
+ "postgresql.conf", qq(
+autovacuum = off
+max_prepared_transactions = 10
+wal_level = replica
+));
+$node->start;
+
+if (!$node->check_extension('test_fileops'))
+{
+ plan skip_all => 'Extension test_fileops not installed';
+}
+$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops');
+
+my $datadir = $node->data_dir;
+my $file = "$datadir/twopc.dat";
+
+# Poll until the file mode equals $want, or fail.
+sub wait_for_mode
+{
+ my ($want, $desc) = @_;
+ my $ok = $node->poll_query_until('postgres',
+ qq{SELECT test_fileops_get_mode('$file') = $want});
+ is($ok, '1', $desc);
+}
+
+# 0644 = 420, 0700 = 448, 0600 = 384 (decimal)
+$node->safe_psql('postgres',
+ qq{SELECT test_fileops_create_tempfile('twopc.dat')});
+$node->safe_psql('postgres', qq{SELECT test_fileops_chmod('$file', 420)});
+my $baseline =
+ $node->safe_psql('postgres', qq{SELECT test_fileops_get_mode('$file')});
+is($baseline, '420', 'baseline mode is 0644');
+
+# ================================================================
+# Test 1: ROLLBACK PREPARED reverses the chmod
+# ================================================================
+$node->safe_psql(
+ 'postgres', qq(
+BEGIN;
+SELECT test_fileops_chmod('$file', 448); -- 0700
+PREPARE TRANSACTION 'fo_rb';
+));
+my $m =
+ $node->safe_psql('postgres', qq{SELECT test_fileops_get_mode('$file')});
+is($m, '448', 'Test 1: chmod applied and still visible while prepared');
+
+$node->safe_psql('postgres', q{ROLLBACK PREPARED 'fo_rb'});
+wait_for_mode(420, 'Test 1: ROLLBACK PREPARED reverses chmod (0700 -> 0644)');
+
+# ================================================================
+# Test 2: COMMIT PREPARED keeps the chmod
+# ================================================================
+$node->safe_psql(
+ 'postgres', qq(
+BEGIN;
+SELECT test_fileops_chmod('$file', 448); -- 0700
+PREPARE TRANSACTION 'fo_cp';
+));
+$node->safe_psql('postgres', q{COMMIT PREPARED 'fo_cp'});
+$m = $node->safe_psql('postgres', qq{SELECT test_fileops_get_mode('$file')});
+is($m, '448', 'Test 2: COMMIT PREPARED keeps chmod (0700)');
+
+# Reset to baseline for the crash test.
+$node->safe_psql('postgres', qq{SELECT test_fileops_chmod('$file', 420)});
+
+# ================================================================
+# Test 3: WAL retention across checkpoints while prepared
+# ================================================================
+$node->safe_psql(
+ 'postgres', qq(
+BEGIN;
+SELECT test_fileops_chmod('$file', 384); -- 0600
+PREPARE TRANSACTION 'fo_ckpt';
+));
+# Churn WAL and checkpoint; the prepared xact's UNDO batch must be retained.
+$node->safe_psql('postgres', 'CHECKPOINT');
+for my $i (1 .. 20)
+{
+ $node->safe_psql('postgres',
+ "CREATE TABLE junk_$i(x int); "
+ . "INSERT INTO junk_$i SELECT g FROM generate_series(1,5000) g; "
+ . "DROP TABLE junk_$i;");
+}
+$node->safe_psql('postgres', 'CHECKPOINT');
+$node->safe_psql('postgres', q{ROLLBACK PREPARED 'fo_ckpt'});
+wait_for_mode(420,
+ 'Test 3: ROLLBACK PREPARED still reverses after WAL churn + checkpoints');
+
+# ================================================================
+# Test 4: crash recovery -- prepared UNDO xact survives, then reverts
+# ================================================================
+$node->safe_psql('postgres', qq{SELECT test_fileops_chmod('$file', 420)});
+$node->safe_psql(
+ 'postgres', qq(
+BEGIN;
+SELECT test_fileops_chmod('$file', 448); -- 0700
+PREPARE TRANSACTION 'fo_crash';
+));
+$node->stop('immediate');
+$node->start;
+
+my $prep = $node->safe_psql('postgres', q{SELECT gid FROM pg_prepared_xacts});
+is($prep, 'fo_crash', 'Test 4: prepared xact survives crash recovery');
+$m = $node->safe_psql('postgres', qq{SELECT test_fileops_get_mode('$file')});
+is($m, '448', 'Test 4: chmod still applied (prepared, not yet rolled back)');
+
+$node->safe_psql('postgres', q{ROLLBACK PREPARED 'fo_crash'});
+wait_for_mode(420,
+ 'Test 4: ROLLBACK PREPARED after crash+restart reverses chmod');
+
+# Server healthy at the end.
+is($node->safe_psql('postgres', 'SELECT 1'),
+ '1', 'server operational after all 2PC UNDO scenarios');
+
+$node->stop;
+done_testing();
diff --git a/src/test/recovery/t/064_fileops_standby_divergence.pl b/src/test/recovery/t/064_fileops_standby_divergence.pl
new file mode 100644
index 0000000000000..65a07fb873e04
--- /dev/null
+++ b/src/test/recovery/t/064_fileops_standby_divergence.pl
@@ -0,0 +1,124 @@
+# Copyright (c) 2024-2026, PostgreSQL Global Development Group
+#
+# Regression test: physical-standby reproduction of FILEOPS deferred
+# DESTRUCTIVE operations (DELETE, RENAME, RMDIR).
+#
+# These three ops execute on the primary at commit inside
+# FileOpsDoPendingOps(). Each is logged at commit-EXECUTION time with a
+# real, idempotent redo handler, so a physical standby (which only replays
+# WAL, never running FileOpsDoPendingOps) reproduces them and its filesystem
+# converges with the primary's.
+#
+# This test sets up a streaming physical standby, performs a committed
+# deferred DELETE, RENAME, and RMDIR on the primary, waits for catchup, and
+# asserts the standby's filesystem MATCHES the primary's. It MUST FAIL on the
+# pre-fix (no-op redo) code -- see the header note in fileops.c and the
+# "prove non-vacuity" comment below.
+#
+# All FILEOPS paths are RELATIVE to the data directory, matching every core
+# FILEOPS caller (dbcommands.c, tablespace.c): the backend's cwd is PGDATA, so
+# a relative path resolves to the right file on both the primary and a standby
+# whose absolute data_dir differs.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $primary = PostgreSQL::Test::Cluster->new('fileops_div_primary');
+$primary->init(allows_streaming => 1);
+$primary->append_conf(
+ "postgresql.conf", qq(
+autovacuum = off
+wal_level = replica
+));
+$primary->start;
+
+if (!$primary->check_extension('test_fileops'))
+{
+ plan skip_all => 'Extension test_fileops not installed';
+}
+$primary->safe_psql('postgres', 'CREATE EXTENSION test_fileops');
+
+my $primary_dir = $primary->data_dir;
+
+# Fixture, all committed and WAL-logged so the standby has them before we
+# start mutating. Use FileOps CREATE (WAL-logged, standby-reproduced) and
+# FileOps MKDIR (WAL-logged) rather than raw open(), so the fixture itself
+# replicates. Paths are relative to PGDATA.
+$primary->safe_psql(
+ 'postgres', qq(
+BEGIN;
+SELECT test_fileops_create('div_delete_me.dat', 384); -- 0600
+SELECT test_fileops_create('div_rename_src.dat', 384);
+SELECT test_fileops_mkdir('div_rmdir_me', 448); -- 0700
+COMMIT;
+));
+
+# Take the base backup AFTER the fixture exists so init_from_backup gives the
+# standby the fixture files, then stream the mutations.
+$primary->backup('backup');
+my $standby = PostgreSQL::Test::Cluster->new('fileops_div_standby');
+$standby->init_from_backup($primary, 'backup', has_streaming => 1);
+$standby->start;
+
+my $standby_dir = $standby->data_dir;
+
+$primary->wait_for_catchup($standby);
+
+# Sanity: the fixture replicated to the standby.
+ok(-e "$standby_dir/div_delete_me.dat", 'fixture: delete target on standby');
+ok(-e "$standby_dir/div_rename_src.dat", 'fixture: rename source on standby');
+ok(-d "$standby_dir/div_rmdir_me", 'fixture: rmdir target on standby');
+
+# Perform the three deferred destructive ops in a committed transaction on the
+# primary. Each goes through the FileOps deferred path (at_commit=true) and,
+# with the fix, is WAL-logged at commit-execution.
+$primary->safe_psql(
+ 'postgres', qq(
+BEGIN;
+SELECT test_fileops_delete('div_delete_me.dat');
+SELECT test_fileops_rename('div_rename_src.dat', 'div_rename_dst.dat');
+SELECT test_fileops_rmdir('div_rmdir_me');
+COMMIT;
+));
+
+# Confirm the primary applied them (the op ran in FileOpsDoPendingOps).
+ok(!-e "$primary_dir/div_delete_me.dat", 'primary: delete applied');
+ok(!-e "$primary_dir/div_rename_src.dat", 'primary: rename source gone');
+ok(-e "$primary_dir/div_rename_dst.dat", 'primary: rename dest present');
+ok(!-d "$primary_dir/div_rmdir_me", 'primary: rmdir applied');
+
+$primary->wait_for_catchup($standby);
+
+# The diff the reviewer asked for: the standby's filesystem must MATCH the
+# primary's. Pre-fix (no-op redo) the standby still has the original file /
+# dir and lacks the renamed file -- these four assertions all fail, which is
+# the non-vacuity proof. Post-fix they pass.
+ok(!-e "$standby_dir/div_delete_me.dat",
+ 'deferred DELETE reproduced on standby (file gone)');
+ok(!-e "$standby_dir/div_rename_src.dat",
+ 'deferred RENAME reproduced on standby (old name gone)');
+ok(-e "$standby_dir/div_rename_dst.dat",
+ 'deferred RENAME reproduced on standby (new name present)');
+ok(!-d "$standby_dir/div_rmdir_me",
+ 'deferred RMDIR reproduced on standby (dir gone)');
+
+# Idempotent redo across a standby restart: replaying the same records again
+# after a crash must not error or diverge.
+$standby->stop('immediate');
+$standby->start;
+$primary->safe_psql('postgres', 'SELECT txid_current()');
+$primary->wait_for_catchup($standby);
+ok(!-e "$standby_dir/div_delete_me.dat",
+ 'standby end state stable after restart (delete)');
+ok(-e "$standby_dir/div_rename_dst.dat",
+ 'standby end state stable after restart (rename)');
+ok(!-d "$standby_dir/div_rmdir_me",
+ 'standby end state stable after restart (rmdir)');
+
+$standby->stop;
+$primary->stop;
+
+done_testing();
diff --git a/src/test/recovery/t/065_fileops_crash_undo.pl b/src/test/recovery/t/065_fileops_crash_undo.pl
new file mode 100644
index 0000000000000..f598c7e74180a
--- /dev/null
+++ b/src/test/recovery/t/065_fileops_crash_undo.pl
@@ -0,0 +1,137 @@
+# Copyright (c) 2024-2026, PostgreSQL Global Development Group
+#
+# FILEOPS crash-recovery revert via the cluster-wide UNDO engine (common WAL).
+#
+# FILEOPS is a consumer of the cluster-wide UNDO engine: its structural
+# operations (file existence, permissions, directory structure) are
+# WAL-durable via RM_FILEOPS_ID forward records (fileops_redo), and each
+# reversible op also writes an UNDO record into the common WAL
+# (PrepareXactUndoData(UNDO_RMID_FILEOPS,...) / InsertXactUndoData) in the
+# same critical section as its forward record. Filesystem structural
+# operations are cluster-wide changes whose durability/revert belong in the
+# common WAL. This test proves that model is crash-safe.
+#
+# A transaction produces FILEOPS create/chmod (immediate, WAL-flushed), then the
+# server is crashed (immediate stop) BEFORE the transaction commits. On
+# recovery:
+# - fileops_redo() REDOES the forward create/chmod unconditionally, and
+# - PerformUndoRecovery() (end of redo) applies the never-committed
+# transaction's FILEOPS UNDO chain via the RM_FILEOPS_ID rm_undo
+# callbacks (fileops_undo.c), reversing every op:
+# - the created file is unlinked (CREATE revert), and
+# - the chmod'd file's mode is restored (CHMOD revert, before-state in WAL).
+#
+# FILEOPS only writes WAL at wal_level >= replica (XLogIsNeeded()), so the test
+# runs at wal_level = replica, matching any FILEOPS deployment.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('fileops_crash');
+$node->init;
+$node->append_conf(
+ 'postgresql.conf', qq(
+autovacuum = off
+wal_level = replica
+));
+$node->start;
+
+if (!$node->check_extension('test_fileops'))
+{
+ plan skip_all => 'Extension test_fileops not installed';
+}
+$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops');
+
+my $datadir = $node->data_dir;
+my $create_path = "$datadir/fileops_crash_create.dat";
+my $chmod_path = "$datadir/fileops_crash_chmod.dat";
+
+# ---------------------------------------------------------------------------
+# Set up a pre-existing file to chmod (this part is committed). 0644 = 420.
+# ---------------------------------------------------------------------------
+$node->safe_psql('postgres',
+ q{SELECT test_fileops_create_tempfile('fileops_crash_chmod.dat')});
+$node->safe_psql('postgres',
+ qq{SELECT test_fileops_chmod('$chmod_path', 420)});
+SKIP:
+{
+ skip 'POSIX permission semantics required', 1 if $windows_os;
+ is( $node->safe_psql(
+ 'postgres', qq{SELECT test_fileops_get_mode('$chmod_path')}),
+ '420',
+ 'baseline mode of pre-existing file is 0644');
+}
+
+# ---------------------------------------------------------------------------
+# Open a transaction that produces FILEOPS UNDO (create a new file and
+# chmod the existing one) and DO NOT commit it. The FILEOPS forward operations
+# execute and are WAL-flushed immediately (the file exists on disk, the mode is
+# changed), but the wrapping transaction stays in progress.
+# ---------------------------------------------------------------------------
+my $bg = $node->background_psql('postgres');
+$bg->query_until(
+ qr/fileops_ready/, qq(
+BEGIN;
+SELECT test_fileops_create('$create_path', 384); -- 0600, new file
+SELECT test_fileops_chmod('$chmod_path', 448); -- 0700, existing file
+\\echo fileops_ready
+));
+
+# The forward ops are visible on disk while the xact is in progress.
+ok(-e $create_path,
+ 'created file exists on disk before crash (in-progress xact)');
+SKIP:
+{
+ skip 'POSIX permission semantics required', 1 if $windows_os;
+ is( $node->safe_psql(
+ 'postgres', qq{SELECT test_fileops_get_mode('$chmod_path')}),
+ '448',
+ 'chmod applied on disk before crash (in-progress xact)');
+}
+
+# ---------------------------------------------------------------------------
+# Crash immediately: the transaction never commits. Stop the server before
+# closing the client: closing it first would let the backend roll the
+# transaction back online, which is a different case. The background psql
+# connection dies with the server; abandon it (do not try to drain/finish it,
+# which would hang waiting on the dead connection).
+# ---------------------------------------------------------------------------
+$node->stop('immediate');
+$bg->{run}->kill_kill;
+$node->start;
+
+# ---------------------------------------------------------------------------
+# Recovery redoes the forward ops from WAL, then PerformUndoRecovery() reverses
+# the never-committed transaction's ops from its common-WAL UNDO chain:
+# - the created file is unlinked, and
+# - the chmod is restored to the original 0644.
+# ---------------------------------------------------------------------------
+ok( !-e $create_path,
+ 'FILEOPS COMMON-WAL CRASH RECOVERY: created file was REVERTED (unlinked) after crash'
+);
+SKIP:
+{
+ skip 'POSIX permission semantics required', 1 if $windows_os;
+ is( $node->safe_psql(
+ 'postgres', qq{SELECT test_fileops_get_mode('$chmod_path')}),
+ '420',
+ 'FILEOPS COMMON-WAL CRASH RECOVERY: chmod was REVERTED (0700 -> 0644) after crash'
+ );
+}
+
+# Recovery must be clean.
+my $rlog = slurp_file($node->logfile);
+unlike(
+ $rlog,
+ qr/PANIC|inconsistent|corrupt/i,
+ 'clean crash recovery, no PANIC/corruption');
+
+# Server healthy afterwards.
+is($node->safe_psql('postgres', 'SELECT 1'),
+ '1', 'server operational after crash-recovery undo');
+
+$node->stop;
+done_testing();
diff --git a/src/tools/pginclude/headerscheck b/src/tools/pginclude/headerscheck
index 785d6f867ad09..29c63abfb2ba8 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 553162c08106d..ca8d7b8a92e06 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
@@ -833,6 +834,17 @@ ExtensionVersionInfo
FDWCollateState
FD_SET
FILE
+FileOpsTwoPhaseRecord
+FileopsUndoChmod
+FileopsUndoChown
+FileopsUndoCreate
+FileopsUndoLink
+FileopsUndoMkdir
+FileopsUndoRemovexattr
+FileopsUndoRename
+FileopsUndoSetxattr
+FileopsUndoSymlink
+FileopsUndoTruncate
FILETIME
FPI
FSMAddress
@@ -1908,6 +1920,8 @@ PACE_HEADER
PACL
PATH
PCtxtHandle
+PendingFileOp
+PendingFileOpType
PERL_CONTEXT
PERL_SI
PFN
@@ -3305,6 +3319,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 +3520,30 @@ X509_NAME_ENTRY
X509_STORE
X509_STORE_CTX
X86FeatureId
+XactUndoContext
+XactUndoData
+XactUndoSubTransactionState
+xl_fileops_chmod
+xl_fileops_chown
+xl_fileops_create
+xl_fileops_delete
+xl_fileops_link
+xl_fileops_mkdir
+xl_fileops_removexattr
+xl_fileops_rename
+xl_fileops_rmdir
+xl_fileops_setxattr
+xl_fileops_symlink
+xl_fileops_truncate
+xl_fileops_write
+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