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