From 835e1150d06207d66d560b8fe16715ad20334b96 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Fri, 11 Sep 2026 10:35:28 -0400 Subject: [PATCH 1/5] ci: fork automation (upstream sync + OCR AI review) Squashes the four CI commits this fork carried on top of upstream into one, and cuts the carry from 3776 lines to ~1000. Removed: - .github/{README,QUICKSTART}.md and .github/docs/* (2273 lines). Beyond being bulk we rebase hourly, they had drifted into being wrong: they described a daily (not hourly) sync, Claude 3.5 Sonnet via the direct Anthropic API (we run Opus on Bedrock), a static IAM user (we use OIDC), and a Windows dependency-builder workflow that the same commit which documented it had already deleted. 16 referenced paths did not exist. An AI reviewer reading this repo ingests that as truth. - sync-upstream-manual.yml (252 lines). sync-upstream.yml already has workflow_dispatch; the sole behavioral difference was a force_push toggle whose "false" setting cannot work after a rebase anyway. - .github/.gitignore, which only ignored scripts/ai-review/, a directory that does not exist here. Rewritten: - sync-upstream.yml -> fork-sync-upstream.yml, 260 lines to 47. git rebase already handles the fast-forward, no-op and diverged cases that the ahead/behind arithmetic hand-rolled, so all that goes away, as do the issue open/comment/close machinery (a scheduled-run failure is already emailed to the one consumer, and the run log carries what the issue body would) and the "dev setup|dev v[0-9]" commit allowlist, which matched zero commits on master and had since it was written. Sync drops to every three hours: hourly meant 24 full-history clones of an 840MB repo per day, each force-push also re-triggering pg-ci.yml on master, to collect a handful of upstream commits. Renamed: - ocr-review.yml, ocr-model-check.yml -> fork-*.yml. Upstream owns .github/ too (it has touched pg-ci.yml three times this year), so fork-owned workflows now live under a name prefix upstream will not collide with, making the rebase structurally conflict-free and letting the sync guard match fork-owned paths exactly rather than allowing all of .github/. Unchanged: the OCR review workflows' logic and .github/ocr/* (rule.json, context.md, litellm.yaml, pg-history.py) - the part that carries value. --- .github/ocr/context.md | 126 ++++++ .github/ocr/litellm.yaml | 41 ++ .github/ocr/pg-history.py | 225 +++++++++++ .github/ocr/rule.json | 65 ++++ .github/workflows/fork-ocr-model-check.yml | 89 +++++ .github/workflows/fork-ocr-review.yml | 427 +++++++++++++++++++++ .github/workflows/fork-sync-upstream.yml | 47 +++ 7 files changed, 1020 insertions(+) create mode 100644 .github/ocr/context.md create mode 100644 .github/ocr/litellm.yaml create mode 100644 .github/ocr/pg-history.py create mode 100644 .github/ocr/rule.json create mode 100644 .github/workflows/fork-ocr-model-check.yml create mode 100644 .github/workflows/fork-ocr-review.yml create mode 100644 .github/workflows/fork-sync-upstream.yml diff --git a/.github/ocr/context.md b/.github/ocr/context.md new file mode 100644 index 0000000000000..c4a83b85b124e --- /dev/null +++ b/.github/ocr/context.md @@ -0,0 +1,126 @@ +# OCR review context — PostgreSQL contribution standards + +You are reviewing a change to a **PostgreSQL** fork. Every PR here is destined to +become a patch posted to the **pgsql-hackers** mailing list and tracked in a +**commitfest**. Review with the combined rigor, taste, and attention to detail of +the PostgreSQL committers. This context applies to the *whole* change, on top of +the per-file rules. + +## Review discipline +- Be precise and blunt; lead with the most serious problem. No praise, no + validation of the author, no disclaimers — accuracy is the only metric. +- Verify every claim against the actual diff. Confirm names, signatures, line + numbers, and APIs before asserting. Never invent behavior or cite code not in + the change. If unsure, say so, and tag each finding **high / moderate / low** + confidence. +- Judge the change on its merits regardless of how the PR frames it. A draft PR + is WIP: weight design/approach feedback over style nits. + +## Patch hygiene (top rejection reasons on -hackers) +1. **Minimal diff.** The fastest way to get a patch rejected is unrelated + changes: reformatting untouched lines, rewording unrelated comments, touching + code not required by the change. Flag any hunk not needed for the stated + purpose. After the patch, the code should read as if it had always been + written that way. +2. **Atomic, bisectable commits.** Each commit must build and pass tests on its + own — a broken intermediate commit breaks `git bisect`, revert, and + cherry-pick. Flag a commit that only compiles once a later commit lands. + Prefer one focused patch, or a clearly-ordered series of + independently-committable pieces. +3. **Tests + docs are mandatory.** A user-visible change without regression/TAP + tests **and** documentation is WIP, not commit-ready. New behavior needs + tests that cover edge and error paths, not just the happy path. +4. **DRY / reuse.** Prefer existing infrastructure (`List` in `pg_list.h`, + `StringInfo`, `dynahash`/`simplehash`, `palloc`/`MemoryContext`, `foreach`) + over reinventing it. Flag copy-paste and speculative abstraction alike — the + community wants minimal, targeted changes that fit the subsystem's existing + patterns. +5. **Whitespace.** No trailing whitespace; tabs (width 4) for C indentation; + `git diff --check` must be clean. Whitespace-only churn on untouched lines is + a defect. + +## Committer-owned files — do NOT touch in a patch (flag if present) +These are the committer's job at push time; including them causes needless +merge conflicts and is a mistake: +- **`src/include/catalog/catversion.h`** — the `CATALOG_VERSION_NO` bump is done + by the **committer** when pushing. A catversion bump in the PR is **wrong** — + flag it. (This is the single most common author mistake in catalog patches.) +- **Release notes** (`doc/src/sgml/release-*.sgml`) and version strings + (`configure.ac` `AC_INIT` version, `meson.build` `version`, `PG_VERSION`). + +## Generated files — never hand-edit; edit the source +Flag direct edits to generated output; point the author at the source instead: +- Catalog headers `src/include/catalog/*_d.h`, `postgres.bki`, `schemapg.h`, + `system_constraints.sql` → edit the `pg_*.dat` files. +- `src/backend/nodes/{copy,equal,out,read}funcs.c` and other + `gen_node_support.pl` output → annotate the `Node` struct in its header. +- `fmgroids.h`, `fmgrprotos.h`, `fmgrtab.c` → edit `pg_proc.dat`. +- `utils/errcodes.h` → `errcodes.txt`; wait-event headers → + `wait_event_names.txt`; `lwlocknames.h` → `lwlocknames.txt`. +- `configure` → `configure.ac`; `*.po` translations are handled separately; + generated Unicode tables come from their source scripts. + +## Portability is a hard gate +PostgreSQL runs on Linux, Windows (MSVC), macOS, the BSDs and Solaris, across +**x86_64, ARM64, RISC-V, PPC64, s390x**, both endiannesses and 32/64-bit. Any +change must be portable across all of them: +- No unaligned memory access; no dependence on `char` signedness, integer/pointer + width, endianness, or struct padding for on-disk/wire formats. +- Use `int16/int32/int64`, `Size`, and `INT64_FORMAT`/`UINT64_FORMAT` (never + `%ld` for `int64`). +- Atomics/barriers only via `port/atomics` (`pg_atomic_*`, `pg_read/write_barrier`). +- **Windows/MSVC:** any `extern` variable used from another module or an + extension needs `PGDLLIMPORT` in its header; no VLAs or compiler-specific + extensions beyond the tree's C99 baseline. + +## Backward compatibility — the strongest constraint +Do not break SQL behavior, the libpq wire protocol, the logical-replication +protocol, dump/restore, `pg_upgrade`, or exported/`PGDLLIMPORT` APIs without +extraordinary justification. **ABI** matters for back-branches: changing the +size/layout of an exported struct or the signature of an exported function +breaks installed extensions. + +## Mailing-list context & etiquette +Because each PR becomes a pgsql-hackers email read by a busy, expert, opinionated +audience, also flag what reliably wastes reviewer time or draws rejection: +- A patch that **does more than one thing** or bundles unrelated cleanup — split it. +- **Footguns**: easy-to-misuse APIs, silent data-loss/corruption hazards, unsafe + defaults — name them explicitly. +- **Performance claims without a reproducible benchmark.** +- No reference to the **design discussion / prior -hackers thread** (Message-Id) + for a non-trivial change. +- **Do not bikeshed:** keep style nits proportionate and clearly separated from + substantive correctness findings. + +## Minimalism — the "ponytail" discipline +The best code is the code you never wrote (YAGNI). Before accepting new code, +apply the ladder: (1) Does this need to exist at all? (2) Can existing +code/infrastructure already do it? (3) Is this the simplest thing that works? +Flag: speculative scaffolding and config for a path that isn't wired yet; dead +code and unused "flexibility" (fields, params, abstractions, options with no +caller); premature abstraction (a helper used exactly once); knobs/GUCs/flags +nobody asked for. Minimal, targeted changes that fit the existing patterns beat +clever or general-purpose ones. + +## Comment & identity accuracy +- Comments must describe what the code does **now**. Flag aspirational/ + future-tense comments for behavior that already shipped ("will be", "for now", + "not yet", "future", and stale "TODO/FIXME/XXX/HACK"); comments that drifted + from the code they sit above; and incomplete/trailing comments. Comments + explain **why**, not what. No commented-out code. +- **ASCII only** in source and diffs — no smart quotes, em-dashes, or ellipsis + characters. + +## Commit & versioning discipline +- Conventional-commit style, imperative subject, one logical change per commit, + each commit building on its own. +- Do **not** bump version numbers or generated version stamps (including + `catversion.h`) — that is the maintainer's job at commit/release time. + +Understand common list shorthand so your comments are precise and not +miscommunicated: WIP (work in progress), GUC (config variable), WAL, LSN, OID, +TOAST, FSM, TAM (table access method), RLS, DSM, 2PC, PITR, CIC (concurrent index +creation), SAOP, ABI/API, backpatch (apply to supported back-branches), HEAD +(master tip), catversion (catalog version), pgindent, buildfarm, cfbot, +`s/x/y/` (suggested text substitution), footgun, bikeshedding, POLA (principle of +least astonishment). diff --git a/.github/ocr/litellm.yaml b/.github/ocr/litellm.yaml new file mode 100644 index 0000000000000..e23cc4eee6fe2 --- /dev/null +++ b/.github/ocr/litellm.yaml @@ -0,0 +1,41 @@ +# LiteLLM proxy config — bridges Open Code Review (OpenAI protocol) to AWS Bedrock. +# +# This proxy is NOT a hosted service. The ocr-review.yml workflow installs it +# (`pip install 'litellm[proxy]'`) and runs it as a background process bound to +# 127.0.0.1:4000 for the duration of a single GitHub Actions job, then it exits. +# +# Auth to Bedrock: LiteLLM uses boto3's default credential chain, which reads +# the temporary AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN +# minted by the workflow's OIDC "Configure AWS credentials" step; region from +# AWS_REGION. + +model_list: + - model_name: ocr-bedrock + litellm_params: + # Set the repo variable OCR_BEDROCK_MODEL to an Opus inference-profile id + # your account has access to, e.g.: + # bedrock/converse/us.anthropic.claude-opus-4-8 + # The 'converse/' prefix uses Bedrock's Converse API, which is the most + # reliable path for Claude tool-use (what OCR relies on). + model: os.environ/OCR_BEDROCK_MODEL + aws_region_name: os.environ/AWS_REGION + + # "High effort" review. Claude Opus 4.8 on Bedrock uses *adaptive* thinking + # controlled by output_config.effort. Set it DIRECTLY here — NOT via + # reasoning_effort, which LiteLLM still maps to the legacy + # thinking.type.enabled that Opus 4.8 rejects. LiteLLM forwards + # output_config into additionalModelRequestFields for Anthropic models; if + # the build doesn't recognize the effort param it is dropped with a warning + # (no error) and the model reviews at its default effort. + # Valid: low|medium|high|max|xhigh (auto-clamped to the model ceiling). + output_config: + effort: xhigh + max_tokens: 32000 + +litellm_settings: + drop_params: true # silently drop params a model doesn't support + modify_params: true # auto-fix minor request incompatibilities + request_timeout: 600 + +general_settings: + master_key: os.environ/LITELLM_MASTER_KEY diff --git a/.github/ocr/pg-history.py b/.github/ocr/pg-history.py new file mode 100644 index 0000000000000..5794f8a920bd7 --- /dev/null +++ b/.github/ocr/pg-history.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +pg-history: tie a PR's changes to PostgreSQL git + pgsql-hackers email history. + +OCR (the code reviewer) cannot call MCP servers, so this is a separate agent: +it runs a Bedrock (Claude Opus) tool-use loop wired to the Agora MCP server at +https://pg.ddx.io/mcp, lets the model search the mailing-list archives / commit +history / commitfest data, and emits a Markdown summary linking the changes to +the relevant threads (https://pg.ddx.io/m/pgsql-hackers/). + +Env: + PG_HISTORY_MCP_URL MCP endpoint (default https://pg.ddx.io/mcp) + PG_HISTORY_MODEL Bedrock model id (e.g. us.anthropic.claude-opus-4-8) + AWS_REGION region (creds come from the OIDC step's env) + BASE_REF, HEAD_SHA PR base ref and head sha (for the git diff context) + GH_PR_TITLE PR title (optional, adds context) + PG_HISTORY_OUT output markdown path (default /tmp/pg-history.md) +Writes the markdown to PG_HISTORY_OUT; exits 0 even on soft failures (writes a note). +""" +import json, os, subprocess, sys, urllib.request + +MCP_URL = os.environ.get("PG_HISTORY_MCP_URL", "https://pg.ddx.io/mcp") +MODEL = os.environ.get("PG_HISTORY_MODEL", "us.anthropic.claude-opus-4-8").replace("bedrock/converse/", "").replace("bedrock/", "") +REGION = os.environ.get("AWS_REGION", "us-east-1") +BASE_REF = os.environ.get("BASE_REF", "") +HEAD_SHA = os.environ.get("HEAD_SHA", "") +PR_TITLE = os.environ.get("GH_PR_TITLE", "") +OUT = os.environ.get("PG_HISTORY_OUT", "/tmp/pg-history.md") +UA = "pg-history/0.1 (+github-actions)" + +# Curated subset of the 108 Agora tools — the ones useful for connecting a +# change to its discussion/commit history. Intersected with what the server +# actually exposes, so unknown names are harmless. +TOOL_WHITELIST = { + "find_related_discussions", "find_similar_messages", "get_thread", + "discussion_links", "get_author_messages", "browse_by_date", + "blame_symbol", "check_upstream_status", "find_related", + "find_entries_for_thread", "find_entries_for_author", "get_commit", + "search", "hybrid_search", "get_callers", "get_callees", "find_pattern", +} +MAX_ROUNDS = 14 +TOOL_RESULT_CAP = 8000 # chars per tool result fed back to the model + + +def _mcp_post(body, sid=None): + headers = {"Content-Type": "application/json", + "Accept": "application/json, text/event-stream", "User-Agent": UA} + if sid: + headers["Mcp-Session-Id"] = sid + req = urllib.request.Request(MCP_URL, data=json.dumps(body).encode(), headers=headers, method="POST") + resp = urllib.request.urlopen(req, timeout=60) + sid_out = resp.headers.get("Mcp-Session-Id") + result = None + for line in resp.read().decode().splitlines(): + line = line.strip() + if line.startswith("data:"): + line = line[5:].strip() + if not line or line.startswith("event:"): + continue + try: + obj = json.loads(line) + except Exception: + continue + if isinstance(obj, dict) and ("result" in obj or "error" in obj): + result = obj + return result, sid_out + + +class MCP: + def __init__(self): + init, self.sid = _mcp_post({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "pg-history", "version": "0.1"}}}) + if not init or "result" not in init: + raise RuntimeError(f"MCP initialize failed: {init}") + try: + _mcp_post({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}, self.sid) + except Exception: + pass + self._id = 1 + + def list_tools(self): + self._id += 1 + res, _ = _mcp_post({"jsonrpc": "2.0", "id": self._id, "method": "tools/list", "params": {}}, self.sid) + return (res or {}).get("result", {}).get("tools", []) + + def call(self, name, args): + self._id += 1 + res, _ = _mcp_post({"jsonrpc": "2.0", "id": self._id, "method": "tools/call", + "params": {"name": name, "arguments": args or {}}}, self.sid) + if not res: + return "(no response)" + if "error" in res: + return f"ERROR: {json.dumps(res['error'])[:500]}" + parts = [] + for c in res.get("result", {}).get("content", []): + if c.get("type") == "text": + parts.append(c["text"]) + return ("\n".join(parts) or "(empty)")[:TOOL_RESULT_CAP] + + +def git(*args): + try: + return subprocess.check_output(["git", *args], text=True, stderr=subprocess.DEVNULL).strip() + except Exception: + return "" + + +def pr_context(): + base = f"origin/{BASE_REF}" if BASE_REF else "" + rng = f"{base}..{HEAD_SHA}" if base and HEAD_SHA else HEAD_SHA + commits = git("log", "--no-merges", "--format=%h %s", f"{rng}") if rng else "" + stat = git("diff", "--stat", rng) if rng else "" + files = git("diff", "--name-only", rng) if rng else "" + return commits[:4000], stat[:3000], files[:2000] + + +SYSTEM = """You are a PostgreSQL community research assistant. Given a pull request's +commits and changed files, use the available tools (backed by the Agora index of +pgsql-hackers mail, commit history, and commitfest data) to connect the change to +its history. Your goal: + +- Find the mailing-list thread(s) and prior discussion behind this change. +- Identify related/superseded prior commits and any commitfest entry. +- Note relevant prior art, rejected approaches, or design rationale. + +Rules (voice & rigor): +- Be precise and blunt. No praise, no filler, no hedging, no disclaimers. Accuracy is + the only success metric — not the author's approval. Lead with the most important finding. +- NEVER hallucinate. Verify every Message-ID, thread subject, commit hash, author name, + and date against an actual tool result before citing it. If a search returns nothing, + say so plainly — do not guess or fabricate a plausible-looking link. +- Assess the change on its merits, independent of how the PR frames it. +- Tag any inferred (not tool-confirmed) linkage with an explicit confidence level: + high / moderate / low. +- Be decisive and efficient: a handful of targeted tool calls, not exhaustive search. +- Cite every mailing-list message as a Markdown link: [subject](https://pg.ddx.io/m/pgsql-hackers/MESSAGE_ID). +- If you find nothing relevant, say so in one line — do not pad. + +When done, output ONLY Markdown (no preamble) with these sections, omitting any that are empty: +## 🧵 Related discussion +## 🔗 Related commits / prior art +## 📋 Commitfest +## 🧭 Context for reviewers +Keep it tight (use bullets; link generously).""" + + +def to_toolspec(t): + schema = t.get("inputSchema") or {"type": "object", "properties": {}} + return {"toolSpec": {"name": t["name"], + "description": (t.get("description") or "")[:600], + "inputSchema": {"json": schema}}} + + +def main(): + commits, stat, files = pr_context() + if not commits and not files: + open(OUT, "w").write("") # nothing to do + print("No PR diff context; skipping.") + return + user = (f"PR title: {PR_TITLE}\n\n" if PR_TITLE else "") + \ + f"Commits:\n{commits or '(none)'}\n\nChanged files:\n{files or '(none)'}\n\nDiffstat:\n{stat or '(none)'}\n" + + try: + mcp = MCP() + tools = [to_toolspec(t) for t in mcp.list_tools() if t.get("name") in TOOL_WHITELIST] + except Exception as e: + open(OUT, "w").write(f"_pg-history: could not reach the Agora MCP server ({MCP_URL}): {e}_\n") + print(f"MCP unavailable: {e}") + return + if not tools: + open(OUT, "w").write("_pg-history: no usable MCP tools available._\n") + return + + import boto3 + from botocore.config import Config + + # botocore's default read timeout (60s) is too short for a multi-round + # (MAX_ROUNDS) tool-use loop against a large PR diff on a reasoning model; + # each converse() call alone can take several minutes. Bump it well past + # what a single round needs; connect_timeout stays short since a stuck + # TCP handshake is a different (and much cheaper to detect) failure mode. + brt = boto3.client("bedrock-runtime", region_name=REGION, + config=Config(read_timeout=900, connect_timeout=10)) + messages = [{"role": "user", "content": [{"text": user}]}] + final_text = "" + try: + for _ in range(MAX_ROUNDS): + resp = brt.converse( + modelId=MODEL, + system=[{"text": SYSTEM}], + messages=messages, + toolConfig={"tools": tools}, + inferenceConfig={"maxTokens": 4096}, + ) + out = resp["output"]["message"] + messages.append(out) + if resp.get("stopReason") == "tool_use": + results = [] + for blk in out["content"]: + tu = blk.get("toolUse") + if not tu: + continue + res_text = mcp.call(tu["name"], tu.get("input") or {}) + results.append({"toolResult": {"toolUseId": tu["toolUseId"], + "content": [{"text": res_text}]}}) + messages.append({"role": "user", "content": results}) + continue + final_text = "".join(b.get("text", "") for b in out["content"]).strip() + break + except Exception as e: + open(OUT, "w").write(f"_pg-history: Bedrock call failed: {e}_\n") + print(f"Bedrock error: {e}") + return + + if not final_text: + final_text = "_pg-history: no related history found._" + body = "## 📜 Change history & discussion (Agora / pg.ddx.io)\n\n" + final_text + \ + "\n\nGenerated by pg-history via the Agora MCP server (pg.ddx.io).\n" + open(OUT, "w").write(body) + print(body) + + +if __name__ == "__main__": + main() diff --git a/.github/ocr/rule.json b/.github/ocr/rule.json new file mode 100644 index 0000000000000..60e13e73dcbe0 --- /dev/null +++ b/.github/ocr/rule.json @@ -0,0 +1,65 @@ +{ + "_comment": "OCR per-file review rules for PostgreSQL core + extensions. Cross-cutting contribution standards & mailing-list etiquette live in .github/ocr/context.md, passed via --background-file. OCR uses FIRST-MATCH-WINS in declaration order, so rules are ordered most-specific first. merge_system_rule:true keeps OCR's built-in fine-tuned checks (thread-safety, injection, NPE) alongside these PostgreSQL-specific rules.", + "rules": [ + { + "path": "src/test/**", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL tests. Coverage is mandatory for any behavioral change and must include edge cases (NULL, empty, boundary/overflow) and ERROR paths, not just the happy path. A test that still passes with the feature reverted is worthless — confirm it actually exercises and would catch regressions in the new code. Regression (.sql/expected): deterministic, portable output — ORDER BY where row order matters, no timing/plan-dependent output except intentional EXPLAIN, no absolute paths, locale-independent (C collation or explicit COLLATE), DROP objects the test creates; expected/ output must stay stable across platforms and under the parallel schedule. Concurrency/locking belongs in isolation tests (src/test/isolation, .spec + permutations). End-to-end/crash/replication/CLI behavior belongs in TAP tests (t/*.pl with PostgreSQL::Test::Cluster/Utils) — no hardcoded ports/paths, no sleep as synchronization (use poll_query_until/wait_for), skip cleanly when prerequisites are missing, and clean up nodes." + }, + { + "path": "**/*.{c,h}", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL backend/frontend C — review as pgsql-hackers committers do, in priority order.\n\n(1) CORRECTNESS (highest): Memory — every palloc lives in the right MemoryContext; error paths via ereport/elog(ERROR) must not leak memory/buffers/locks/fds (rely on MemoryContext/ResourceOwner reset or PG_TRY/PG_FINALLY); no use-after-free; delete temp contexts. Concurrency — consistent lock ordering (deadlock-free), correct lock levels, balanced LWLockAcquire/Release and START_/END_CRIT_SECTION, no TOCTOU, CHECK_FOR_INTERRUPTS in long loops, async-signal-safe signal handlers (volatile sig_atomic_t). WAL — any change to shared on-disk state must be WAL-logged AND correctly replayed (redo path), crash- and replica-consistent. NULL/edge/overflow handling.\n\n(2) BACKWARD COMPATIBILITY / ABI: don't break behavior, dump/restore, pg_upgrade, libpq wire protocol, logical-replication protocol, or exported/PGDLLIMPORT'd APIs (struct size/layout, function signatures) without extraordinary justification.\n\n(3) CATALOG / GENERATED: new/changed catalog data goes in pg_*.dat, NOT the generated *_d.h/.bki. New Node types: ANNOTATE the struct in its header so gen_node_support.pl regenerates copy/equal/out/read — do NOT hand-edit *funcs.c. New SQL-callable functions: add to pg_proc.dat with an OID from the 8000-9999 developer range (src/include/catalog/unused_oids; check duplicate_oids); committer renumbers at commit. DO NOT bump CATALOG_VERSION_NO in the patch — flag any catversion.h change as a mistake (committer's job).\n\n(4) PERFORMANCE: no regression on hot paths; avoid O(n^2) where better is feasible; minimize work under contended locks; avoid needless palloc churn and large struct copies in hot paths.\n\n(5) SECURITY: bounded string ops (snprintf/strlcpy/strlcat — never strcpy/strcat/sprintf); integer/size-overflow checks before allocation; never user input as a format string; privilege checks via pg_*_aclcheck; beware search_path and SECURITY DEFINER.\n\n(6) PORTABILITY (hard gate): no unaligned access; no dependence on char signedness, int/long/pointer width, endianness, or struct padding for on-disk/wire formats; use int16/int32/int64 + INT64_FORMAT/UINT64_FORMAT (never %ld for int64); align contended shared structs (pg_attribute_aligned/cache-line pad). Atomics/barriers only via port/atomics (pg_atomic_*, pg_read/write_barrier) — never raw intrinsics or volatile-as-barrier. WINDOWS/MSVC: extern vars used cross-module/extension need PGDLLIMPORT; no VLAs or features beyond the C99 baseline the tree targets; use pg_pread/pg_pwrite. Applies across x86_64/ARM64/RISC-V/PPC64/s390x, big/little endian, 32/64-bit.\n\n(7) CONVENTIONS: errmsg starts lowercase, no trailing period, no embedded newlines; errdetail/errhint are complete capitalized sentences; correct ERRCODE_*; wrap user-facing text in _(); errmsg_plural for counts. Assert() only for can't-happen invariants (never user-reachable). Naming: snake_case with subsystem prefix (heap_insert) or CamelCase for major subsystems (ExecInitNode); ALL_CAPS macros. Must pgindent cleanly (tabs, width 4). Comments explain WHY not WHAT; no #ifdef 0 blocks, no commented-out code, no #ifdef fencing your feature. Reuse existing helpers (DRY)." + }, + { + "path": "**/*.dat", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL catalog data (pg_proc.dat, pg_type.dat, etc.) — the SOURCE for generated headers. The generated *_d.h, postgres.bki, fmgroids.h, fmgrtab.c must NOT be hand-edited (they regenerate from these files). OIDs: use a value from the developer range 8000-9999 (src/include/catalog/unused_oids; verify with duplicate_oids); committer renumbers to a final contiguous block, so stay in-range and unique but don't over-optimize the exact number. Keep proc entries complete/consistent (prosrc, provolatile, proparallel, prorettype/proargtypes, matching description). DO NOT bump CATALOG_VERSION_NO / catversion.h — committer's job at push time; flag any such change. New catalog columns/views need documentation in doc/src/sgml/catalogs.sgml." + }, + { + "path": "**/*.{sql,pgsql}", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL SQL. Valid PostgreSQL dialect (not MySQL/Oracle); correct types (bigint vs int, text vs varchar); sound transaction/isolation and CTE-materialization assumptions. SECURITY: flag SQL injection in dynamic SQL (require quote_identifier/quote_literal or format() with %I/%L), SECURITY DEFINER without a locked-down search_path, inappropriate RLS bypass. Prefer set-based over row-at-a-time/N+1. BACKWARD COMPATIBILITY (a top rejection reason): changing existing SQL behavior, the output of existing functions, or default GUCs needs extraordinary justification. New SQL-callable objects belong in pg_*.dat with OIDs from the 8000-9999 range, not in generated files. Minimal diff; add regression tests + docs." + }, + { + "path": "**/*.{pl,pm}", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL Perl (TAP tests and build/catalog tooling). Require 'use strict; use warnings;'. Must be perltidy-clean with the tree's src/tools/pgindent/perltidyrc and pass src/tools/perlcheck/pgperlcritic. Use the framework: PostgreSQL::Test::Cluster, PostgreSQL::Test::Utils, Test::More; no hardcoded ports/paths/PIDs; use safe_psql/poll_query_until, not sleep; skippable without optional prerequisites; clean up nodes. PORTABILITY: run on Windows (no fork-only constructs, use File::Spec, avoid unavailable signals) and the minimum supported Perl. Robustness: avoid two-arg open and string system()/qx with interpolated data (use list forms). Generator scripts (gen_node_support.pl, catalog Perl) must be deterministic and stay in sync with inputs; do not commit their generated output." + }, + { + "path": "**/*.py", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL Python (build/test tooling, oauth/pytest tests, src/tools). Follow surrounding style; keep imports to the standard library unless the dependency is already required by the tree (no surprise third-party deps in build/test tooling). PORTABILITY: support the project's minimum Python 3 and run on Windows and the BSDs (use os.path/pathlib, avoid POSIX-only calls and shell=True with interpolated input). Deterministic, self-cleaning tests; no hardcoded ports/paths; skip cleanly without prerequisites. For the Perl->pytest porting effort, confirm behavior parity with the TAP test replaced (same assertions/coverage), not a superficial translation. Minimal diff; match the tree's ruff/black config if present." + }, + { + "path": "**/*.{rs,toml}", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. Rust PostgreSQL extension (pgrx) or Rust support crate. Not core C, but it runs inside/alongside the backend, so backend safety applies. SAFETY: in code reachable from an SQL call, a Rust panic aborts the Postgres process — forbid unwrap()/expect()/panic!/unreachable!/todo! and index-panics on reachable paths; use Result and pgrx error reporting (error!/ereport!). Every `unsafe` block needs a comment justifying its invariant; scrutinize raw pointers and FFI across the pg_sys boundary. pgrx: honor #[pg_guard] on extern C fns (correct panic/longjmp handling); never hold Rust references across SPI or anything that can longjmp (skips Rust destructors -> leaks); respect MemoryContext lifetimes for palloc'd data; datum<->Rust conversions must handle NULL. Concurrency uses Postgres shmem/LWLocks (pgrx shmem API), not std::sync alone. Lints: must pass `cargo clippy --all-targets --all-features -- -D warnings` and `cargo fmt --check`; deny unwrap_used/expect_used/panic in libraries; thiserror (libs) / anyhow (bins). Justify every new dependency. Tests: #[pg_test] for in-backend behavior, #[test] for pure logic; cover error and NULL paths. Minimal, idiomatic diff." + }, + { + "path": "**/{configure.ac,*.m4,aclocal.m4}", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL Autoconf. Edit configure.ac / the m4 macros — do NOT hand-edit generated 'configure' or pg_config.h.in in the same patch (regeneration is the committer's step; a patch that also rewrites generated configure output is suspect). Feature/header/function probes must be portable and not assume a specific OS/compiler. Every configure knob must be mirrored on the Meson side (meson_options.txt/meson.build) and documented. Minimal diff." + }, + { + "path": "**/{meson.build,meson_options.txt}", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL Meson build. Valid syntax; correct subdir()/dependency()/declare_dependency and install paths; new source files must be added here. CRITICAL: PostgreSQL maintains BOTH Meson and Autoconf/Make — any new file, option, or feature check must be mirrored on the configure.ac/Makefile side so the two never drift (a file built by only one system is a common defect). New options need matching docs and sensible defaults. Minimal diff." + }, + { + "path": "**/{Makefile,GNUmakefile,*.mk}", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL Makefile (GNU Make). $(VAR) refs; correct .PHONY; accurate dependencies (no parallel -j races); $(MAKE) for recursion; VPATH/out-of-tree build support; no hardcoded paths (use standard PostgreSQL makefile vars and $(top_builddir)); clean/distclean/maintainer-clean must remove new artifacts; extensions use PGXS. Must stay in sync with meson.build. Minimal diff." + }, + { + "path": "doc/**/*.sgml", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL documentation (DocBook SGML). Technically accurate/complete (parameters, limitations, version/compat notes); correct tag usage/nesting (, , , , , /); working cross-references; spell it 'PostgreSQL' in prose; SQL keywords uppercase in examples. Coverage: a new GUC -> config.sgml (and postgresql.conf.sample); new/changed catalogs or views -> catalogs.sgml; new SQL syntax -> the matching ref/*.sgml; new functions -> func.sgml. Do NOT edit release-notes (release-*.sgml) — written by the release team/committers; flag such edits. New user-facing behavior in this PR should ship with matching docs." + }, + { + "path": "**/*.md", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. Markdown docs. Clear heading hierarchy; fenced code blocks with language hints; accurate instructions/prerequisites; consistent PostgreSQL terminology; no broken relative links or stale claims. Minimal diff." + } + ] +} diff --git a/.github/workflows/fork-ocr-model-check.yml b/.github/workflows/fork-ocr-model-check.yml new file mode 100644 index 0000000000000..10d250528cf7c --- /dev/null +++ b/.github/workflows/fork-ocr-model-check.yml @@ -0,0 +1,89 @@ +# Checks AWS Bedrock weekly for a newer Claude Opus inference profile than the +# one OCR currently uses (vars.OCR_BEDROCK_MODEL) and, if found, opens/updates a +# single GitHub issue telling the maintainer to bump the variable. It does NOT +# change the model automatically: GITHUB_TOKEN cannot write Actions *variables* +# (that needs a PAT with admin), so this is a notify-only mechanism by design. +name: OCR model self-check + +on: + schedule: + - cron: '0 12 * * 1' # Mondays 12:00 UTC + workflow_dispatch: + +permissions: + id-token: write + contents: read + issues: write + +jobs: + check-model: + runs-on: ubuntu-latest + steps: + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + role-session-name: ocr-model-check-${{ github.run_id }} + + - name: Find newest Opus vs configured + id: check + env: + CURRENT: ${{ vars.OCR_BEDROCK_MODEL }} + AWS_REGION: ${{ vars.AWS_REGION }} + run: | + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import os, re, subprocess, json + region = os.environ.get("AWS_REGION", "us-east-1") + current = os.environ.get("CURRENT", "") + out = subprocess.run( + ["aws", "bedrock", "list-inference-profiles", "--region", region, + "--query", "inferenceProfileSummaries[].inferenceProfileId", "--output", "json"], + capture_output=True, text=True) + ids = json.loads(out.stdout or "[]") + # Parse claude-opus-- from any profile id (prefix us./global. ok). + def ver(s): + m = re.search(r"claude-opus-(\d+)-(\d+)", s) + return (int(m.group(1)), int(m.group(2))) if m else None + opus = [(ver(i), i) for i in ids if ver(i) and i.startswith(("us.", "global."))] + if not opus: + print("newer=false"); raise SystemExit(0) + best_ver, best_id = max(opus, key=lambda x: x[0]) + cur = ver(current) + newer = (cur is None) or (best_ver > cur) + print(f"newer={'true' if newer else 'false'}") + print(f"best_id={best_id}") + print(f"best_ver={best_ver[0]}.{best_ver[1]}") + print(f"cur_ver={'unknown' if cur is None else f'{cur[0]}.{cur[1]}'}") + PY + + - name: Open/update issue if a newer model exists + if: steps.check.outputs.newer == 'true' + uses: actions/github-script@v9 + with: + script: | + const best = '${{ steps.check.outputs.best_id }}'; + const bestVer = '${{ steps.check.outputs.best_ver }}'; + const curVer = '${{ steps.check.outputs.cur_ver }}'; + const marker = ''; + const title = `OCR: newer Claude Opus available (${bestVer} > ${curVer})`; + const body = `${marker}\n` + + `A newer Claude Opus inference profile is available on Bedrock.\n\n` + + `- **Configured** (\`vars.OCR_BEDROCK_MODEL\`): Opus ${curVer}\n` + + `- **Newest on Bedrock**: \`${best}\` (Opus ${bestVer})\n\n` + + `To upgrade, set the repo variable:\n\n` + + '```\n' + + `gh variable set OCR_BEDROCK_MODEL -R ${context.repo.owner}/${context.repo.repo} \\\n` + + ` -b "bedrock/converse/${best}"\n` + + '```\n\n' + + `Also confirm the \`ocr-bedrock-ci\` IAM inline policy allows invoking the new model ` + + `(the resource is scoped to \`anthropic.claude-opus-*\`), then re-run OCR.\n\n` + + `_Automated by \`.github/workflows/ocr-model-check.yml\`; this issue is upserted._`; + const q = `repo:${context.repo.owner}/${context.repo.repo} in:body "${marker}" state:open`; + const found = await github.rest.search.issuesAndPullRequests({ q, per_page: 1 }); + if (found.data.total_count > 0) { + const n = found.data.items[0].number; + await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, issue_number: n, title, body }); + } else { + await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title, body }); + } diff --git a/.github/workflows/fork-ocr-review.yml b/.github/workflows/fork-ocr-review.yml new file mode 100644 index 0000000000000..0828af429b57c --- /dev/null +++ b/.github/workflows/fork-ocr-review.yml @@ -0,0 +1,427 @@ +# Open Code Review (OCR) — AI PR review backed by AWS Bedrock via a LiteLLM proxy. +# +# Flow: +# PR opened/updated (incl. DRAFTS) ─┐ +# /open-code-review PR comment ─┼─► start LiteLLM (127.0.0.1:4000 → Bedrock) +# manual workflow_dispatch ─┘ └► ocr review --format json +# └► post inline PR review comments +# +# Required (repo settings — all repo *variables*, no secrets; auth is via GitHub OIDC): +# vars.AWS_ROLE_ARN - IAM role to assume via OIDC (granting bedrock:InvokeModel*) +# vars.AWS_REGION - e.g. us-east-1 +# vars.OCR_BEDROCK_MODEL - LiteLLM model string for the Opus inference profile, e.g. +# bedrock/converse/us.anthropic.claude-opus-4-8 +# +# No static AWS keys are stored. GITHUB_TOKEN (auto) posts the review comments. + +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 + +permissions: + id-token: write # required to mint the GitHub OIDC token for AWS role assumption + contents: read + pull-requests: write + +jobs: + ocr-review: + runs-on: ubuntu-latest + # PR events always; comment events only when the comment is on a PR and + # starts with the trigger keyword; 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'))) + + env: + # LiteLLM listens on localhost only; this key never leaves the runner. + LITELLM_MASTER_KEY: sk-ocr-ci-local + OCR_BEDROCK_MODEL: ${{ vars.OCR_BEDROCK_MODEL }} + # Region is a static var (safe at job level). AWS credentials are NOT set + # here — they're minted by the OIDC "Configure AWS credentials" step below + # and exported to the environment for the LiteLLM/boto3 Bedrock calls. + AWS_REGION: ${{ vars.AWS_REGION }} + + steps: + - name: Resolve PR context + id: pr + uses: actions/github-script@v9 + with: + script: | + let prNumber; + if (context.eventName === 'pull_request') { + prNumber = context.payload.pull_request.number; + } else if (context.eventName === 'issue_comment') { + prNumber = context.issue.number; + } else { + prNumber = parseInt('${{ github.event.inputs.pr_number }}', 10); + } + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + const { data: repo } = await github.rest.repos.get({ + owner: context.repo.owner, + repo: context.repo.repo, + }); + core.setOutput('number', String(prNumber)); + core.setOutput('base_ref', pr.base.ref); + core.setOutput('head_ref', pr.head.ref); + core.setOutput('head_sha', pr.head.sha); + core.setOutput('default_branch', repo.default_branch); + core.setOutput('cross_repo', String(pr.head.repo.full_name !== pr.base.repo.full_name)); + + # NOTE: do NOT checkout the PR head. OCR reads the diff and file contents + # straight from git refs (git diff , git show :path, + # git grep ), so the working tree is irrelevant — but our OCR config + # lives on the default branch, not on the PR branch. We check out the repo + # (default ref), fetch the base/head objects, and materialize the config + # from origin/. + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Prepare git refs and OCR config + env: + BASE_REF: ${{ steps.pr.outputs.base_ref }} + HEAD_REF: ${{ steps.pr.outputs.head_ref }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + DEFAULT_BRANCH: ${{ steps.pr.outputs.default_branch }} + run: | + git fetch --no-tags origin "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" || true + git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" || true + git fetch --no-tags origin "+refs/heads/${HEAD_REF}:refs/remotes/origin/${HEAD_REF}" || true + git fetch --no-tags origin "${HEAD_SHA}" || true + + # OCR config lives on the default branch; materialize it independently + # of whatever ref is checked out. + mkdir -p "$RUNNER_TEMP/ocr" + git show "origin/${DEFAULT_BRANCH}:.github/ocr/litellm.yaml" > "$RUNNER_TEMP/ocr/litellm.yaml" + git show "origin/${DEFAULT_BRANCH}:.github/ocr/rule.json" > "$RUNNER_TEMP/ocr/rule.json" + git show "origin/${DEFAULT_BRANCH}:.github/ocr/context.md" > "$RUNNER_TEMP/ocr/context.md" + echo "Config materialized:"; ls -l "$RUNNER_TEMP/ocr" + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + + - name: Install LiteLLM proxy + Open Code Review + run: | + python -m pip install --upgrade pip + # Pin LiteLLM to a main commit that supports Claude Opus 4.8 adaptive + # thinking (maps reasoning_effort -> output_config.effort, incl. xhigh). + # Not in any tagged release yet (PyPI latest 1.87.1 lacks the Opus + # normalizer). Bump this SHA once a release ships the feature. + pip install "litellm[proxy] @ git+https://github.com/BerriAI/litellm.git@5be0797d24a2f26eb2123e13788f90055a59d91d" + npm install -g @alibaba-group/open-code-review + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + role-session-name: ocr-review-${{ github.run_id }} + + - name: Start LiteLLM proxy (Bedrock bridge) + run: | + if [ -z "$OCR_BEDROCK_MODEL" ]; then + echo "::error::vars.OCR_BEDROCK_MODEL is not set (e.g. bedrock/converse/us.anthropic.claude-opus-4-1-20250805-v1:0)" + exit 1 + fi + nohup litellm --config "$RUNNER_TEMP/ocr/litellm.yaml" --host 127.0.0.1 --port 4000 \ + > /tmp/litellm.log 2>&1 & + echo "Waiting for LiteLLM to become ready..." + for i in $(seq 1 60); do + if curl -sf http://127.0.0.1:4000/health/readiness >/dev/null; then + echo "LiteLLM ready."; exit 0 + fi + sleep 2 + done + echo "::error::LiteLLM did not become ready in time"; cat /tmp/litellm.log; exit 1 + + - name: Configure OCR + run: | + ocr config set llm.url http://127.0.0.1:4000/v1/chat/completions + ocr config set llm.auth_token "$LITELLM_MASTER_KEY" + ocr config set llm.model ocr-bedrock + ocr config set llm.use_anthropic false + ocr config set language English + + - name: Run OCR review + run: | + ocr review \ + --from "origin/${{ steps.pr.outputs.base_ref }}" \ + --to "${{ steps.pr.outputs.head_sha }}" \ + --rule "$RUNNER_TEMP/ocr/rule.json" \ + --background-file "$RUNNER_TEMP/ocr/context.md" \ + --concurrency 3 \ + --timeout 20 \ + --format json \ + > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true + echo "----- OCR stdout -----"; cat /tmp/ocr-result.json || true + echo "----- OCR stderr -----"; cat /tmp/ocr-stderr.log || true + echo "----- LiteLLM log (tail) -----"; tail -n 50 /tmp/litellm.log || true + + - name: Post review to PR + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const prNumber = parseInt('${{ steps.pr.outputs.number }}', 10); + const commitSha = '${{ steps.pr.outputs.head_sha }}'; + + // Opus at high effort can emit dozens of findings. Posting them all + // one-by-one trips GitHub's SECONDARY rate limit (403 "content + // creation"), which is what made every run fail after the review + // was already generated. We (a) cap inline comments and overflow + // the rest into the summary, (b) prefer a single bulk createReview, + // and (c) throttle + back off with Retry-After on any fallback. + const MAX_INLINE = 25; + const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + + async function withRetry(fn, label) { + for (let attempt = 1; attempt <= 5; attempt++) { + try { return await fn(); } + catch (e) { + const status = e.status || (e.response && e.response.status); + const h = (e.response && e.response.headers) || {}; + const isRate = status === 403 || status === 429; + if (!isRate || attempt === 5) throw e; + let waitMs = 0; + if (h['retry-after']) waitMs = parseInt(h['retry-after'], 10) * 1000; + else if (h['x-ratelimit-reset']) waitMs = parseInt(h['x-ratelimit-reset'], 10) * 1000 - Date.now(); + if (!waitMs || Number.isNaN(waitMs) || waitMs < 0) waitMs = 1000 * Math.pow(2, attempt); + waitMs = Math.min(waitMs, 60000) + 500; + core.warning(`${label}: rate-limited (status ${status}); waiting ${Math.round(waitMs / 1000)}s (attempt ${attempt}/5)`); + await sleep(waitMs); + } + } + } + + let result; + try { + result = JSON.parse(fs.readFileSync('/tmp/ocr-result.json', 'utf8')); + } catch (e) { + const stderr = (() => { try { return fs.readFileSync('/tmp/ocr-stderr.log', 'utf8').trim(); } catch { return ''; } })(); + await withRetry(() => github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, + body: `⚠️ **OCR** could not produce a review.\n\n\`\`\`\n${(stderr || e.message).slice(0, 8000)}\n\`\`\``, + }), 'error-comment'); + return; + } + + const comments = result.comments || []; + const warnings = result.warnings || []; + + const formatComment = (c) => { + let body = c.content || ''; + if (c.suggestion_code && c.existing_code) { + body += '\n\n```suggestion\n' + c.suggestion_code + (c.suggestion_code.endsWith('\n') ? '' : '\n') + '```'; + } + return body; + }; + const formatMarkdown = (c) => { + let md = `### 📄 \`${c.path}\``; + if (c.start_line && c.end_line) md += ` (L${c.start_line}-L${c.end_line})`; + md += '\n\n' + (c.content || ''); + if (c.suggestion_code && c.existing_code) { + md += '\n\n
💡 Suggested change\n\n'; + md += '**Before:**\n```\n' + c.existing_code + '\n```\n\n**After:**\n```\n' + c.suggestion_code + '\n```\n\n
'; + } + return md; + }; + + if (comments.length === 0) { + await withRetry(() => github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, + body: `✅ **OCR**: ${result.message || 'No issues found.'}`, + }), 'no-issues-comment'); + return; + } + + const inlineAll = []; + const noLine = []; + for (const c of comments) { + const body = formatComment(c); + const hasLine = (c.start_line >= 1) || (c.end_line >= 1); + if (!hasLine) { noLine.push(c); continue; } + const rc = { path: c.path, body, side: 'RIGHT' }; + if (c.start_line >= 1 && c.end_line >= 1 && c.start_line !== c.end_line) { + rc.start_line = c.start_line; rc.line = c.end_line; rc.start_side = 'RIGHT'; + } else { + rc.line = c.end_line >= 1 ? c.end_line : c.start_line; + } + inlineAll.push({ rc, c }); + } + + const inline = inlineAll.slice(0, MAX_INLINE).map(x => x.rc); + const overflow = inlineAll.slice(MAX_INLINE).map(x => x.c); + + let summary = `🔍 **OCR** found **${comments.length}** issue(s).`; + summary += `\n- ${inline.length} inline, ${noLine.length + overflow.length} in summary`; + if (overflow.length) summary += ` (inline capped at ${MAX_INLINE})`; + if (warnings.length) summary += `\n- ⚠️ ${warnings.length} warning(s) during review`; + for (const c of noLine.concat(overflow)) summary += '\n\n---\n\n' + formatMarkdown(c); + + // Preferred path: ONE createReview carrying every inline comment. + try { + await withRetry(() => github.rest.pulls.createReview({ + owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, + commit_id: commitSha, body: summary, event: 'COMMENT', comments: inline, + }), 'bulk-review'); + return; + } catch (e) { + core.warning(`bulk createReview failed (${e.status || '?'}: ${e.message}); falling back to throttled per-comment posting`); + } + + // Fallback: an invalid inline position (line not in the diff -> 422) + // rejects the whole bulk review. Post the summary, then each comment + // individually with a delay + backoff, skipping ones GitHub rejects. + let ok = 0; const failed = []; + try { + await withRetry(() => github.rest.pulls.createReview({ + owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, + commit_id: commitSha, body: summary, event: 'COMMENT', + }), 'summary-review'); + } catch (err) { failed.push(`summary: ${err.message}`); } + + for (const rc of inline) { + try { + await withRetry(() => github.rest.pulls.createReviewComment({ + owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, + commit_id: commitSha, path: rc.path, body: rc.body, + ...(rc.start_line ? { start_line: rc.start_line, start_side: rc.start_side } : {}), + line: rc.line, side: rc.side, + }), `comment ${rc.path}:${rc.line}`); + ok++; + } catch (inner) { + failed.push(`\`${rc.path}\` L${rc.line}: ${inner.message}`); + } + await sleep(1200); // stay under the secondary content-creation limit + } + + if (failed.length) { + await withRetry(() => github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, + body: `📊 OCR posted ${ok}/${inline.length} inline comment(s).\n\n
${failed.length} could not be posted\n\n${failed.join('\n')}\n
`, + }), 'summary-failures'); + } + + # Companion job: OCR can't call MCP, so this separate agent ties the PR's + # changes to PostgreSQL git + pgsql-hackers history via the Agora MCP server + # (pg.ddx.io) and posts a single, upserted "history & discussion" comment. + pg-history: + runs-on: ubuntu-latest + 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'))) + steps: + - name: Resolve PR context + id: pr + uses: actions/github-script@v9 + with: + script: | + let prNumber; + if (context.eventName === 'pull_request') prNumber = context.payload.pull_request.number; + else if (context.eventName === 'issue_comment') prNumber = context.issue.number; + else prNumber = parseInt('${{ github.event.inputs.pr_number }}', 10); + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber }); + core.setOutput('number', String(prNumber)); + core.setOutput('base_ref', pr.base.ref); + core.setOutput('head_sha', pr.head.sha); + core.setOutput('title', pr.title || ''); + + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Make base/head refs available + env: + BASE_REF: ${{ steps.pr.outputs.base_ref }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: | + git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" || true + git fetch --no-tags origin "${HEAD_SHA}" || true + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + role-session-name: pg-history-${{ github.run_id }} + + - name: Install deps + run: pip install boto3 + + - name: Run pg-history (Agora MCP) + env: + PG_HISTORY_MODEL: ${{ vars.OCR_BEDROCK_MODEL }} + AWS_REGION: ${{ vars.AWS_REGION }} + BASE_REF: ${{ steps.pr.outputs.base_ref }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + GH_PR_TITLE: ${{ steps.pr.outputs.title }} + PG_HISTORY_OUT: ${{ runner.temp }}/pg-history.md + run: | + python .github/ocr/pg-history.py || true + echo "----- output -----"; cat "${{ runner.temp }}/pg-history.md" 2>/dev/null || echo "(no output)" + + - name: Upsert PR comment + uses: actions/github-script@v9 + with: + script: | + const fs = require('fs'); + const path = process.env.RUNNER_TEMP + '/pg-history.md'; + let body = ''; + try { body = fs.readFileSync(path, 'utf8').trim(); } catch (e) {} + if (!body) { console.log('pg-history: empty output, nothing to post'); return; } + const prNumber = parseInt('${{ steps.pr.outputs.number }}', 10); + const marker = ''; + body = marker + '\n' + body; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, per_page: 100 }); + const mine = comments.find(c => c.user.type === 'Bot' && c.body && c.body.includes(marker)); + if (mine) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, repo: context.repo.repo, comment_id: mine.id, body }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, body }); + } diff --git a/.github/workflows/fork-sync-upstream.yml b/.github/workflows/fork-sync-upstream.yml new file mode 100644 index 0000000000000..e950821239968 --- /dev/null +++ b/.github/workflows/fork-sync-upstream.yml @@ -0,0 +1,47 @@ +# Keep this fork's master rebased on postgres/postgres master, carrying only +# the fork-owned CI files (.github/workflows/fork-*.yml and .github/ocr/). +# +# 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 -vE '^(\.github/workflows/fork-|\.github/ocr/)'; 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 From 483150484de7028aef28e8ecf0a4e53779745a7e Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Fri, 11 Sep 2026 10:48:09 -0400 Subject: [PATCH 2/5] ci: move fork CI logic to gburd/ci-workflows The OCR review workflows and their config were the last bulk this fork carried on top of upstream: ~970 lines rebased onto postgres/postgres every few hours, editable only by amending a commit that gets force- pushed. They are now reusable (workflow_call) workflows in the sidecar repo gburd/ci-workflows, and master carries two caller stubs instead. Carry after this: 3 files, ~110 lines, one commit. The sidecar is also where the value is: ocr/rule.json and ocr/context.md (the PostgreSQL committer-grade review corpus) become normal reviewable files with normal PRs and history, rather than a blob inside a commit that is rewritten on every sync. Mechanics worth knowing: - The review jobs check ci-workflows out at github.job_workflow_sha, so the rules always come from the same commit as the logic using them, and pinning @v1 (or a sha) in the stub pins both. This replaces the old "git show origin/:.github/ocr/..." dance, which existed only because the config had to be read off a branch other than the PR's. - AWS settings are passed as explicit inputs rather than read from vars inside the reusable workflow. vars does resolve across the boundary, but the contract stays visible at the call site. - A reusable workflow does not change the OIDC subject: it stays repo:gburd/postgres:*, so the ocr-bedrock-ci trust policy is unchanged. - ci-workflows must stay public; callers read it with GITHUB_TOKEN. The sync guard narrows to .github/workflows/fork-* accordingly, and sync-upstream keeps needing SYNC_TOKEN: the stubs are still workflow files, which GITHUB_TOKEN may not push. --- .github/ocr/context.md | 126 ------- .github/ocr/litellm.yaml | 41 --- .github/ocr/pg-history.py | 225 ----------- .github/ocr/rule.json | 65 ---- .github/workflows/fork-ocr-model-check.yml | 91 +---- .github/workflows/fork-ocr-review.yml | 410 +-------------------- .github/workflows/fork-sync-upstream.yml | 5 +- 7 files changed, 26 insertions(+), 937 deletions(-) delete mode 100644 .github/ocr/context.md delete mode 100644 .github/ocr/litellm.yaml delete mode 100644 .github/ocr/pg-history.py delete mode 100644 .github/ocr/rule.json diff --git a/.github/ocr/context.md b/.github/ocr/context.md deleted file mode 100644 index c4a83b85b124e..0000000000000 --- a/.github/ocr/context.md +++ /dev/null @@ -1,126 +0,0 @@ -# OCR review context — PostgreSQL contribution standards - -You are reviewing a change to a **PostgreSQL** fork. Every PR here is destined to -become a patch posted to the **pgsql-hackers** mailing list and tracked in a -**commitfest**. Review with the combined rigor, taste, and attention to detail of -the PostgreSQL committers. This context applies to the *whole* change, on top of -the per-file rules. - -## Review discipline -- Be precise and blunt; lead with the most serious problem. No praise, no - validation of the author, no disclaimers — accuracy is the only metric. -- Verify every claim against the actual diff. Confirm names, signatures, line - numbers, and APIs before asserting. Never invent behavior or cite code not in - the change. If unsure, say so, and tag each finding **high / moderate / low** - confidence. -- Judge the change on its merits regardless of how the PR frames it. A draft PR - is WIP: weight design/approach feedback over style nits. - -## Patch hygiene (top rejection reasons on -hackers) -1. **Minimal diff.** The fastest way to get a patch rejected is unrelated - changes: reformatting untouched lines, rewording unrelated comments, touching - code not required by the change. Flag any hunk not needed for the stated - purpose. After the patch, the code should read as if it had always been - written that way. -2. **Atomic, bisectable commits.** Each commit must build and pass tests on its - own — a broken intermediate commit breaks `git bisect`, revert, and - cherry-pick. Flag a commit that only compiles once a later commit lands. - Prefer one focused patch, or a clearly-ordered series of - independently-committable pieces. -3. **Tests + docs are mandatory.** A user-visible change without regression/TAP - tests **and** documentation is WIP, not commit-ready. New behavior needs - tests that cover edge and error paths, not just the happy path. -4. **DRY / reuse.** Prefer existing infrastructure (`List` in `pg_list.h`, - `StringInfo`, `dynahash`/`simplehash`, `palloc`/`MemoryContext`, `foreach`) - over reinventing it. Flag copy-paste and speculative abstraction alike — the - community wants minimal, targeted changes that fit the subsystem's existing - patterns. -5. **Whitespace.** No trailing whitespace; tabs (width 4) for C indentation; - `git diff --check` must be clean. Whitespace-only churn on untouched lines is - a defect. - -## Committer-owned files — do NOT touch in a patch (flag if present) -These are the committer's job at push time; including them causes needless -merge conflicts and is a mistake: -- **`src/include/catalog/catversion.h`** — the `CATALOG_VERSION_NO` bump is done - by the **committer** when pushing. A catversion bump in the PR is **wrong** — - flag it. (This is the single most common author mistake in catalog patches.) -- **Release notes** (`doc/src/sgml/release-*.sgml`) and version strings - (`configure.ac` `AC_INIT` version, `meson.build` `version`, `PG_VERSION`). - -## Generated files — never hand-edit; edit the source -Flag direct edits to generated output; point the author at the source instead: -- Catalog headers `src/include/catalog/*_d.h`, `postgres.bki`, `schemapg.h`, - `system_constraints.sql` → edit the `pg_*.dat` files. -- `src/backend/nodes/{copy,equal,out,read}funcs.c` and other - `gen_node_support.pl` output → annotate the `Node` struct in its header. -- `fmgroids.h`, `fmgrprotos.h`, `fmgrtab.c` → edit `pg_proc.dat`. -- `utils/errcodes.h` → `errcodes.txt`; wait-event headers → - `wait_event_names.txt`; `lwlocknames.h` → `lwlocknames.txt`. -- `configure` → `configure.ac`; `*.po` translations are handled separately; - generated Unicode tables come from their source scripts. - -## Portability is a hard gate -PostgreSQL runs on Linux, Windows (MSVC), macOS, the BSDs and Solaris, across -**x86_64, ARM64, RISC-V, PPC64, s390x**, both endiannesses and 32/64-bit. Any -change must be portable across all of them: -- No unaligned memory access; no dependence on `char` signedness, integer/pointer - width, endianness, or struct padding for on-disk/wire formats. -- Use `int16/int32/int64`, `Size`, and `INT64_FORMAT`/`UINT64_FORMAT` (never - `%ld` for `int64`). -- Atomics/barriers only via `port/atomics` (`pg_atomic_*`, `pg_read/write_barrier`). -- **Windows/MSVC:** any `extern` variable used from another module or an - extension needs `PGDLLIMPORT` in its header; no VLAs or compiler-specific - extensions beyond the tree's C99 baseline. - -## Backward compatibility — the strongest constraint -Do not break SQL behavior, the libpq wire protocol, the logical-replication -protocol, dump/restore, `pg_upgrade`, or exported/`PGDLLIMPORT` APIs without -extraordinary justification. **ABI** matters for back-branches: changing the -size/layout of an exported struct or the signature of an exported function -breaks installed extensions. - -## Mailing-list context & etiquette -Because each PR becomes a pgsql-hackers email read by a busy, expert, opinionated -audience, also flag what reliably wastes reviewer time or draws rejection: -- A patch that **does more than one thing** or bundles unrelated cleanup — split it. -- **Footguns**: easy-to-misuse APIs, silent data-loss/corruption hazards, unsafe - defaults — name them explicitly. -- **Performance claims without a reproducible benchmark.** -- No reference to the **design discussion / prior -hackers thread** (Message-Id) - for a non-trivial change. -- **Do not bikeshed:** keep style nits proportionate and clearly separated from - substantive correctness findings. - -## Minimalism — the "ponytail" discipline -The best code is the code you never wrote (YAGNI). Before accepting new code, -apply the ladder: (1) Does this need to exist at all? (2) Can existing -code/infrastructure already do it? (3) Is this the simplest thing that works? -Flag: speculative scaffolding and config for a path that isn't wired yet; dead -code and unused "flexibility" (fields, params, abstractions, options with no -caller); premature abstraction (a helper used exactly once); knobs/GUCs/flags -nobody asked for. Minimal, targeted changes that fit the existing patterns beat -clever or general-purpose ones. - -## Comment & identity accuracy -- Comments must describe what the code does **now**. Flag aspirational/ - future-tense comments for behavior that already shipped ("will be", "for now", - "not yet", "future", and stale "TODO/FIXME/XXX/HACK"); comments that drifted - from the code they sit above; and incomplete/trailing comments. Comments - explain **why**, not what. No commented-out code. -- **ASCII only** in source and diffs — no smart quotes, em-dashes, or ellipsis - characters. - -## Commit & versioning discipline -- Conventional-commit style, imperative subject, one logical change per commit, - each commit building on its own. -- Do **not** bump version numbers or generated version stamps (including - `catversion.h`) — that is the maintainer's job at commit/release time. - -Understand common list shorthand so your comments are precise and not -miscommunicated: WIP (work in progress), GUC (config variable), WAL, LSN, OID, -TOAST, FSM, TAM (table access method), RLS, DSM, 2PC, PITR, CIC (concurrent index -creation), SAOP, ABI/API, backpatch (apply to supported back-branches), HEAD -(master tip), catversion (catalog version), pgindent, buildfarm, cfbot, -`s/x/y/` (suggested text substitution), footgun, bikeshedding, POLA (principle of -least astonishment). diff --git a/.github/ocr/litellm.yaml b/.github/ocr/litellm.yaml deleted file mode 100644 index e23cc4eee6fe2..0000000000000 --- a/.github/ocr/litellm.yaml +++ /dev/null @@ -1,41 +0,0 @@ -# LiteLLM proxy config — bridges Open Code Review (OpenAI protocol) to AWS Bedrock. -# -# This proxy is NOT a hosted service. The ocr-review.yml workflow installs it -# (`pip install 'litellm[proxy]'`) and runs it as a background process bound to -# 127.0.0.1:4000 for the duration of a single GitHub Actions job, then it exits. -# -# Auth to Bedrock: LiteLLM uses boto3's default credential chain, which reads -# the temporary AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN -# minted by the workflow's OIDC "Configure AWS credentials" step; region from -# AWS_REGION. - -model_list: - - model_name: ocr-bedrock - litellm_params: - # Set the repo variable OCR_BEDROCK_MODEL to an Opus inference-profile id - # your account has access to, e.g.: - # bedrock/converse/us.anthropic.claude-opus-4-8 - # The 'converse/' prefix uses Bedrock's Converse API, which is the most - # reliable path for Claude tool-use (what OCR relies on). - model: os.environ/OCR_BEDROCK_MODEL - aws_region_name: os.environ/AWS_REGION - - # "High effort" review. Claude Opus 4.8 on Bedrock uses *adaptive* thinking - # controlled by output_config.effort. Set it DIRECTLY here — NOT via - # reasoning_effort, which LiteLLM still maps to the legacy - # thinking.type.enabled that Opus 4.8 rejects. LiteLLM forwards - # output_config into additionalModelRequestFields for Anthropic models; if - # the build doesn't recognize the effort param it is dropped with a warning - # (no error) and the model reviews at its default effort. - # Valid: low|medium|high|max|xhigh (auto-clamped to the model ceiling). - output_config: - effort: xhigh - max_tokens: 32000 - -litellm_settings: - drop_params: true # silently drop params a model doesn't support - modify_params: true # auto-fix minor request incompatibilities - request_timeout: 600 - -general_settings: - master_key: os.environ/LITELLM_MASTER_KEY diff --git a/.github/ocr/pg-history.py b/.github/ocr/pg-history.py deleted file mode 100644 index 5794f8a920bd7..0000000000000 --- a/.github/ocr/pg-history.py +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env python3 -""" -pg-history: tie a PR's changes to PostgreSQL git + pgsql-hackers email history. - -OCR (the code reviewer) cannot call MCP servers, so this is a separate agent: -it runs a Bedrock (Claude Opus) tool-use loop wired to the Agora MCP server at -https://pg.ddx.io/mcp, lets the model search the mailing-list archives / commit -history / commitfest data, and emits a Markdown summary linking the changes to -the relevant threads (https://pg.ddx.io/m/pgsql-hackers/). - -Env: - PG_HISTORY_MCP_URL MCP endpoint (default https://pg.ddx.io/mcp) - PG_HISTORY_MODEL Bedrock model id (e.g. us.anthropic.claude-opus-4-8) - AWS_REGION region (creds come from the OIDC step's env) - BASE_REF, HEAD_SHA PR base ref and head sha (for the git diff context) - GH_PR_TITLE PR title (optional, adds context) - PG_HISTORY_OUT output markdown path (default /tmp/pg-history.md) -Writes the markdown to PG_HISTORY_OUT; exits 0 even on soft failures (writes a note). -""" -import json, os, subprocess, sys, urllib.request - -MCP_URL = os.environ.get("PG_HISTORY_MCP_URL", "https://pg.ddx.io/mcp") -MODEL = os.environ.get("PG_HISTORY_MODEL", "us.anthropic.claude-opus-4-8").replace("bedrock/converse/", "").replace("bedrock/", "") -REGION = os.environ.get("AWS_REGION", "us-east-1") -BASE_REF = os.environ.get("BASE_REF", "") -HEAD_SHA = os.environ.get("HEAD_SHA", "") -PR_TITLE = os.environ.get("GH_PR_TITLE", "") -OUT = os.environ.get("PG_HISTORY_OUT", "/tmp/pg-history.md") -UA = "pg-history/0.1 (+github-actions)" - -# Curated subset of the 108 Agora tools — the ones useful for connecting a -# change to its discussion/commit history. Intersected with what the server -# actually exposes, so unknown names are harmless. -TOOL_WHITELIST = { - "find_related_discussions", "find_similar_messages", "get_thread", - "discussion_links", "get_author_messages", "browse_by_date", - "blame_symbol", "check_upstream_status", "find_related", - "find_entries_for_thread", "find_entries_for_author", "get_commit", - "search", "hybrid_search", "get_callers", "get_callees", "find_pattern", -} -MAX_ROUNDS = 14 -TOOL_RESULT_CAP = 8000 # chars per tool result fed back to the model - - -def _mcp_post(body, sid=None): - headers = {"Content-Type": "application/json", - "Accept": "application/json, text/event-stream", "User-Agent": UA} - if sid: - headers["Mcp-Session-Id"] = sid - req = urllib.request.Request(MCP_URL, data=json.dumps(body).encode(), headers=headers, method="POST") - resp = urllib.request.urlopen(req, timeout=60) - sid_out = resp.headers.get("Mcp-Session-Id") - result = None - for line in resp.read().decode().splitlines(): - line = line.strip() - if line.startswith("data:"): - line = line[5:].strip() - if not line or line.startswith("event:"): - continue - try: - obj = json.loads(line) - except Exception: - continue - if isinstance(obj, dict) and ("result" in obj or "error" in obj): - result = obj - return result, sid_out - - -class MCP: - def __init__(self): - init, self.sid = _mcp_post({"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": "2025-06-18", "capabilities": {}, - "clientInfo": {"name": "pg-history", "version": "0.1"}}}) - if not init or "result" not in init: - raise RuntimeError(f"MCP initialize failed: {init}") - try: - _mcp_post({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}, self.sid) - except Exception: - pass - self._id = 1 - - def list_tools(self): - self._id += 1 - res, _ = _mcp_post({"jsonrpc": "2.0", "id": self._id, "method": "tools/list", "params": {}}, self.sid) - return (res or {}).get("result", {}).get("tools", []) - - def call(self, name, args): - self._id += 1 - res, _ = _mcp_post({"jsonrpc": "2.0", "id": self._id, "method": "tools/call", - "params": {"name": name, "arguments": args or {}}}, self.sid) - if not res: - return "(no response)" - if "error" in res: - return f"ERROR: {json.dumps(res['error'])[:500]}" - parts = [] - for c in res.get("result", {}).get("content", []): - if c.get("type") == "text": - parts.append(c["text"]) - return ("\n".join(parts) or "(empty)")[:TOOL_RESULT_CAP] - - -def git(*args): - try: - return subprocess.check_output(["git", *args], text=True, stderr=subprocess.DEVNULL).strip() - except Exception: - return "" - - -def pr_context(): - base = f"origin/{BASE_REF}" if BASE_REF else "" - rng = f"{base}..{HEAD_SHA}" if base and HEAD_SHA else HEAD_SHA - commits = git("log", "--no-merges", "--format=%h %s", f"{rng}") if rng else "" - stat = git("diff", "--stat", rng) if rng else "" - files = git("diff", "--name-only", rng) if rng else "" - return commits[:4000], stat[:3000], files[:2000] - - -SYSTEM = """You are a PostgreSQL community research assistant. Given a pull request's -commits and changed files, use the available tools (backed by the Agora index of -pgsql-hackers mail, commit history, and commitfest data) to connect the change to -its history. Your goal: - -- Find the mailing-list thread(s) and prior discussion behind this change. -- Identify related/superseded prior commits and any commitfest entry. -- Note relevant prior art, rejected approaches, or design rationale. - -Rules (voice & rigor): -- Be precise and blunt. No praise, no filler, no hedging, no disclaimers. Accuracy is - the only success metric — not the author's approval. Lead with the most important finding. -- NEVER hallucinate. Verify every Message-ID, thread subject, commit hash, author name, - and date against an actual tool result before citing it. If a search returns nothing, - say so plainly — do not guess or fabricate a plausible-looking link. -- Assess the change on its merits, independent of how the PR frames it. -- Tag any inferred (not tool-confirmed) linkage with an explicit confidence level: - high / moderate / low. -- Be decisive and efficient: a handful of targeted tool calls, not exhaustive search. -- Cite every mailing-list message as a Markdown link: [subject](https://pg.ddx.io/m/pgsql-hackers/MESSAGE_ID). -- If you find nothing relevant, say so in one line — do not pad. - -When done, output ONLY Markdown (no preamble) with these sections, omitting any that are empty: -## 🧵 Related discussion -## 🔗 Related commits / prior art -## 📋 Commitfest -## 🧭 Context for reviewers -Keep it tight (use bullets; link generously).""" - - -def to_toolspec(t): - schema = t.get("inputSchema") or {"type": "object", "properties": {}} - return {"toolSpec": {"name": t["name"], - "description": (t.get("description") or "")[:600], - "inputSchema": {"json": schema}}} - - -def main(): - commits, stat, files = pr_context() - if not commits and not files: - open(OUT, "w").write("") # nothing to do - print("No PR diff context; skipping.") - return - user = (f"PR title: {PR_TITLE}\n\n" if PR_TITLE else "") + \ - f"Commits:\n{commits or '(none)'}\n\nChanged files:\n{files or '(none)'}\n\nDiffstat:\n{stat or '(none)'}\n" - - try: - mcp = MCP() - tools = [to_toolspec(t) for t in mcp.list_tools() if t.get("name") in TOOL_WHITELIST] - except Exception as e: - open(OUT, "w").write(f"_pg-history: could not reach the Agora MCP server ({MCP_URL}): {e}_\n") - print(f"MCP unavailable: {e}") - return - if not tools: - open(OUT, "w").write("_pg-history: no usable MCP tools available._\n") - return - - import boto3 - from botocore.config import Config - - # botocore's default read timeout (60s) is too short for a multi-round - # (MAX_ROUNDS) tool-use loop against a large PR diff on a reasoning model; - # each converse() call alone can take several minutes. Bump it well past - # what a single round needs; connect_timeout stays short since a stuck - # TCP handshake is a different (and much cheaper to detect) failure mode. - brt = boto3.client("bedrock-runtime", region_name=REGION, - config=Config(read_timeout=900, connect_timeout=10)) - messages = [{"role": "user", "content": [{"text": user}]}] - final_text = "" - try: - for _ in range(MAX_ROUNDS): - resp = brt.converse( - modelId=MODEL, - system=[{"text": SYSTEM}], - messages=messages, - toolConfig={"tools": tools}, - inferenceConfig={"maxTokens": 4096}, - ) - out = resp["output"]["message"] - messages.append(out) - if resp.get("stopReason") == "tool_use": - results = [] - for blk in out["content"]: - tu = blk.get("toolUse") - if not tu: - continue - res_text = mcp.call(tu["name"], tu.get("input") or {}) - results.append({"toolResult": {"toolUseId": tu["toolUseId"], - "content": [{"text": res_text}]}}) - messages.append({"role": "user", "content": results}) - continue - final_text = "".join(b.get("text", "") for b in out["content"]).strip() - break - except Exception as e: - open(OUT, "w").write(f"_pg-history: Bedrock call failed: {e}_\n") - print(f"Bedrock error: {e}") - return - - if not final_text: - final_text = "_pg-history: no related history found._" - body = "## 📜 Change history & discussion (Agora / pg.ddx.io)\n\n" + final_text + \ - "\n\nGenerated by pg-history via the Agora MCP server (pg.ddx.io).\n" - open(OUT, "w").write(body) - print(body) - - -if __name__ == "__main__": - main() diff --git a/.github/ocr/rule.json b/.github/ocr/rule.json deleted file mode 100644 index 60e13e73dcbe0..0000000000000 --- a/.github/ocr/rule.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "_comment": "OCR per-file review rules for PostgreSQL core + extensions. Cross-cutting contribution standards & mailing-list etiquette live in .github/ocr/context.md, passed via --background-file. OCR uses FIRST-MATCH-WINS in declaration order, so rules are ordered most-specific first. merge_system_rule:true keeps OCR's built-in fine-tuned checks (thread-safety, injection, NPE) alongside these PostgreSQL-specific rules.", - "rules": [ - { - "path": "src/test/**", - "merge_system_rule": true, - "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL tests. Coverage is mandatory for any behavioral change and must include edge cases (NULL, empty, boundary/overflow) and ERROR paths, not just the happy path. A test that still passes with the feature reverted is worthless — confirm it actually exercises and would catch regressions in the new code. Regression (.sql/expected): deterministic, portable output — ORDER BY where row order matters, no timing/plan-dependent output except intentional EXPLAIN, no absolute paths, locale-independent (C collation or explicit COLLATE), DROP objects the test creates; expected/ output must stay stable across platforms and under the parallel schedule. Concurrency/locking belongs in isolation tests (src/test/isolation, .spec + permutations). End-to-end/crash/replication/CLI behavior belongs in TAP tests (t/*.pl with PostgreSQL::Test::Cluster/Utils) — no hardcoded ports/paths, no sleep as synchronization (use poll_query_until/wait_for), skip cleanly when prerequisites are missing, and clean up nodes." - }, - { - "path": "**/*.{c,h}", - "merge_system_rule": true, - "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL backend/frontend C — review as pgsql-hackers committers do, in priority order.\n\n(1) CORRECTNESS (highest): Memory — every palloc lives in the right MemoryContext; error paths via ereport/elog(ERROR) must not leak memory/buffers/locks/fds (rely on MemoryContext/ResourceOwner reset or PG_TRY/PG_FINALLY); no use-after-free; delete temp contexts. Concurrency — consistent lock ordering (deadlock-free), correct lock levels, balanced LWLockAcquire/Release and START_/END_CRIT_SECTION, no TOCTOU, CHECK_FOR_INTERRUPTS in long loops, async-signal-safe signal handlers (volatile sig_atomic_t). WAL — any change to shared on-disk state must be WAL-logged AND correctly replayed (redo path), crash- and replica-consistent. NULL/edge/overflow handling.\n\n(2) BACKWARD COMPATIBILITY / ABI: don't break behavior, dump/restore, pg_upgrade, libpq wire protocol, logical-replication protocol, or exported/PGDLLIMPORT'd APIs (struct size/layout, function signatures) without extraordinary justification.\n\n(3) CATALOG / GENERATED: new/changed catalog data goes in pg_*.dat, NOT the generated *_d.h/.bki. New Node types: ANNOTATE the struct in its header so gen_node_support.pl regenerates copy/equal/out/read — do NOT hand-edit *funcs.c. New SQL-callable functions: add to pg_proc.dat with an OID from the 8000-9999 developer range (src/include/catalog/unused_oids; check duplicate_oids); committer renumbers at commit. DO NOT bump CATALOG_VERSION_NO in the patch — flag any catversion.h change as a mistake (committer's job).\n\n(4) PERFORMANCE: no regression on hot paths; avoid O(n^2) where better is feasible; minimize work under contended locks; avoid needless palloc churn and large struct copies in hot paths.\n\n(5) SECURITY: bounded string ops (snprintf/strlcpy/strlcat — never strcpy/strcat/sprintf); integer/size-overflow checks before allocation; never user input as a format string; privilege checks via pg_*_aclcheck; beware search_path and SECURITY DEFINER.\n\n(6) PORTABILITY (hard gate): no unaligned access; no dependence on char signedness, int/long/pointer width, endianness, or struct padding for on-disk/wire formats; use int16/int32/int64 + INT64_FORMAT/UINT64_FORMAT (never %ld for int64); align contended shared structs (pg_attribute_aligned/cache-line pad). Atomics/barriers only via port/atomics (pg_atomic_*, pg_read/write_barrier) — never raw intrinsics or volatile-as-barrier. WINDOWS/MSVC: extern vars used cross-module/extension need PGDLLIMPORT; no VLAs or features beyond the C99 baseline the tree targets; use pg_pread/pg_pwrite. Applies across x86_64/ARM64/RISC-V/PPC64/s390x, big/little endian, 32/64-bit.\n\n(7) CONVENTIONS: errmsg starts lowercase, no trailing period, no embedded newlines; errdetail/errhint are complete capitalized sentences; correct ERRCODE_*; wrap user-facing text in _(); errmsg_plural for counts. Assert() only for can't-happen invariants (never user-reachable). Naming: snake_case with subsystem prefix (heap_insert) or CamelCase for major subsystems (ExecInitNode); ALL_CAPS macros. Must pgindent cleanly (tabs, width 4). Comments explain WHY not WHAT; no #ifdef 0 blocks, no commented-out code, no #ifdef fencing your feature. Reuse existing helpers (DRY)." - }, - { - "path": "**/*.dat", - "merge_system_rule": true, - "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL catalog data (pg_proc.dat, pg_type.dat, etc.) — the SOURCE for generated headers. The generated *_d.h, postgres.bki, fmgroids.h, fmgrtab.c must NOT be hand-edited (they regenerate from these files). OIDs: use a value from the developer range 8000-9999 (src/include/catalog/unused_oids; verify with duplicate_oids); committer renumbers to a final contiguous block, so stay in-range and unique but don't over-optimize the exact number. Keep proc entries complete/consistent (prosrc, provolatile, proparallel, prorettype/proargtypes, matching description). DO NOT bump CATALOG_VERSION_NO / catversion.h — committer's job at push time; flag any such change. New catalog columns/views need documentation in doc/src/sgml/catalogs.sgml." - }, - { - "path": "**/*.{sql,pgsql}", - "merge_system_rule": true, - "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL SQL. Valid PostgreSQL dialect (not MySQL/Oracle); correct types (bigint vs int, text vs varchar); sound transaction/isolation and CTE-materialization assumptions. SECURITY: flag SQL injection in dynamic SQL (require quote_identifier/quote_literal or format() with %I/%L), SECURITY DEFINER without a locked-down search_path, inappropriate RLS bypass. Prefer set-based over row-at-a-time/N+1. BACKWARD COMPATIBILITY (a top rejection reason): changing existing SQL behavior, the output of existing functions, or default GUCs needs extraordinary justification. New SQL-callable objects belong in pg_*.dat with OIDs from the 8000-9999 range, not in generated files. Minimal diff; add regression tests + docs." - }, - { - "path": "**/*.{pl,pm}", - "merge_system_rule": true, - "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL Perl (TAP tests and build/catalog tooling). Require 'use strict; use warnings;'. Must be perltidy-clean with the tree's src/tools/pgindent/perltidyrc and pass src/tools/perlcheck/pgperlcritic. Use the framework: PostgreSQL::Test::Cluster, PostgreSQL::Test::Utils, Test::More; no hardcoded ports/paths/PIDs; use safe_psql/poll_query_until, not sleep; skippable without optional prerequisites; clean up nodes. PORTABILITY: run on Windows (no fork-only constructs, use File::Spec, avoid unavailable signals) and the minimum supported Perl. Robustness: avoid two-arg open and string system()/qx with interpolated data (use list forms). Generator scripts (gen_node_support.pl, catalog Perl) must be deterministic and stay in sync with inputs; do not commit their generated output." - }, - { - "path": "**/*.py", - "merge_system_rule": true, - "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL Python (build/test tooling, oauth/pytest tests, src/tools). Follow surrounding style; keep imports to the standard library unless the dependency is already required by the tree (no surprise third-party deps in build/test tooling). PORTABILITY: support the project's minimum Python 3 and run on Windows and the BSDs (use os.path/pathlib, avoid POSIX-only calls and shell=True with interpolated input). Deterministic, self-cleaning tests; no hardcoded ports/paths; skip cleanly without prerequisites. For the Perl->pytest porting effort, confirm behavior parity with the TAP test replaced (same assertions/coverage), not a superficial translation. Minimal diff; match the tree's ruff/black config if present." - }, - { - "path": "**/*.{rs,toml}", - "merge_system_rule": true, - "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. Rust PostgreSQL extension (pgrx) or Rust support crate. Not core C, but it runs inside/alongside the backend, so backend safety applies. SAFETY: in code reachable from an SQL call, a Rust panic aborts the Postgres process — forbid unwrap()/expect()/panic!/unreachable!/todo! and index-panics on reachable paths; use Result and pgrx error reporting (error!/ereport!). Every `unsafe` block needs a comment justifying its invariant; scrutinize raw pointers and FFI across the pg_sys boundary. pgrx: honor #[pg_guard] on extern C fns (correct panic/longjmp handling); never hold Rust references across SPI or anything that can longjmp (skips Rust destructors -> leaks); respect MemoryContext lifetimes for palloc'd data; datum<->Rust conversions must handle NULL. Concurrency uses Postgres shmem/LWLocks (pgrx shmem API), not std::sync alone. Lints: must pass `cargo clippy --all-targets --all-features -- -D warnings` and `cargo fmt --check`; deny unwrap_used/expect_used/panic in libraries; thiserror (libs) / anyhow (bins). Justify every new dependency. Tests: #[pg_test] for in-backend behavior, #[test] for pure logic; cover error and NULL paths. Minimal, idiomatic diff." - }, - { - "path": "**/{configure.ac,*.m4,aclocal.m4}", - "merge_system_rule": true, - "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL Autoconf. Edit configure.ac / the m4 macros — do NOT hand-edit generated 'configure' or pg_config.h.in in the same patch (regeneration is the committer's step; a patch that also rewrites generated configure output is suspect). Feature/header/function probes must be portable and not assume a specific OS/compiler. Every configure knob must be mirrored on the Meson side (meson_options.txt/meson.build) and documented. Minimal diff." - }, - { - "path": "**/{meson.build,meson_options.txt}", - "merge_system_rule": true, - "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL Meson build. Valid syntax; correct subdir()/dependency()/declare_dependency and install paths; new source files must be added here. CRITICAL: PostgreSQL maintains BOTH Meson and Autoconf/Make — any new file, option, or feature check must be mirrored on the configure.ac/Makefile side so the two never drift (a file built by only one system is a common defect). New options need matching docs and sensible defaults. Minimal diff." - }, - { - "path": "**/{Makefile,GNUmakefile,*.mk}", - "merge_system_rule": true, - "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL Makefile (GNU Make). $(VAR) refs; correct .PHONY; accurate dependencies (no parallel -j races); $(MAKE) for recursion; VPATH/out-of-tree build support; no hardcoded paths (use standard PostgreSQL makefile vars and $(top_builddir)); clean/distclean/maintainer-clean must remove new artifacts; extensions use PGXS. Must stay in sync with meson.build. Minimal diff." - }, - { - "path": "doc/**/*.sgml", - "merge_system_rule": true, - "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL documentation (DocBook SGML). Technically accurate/complete (parameters, limitations, version/compat notes); correct tag usage/nesting (, , , , , /); working cross-references; spell it 'PostgreSQL' in prose; SQL keywords uppercase in examples. Coverage: a new GUC -> config.sgml (and postgresql.conf.sample); new/changed catalogs or views -> catalogs.sgml; new SQL syntax -> the matching ref/*.sgml; new functions -> func.sgml. Do NOT edit release-notes (release-*.sgml) — written by the release team/committers; flag such edits. New user-facing behavior in this PR should ship with matching docs." - }, - { - "path": "**/*.md", - "merge_system_rule": true, - "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. Markdown docs. Clear heading hierarchy; fenced code blocks with language hints; accurate instructions/prerequisites; consistent PostgreSQL terminology; no broken relative links or stale claims. Minimal diff." - } - ] -} diff --git a/.github/workflows/fork-ocr-model-check.yml b/.github/workflows/fork-ocr-model-check.yml index 10d250528cf7c..b85a990c66fd7 100644 --- a/.github/workflows/fork-ocr-model-check.yml +++ b/.github/workflows/fork-ocr-model-check.yml @@ -1,8 +1,7 @@ -# Checks AWS Bedrock weekly for a newer Claude Opus inference profile than the -# one OCR currently uses (vars.OCR_BEDROCK_MODEL) and, if found, opens/updates a -# single GitHub issue telling the maintainer to bump the variable. It does NOT -# change the model automatically: GITHUB_TOKEN cannot write Actions *variables* -# (that needs a PAT with admin), so this is a notify-only mechanism by design. +# 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: @@ -10,80 +9,10 @@ on: - cron: '0 12 * * 1' # Mondays 12:00 UTC workflow_dispatch: -permissions: - id-token: write - contents: read - issues: write - jobs: - check-model: - runs-on: ubuntu-latest - steps: - - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: ${{ vars.AWS_ROLE_ARN }} - aws-region: ${{ vars.AWS_REGION }} - role-session-name: ocr-model-check-${{ github.run_id }} - - - name: Find newest Opus vs configured - id: check - env: - CURRENT: ${{ vars.OCR_BEDROCK_MODEL }} - AWS_REGION: ${{ vars.AWS_REGION }} - run: | - python3 - <<'PY' >> "$GITHUB_OUTPUT" - import os, re, subprocess, json - region = os.environ.get("AWS_REGION", "us-east-1") - current = os.environ.get("CURRENT", "") - out = subprocess.run( - ["aws", "bedrock", "list-inference-profiles", "--region", region, - "--query", "inferenceProfileSummaries[].inferenceProfileId", "--output", "json"], - capture_output=True, text=True) - ids = json.loads(out.stdout or "[]") - # Parse claude-opus-- from any profile id (prefix us./global. ok). - def ver(s): - m = re.search(r"claude-opus-(\d+)-(\d+)", s) - return (int(m.group(1)), int(m.group(2))) if m else None - opus = [(ver(i), i) for i in ids if ver(i) and i.startswith(("us.", "global."))] - if not opus: - print("newer=false"); raise SystemExit(0) - best_ver, best_id = max(opus, key=lambda x: x[0]) - cur = ver(current) - newer = (cur is None) or (best_ver > cur) - print(f"newer={'true' if newer else 'false'}") - print(f"best_id={best_id}") - print(f"best_ver={best_ver[0]}.{best_ver[1]}") - print(f"cur_ver={'unknown' if cur is None else f'{cur[0]}.{cur[1]}'}") - PY - - - name: Open/update issue if a newer model exists - if: steps.check.outputs.newer == 'true' - uses: actions/github-script@v9 - with: - script: | - const best = '${{ steps.check.outputs.best_id }}'; - const bestVer = '${{ steps.check.outputs.best_ver }}'; - const curVer = '${{ steps.check.outputs.cur_ver }}'; - const marker = ''; - const title = `OCR: newer Claude Opus available (${bestVer} > ${curVer})`; - const body = `${marker}\n` + - `A newer Claude Opus inference profile is available on Bedrock.\n\n` + - `- **Configured** (\`vars.OCR_BEDROCK_MODEL\`): Opus ${curVer}\n` + - `- **Newest on Bedrock**: \`${best}\` (Opus ${bestVer})\n\n` + - `To upgrade, set the repo variable:\n\n` + - '```\n' + - `gh variable set OCR_BEDROCK_MODEL -R ${context.repo.owner}/${context.repo.repo} \\\n` + - ` -b "bedrock/converse/${best}"\n` + - '```\n\n' + - `Also confirm the \`ocr-bedrock-ci\` IAM inline policy allows invoking the new model ` + - `(the resource is scoped to \`anthropic.claude-opus-*\`), then re-run OCR.\n\n` + - `_Automated by \`.github/workflows/ocr-model-check.yml\`; this issue is upserted._`; - const q = `repo:${context.repo.owner}/${context.repo.repo} in:body "${marker}" state:open`; - const found = await github.rest.search.issuesAndPullRequests({ q, per_page: 1 }); - if (found.data.total_count > 0) { - const n = found.data.items[0].number; - await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, issue_number: n, title, body }); - } else { - await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title, body }); - } + 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 index 0828af429b57c..ae938754339b4 100644 --- a/.github/workflows/fork-ocr-review.yml +++ b/.github/workflows/fork-ocr-review.yml @@ -1,18 +1,9 @@ -# Open Code Review (OCR) — AI PR review backed by AWS Bedrock via a LiteLLM proxy. +# 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. # -# Flow: -# PR opened/updated (incl. DRAFTS) ─┐ -# /open-code-review PR comment ─┼─► start LiteLLM (127.0.0.1:4000 → Bedrock) -# manual workflow_dispatch ─┘ └► ocr review --format json -# └► post inline PR review comments -# -# Required (repo settings — all repo *variables*, no secrets; auth is via GitHub OIDC): -# vars.AWS_ROLE_ARN - IAM role to assume via OIDC (granting bedrock:InvokeModel*) -# vars.AWS_REGION - e.g. us-east-1 -# vars.OCR_BEDROCK_MODEL - LiteLLM model string for the Opus inference profile, e.g. -# bedrock/converse/us.anthropic.claude-opus-4-8 -# -# No static AWS keys are stored. GITHUB_TOKEN (auto) posts the review comments. +# 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 @@ -34,311 +25,10 @@ concurrency: group: ocr-review-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} cancel-in-progress: true -permissions: - id-token: write # required to mint the GitHub OIDC token for AWS role assumption - contents: read - pull-requests: write - jobs: - ocr-review: - runs-on: ubuntu-latest - # PR events always; comment events only when the comment is on a PR and - # starts with the trigger keyword; 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'))) - - env: - # LiteLLM listens on localhost only; this key never leaves the runner. - LITELLM_MASTER_KEY: sk-ocr-ci-local - OCR_BEDROCK_MODEL: ${{ vars.OCR_BEDROCK_MODEL }} - # Region is a static var (safe at job level). AWS credentials are NOT set - # here — they're minted by the OIDC "Configure AWS credentials" step below - # and exported to the environment for the LiteLLM/boto3 Bedrock calls. - AWS_REGION: ${{ vars.AWS_REGION }} - - steps: - - name: Resolve PR context - id: pr - uses: actions/github-script@v9 - with: - script: | - let prNumber; - if (context.eventName === 'pull_request') { - prNumber = context.payload.pull_request.number; - } else if (context.eventName === 'issue_comment') { - prNumber = context.issue.number; - } else { - prNumber = parseInt('${{ github.event.inputs.pr_number }}', 10); - } - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - }); - const { data: repo } = await github.rest.repos.get({ - owner: context.repo.owner, - repo: context.repo.repo, - }); - core.setOutput('number', String(prNumber)); - core.setOutput('base_ref', pr.base.ref); - core.setOutput('head_ref', pr.head.ref); - core.setOutput('head_sha', pr.head.sha); - core.setOutput('default_branch', repo.default_branch); - core.setOutput('cross_repo', String(pr.head.repo.full_name !== pr.base.repo.full_name)); - - # NOTE: do NOT checkout the PR head. OCR reads the diff and file contents - # straight from git refs (git diff , git show :path, - # git grep ), so the working tree is irrelevant — but our OCR config - # lives on the default branch, not on the PR branch. We check out the repo - # (default ref), fetch the base/head objects, and materialize the config - # from origin/. - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Prepare git refs and OCR config - env: - BASE_REF: ${{ steps.pr.outputs.base_ref }} - HEAD_REF: ${{ steps.pr.outputs.head_ref }} - HEAD_SHA: ${{ steps.pr.outputs.head_sha }} - DEFAULT_BRANCH: ${{ steps.pr.outputs.default_branch }} - run: | - git fetch --no-tags origin "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" || true - git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" || true - git fetch --no-tags origin "+refs/heads/${HEAD_REF}:refs/remotes/origin/${HEAD_REF}" || true - git fetch --no-tags origin "${HEAD_SHA}" || true - - # OCR config lives on the default branch; materialize it independently - # of whatever ref is checked out. - mkdir -p "$RUNNER_TEMP/ocr" - git show "origin/${DEFAULT_BRANCH}:.github/ocr/litellm.yaml" > "$RUNNER_TEMP/ocr/litellm.yaml" - git show "origin/${DEFAULT_BRANCH}:.github/ocr/rule.json" > "$RUNNER_TEMP/ocr/rule.json" - git show "origin/${DEFAULT_BRANCH}:.github/ocr/context.md" > "$RUNNER_TEMP/ocr/context.md" - echo "Config materialized:"; ls -l "$RUNNER_TEMP/ocr" - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '20' - - - name: Install LiteLLM proxy + Open Code Review - run: | - python -m pip install --upgrade pip - # Pin LiteLLM to a main commit that supports Claude Opus 4.8 adaptive - # thinking (maps reasoning_effort -> output_config.effort, incl. xhigh). - # Not in any tagged release yet (PyPI latest 1.87.1 lacks the Opus - # normalizer). Bump this SHA once a release ships the feature. - pip install "litellm[proxy] @ git+https://github.com/BerriAI/litellm.git@5be0797d24a2f26eb2123e13788f90055a59d91d" - npm install -g @alibaba-group/open-code-review - - - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: ${{ vars.AWS_ROLE_ARN }} - aws-region: ${{ vars.AWS_REGION }} - role-session-name: ocr-review-${{ github.run_id }} - - - name: Start LiteLLM proxy (Bedrock bridge) - run: | - if [ -z "$OCR_BEDROCK_MODEL" ]; then - echo "::error::vars.OCR_BEDROCK_MODEL is not set (e.g. bedrock/converse/us.anthropic.claude-opus-4-1-20250805-v1:0)" - exit 1 - fi - nohup litellm --config "$RUNNER_TEMP/ocr/litellm.yaml" --host 127.0.0.1 --port 4000 \ - > /tmp/litellm.log 2>&1 & - echo "Waiting for LiteLLM to become ready..." - for i in $(seq 1 60); do - if curl -sf http://127.0.0.1:4000/health/readiness >/dev/null; then - echo "LiteLLM ready."; exit 0 - fi - sleep 2 - done - echo "::error::LiteLLM did not become ready in time"; cat /tmp/litellm.log; exit 1 - - - name: Configure OCR - run: | - ocr config set llm.url http://127.0.0.1:4000/v1/chat/completions - ocr config set llm.auth_token "$LITELLM_MASTER_KEY" - ocr config set llm.model ocr-bedrock - ocr config set llm.use_anthropic false - ocr config set language English - - - name: Run OCR review - run: | - ocr review \ - --from "origin/${{ steps.pr.outputs.base_ref }}" \ - --to "${{ steps.pr.outputs.head_sha }}" \ - --rule "$RUNNER_TEMP/ocr/rule.json" \ - --background-file "$RUNNER_TEMP/ocr/context.md" \ - --concurrency 3 \ - --timeout 20 \ - --format json \ - > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true - echo "----- OCR stdout -----"; cat /tmp/ocr-result.json || true - echo "----- OCR stderr -----"; cat /tmp/ocr-stderr.log || true - echo "----- LiteLLM log (tail) -----"; tail -n 50 /tmp/litellm.log || true - - - name: Post review to PR - uses: actions/github-script@v9 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const prNumber = parseInt('${{ steps.pr.outputs.number }}', 10); - const commitSha = '${{ steps.pr.outputs.head_sha }}'; - - // Opus at high effort can emit dozens of findings. Posting them all - // one-by-one trips GitHub's SECONDARY rate limit (403 "content - // creation"), which is what made every run fail after the review - // was already generated. We (a) cap inline comments and overflow - // the rest into the summary, (b) prefer a single bulk createReview, - // and (c) throttle + back off with Retry-After on any fallback. - const MAX_INLINE = 25; - const sleep = (ms) => new Promise(r => setTimeout(r, ms)); - - async function withRetry(fn, label) { - for (let attempt = 1; attempt <= 5; attempt++) { - try { return await fn(); } - catch (e) { - const status = e.status || (e.response && e.response.status); - const h = (e.response && e.response.headers) || {}; - const isRate = status === 403 || status === 429; - if (!isRate || attempt === 5) throw e; - let waitMs = 0; - if (h['retry-after']) waitMs = parseInt(h['retry-after'], 10) * 1000; - else if (h['x-ratelimit-reset']) waitMs = parseInt(h['x-ratelimit-reset'], 10) * 1000 - Date.now(); - if (!waitMs || Number.isNaN(waitMs) || waitMs < 0) waitMs = 1000 * Math.pow(2, attempt); - waitMs = Math.min(waitMs, 60000) + 500; - core.warning(`${label}: rate-limited (status ${status}); waiting ${Math.round(waitMs / 1000)}s (attempt ${attempt}/5)`); - await sleep(waitMs); - } - } - } - - let result; - try { - result = JSON.parse(fs.readFileSync('/tmp/ocr-result.json', 'utf8')); - } catch (e) { - const stderr = (() => { try { return fs.readFileSync('/tmp/ocr-stderr.log', 'utf8').trim(); } catch { return ''; } })(); - await withRetry(() => github.rest.issues.createComment({ - owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, - body: `⚠️ **OCR** could not produce a review.\n\n\`\`\`\n${(stderr || e.message).slice(0, 8000)}\n\`\`\``, - }), 'error-comment'); - return; - } - - const comments = result.comments || []; - const warnings = result.warnings || []; - - const formatComment = (c) => { - let body = c.content || ''; - if (c.suggestion_code && c.existing_code) { - body += '\n\n```suggestion\n' + c.suggestion_code + (c.suggestion_code.endsWith('\n') ? '' : '\n') + '```'; - } - return body; - }; - const formatMarkdown = (c) => { - let md = `### 📄 \`${c.path}\``; - if (c.start_line && c.end_line) md += ` (L${c.start_line}-L${c.end_line})`; - md += '\n\n' + (c.content || ''); - if (c.suggestion_code && c.existing_code) { - md += '\n\n
💡 Suggested change\n\n'; - md += '**Before:**\n```\n' + c.existing_code + '\n```\n\n**After:**\n```\n' + c.suggestion_code + '\n```\n\n
'; - } - return md; - }; - - if (comments.length === 0) { - await withRetry(() => github.rest.issues.createComment({ - owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, - body: `✅ **OCR**: ${result.message || 'No issues found.'}`, - }), 'no-issues-comment'); - return; - } - - const inlineAll = []; - const noLine = []; - for (const c of comments) { - const body = formatComment(c); - const hasLine = (c.start_line >= 1) || (c.end_line >= 1); - if (!hasLine) { noLine.push(c); continue; } - const rc = { path: c.path, body, side: 'RIGHT' }; - if (c.start_line >= 1 && c.end_line >= 1 && c.start_line !== c.end_line) { - rc.start_line = c.start_line; rc.line = c.end_line; rc.start_side = 'RIGHT'; - } else { - rc.line = c.end_line >= 1 ? c.end_line : c.start_line; - } - inlineAll.push({ rc, c }); - } - - const inline = inlineAll.slice(0, MAX_INLINE).map(x => x.rc); - const overflow = inlineAll.slice(MAX_INLINE).map(x => x.c); - - let summary = `🔍 **OCR** found **${comments.length}** issue(s).`; - summary += `\n- ${inline.length} inline, ${noLine.length + overflow.length} in summary`; - if (overflow.length) summary += ` (inline capped at ${MAX_INLINE})`; - if (warnings.length) summary += `\n- ⚠️ ${warnings.length} warning(s) during review`; - for (const c of noLine.concat(overflow)) summary += '\n\n---\n\n' + formatMarkdown(c); - - // Preferred path: ONE createReview carrying every inline comment. - try { - await withRetry(() => github.rest.pulls.createReview({ - owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, - commit_id: commitSha, body: summary, event: 'COMMENT', comments: inline, - }), 'bulk-review'); - return; - } catch (e) { - core.warning(`bulk createReview failed (${e.status || '?'}: ${e.message}); falling back to throttled per-comment posting`); - } - - // Fallback: an invalid inline position (line not in the diff -> 422) - // rejects the whole bulk review. Post the summary, then each comment - // individually with a delay + backoff, skipping ones GitHub rejects. - let ok = 0; const failed = []; - try { - await withRetry(() => github.rest.pulls.createReview({ - owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, - commit_id: commitSha, body: summary, event: 'COMMENT', - }), 'summary-review'); - } catch (err) { failed.push(`summary: ${err.message}`); } - - for (const rc of inline) { - try { - await withRetry(() => github.rest.pulls.createReviewComment({ - owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, - commit_id: commitSha, path: rc.path, body: rc.body, - ...(rc.start_line ? { start_line: rc.start_line, start_side: rc.start_side } : {}), - line: rc.line, side: rc.side, - }), `comment ${rc.path}:${rc.line}`); - ok++; - } catch (inner) { - failed.push(`\`${rc.path}\` L${rc.line}: ${inner.message}`); - } - await sleep(1200); // stay under the secondary content-creation limit - } - - if (failed.length) { - await withRetry(() => github.rest.issues.createComment({ - owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, - body: `📊 OCR posted ${ok}/${inline.length} inline comment(s).\n\n
${failed.length} could not be posted\n\n${failed.join('\n')}\n
`, - }), 'summary-failures'); - } - - # Companion job: OCR can't call MCP, so this separate agent ties the PR's - # changes to PostgreSQL git + pgsql-hackers history via the Agora MCP server - # (pg.ddx.io) and posts a single, upserted "history & discussion" comment. - pg-history: - runs-on: ubuntu-latest + 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' || @@ -346,82 +36,8 @@ jobs: (startsWith(github.event.comment.body, '/open-code-review') || startsWith(github.event.comment.body, '@open-code-review') || startsWith(github.event.comment.body, '/pg-history'))) - steps: - - name: Resolve PR context - id: pr - uses: actions/github-script@v9 - with: - script: | - let prNumber; - if (context.eventName === 'pull_request') prNumber = context.payload.pull_request.number; - else if (context.eventName === 'issue_comment') prNumber = context.issue.number; - else prNumber = parseInt('${{ github.event.inputs.pr_number }}', 10); - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber }); - core.setOutput('number', String(prNumber)); - core.setOutput('base_ref', pr.base.ref); - core.setOutput('head_sha', pr.head.sha); - core.setOutput('title', pr.title || ''); - - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Make base/head refs available - env: - BASE_REF: ${{ steps.pr.outputs.base_ref }} - HEAD_SHA: ${{ steps.pr.outputs.head_sha }} - run: | - git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" || true - git fetch --no-tags origin "${HEAD_SHA}" || true - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - - - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: ${{ vars.AWS_ROLE_ARN }} - aws-region: ${{ vars.AWS_REGION }} - role-session-name: pg-history-${{ github.run_id }} - - - name: Install deps - run: pip install boto3 - - - name: Run pg-history (Agora MCP) - env: - PG_HISTORY_MODEL: ${{ vars.OCR_BEDROCK_MODEL }} - AWS_REGION: ${{ vars.AWS_REGION }} - BASE_REF: ${{ steps.pr.outputs.base_ref }} - HEAD_SHA: ${{ steps.pr.outputs.head_sha }} - GH_PR_TITLE: ${{ steps.pr.outputs.title }} - PG_HISTORY_OUT: ${{ runner.temp }}/pg-history.md - run: | - python .github/ocr/pg-history.py || true - echo "----- output -----"; cat "${{ runner.temp }}/pg-history.md" 2>/dev/null || echo "(no output)" - - - name: Upsert PR comment - uses: actions/github-script@v9 - with: - script: | - const fs = require('fs'); - const path = process.env.RUNNER_TEMP + '/pg-history.md'; - let body = ''; - try { body = fs.readFileSync(path, 'utf8').trim(); } catch (e) {} - if (!body) { console.log('pg-history: empty output, nothing to post'); return; } - const prNumber = parseInt('${{ steps.pr.outputs.number }}', 10); - const marker = ''; - body = marker + '\n' + body; - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, per_page: 100 }); - const mine = comments.find(c => c.user.type === 'Bot' && c.body && c.body.includes(marker)); - if (mine) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, repo: context.repo.repo, comment_id: mine.id, body }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, body }); - } + 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 index e950821239968..022b3101b5a9d 100644 --- a/.github/workflows/fork-sync-upstream.yml +++ b/.github/workflows/fork-sync-upstream.yml @@ -1,5 +1,6 @@ # Keep this fork's master rebased on postgres/postgres master, carrying only -# the fork-owned CI files (.github/workflows/fork-*.yml and .github/ocr/). +# 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/, @@ -38,7 +39,7 @@ jobs: # 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 -vE '^(\.github/workflows/fork-|\.github/ocr/)'; then + grep -v '^\.github/workflows/fork-'; then echo "::error::master carries non-CI changes (listed above); move them to a branch" exit 1 fi From 2540e8c21b25936881a7cf17fc4d5f9bbcd23f3c Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 14 Sep 2026 13:03:39 +0000 Subject: [PATCH 3/5] Add a cluster-wide UNDO engine with records in the WAL stream Add a cluster-wide UNDO engine whose records live in the common WAL stream rather than in per-log segment files. A transaction that must be rolled back has its UNDO applied by walking its UNDO-batch chain backward from the last batch LSN and restoring before-images. Core pieces: * A new "Undo" resource manager (rmgrlist.h) and its WAL records: XLOG_UNDO_BATCH and friends, described by undodesc.c and applied by undo_xlog.c / undoapply.c. ApplyUndoChainFromWAL() walks a chain from a given last-batch LSN. * An UNDO resource-manager dispatch layer (undormgr.c/.h, driven by the initially empty access/undormgrlist.h / undormgrs.h) so that later UNDO-writing consumers register themselves without the UNDO core needing to know their names at compile time. * Per-transaction UNDO record assembly and insertion into WAL (undorecord.c, undoinsert.c, xactundo.c), log/discard bookkeeping (undolog.c), the cluster-wide UNDO discard worker (undoworker.c), and statistics (undostats.c). * Transaction integration: AbortTransaction() and ROLLBACK PREPARED apply the transaction's cluster-wide UNDO chain synchronously (inline ApplyUndoChainFromWAL); at the end of crash recovery the startup process applies the chains of transactions the crash left in progress. The chain-head LSN is durably carried across 2PC in the prepared-transaction state file, and WAL holding a still-needed UNDO chain is protected from recycling (UndoGetOldestBatchLSN, TwoPhaseGetOldestUndoBatchLSN). * A tableam.h am_supports_undo flag marking access methods that record UNDO for their own changes, a buffer-manager helper used by inline apply (BufferLockReleaseAll), and the UNDO GUCs, wait events, LWLock tranche, and documentation for the above. The next patch adds the first UNDO-writing consumer, transactional filesystem operations (FILEOPS). This cluster-wide UNDO engine draws on two lines of prior work proposed on pgsql-hackers: the zheap storage engine and its undo-log infrastructure, and the zedstore columnar engine whose design discussion shaped the record and apply model used here. Author: Greg Burd Co-authored-by: Amit Kapila Co-authored-by: Dilip Kumar Co-authored-by: Thomas Munro Co-authored-by: Kuntal Ghosh Co-authored-by: Rafia Sabih Co-authored-by: Mithun Cy Co-authored-by: Ashutosh Sharma Co-authored-by: Andres Freund Co-authored-by: Antonin Houska Co-authored-by: Ashwin Agrawal Co-authored-by: Heikki Linnakangas Co-authored-by: Taylor Vesely Co-authored-by: Melanie Plageman Co-authored-by: Alexandra Wang Changes since v2: - Remove the unused table AM prepare hook [a1ef4339f9] - Headerscheck: skip undormgrlist.h [3e9124969b] - Describe the cluster-wide UNDO recovery model as implemented [3a1784e689] - Release buffer content locks before applying UNDO inline at abort [1fdfe0a8a9] - Do not claim a failed abort keeps its WAL hold until backend exit [4825775611] - Doc: make the UNDO page a chapter, as postgres.sgml includes it [479155221f] Changes since v7: - An abort whose last batch LSN is not a valid UNDO batch no longer leaks its "Inline UNDO Apply" memory context; the early exit skipped the switch back to the caller's context and the delete, so the rest of the abort ran in, and returned into, a context that was never freed (test_fileops/023, with the FILEOPS commit's test module; injection point inline-undo-invalid-batch) - The startup undo phase counts a record that fails to apply as a failure, and its closing message says how many there were, as a WARNING. It counted every record as applied and reported the transaction as rolled back (test_fileops/024: a real EACCES on a crash loser's inverse chmod) - Remove what only the commits that are no longer in the series used: undo_bufmgr.c/h, MarkBufferDirtyShared(), DropRelationBufferRange(), the deferred-batch and two-part record entry points the index AMs called, the per-backend UNDO fields in TransactionStateData, the nbtree/hash UNDO rmgr IDs, the revert worker's LWLock tranche, and the legacy no-op stubs and getters that had no caller at all, with AtAbort_XactUndo's and AtProcExit_XactUndo's calls to two of them, which the FILEOPS commit used to remove; about 1,700 lines - Comments, log messages, the README and the documentation chapter no longer describe the ATM, the logical revert worker, per-backend UNDO, index UNDO (index_undo) or FLUX. The startup undo phase's message for a transaction it cannot apply now says that, not that it was handed to a revert worker. doc/src/sgml/undo.sgml is rewritten around what this series does (FILEOPS) and drops pg_stat_undo, index_undo and the table AM DML records, none of which exist here - undostats.c no longer marks its three SQL functions with PG_FUNCTION_INFO_V1. They are built-in (pg_proc.dat), so fmgrprotos.h already declares them, and MSVC rejected the second declaration with C2375 --- doc/src/sgml/filelist.sgml | 1 + doc/src/sgml/postgres.sgml | 1 + doc/src/sgml/undo.sgml | 160 ++ src/backend/access/Makefile | 3 +- src/backend/access/meson.build | 1 + src/backend/access/rmgrdesc/Makefile | 1 + src/backend/access/rmgrdesc/meson.build | 1 + src/backend/access/rmgrdesc/undodesc.c | 209 +++ src/backend/access/transam/rmgr.c | 1 + src/backend/access/transam/twophase.c | 130 +- src/backend/access/transam/xact.c | 184 +++ src/backend/access/transam/xlog.c | 89 ++ src/backend/access/transam/xlogrecovery.c | 25 + src/backend/access/undo/Makefile | 27 + src/backend/access/undo/README | 901 +++++++++++ src/backend/access/undo/meson.build | 14 + src/backend/access/undo/undo.c | 254 ++++ src/backend/access/undo/undo_xlog.c | 1167 ++++++++++++++ src/backend/access/undo/undoapply.c | 327 ++++ src/backend/access/undo/undoinsert.c | 166 ++ src/backend/access/undo/undolog.c | 383 +++++ src/backend/access/undo/undorecord.c | 398 +++++ src/backend/access/undo/undormgr.c | 70 + src/backend/access/undo/undostats.c | 348 +++++ src/backend/access/undo/undoworker.c | 625 ++++++++ src/backend/access/undo/xactundo.c | 1334 +++++++++++++++++ src/backend/commands/tablecmds.c | 1 + src/backend/postmaster/bgworker.c | 5 + src/backend/storage/buffer/bufmgr.c | 73 + src/backend/tcop/utility.c | 1 + .../utils/activity/wait_event_names.txt | 4 + src/backend/utils/init/postinit.c | 4 + src/backend/utils/misc/guc_parameters.dat | 40 + src/backend/utils/misc/guc_tables.c | 2 + src/backend/utils/misc/postgresql.conf.sample | 7 + src/bin/pg_dump/pg_dump.c | 1 + src/bin/pg_waldump/rmgrdesc.c | 1 + src/bin/pg_waldump/t/001_basic.pl | 3 +- src/include/access/heapam.h | 1 + src/include/access/rmgrlist.h | 1 + src/include/access/tableam.h | 9 + src/include/access/twophase.h | 3 + src/include/access/undo.h | 50 + src/include/access/undo_xlog.h | 284 ++++ src/include/access/undodefs.h | 56 + src/include/access/undolog.h | 202 +++ src/include/access/undorecord.h | 218 +++ src/include/access/undormgr.h | 115 ++ src/include/access/undormgrlist.h | 33 + src/include/access/undormgrs.h | 28 + src/include/access/undostats.h | 62 + src/include/access/undoworker.h | 70 + src/include/access/xact.h | 44 + src/include/access/xactundo.h | 119 ++ src/include/access/xlog_internal.h | 2 +- src/include/catalog/pg_proc.dat | 22 + src/include/storage/bufmgr.h | 1 + src/include/storage/lwlocklist.h | 1 + src/include/storage/subsystemlist.h | 3 + src/tools/pginclude/headerscheck | 1 + src/tools/pgindent/typedefs.list | 33 + 61 files changed, 8316 insertions(+), 4 deletions(-) create mode 100644 doc/src/sgml/undo.sgml create mode 100644 src/backend/access/rmgrdesc/undodesc.c create mode 100644 src/backend/access/undo/Makefile create mode 100644 src/backend/access/undo/README create mode 100644 src/backend/access/undo/meson.build create mode 100644 src/backend/access/undo/undo.c create mode 100644 src/backend/access/undo/undo_xlog.c create mode 100644 src/backend/access/undo/undoapply.c create mode 100644 src/backend/access/undo/undoinsert.c create mode 100644 src/backend/access/undo/undolog.c create mode 100644 src/backend/access/undo/undorecord.c create mode 100644 src/backend/access/undo/undormgr.c create mode 100644 src/backend/access/undo/undostats.c create mode 100644 src/backend/access/undo/undoworker.c create mode 100644 src/backend/access/undo/xactundo.c create mode 100644 src/include/access/undo.h create mode 100644 src/include/access/undo_xlog.h create mode 100644 src/include/access/undodefs.h create mode 100644 src/include/access/undolog.h create mode 100644 src/include/access/undorecord.h create mode 100644 src/include/access/undormgr.h create mode 100644 src/include/access/undormgrlist.h create mode 100644 src/include/access/undormgrs.h create mode 100644 src/include/access/undostats.h create mode 100644 src/include/access/undoworker.h create mode 100644 src/include/access/xactundo.h diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml index 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/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..bf6709e738d99 100644 --- a/src/backend/access/rmgrdesc/Makefile +++ b/src/backend/access/rmgrdesc/Makefile @@ -29,6 +29,7 @@ OBJS = \ spgdesc.o \ standbydesc.o \ tblspcdesc.o \ + undodesc.o \ xactdesc.o \ xlogdesc.o diff --git a/src/backend/access/rmgrdesc/meson.build b/src/backend/access/rmgrdesc/meson.build index d9000ccd9fd10..d0dc4cb229a18 100644 --- a/src/backend/access/rmgrdesc/meson.build +++ b/src/backend/access/rmgrdesc/meson.build @@ -22,6 +22,7 @@ rmgr_desc_sources = files( 'spgdesc.c', 'standbydesc.c', 'tblspcdesc.c', + 'undodesc.c', 'xactdesc.c', 'xlogdesc.c', ) diff --git a/src/backend/access/rmgrdesc/undodesc.c b/src/backend/access/rmgrdesc/undodesc.c new file mode 100644 index 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..130eb06bee3f3 100644 --- a/src/backend/access/transam/rmgr.c +++ b/src/backend/access/transam/rmgr.c @@ -40,6 +40,7 @@ #include "replication/origin.h" #include "storage/standby.h" #include "utils/relmapper.h" +#include "access/undo_xlog.h" /* IWYU pragma: end_keep */ diff --git a/src/backend/access/transam/twophase.c b/src/backend/access/transam/twophase.c index d89afb093209c..9463c065e1fb6 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" @@ -163,6 +165,17 @@ typedef struct GlobalTransactionData */ XLogRecPtr prepare_start_lsn; /* XLOG offset of prepare record start */ XLogRecPtr prepare_end_lsn; /* XLOG offset of prepare record end */ + + /* + * Permanent-level UNDO chain-head LSN for this prepared xact, or + * InvalidXLogRecPtr if it generated no cluster-wide UNDO. Set at PREPARE + * (MarkAsPreparingGuts), at redo (PrepareRedoAdd), and when a stale 2PC + * file is recovered from disk. UndoGetOldestBatchLSN() scans this so the + * UNDO-batch WAL a still-prepared xact needs for ROLLBACK PREPARED is not + * recycled -- the per-backend retention slot is cleared when the preparing + * backend exits, so it does not cover this case. + */ + XLogRecPtr undo_batch_lsn; FullTransactionId fxid; /* The GXACT full xid */ Oid owner; /* ID of user that executed the xact */ @@ -493,6 +506,7 @@ MarkAsPreparingGuts(GlobalTransaction gxact, FullTransactionId fxid, gxact->locking_backend = MyProcNumber; gxact->valid = false; gxact->inredo = false; + gxact->undo_batch_lsn = GetCurrentXactLastBatchLSN(UNDOPERSISTENCE_PERMANENT); strlcpy(gxact->gid, gid, GIDSIZE); /* @@ -979,8 +993,14 @@ TwoPhaseFilePath(char *path, FullTransactionId fxid) /* * Header for a 2PC state file + * + * TWOPHASE_MAGIC must be bumped whenever xl_xact_prepare changes layout. + * The struct gained last_batch_lsn[NUndoPersistenceLevels] (24 bytes) for + * UNDO chain tracking across 2PC boundaries, requiring this bump from + * 0x57F94534 to 0x57F94535 to prevent old servers from silently misreading + * the variable-length arrays that follow the fixed header at the wrong offsets. */ -#define TWOPHASE_MAGIC 0x57F94534 /* format identifier */ +#define TWOPHASE_MAGIC 0x57F94535 /* format identifier */ typedef xl_xact_prepare TwoPhaseFileHeader; @@ -1102,6 +1122,10 @@ StartPrepare(GlobalTransaction gxact) hdr.origin_lsn = InvalidXLogRecPtr; hdr.origin_timestamp = 0; + /* Save UNDO chain head LSNs so recovery can find UNDO records */ + for (int j = 0; j < NUndoPersistenceLevels; j++) + hdr.last_batch_lsn[j] = GetCurrentXactLastBatchLSN(j); + save_state_data(&hdr, sizeof(TwoPhaseFileHeader)); save_state_data(gxact->gid, hdr.gidlen); @@ -1499,6 +1523,47 @@ StandbyTransactionIdIsPrepared(TransactionId xid) return result; } +/* + * RecoveryTransactionIdIsPrepared + * Check if a transaction ID is in the in-memory prepared transaction list. + * + * This is used during crash recovery UNDO phase, before prepared transaction + * files exist on disk. It checks the in-memory TwoPhaseState that was + * reconstructed from WAL replay. + */ +bool +RecoveryTransactionIdIsPrepared(TransactionId xid) +{ + int i; + FullTransactionId fxid; + + Assert(TransactionIdIsValid(xid)); + + if (max_prepared_xacts <= 0) + return false; /* 2PC not enabled */ + + if (TwoPhaseState == NULL) + return false; /* 2PC not initialized yet */ + + fxid = AdjustToFullTransactionId(xid); + + LWLockAcquire(TwoPhaseStateLock, LW_SHARED); + + for (i = 0; i < TwoPhaseState->numPrepXacts; i++) + { + GlobalTransaction gxact = TwoPhaseState->prepXacts[i]; + + if (FullTransactionIdEquals(gxact->fxid, fxid)) + { + LWLockRelease(TwoPhaseStateLock); + return true; + } + } + + LWLockRelease(TwoPhaseStateLock); + return false; +} + /* * FinishPreparedTransaction: execute COMMIT PREPARED or ROLLBACK PREPARED */ @@ -1586,6 +1651,7 @@ FinishPreparedTransaction(const char *gid, bool isCommit) hdr->ninvalmsgs, invalmsgs, hdr->initfileinval, gid); else + { RecordTransactionAbortPrepared(xid, hdr->nsubxacts, children, hdr->nabortrels, abortrels, @@ -1593,6 +1659,26 @@ FinishPreparedTransaction(const char *gid, bool isCommit) abortstats, gid); + /* + * ROLLBACK PREPARED: apply any cluster-wide UNDO chain synchronously, + * here, before the prepared transaction's resources are torn down. + * The permanent-level chain-head LSN was durably saved in the 2PC + * header at PREPARE; ApplyUndoChainFromWAL() walks the UNDO chain + * backwards from that LSN, restoring before-images. + * + * Only the permanent level is applied: TEMP undo is gone (the + * originating backend exited) and UNLOGGED forks are reset on crash; + * this matches the crash-recovery UNDO phase in undo_xlog.c. + */ + { + XLogRecPtr perm_lsn = + hdr->last_batch_lsn[UNDOPERSISTENCE_PERMANENT]; + + if (XLogRecPtrIsValid(perm_lsn)) + (void) ApplyUndoChainFromWAL(perm_lsn); + } + } + ProcArrayRemove(proc, latestXid); /* @@ -2173,6 +2259,14 @@ RecoverPreparedTransactions(void) hdr->prepared_at, hdr->owner, hdr->database); + /* + * MarkAsPreparingGuts read the live backend's last_batch_lsn (wrong + * xact during recovery); restore this xact's real value from the 2PC + * header so WAL retention pins its UNDO batch until ROLLBACK + * PREPARED. + */ + gxact->undo_batch_lsn = hdr->last_batch_lsn[UNDOPERSISTENCE_PERMANENT]; + /* recovered, so reset the flag for entries generated by redo */ gxact->inredo = false; @@ -2646,6 +2740,7 @@ PrepareRedoAdd(FullTransactionId fxid, char *buf, gxact->valid = false; gxact->ondisk = !XLogRecPtrIsValid(start_lsn); gxact->inredo = true; /* yes, added in redo */ + gxact->undo_batch_lsn = hdr->last_batch_lsn[UNDOPERSISTENCE_PERMANENT]; strlcpy(gxact->gid, gid, GIDSIZE); /* And insert it into the active array */ @@ -2927,3 +3022,36 @@ TwoPhaseGetOldestXidInCommit(void) return oldestRunningXid; } + +/* + * TwoPhaseGetOldestUndoBatchLSN + * Return the oldest permanent-level UNDO chain-head LSN across all + * prepared transactions, or InvalidXLogRecPtr if none generated UNDO. + * + * UndoGetOldestBatchLSN() folds this into the WAL-retention horizon so the + * UNDO-batch WAL a still-prepared xact needs for ROLLBACK PREPARED survives + * checkpoints and backend exit (see undo_batch_lsn in GlobalTransactionData). + */ +XLogRecPtr +TwoPhaseGetOldestUndoBatchLSN(void) +{ + XLogRecPtr oldest = InvalidXLogRecPtr; + + if (max_prepared_xacts <= 0) + return InvalidXLogRecPtr; + + LWLockAcquire(TwoPhaseStateLock, LW_SHARED); + + for (int i = 0; i < TwoPhaseState->numPrepXacts; i++) + { + XLogRecPtr lsn = TwoPhaseState->prepXacts[i]->undo_batch_lsn; + + if (XLogRecPtrIsValid(lsn) && + (!XLogRecPtrIsValid(oldest) || lsn < oldest)) + oldest = lsn; + } + + LWLockRelease(TwoPhaseStateLock); + + return oldest; +} diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c index 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/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/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/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..8570f17916fc3 100644 --- a/src/bin/pg_waldump/rmgrdesc.c +++ b/src/bin/pg_waldump/rmgrdesc.c @@ -20,6 +20,7 @@ #include "access/nbtxlog.h" #include "access/rmgr.h" #include "access/spgxlog.h" +#include "access/undo_xlog.h" #include "access/xact.h" #include "access/xlog_internal.h" #include "catalog/storage_xlog.h" diff --git a/src/bin/pg_waldump/t/001_basic.pl b/src/bin/pg_waldump/t/001_basic.pl index 7b33efc62999f..fbeb19ee4a4e8 100644 --- a/src/bin/pg_waldump/t/001_basic.pl +++ b/src/bin/pg_waldump/t/001_basic.pl @@ -96,7 +96,8 @@ ReplicationOrigin Generic LogicalMessage -XLOG2$/, +XLOG2 +Undo$/, 'rmgr list'); 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..046ba45cb2532 100644 --- a/src/include/access/rmgrlist.h +++ b/src/include/access/rmgrlist.h @@ -48,3 +48,4 @@ PG_RMGR(RM_REPLORIGIN_ID, "ReplicationOrigin", replorigin_redo, replorigin_desc, PG_RMGR(RM_GENERIC_ID, "Generic", generic_redo, generic_desc, generic_identify, NULL, NULL, generic_mask, NULL) PG_RMGR(RM_LOGICALMSG_ID, "LogicalMessage", logicalmsg_redo, logicalmsg_desc, logicalmsg_identify, NULL, NULL, NULL, logicalmsg_decode) PG_RMGR(RM_XLOG2_ID, "XLOG2", xlog2_redo, xlog2_desc, xlog2_identify, NULL, NULL, NULL, xlog2_decode) +PG_RMGR(RM_UNDO_ID, "Undo", undo_redo, undo_desc, undo_identify, NULL, NULL, NULL, NULL) diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h index 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/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..4ab60e2141881 --- /dev/null +++ b/src/include/access/undormgrlist.h @@ -0,0 +1,33 @@ +/*------------------------------------------------------------------------- + * + * undormgrlist.h + * + * List of *UndoRmgrInit() initialization calls for built-in UNDO resource + * managers. Kept in its own source file, mirroring the pattern used by + * storage/subsystemlist.h for ShmemCallbacks registration, so that the + * generic UNDO core (undo.c) can register every built-in resource manager + * without knowing any of their names at compile time: undo.c only expands + * this list through a macro it controls, and the extern declarations are + * generated the same way (see access/undormgrs.h). + * + * Each access method or subsystem that writes UNDO records adds itself here + * when it is compiled in. This file is edited by whichever commit + * introduces a new UNDO-writing consumer; undo.c itself is never touched to + * add a new consumer. + * + * UNDO_RMGR_INIT is defined by the caller depending on how the list is used. + * + * No built-in resource managers exist yet at this point in the UNDO + * subsystem's history; this file starts empty and grows as each consumer + * (an index AM, a table AM, or a subsystem that writes UNDO records) is + * compiled in and adds itself here. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undormgrlist.h + * + *------------------------------------------------------------------------- + */ + +/* there is deliberately not an #ifndef UNDORMGRLIST_H here */ diff --git a/src/include/access/undormgrs.h b/src/include/access/undormgrs.h new file mode 100644 index 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/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/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/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..336028f7d8d96 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -616,6 +616,7 @@ DBState DCHCacheEntry DEADLOCK_INFO DECountItem +DeferredUndoXact DH DIR DNSServiceErrorType @@ -3305,6 +3306,27 @@ UINT ULARGE_INTEGER ULONG ULONG_PTR +UndoApplyResult +UndoBatchData +UndoBufferStat +UndoLogControl +UndoLogNumber +UndoLogOffset +UndoLogSharedData +UndoLogStat +UndoLogState +UndoPersistenceLevel +UndoRecordHeader +UndoRecordSet +UndoRecordSetChunkHeader +UndoRecordSetType +UndoRecordSize +UndoRecoveryEntry +UndoRecPtr +UndoRmgrApplyFunc +UndoRmgrData +UndoRmgrDescFunc +UndoWorkerShmemData UV UVersionInfo UnicodeNormalizationForm @@ -3485,6 +3507,17 @@ X509_NAME_ENTRY X509_STORE X509_STORE_CTX X86FeatureId +XactUndoContext +XactUndoData +XactUndoSubTransactionState +xl_undo_allocate +xl_undo_apply +xl_undo_batch +xl_undo_chain_state +xl_undo_discard +xl_undo_extend +xl_undo_page_write +xl_undo_rotate XLTW_Oper XLogCtlData XLogCtlInsert From 9ac6eb0d5b1ebae67d11e1c3e83fb9fed11b262e Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 14 Sep 2026 13:03:40 +0000 Subject: [PATCH 4/5] Add transactional filesystem operations (FILEOPS) backed by UNDO Make the structural filesystem mutations the server performs outside the buffer manager crash-safe and rollback-safe via a WAL resource manager (RM_FILEOPS_ID). Forward operations are WAL-logged and, inside a transaction, register a pending-op / UNDO record so an abort or crash recovery recursively reverts them. FILEOPS records its UNDO in the common WAL stream, so crash recovery reverts a loser transaction's structural changes through the cluster-wide UNDO engine's recovery pass; the AtPrepare path is handled so a prepared transaction's pending file operations survive 2PC. This commit adds the FILEOPS access method and its UNDO plumbing but does not yet route any in-tree DDL/storage caller through it (that is the next commit): the FileOps* API, fileops_redo, the fileops UNDO resource manager (FileopsUndoRmgrInit, registered via undormgrlist.h), the pg_xattr portability wrapper, rmgrdesc + pg_waldump support, the test_fileops module, SQL regression coverage, and recovery TAP tests for crash and 2PC. Author: Greg Burd Changes since v2: - Report FILEOPS UNDO apply failures as failures [f3bd4a3cc7] Tests: - Do not set logical_revert_naptime in 059 and 061 [3072609035] - Show that a crash right after commit loses deferred ops [b34b2463bb] Changes since v7: - recovery/060_fileops_standby_divergence is now 064: upstream added t/060_multixact_crashed_updater.pl - test_fileops gains 023 and 024, the tests for the two UNDO engine fixes above (in the previous commit) - recovery/072_perbackend_crash_undo is now 065_fileops_crash_undo; it never involved per-backend UNDO - Comments and tests no longer describe ROLLBACK PREPARED as handing the chain to the ATM and an asynchronous revert worker; in this series FinishPreparedTransaction() applies it synchronously - recovery/062 (Test 12) and 065 skip their file-mode checks on Windows, which has no POSIX modes; v8 carried these guards in 0005 - recovery/065 crashes the server before it closes the client. It closed the client first, so the backend could roll the transaction back online, and when that abort reached disk before the crash, recovery redid the changes (see the known gap in the cover letter) --- meson.build | 11 + src/Makefile.global.in | 5 + src/backend/access/rmgrdesc/Makefile | 1 + src/backend/access/rmgrdesc/fileopsdesc.c | 219 ++ src/backend/access/rmgrdesc/meson.build | 6 + src/backend/access/transam/rmgr.c | 1 + src/backend/access/transam/twophase.c | 27 +- src/backend/access/transam/twophase_rmgr.c | 20 +- src/backend/storage/file/Makefile | 2 + src/backend/storage/file/README | 201 ++ src/backend/storage/file/fileops.c | 2561 +++++++++++++++++ src/backend/storage/file/fileops_undo.c | 431 +++ src/backend/storage/file/meson.build | 2 + src/bin/pg_waldump/rmgrdesc.c | 1 + src/bin/pg_waldump/t/001_basic.pl | 3 +- src/include/access/fileops_xlog.h | 40 + src/include/access/rmgrlist.h | 3 + src/include/access/twophase_rmgr.h | 9 +- src/include/access/undormgrlist.h | 3 + src/include/port/pg_xattr.h | 40 + src/include/storage/fileops.h | 463 +++ src/port/Makefile | 1 + src/port/meson.build | 1 + src/port/pg_xattr.c | 217 ++ src/test/modules/Makefile | 1 + src/test/modules/meson.build | 1 + src/test/modules/test_fileops/Makefile | 19 + .../test_fileops/expected/test_fileops.out | 413 +++ .../test_fileops/expected/test_fileops_1.out | 332 +++ .../test_fileops/expected/test_fileops_2.out | 18 + src/test/modules/test_fileops/meson.build | 44 + .../modules/test_fileops/sql/test_fileops.sql | 229 ++ .../t/001_fileops_apply_failure.pl | 42 + .../t/007_fileops_commit_crash.pl | 77 + .../t/023_fileops_inline_abort_ctx.pl | 60 + .../t/024_fileops_recovery_apply_failure.pl | 59 + .../test_fileops/test_fileops--1.0.sql | 99 + src/test/modules/test_fileops/test_fileops.c | 471 +++ .../modules/test_fileops/test_fileops.control | 4 + src/test/recovery/Makefile | 1 + src/test/recovery/meson.build | 5 + .../recovery/t/061_fileops_2pc_deferred.pl | 167 ++ src/test/recovery/t/062_fileops_recovery.pl | 446 +++ src/test/recovery/t/063_fileops_2pc.pl | 149 + .../t/064_fileops_standby_divergence.pl | 124 + src/test/recovery/t/065_fileops_crash_undo.pl | 137 + src/tools/pgindent/typedefs.list | 26 + 47 files changed, 7184 insertions(+), 8 deletions(-) create mode 100644 src/backend/access/rmgrdesc/fileopsdesc.c create mode 100644 src/backend/storage/file/README create mode 100644 src/backend/storage/file/fileops.c create mode 100644 src/backend/storage/file/fileops_undo.c create mode 100644 src/include/access/fileops_xlog.h create mode 100644 src/include/port/pg_xattr.h create mode 100644 src/include/storage/fileops.h create mode 100644 src/port/pg_xattr.c create mode 100644 src/test/modules/test_fileops/Makefile create mode 100644 src/test/modules/test_fileops/expected/test_fileops.out create mode 100644 src/test/modules/test_fileops/expected/test_fileops_1.out create mode 100644 src/test/modules/test_fileops/expected/test_fileops_2.out create mode 100644 src/test/modules/test_fileops/meson.build create mode 100644 src/test/modules/test_fileops/sql/test_fileops.sql create mode 100644 src/test/modules/test_fileops/t/001_fileops_apply_failure.pl create mode 100644 src/test/modules/test_fileops/t/007_fileops_commit_crash.pl create mode 100644 src/test/modules/test_fileops/t/023_fileops_inline_abort_ctx.pl create mode 100644 src/test/modules/test_fileops/t/024_fileops_recovery_apply_failure.pl create mode 100644 src/test/modules/test_fileops/test_fileops--1.0.sql create mode 100644 src/test/modules/test_fileops/test_fileops.c create mode 100644 src/test/modules/test_fileops/test_fileops.control create mode 100644 src/test/recovery/t/061_fileops_2pc_deferred.pl create mode 100644 src/test/recovery/t/062_fileops_recovery.pl create mode 100644 src/test/recovery/t/063_fileops_2pc.pl create mode 100644 src/test/recovery/t/064_fileops_standby_divergence.pl create mode 100644 src/test/recovery/t/065_fileops_crash_undo.pl 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/rmgrdesc/Makefile b/src/backend/access/rmgrdesc/Makefile index bf6709e738d99..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 \ 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 d0dc4cb229a18..c8af1ce82dd69 100644 --- a/src/backend/access/rmgrdesc/meson.build +++ b/src/backend/access/rmgrdesc/meson.build @@ -27,4 +27,10 @@ rmgr_desc_sources = files( '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/transam/rmgr.c b/src/backend/access/transam/rmgr.c index 130eb06bee3f3..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" diff --git a/src/backend/access/transam/twophase.c b/src/backend/access/transam/twophase.c index 9463c065e1fb6..55c553339d692 100644 --- a/src/backend/access/transam/twophase.c +++ b/src/backend/access/transam/twophase.c @@ -100,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" @@ -150,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 */ @@ -996,11 +1006,17 @@ TwoPhaseFilePath(char *path, FullTransactionId fxid) * * TWOPHASE_MAGIC must be bumped whenever xl_xact_prepare changes layout. * The struct gained last_batch_lsn[NUndoPersistenceLevels] (24 bytes) for - * UNDO chain tracking across 2PC boundaries, requiring this bump from + * 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 0x57F94535 /* format identifier */ +#define TWOPHASE_MAGIC 0x57F94536 /* format identifier */ typedef xl_xact_prepare TwoPhaseFileHeader; @@ -1179,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); 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/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/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/bin/pg_waldump/rmgrdesc.c b/src/bin/pg_waldump/rmgrdesc.c index 8570f17916fc3..ed27a243febb7 100644 --- a/src/bin/pg_waldump/rmgrdesc.c +++ b/src/bin/pg_waldump/rmgrdesc.c @@ -17,6 +17,7 @@ #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" diff --git a/src/bin/pg_waldump/t/001_basic.pl b/src/bin/pg_waldump/t/001_basic.pl index fbeb19ee4a4e8..ded5e37feda65 100644 --- a/src/bin/pg_waldump/t/001_basic.pl +++ b/src/bin/pg_waldump/t/001_basic.pl @@ -97,7 +97,8 @@ Generic LogicalMessage XLOG2 -Undo$/, +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/rmgrlist.h b/src/include/access/rmgrlist.h index 046ba45cb2532..a34394a1e233e 100644 --- a/src/include/access/rmgrlist.h +++ b/src/include/access/rmgrlist.h @@ -49,3 +49,6 @@ PG_RMGR(RM_GENERIC_ID, "Generic", generic_redo, generic_desc, generic_identify, 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/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/undormgrlist.h b/src/include/access/undormgrlist.h index 4ab60e2141881..a9f8ec5afd730 100644 --- a/src/include/access/undormgrlist.h +++ b/src/include/access/undormgrlist.h @@ -31,3 +31,6 @@ */ /* 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/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/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/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..0ff3e80890f5a --- /dev/null +++ b/src/test/modules/test_fileops/t/007_fileops_commit_crash.pl @@ -0,0 +1,77 @@ +# 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'); +} + +$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/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 336028f7d8d96..ca8d7b8a92e06 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -834,6 +834,17 @@ ExtensionVersionInfo FDWCollateState FD_SET FILE +FileOpsTwoPhaseRecord +FileopsUndoChmod +FileopsUndoChown +FileopsUndoCreate +FileopsUndoLink +FileopsUndoMkdir +FileopsUndoRemovexattr +FileopsUndoRename +FileopsUndoSetxattr +FileopsUndoSymlink +FileopsUndoTruncate FILETIME FPI FSMAddress @@ -1909,6 +1920,8 @@ PACE_HEADER PACL PATH PCtxtHandle +PendingFileOp +PendingFileOpType PERL_CONTEXT PERL_SI PFN @@ -3510,6 +3523,19 @@ 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 From be1a3e05c6d8bf01807be48c1fff805a1b050cf5 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 14 Sep 2026 13:03:40 +0000 Subject: [PATCH 5/5] Route filesystem mutations through FILEOPS Route the server's transactional DDL/storage callers that mutate the filesystem outside the buffer manager through the FILEOPS resource manager added in the previous commit, so an abort or crash recovery recursively reverts a partial filesystem change instead of leaving orphan trees behind. - copydir() gains a register_for_abort_cleanup argument: forward-path callers register the destination tree with FILEOPS (FileOpsRmtree at abort), while WAL-replay callers pass false because the redo handler is itself the recovery action. - CREATE DATABASE (STRATEGY=FILE_COPY) and ALTER DATABASE SET TABLESPACE copy their directory trees with abort cleanup enabled, closing the long-standing crash-asymmetry where a rolled-back copy left an orphan tree. - CREATE/DROP TABLESPACE route their directory create/remove through FileOps* so tablespace symlink and directory changes are transactional. Author: Greg Burd --- src/backend/commands/dbcommands.c | 144 +++++++++----- src/backend/commands/tablespace.c | 175 +++++++++++++----- src/backend/storage/file/copydir.c | 33 +++- src/include/storage/copydir.h | 14 +- .../t/007_fileops_commit_crash.pl | 18 ++ 5 files changed, 289 insertions(+), 95 deletions(-) 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/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/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/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/test/modules/test_fileops/t/007_fileops_commit_crash.pl b/src/test/modules/test_fileops/t/007_fileops_commit_crash.pl index 0ff3e80890f5a..f40dcd271b05b 100644 --- a/src/test/modules/test_fileops/t/007_fileops_commit_crash.pl +++ b/src/test/modules/test_fileops/t/007_fileops_commit_crash.pl @@ -73,5 +73,23 @@ sub commit_and_crash '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();