From f571d51c168b74c88a70d99e1ac086e686491c09 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Fri, 11 Sep 2026 10:35:28 -0400 Subject: [PATCH 01/22] 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 4678ff779b7d6b5da06c3f6d1e43cdb3ecbb7430 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Fri, 11 Sep 2026 10:48:09 -0400 Subject: [PATCH 02/22] 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 b7667c1ca7bf01aa9a39e51b7e226357129ff5ad Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 14 Sep 2026 13:03:39 +0000 Subject: [PATCH 03/22] 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), a dedicated UNDO buffer manager (undo_bufmgr.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); the end-of-recovery phase applies any chains deferred during redo. 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, buffer-manager helpers used by inline apply (BufferLockReleaseAll and friends), and the UNDO GUCs, wait events, LWLock tranches, and documentation for the above. Later patches add UNDO-writing consumers (index and table access methods) and the additional UNDO layers built on top of this engine. 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] --- doc/src/sgml/filelist.sgml | 1 + doc/src/sgml/postgres.sgml | 1 + doc/src/sgml/undo.sgml | 612 +++++++ src/backend/access/Makefile | 3 +- src/backend/access/meson.build | 1 + src/backend/access/rmgrdesc/Makefile | 1 + src/backend/access/rmgrdesc/meson.build | 4 + src/backend/access/rmgrdesc/undodesc.c | 209 +++ src/backend/access/transam/rmgr.c | 1 + src/backend/access/transam/twophase.c | 131 +- src/backend/access/transam/xact.c | 202 +++ src/backend/access/transam/xlog.c | 89 + src/backend/access/transam/xlogrecovery.c | 25 + src/backend/access/undo/Makefile | 28 + src/backend/access/undo/README | 925 +++++++++++ src/backend/access/undo/meson.build | 15 + src/backend/access/undo/undo.c | 272 +++ src/backend/access/undo/undo_bufmgr.c | 250 +++ src/backend/access/undo/undo_xlog.c | 1178 +++++++++++++ src/backend/access/undo/undoapply.c | 327 ++++ src/backend/access/undo/undoinsert.c | 166 ++ src/backend/access/undo/undolog.c | 519 ++++++ src/backend/access/undo/undorecord.c | 415 +++++ src/backend/access/undo/undormgr.c | 70 + src/backend/access/undo/undostats.c | 352 ++++ src/backend/access/undo/undoworker.c | 628 +++++++ src/backend/access/undo/xactundo.c | 1459 +++++++++++++++++ src/backend/commands/tablecmds.c | 1 + src/backend/postmaster/bgworker.c | 5 + src/backend/storage/buffer/bufmgr.c | 167 ++ src/backend/tcop/utility.c | 1 + .../utils/activity/wait_event_names.txt | 5 + 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 | 52 + src/include/access/undo_bufmgr.h | 297 ++++ src/include/access/undo_xlog.h | 290 ++++ src/include/access/undodefs.h | 56 + src/include/access/undolog.h | 196 +++ src/include/access/undorecord.h | 222 +++ src/include/access/undormgr.h | 118 ++ src/include/access/undormgrlist.h | 33 + src/include/access/undormgrs.h | 28 + src/include/access/undostats.h | 63 + src/include/access/undoworker.h | 65 + src/include/access/xact.h | 44 + src/include/access/xactundo.h | 131 ++ src/include/access/xlog_internal.h | 2 +- src/include/catalog/pg_proc.dat | 22 + src/include/storage/buf_internals.h | 14 + src/include/storage/bufmgr.h | 15 + src/include/storage/lwlocklist.h | 2 + src/include/storage/subsystemlist.h | 3 + src/tools/pginclude/headerscheck | 1 + src/tools/pgindent/typedefs.list | 33 + 64 files changed, 9818 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_bufmgr.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_bufmgr.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..c1dbcd478fa24 --- /dev/null +++ b/doc/src/sgml/undo.sgml @@ -0,0 +1,612 @@ + + + + UNDO Logging + + + UNDO logging + + + + PostgreSQL provides UNDO logging + infrastructure that records the inverse of data modifications, so that a + transaction can be rolled back by restoring the recorded before-images, + with full crash recovery and standby replay support. + + + + UNDO is not a cluster-wide switch. It is always-on infrastructure that only + the access methods which opt in actually use: a table access method declares + am_supports_undo, and the default + heap access method does not, so ordinary heap tables carry + no UNDO overhead. A table gets UNDO by being created with an access method + that uses it. + + + + The UNDO system uses a physical approach to + transaction rollback: rather than replaying high-level operations in + reverse, it restores the original page bytes directly. Each rollback + operation generates a WAL record (called a Compensation Log Record, or + CLR) that ensures correct replay on standbys and during crash recovery. + + + + Enabling UNDO Logging + + + A table uses UNDO when its access method uses UNDO. There is no server-level + toggle and no per-table UNDO storage parameter: + + + +CREATE TABLE important_data ( + id serial PRIMARY KEY, + payload text +) USING undo_am; + + + + Index UNDO is separate and is controlled per relation by the + index_undo storage parameter, which reverses a table's + index entries during ROLLBACK instead of leaving them for + VACUUM. It defaults to on: + + + +-- opt out for a commit-mostly table +ALTER TABLE important_data SET (index_undo = off); + + + + + Changing index_undo requires an + ACCESS EXCLUSIVE lock on the table. Plan for + a maintenance window if the table is under active use. The setting affects + only subsequent inserts; index entries that already exist have no UNDO + record and are left to VACUUM as before. + + + + + + When to Use UNDO + + + Consider enabling UNDO logging when: + + + + + + You want crash-safe rollback with full WAL integration for + critical tables, ensuring that aborted transactions are correctly + rolled back even after a crash or on streaming replication standbys. + + + + + + Do not enable UNDO logging on: + + + + + + High-throughput write-heavy tables where the additional I/O + overhead is unacceptable. + + + + + Temporary tables or tables with short-lived data that does not + need recovery protection. + + + + + + + Logged Operations + + + On a table whose access method uses UNDO, the following operations generate + UNDO records: + + + + + INSERT + + + Records the block and offset of the newly inserted tuple along + with the ItemId state. On rollback, the inserted tuple is + physically removed from the page and the ItemId is restored to + its prior state. No full tuple payload is stored. + + + + + + DELETE + + + Records the full raw tuple data as it appears on the heap page. + On rollback, the original tuple bytes are restored to the page + via direct memory copy, and the ItemId is restored. + + + + + + UPDATE + + + Records the full raw data of the old tuple version before the + update. On rollback, the old tuple bytes are restored to their + original page location, and the new tuple is removed. + + + + + + Pruning (HOT cleanup and VACUUM) + + + Records full copies of tuples being marked as dead or unused + during page pruning. These records are not rolled back: pruning + is a maintenance operation, not a transactional data change. They are + retained for diagnostics only; there is no user-facing tool that reads + them back. + + + + + + + Each rollback operation generates a Compensation Log Record (CLR) in + the WAL stream. CLRs carry full page images, ensuring that the + rollback is correctly replayed on standbys and during crash recovery. + + + + + Crash Recovery and Replication + + + The UNDO system is fully integrated with PostgreSQL's WAL-based + crash recovery and streaming replication. + + + + When a transaction with UNDO records aborts, each UNDO application + generates a CLR (Compensation Log Record) WAL record. These CLRs + contain full page images of the restored heap pages, making them + self-contained and safe to replay. + + + + During crash recovery: + + + + + + The redo phase replays all WAL records forward, including any CLRs + that were generated before the crash. Pages are restored to their + post-rollback state. + + + + + For transactions that were aborting at crash time but had not + completed rollback, the recovery process walks the remaining UNDO + chain and generates new CLRs, using CLR pointers to skip + already-applied records. + + + + + + On streaming replication standbys, CLRs are replayed like any other + WAL record. The standby does not need access to the UNDO log data + itself, since the CLR WAL records are self-contained with full page + images. + + + + + Configuration Parameters + + + + undo_worker_naptime (integer) + + + Time in milliseconds between UNDO discard worker cycles. + The worker wakes periodically to check for UNDO records that + are no longer needed by any active transaction. + Default: 10000 (10 seconds). + + + + + + + UNDO data is stored in the standard shared buffer pool alongside + heap and index pages. No dedicated UNDO buffer cache configuration + is needed. The shared buffer pool dynamically adapts to the UNDO + workload through its normal clock-sweep eviction policy. + + + + + UNDO Space Management + + + UNDO records are embedded directly in the WAL stream as + XLOG_UNDO_BATCH records (UNDO-in-WAL architecture). + There are no separate UNDO segment files or directories. This + eliminates a separate storage tier and leverages existing WAL + infrastructure for durability, replication, and archival. + + + + The UNDO discard worker background process advances the + undo_discard_horizon, allowing WAL segments + containing fully-discarded UNDO batches to be recycled. UNDO records + for unresolved (uncommitted/unaborted) transactions are never discarded. + + + + To monitor UNDO WAL retention: + + + +SELECT pg_size_pretty( + pg_wal_lsn_diff(pg_current_wal_lsn(), undo_discard_horizon) +) AS undo_wal_retained +FROM pg_stat_undo; + + + + If UNDO space is growing unexpectedly, check for: + + + + + + Long-running transactions that prevent discard. + + + + + The UNDO worker not running (check + pg_stat_activity for the + undo worker process). + + + + + + + Performance Impact + + + For an access method that does not use UNDO, such as the default + heap, there is no measurable performance impact. For one + that does, expect: + + + + + + INSERT: Minimal overhead. A small header + record (~40 bytes) is written to the UNDO log recording the + ItemId state. + + + + + DELETE/UPDATE: Moderate overhead. The old tuple + data is copied to the UNDO log as raw page bytes; an in-place UPDATE may + instead log only the changed byte range. + Cost scales with tuple size. + + + + + PRUNE: Overhead proportional to the number + of tuples being pruned. Records are batched for efficiency. + + + + + ABORT: Each UNDO record applied during + rollback generates a CLR WAL record with a full page image + (~8 KB). This increases abort latency by approximately 20-50% + compared to systems without CLR generation, but ensures crash + safety and correct standby replay. + + + + + + UNDO I/O is performed outside critical sections, so it does not + extend the time that buffer locks are held. + + + + + Monitoring + + + Monitor UNDO system health using the following SQL-callable functions + and views: + + + + + + pg_stat_get_undo_logs(): returns one row per + UNDO log with its log number, insert and discard offsets, size in + bytes, oldest active transaction, and state. + + + + + pg_stat_get_undo_buffers(): returns a single row + of UNDO buffer-cache statistics (buffer count, hits, misses, + evictions, writes, and hit ratio). + + + + + pg_undo_force_discard(force_rotate boolean): + forces an immediate discard of reclaimable UNDO records and returns + the number of log segments freed. Membership in the + pg_maintain role is required. + + + + + pg_waldump: Inspect CLR records in WAL. + CLR records appear as UNDO/APPLY_RECORD entries + and can be filtered with . + + + + + pg_stat_activity: Verify the + undo worker background process is running. + + + + + + Key log messages to watch for (at DEBUG1 and above): + + + + + + "applying UNDO chain starting at ..." indicates + a transaction abort is applying its UNDO chain. + + + + + "UNDO rollback: relation %u no longer exists, skipping" + indicates an UNDO record was skipped because the target relation was + dropped before rollback completed. + + + + + + + Architecture Notes + + + The following notes describe the internal architecture for users + interested in the design rationale. + + + + Physical vs Logical UNDO + + + The UNDO system uses physical UNDO operations: + when rolling back a transaction, the original page bytes are restored + directly using memory copy operations. This contrasts with a + logical approach that would replay high-level + operations (like simple_heap_insert or + simple_heap_delete) in reverse. + + + + Advantages of physical UNDO: + + + + + + Crash Safety: Each UNDO application generates a + Compensation Log Record (CLR) in WAL, ensuring that rollback completes + correctly even after a system crash. + + + + + Standby Support: CLRs are replayed on physical + standbys just like forward-progress WAL records. Standbys see + identical heap state as the primary after an abort. + + + + + Determinism: Physical operations cannot fail due + to page-full conditions, TOAST complications, or index conflicts. + The operation is a direct memory copy with no side effects. + + + + + Simplicity: Direct memory copy operations are + simpler and faster than reconstructing logical operations, and have + no side effects (no index updates, no TOAST operations, no + statistics maintenance). + + + + + + Trade-offs: + + + + + + WAL Volume: CLRs with full page images (~8 KB + each) increase WAL generation significantly per abort compared to + PostgreSQL's default rollback mechanism + which generates no WAL. + + + + + Abort Latency: Approximately 20-50% overhead + compared to PostgreSQL's default rollback, + due to reading UNDO records, modifying pages, and writing CLRs. + + + + + + The design prioritizes correctness and crash safety over abort speed. + For workloads where transaction aborts are rare, the overhead is + negligible. + + + + + Compensation Log Records (CLRs) + + + A CLR is a WAL record generated each time an UNDO record is physically + applied to a heap page during rollback. CLRs serve three purposes: + + + + + + Crash recovery: If the server crashes during + rollback, the redo phase replays any CLRs that were already written, + restoring pages to their post-undo state. Rollback then continues + from where it left off, using CLR pointers in the UNDO records to + skip already-applied operations. + + + + + Standby replication: CLRs are streamed to + standbys like any other WAL record. The standby does not need + access to the UNDO log data itself, since CLRs are self-contained + with full page images. + + + + + Audit trail: CLRs provide a permanent record + in WAL of every rollback operation, viewable with + pg_waldump. + + + + + + Each CLR uses REGBUF_FORCE_IMAGE to store a + complete page image, making the CLR self-contained for recovery. + During redo, the page image is restored directly without needing + to re-read the UNDO record or re-apply the operation. + + + + + Buffer Pool Integration + + + UNDO log data is stored in the standard shared buffer pool alongside + heap and index pages. Each UNDO log is mapped to a virtual + RelFileLocator with a dedicated pseudo-database + OID (UNDO_DB_OID = 9), allowing the buffer manager + to handle UNDO data without any changes to the core + BufferTag structure. + + + + This design eliminates the need for a separate UNDO buffer cache, + reducing code complexity and allowing UNDO pages to participate in + the buffer manager's clock-sweep eviction and checkpoint mechanisms + automatically. No dedicated UNDO buffer cache configuration is needed; + the standard shared_buffers setting controls memory + available for all buffer types including UNDO. + + + + + Rollback Flow + + + When a transaction aborts, the rollback proceeds as follows: + + + + + + The transaction manager (xact.c) calls + ApplyUndoChain() with the first UNDO record + pointer for the aborting transaction. + + + + + For each UNDO record in the chain (walked backward): + + + + Read the UNDO record from the log. + + + Check the CLR pointer: if valid, this record was already + applied during a previous rollback attempt; skip it. + + + Open the target relation and read the target page into a + shared buffer with an exclusive lock. + + + Apply the physical modification (memcpy) within a critical + section. + + + Generate a CLR WAL record with a full page image. + + + Store the CLR's LSN back into the UNDO record's + urec_clr_ptr field to mark it as + applied. + + + + + + AtAbort_XactUndo() cleans up record sets and + resets per-transaction state. + + + + + + + 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..dc477ecded6d0 100644 --- a/src/backend/access/rmgrdesc/meson.build +++ b/src/backend/access/rmgrdesc/meson.build @@ -22,8 +22,12 @@ rmgr_desc_sources = files( 'spgdesc.c', 'standbydesc.c', 'tblspcdesc.c', + 'undodesc.c', 'xactdesc.c', 'xlogdesc.c', ) + +# FLUX is always built; rmgrlist.h registers flux_desc/flux_identify + backend_sources += rmgr_desc_sources diff --git a/src/backend/access/rmgrdesc/undodesc.c b/src/backend/access/rmgrdesc/undodesc.c new file mode 100644 index 0000000000000..d4817684ad471 --- /dev/null +++ b/src/backend/access/rmgrdesc/undodesc.c @@ -0,0 +1,209 @@ +/*------------------------------------------------------------------------- + * + * undodesc.c + * rmgr descriptor routines for access/undo + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/rmgrdesc/undodesc.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/undo_xlog.h" +#include "access/xlogreader.h" + +/* + * undo_desc - Describe an UNDO WAL record for pg_waldump + * + * This function generates human-readable output for UNDO WAL records, + * used by pg_waldump and other debugging tools. + */ +void +undo_desc(StringInfo buf, XLogReaderState *record) +{ + char *rec = XLogRecGetData(record); + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + switch (info) + { + case XLOG_UNDO_ALLOCATE: + { + xl_undo_allocate *xlrec = (xl_undo_allocate *) rec; + + appendStringInfo(buf, "log %u, start %llu, len %u, xid %u", + xlrec->log_number, + (unsigned long long) xlrec->start_ptr, + xlrec->length, + xlrec->xid); + } + break; + + case XLOG_UNDO_DISCARD: + { + xl_undo_discard *xlrec = (xl_undo_discard *) rec; + + appendStringInfo(buf, "log %u, discard_ptr %llu, oldest_xid %u", + xlrec->log_number, + (unsigned long long) xlrec->discard_ptr, + xlrec->oldest_xid); + } + break; + + case XLOG_UNDO_EXTEND: + { + xl_undo_extend *xlrec = (xl_undo_extend *) rec; + + appendStringInfo(buf, "log %u, new_size %llu", + xlrec->log_number, + (unsigned long long) xlrec->new_size); + } + break; + + case XLOG_UNDO_APPLY_RECORD: + { + xl_undo_apply *xlrec = (xl_undo_apply *) rec; + const char *op_name; + + switch (xlrec->operation_type) + { + case 0x0001: + op_name = "INSERT"; + break; + case 0x0002: + op_name = "DELETE"; + break; + case 0x0003: + op_name = "UPDATE"; + break; + case 0x0004: + op_name = "PRUNE"; + break; + case 0x0005: + op_name = "INPLACE"; + break; + case 0x0006: + op_name = "HOT_UPDATE"; + break; + default: + op_name = "UNKNOWN"; + break; + } + + appendStringInfo(buf, + "undo apply %s: urec_ptr %llu, xid %u, " + "block %u, offset %u, clr_flags 0x%04x, " + "tuple_len %u", + op_name, + (unsigned long long) xlrec->urec_ptr, + xlrec->xid, + xlrec->target_block, + xlrec->target_offset, + xlrec->clr_flags, + xlrec->tuple_len); + } + break; + + case XLOG_UNDO_PAGE_WRITE: + { + xl_undo_page_write *xlrec = (xl_undo_page_write *) rec; + + appendStringInfo(buf, "page_offset %u, data_len %u", + xlrec->page_offset, + xlrec->data_len); + } + break; + + case XLOG_UNDO_BATCH: + { + xl_undo_batch *xlrec = (xl_undo_batch *) rec; + + appendStringInfo(buf, + "undo batch: xid %u, nrecords %u, " + "total_len %u, chain_prev %X/%X, " + "primary_reloid %u, persistence %d", + xlrec->xid, + xlrec->nrecords, + xlrec->total_len, + LSN_FORMAT_ARGS(xlrec->chain_prev), + xlrec->primary_reloid, + xlrec->persistence); + } + break; + + case XLOG_UNDO_ROTATE: + { + xl_undo_rotate *xlrec = (xl_undo_rotate *) rec; + const char *trigger_name; + + switch (xlrec->trigger) + { + case UNDO_ROTATE_CAPACITY: + trigger_name = "capacity"; + break; + case UNDO_ROTATE_CHECKPOINT: + trigger_name = "checkpoint"; + break; + case UNDO_ROTATE_PRESSURE: + trigger_name = "pressure"; + break; + case UNDO_ROTATE_MANUAL: + trigger_name = "manual"; + break; + default: + trigger_name = "unknown"; + break; + } + + appendStringInfo(buf, + "old_log %u, seal_ptr %llu, new_log %u, " + "trigger %s", + xlrec->old_log_number, + (unsigned long long) xlrec->old_seal_ptr, + xlrec->new_log_number, + trigger_name); + } + break; + } +} + +/* + * undo_identify - Identify an UNDO WAL record type + * + * Returns a string identifying the operation type for debugging output. + */ +const char * +undo_identify(uint8 info) +{ + const char *id = NULL; + + switch (info & ~XLR_INFO_MASK) + { + case XLOG_UNDO_ALLOCATE: + id = "ALLOCATE"; + break; + case XLOG_UNDO_DISCARD: + id = "DISCARD"; + break; + case XLOG_UNDO_EXTEND: + id = "EXTEND"; + break; + case XLOG_UNDO_APPLY_RECORD: + id = "APPLY_RECORD"; + break; + case XLOG_UNDO_ROTATE: + id = "ROTATE"; + break; + case XLOG_UNDO_PAGE_WRITE: + id = "PAGE_WRITE"; + break; + case XLOG_UNDO_BATCH: + id = "BATCH"; + break; + } + + return id; +} diff --git a/src/backend/access/transam/rmgr.c b/src/backend/access/transam/rmgr.c index 4fda03a3cfcc6..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 48e478a4ecbd8..5a5ffd18c5510 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" @@ -162,6 +164,18 @@ 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 -- neither the per-backend retention slot (cleared when the + * preparing backend exits) nor the ATM (prepared xacts aren't in it) + * covers this case. + */ + XLogRecPtr undo_batch_lsn; FullTransactionId fxid; /* The GXACT full xid */ Oid owner; /* ID of user that executed the xact */ @@ -492,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); /* @@ -978,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; @@ -1101,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); @@ -1498,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 */ @@ -1585,6 +1651,7 @@ FinishPreparedTransaction(const char *gid, bool isCommit) hdr->ninvalmsgs, invalmsgs, hdr->initfileinval, gid); else + { RecordTransactionAbortPrepared(xid, hdr->nsubxacts, children, hdr->nabortrels, abortrels, @@ -1592,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); /* @@ -2145,6 +2232,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; @@ -2597,6 +2692,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 */ @@ -2878,3 +2974,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 cab99f25e1ed4..a56c32aee0b28 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,10 @@ 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 */ + uint64 pbuUndoStartPtr; /* per-backend UNDO: first record of this + * xact (0 = none) */ + uint64 pbuUndoLatestPtr; /* per-backend UNDO: most recent record */ struct TransactionStateData *parent; /* back link to parent */ } TransactionStateData; @@ -317,6 +325,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 +437,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 +1173,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 */ @@ -2152,6 +2231,9 @@ StartTransaction(void) s->childXids = NULL; s->nChildXids = 0; s->maxChildXids = 0; + s->undoRecPtr = 0; /* no UNDO records yet */ + s->pbuUndoStartPtr = 0; /* no per-backend UNDO records yet */ + s->pbuUndoLatestPtr = 0; /* * Once the current user ID and the security context flags are fetched, @@ -2458,6 +2540,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, @@ -2502,6 +2587,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 @@ -2678,6 +2765,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(); @@ -2790,6 +2911,8 @@ PrepareTransaction(void) PostPrepare_Inval(); PostPrepare_smgr(); + if (PendingPhysOpsPostPrepare_hook) + PendingPhysOpsPostPrepare_hook(); PostPrepare_MultiXact(fxid); @@ -2828,6 +2951,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; @@ -2937,6 +3070,25 @@ AbortTransaction(void) TransStateAsString(s->state)); Assert(s->parent == NULL); + /* + * Discard the UNDO record pointer for this transaction. + * + * Physical UNDO application is NOT needed during standard transaction + * abort because PostgreSQL's MVCC-based heap already handles rollback + * through CLOG: the aborting transaction's xid is marked as aborted in + * CLOG, and subsequent visibility checks will ignore changes made by this + * transaction. INSERT tuples become invisible (eventually pruned), + * DELETE/UPDATE changes are ignored (old tuple versions remain visible). + * + * Physical UNDO application is intended for cases where the page has been + * modified in-place and the old state cannot be recovered through CLOG + * alone (e.g., in ZHeap-style in-place updates, or after pruning has + * removed old tuple versions). The UNDO records written during this + * transaction are preserved in the UNDO log for use by the undo worker, + * crash recovery, or future in-place update mechanisms. + */ + s->undoRecPtr = 0; + /* * set the current transaction state information appropriately during the * abort processing @@ -2972,6 +3124,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 */ @@ -3039,7 +3194,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); @@ -5238,6 +5396,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. @@ -5429,6 +5589,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); @@ -6455,6 +6617,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) { @@ -6469,6 +6636,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) { @@ -6478,6 +6648,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) { @@ -6492,12 +6669,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, @@ -6506,6 +6693,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 63d214782dac5..9a028e6fc95a3 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, @@ -6836,6 +6839,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. @@ -7697,6 +7709,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 @@ -7727,7 +7749,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); @@ -8001,6 +8027,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) { @@ -8020,9 +8047,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) { @@ -8037,6 +8068,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 @@ -8060,7 +8094,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 @@ -8094,6 +8132,7 @@ CreateCheckPoint(int flags) /* * Update the control file. */ + INSTR_TIME_SET_CURRENT(phase_start); LWLockAcquire(ControlFileLock, LW_EXCLUSIVE); if (shutdown) ControlFile->state = DB_SHUTDOWNED; @@ -8147,6 +8186,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 @@ -8176,7 +8218,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 @@ -8205,8 +8251,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 @@ -8222,8 +8272,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); @@ -8397,6 +8463,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(); @@ -8965,6 +9034,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 54aaec9529f59..9fd5dfd4c7f2f 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" @@ -1869,6 +1871,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..fc1c26953cd89 --- /dev/null +++ b/src/backend/access/undo/Makefile @@ -0,0 +1,28 @@ +#------------------------------------------------------------------------- +# +# 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_bufmgr.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..fe61d68fff9b6 --- /dev/null +++ b/src/backend/access/undo/README @@ -0,0 +1,925 @@ +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 + +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: UndoShmemSize/UndoShmemInit + aggregates all subsystem shared memory needs. + UndoContext memory context management. + + undolog.c - UNDO log control structures and WAL batch coordination. + UndoLogControl/UndoLogSharedData structures. + + undorecord.c - UndoRecordSet and UndoRecordHeader: record format, + serialization, deserialization, 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. + + undo_bufmgr.c - Buffer management mapping undo logs into shared_buffers. + Virtual RelFileLocator: spcOid=1663, dbOid=9, + relNumber=log_number. + + 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 + UndoShmemSize() and UndoShmemInit() in undo.c: + + UndoShmemSize() = UndoLogShmemSize() + + XactUndoShmemSize() + + UndoWorkerShmemSize() + + 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 + +### UNDO Buffer Manager (undo_bufmgr.c) + +UNDO log blocks are managed through PostgreSQL's standard shared_buffers +pool via undo_bufmgr.c. Each undo log is mapped to a virtual +RelFileLocator (spcOid=1663, dbOid=UNDO_DB_OID=9, relNumber=log_number) +and accessed via ReadBufferWithoutRelcache(). This provides: + + - Unified buffer management (no separate cache to tune) + - Automatic clock-sweep eviction via shared_buffers + - Built-in dirty buffer tracking and checkpoint support + - Standard buffer locking and pin semantics + +## 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. (The per-backend UNDO logs, added in +a later patch, do keep their own segment files and recycle them physically +when their discard pointer crosses a segment boundary.) + +### 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. + +### Buffer Cache + +UNDO blocks share the standard shared_buffers pool with heap and index +data. No separate cache tuning is needed; the standard shared_buffers +setting controls memory available for all buffer types including UNDO. + +## 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 are NOT SQL-callable: there are no pg_proc.dat entries for them, so no +CREATE FUNCTION binding exists and they cannot be queried from SQL as written. +Wiring them into the catalog is outstanding work. + +### 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, serialization, 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 + undo_bufmgr.c shared_buffers integration, virtual RelFileLocator mapping + 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 + undo_bufmgr.h shared_buffers wrapper API for UNDO log blocks + 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 + 5 LWTRANCHE_UNDO_WORKER Revert worker state + (logical_revert_worker.c) + Protects revert queue and worker state + +Rules: +- Never acquire a buffer content lock while holding any UNDO LWLock. +- The revert worker acquires locks 2->5 in sequence during UNDO apply; + backends acquiring lock 5 (to enqueue work) must not already hold 2-4. diff --git a/src/backend/access/undo/meson.build b/src/backend/access/undo/meson.build new file mode 100644 index 0000000000000..ca8fc14f29d2e --- /dev/null +++ b/src/backend/access/undo/meson.build @@ -0,0 +1,15 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +backend_sources += files( + 'undo.c', + 'undo_bufmgr.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..b95efee0339f7 --- /dev/null +++ b/src/backend/access/undo/undo.c @@ -0,0 +1,272 @@ +/*------------------------------------------------------------------------- + * + * 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, +}; + +/* + * UndoShmemSize + * Figure out how much shared memory will be needed for undo. + * + * Each subsystem separately computes the space it requires, and we + * carefully add up those values here. AM-specific tracking structures + * (such as an optional per-tuple tracking hash) are sized by their own + * PG_SHMEM_SUBSYSTEM entry and are not included here. + */ +Size +UndoShmemSize(void) +{ + Size size; + + size = UndoLogShmemSize(); + size = add_size(size, XactUndoShmemSize()); + size = add_size(size, UndoWorkerShmemSize()); + + return size; +} + +/* + * 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. + */ +} + +/* + * 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. The pointers into shared memory held by the legacy ShmemInitStruct-based + * sub-modules (UndoLogShared, UndoWorkerShmem, WorkQueue, RevertState). + * Re-calling each *ShmemInit() in a child is safe: + * ShmemInitStruct() self-attaches via AttachShmemIndexEntry() when + * IsUnderPostmaster, re-assigns the module global, and reports found=true so + * the one-time "if (!found)" initialization block is correctly skipped. + * + * 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) +{ + UndoLogShmemInit(); + UndoWorkerShmemInit(); + + 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_bufmgr.c b/src/backend/access/undo/undo_bufmgr.c new file mode 100644 index 0000000000000..1d35cde5596f1 --- /dev/null +++ b/src/backend/access/undo/undo_bufmgr.c @@ -0,0 +1,250 @@ +/*------------------------------------------------------------------------- + * + * undo_bufmgr.c + * UNDO log buffer manager integration with PostgreSQL's shared_buffers + * + * This module routes undo log I/O through PostgreSQL's standard + * shared buffer pool. The approach follows ZHeap's design where undo + * data is "accessed through the buffer pool ... similar to regular + * relation data" (ZHeap README, lines 30-40). + * + * Each undo log is mapped to a virtual RelFileLocator: + * + * spcOid = UNDO_DEFAULT_TABLESPACE_OID (pg_default, 1663) + * dbOid = UNDO_DB_OID (pseudo-database 9) + * relNumber = undo log number + * + * This virtual locator is used with ReadBufferWithoutRelcache() to + * read/write undo blocks through the shared buffer pool. The fork + * number MAIN_FORKNUM is used (following ZHeap's UndoLogForkNum + * convention), and undo buffers are distinguished from regular data + * by the UNDO_DB_OID in the BufferTag's dbOid field. + * + * Benefits: + * - Unified buffer management (no separate cache to tune) + * - Automatic clock-sweep eviction via shared_buffers + * - Built-in dirty buffer tracking and checkpoint support + * - WAL integration for crash safety + * - Standard buffer locking and pin semantics + * + * 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_bufmgr.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "storage/buf_internals.h" + +#include "access/undo_bufmgr.h" + + +/* ---------------------------------------------------------------- + * Buffer tag construction + * ---------------------------------------------------------------- + */ + +/* + * UndoMakeBufferTag + * Initialize a BufferTag for an undo log block. + * + * This constructs the BufferTag that the shared buffer manager uses + * to identify this undo block in its hash table. The tag encodes the + * virtual RelFileLocator (mapping log_number to a pseudo-relation) + * and UndoLogForkNum (MAIN_FORKNUM) as the fork number. + */ +void +UndoMakeBufferTag(BufferTag *tag, uint32 log_number, + BlockNumber block_number) +{ + RelFileLocator rlocator; + + UndoLogGetRelFileLocator(log_number, &rlocator); + InitBufferTag(tag, &rlocator, UndoLogForkNum, block_number); +} + + +/* ---------------------------------------------------------------- + * Buffer read/release API + * ---------------------------------------------------------------- + */ + +/* + * ReadUndoBuffer + * Read an undo log block into the shared buffer pool. + * + * Translates the undo log number and block number into a virtual + * RelFileLocator and calls ReadBufferWithoutRelcache() to obtain + * a shared buffer. + * + * The returned Buffer handle is pinned. The caller must release it + * via ReleaseUndoBuffer() (or UnlockReleaseUndoBuffer() if locked). + * + * For normal reads (RBM_NORMAL), the caller should lock the buffer + * after this call: + * + * buf = ReadUndoBuffer(logno, blkno, RBM_NORMAL); + * LockBuffer(buf, BUFFER_LOCK_SHARE); + * ... read data from BufferGetPage(buf) ... + * UnlockReleaseUndoBuffer(buf); + * + * For new page allocation (RBM_ZERO_AND_LOCK), the buffer is returned + * zero-filled and exclusively locked: + * + * buf = ReadUndoBuffer(logno, blkno, RBM_ZERO_AND_LOCK); + * ... initialize page contents ... + * MarkUndoBufferDirty(buf); + * UnlockReleaseUndoBuffer(buf); + */ +Buffer +ReadUndoBuffer(uint32 log_number, BlockNumber block_number, + ReadBufferMode mode) +{ + return ReadUndoBufferExtended(log_number, block_number, mode, NULL); +} + +/* + * ReadUndoBufferExtended + * Like ReadUndoBuffer but with explicit buffer access strategy. + * + * The strategy parameter can be used to control buffer pool usage when + * performing bulk undo log operations (e.g., sequential scan during + * discard, or recovery). Pass NULL for the default strategy. + * + * Undo logs are always permanent (they must survive crashes for + * recovery purposes), so we pass permanent=true to + * ReadBufferWithoutRelcache(). + */ +Buffer +ReadUndoBufferExtended(uint32 log_number, BlockNumber block_number, + ReadBufferMode mode, BufferAccessStrategy strategy) +{ + RelFileLocator rlocator; + + UndoLogGetRelFileLocator(log_number, &rlocator); + + return ReadBufferWithoutRelcache(rlocator, + UndoLogForkNum, + block_number, + mode, + strategy, + true); /* permanent */ +} + +/* + * ReleaseUndoBuffer + * Release a pinned undo buffer. + * + * The buffer must not be locked when this is called. + * This is a thin wrapper for API consistency; callers that hold + * a lock should use UnlockReleaseUndoBuffer() instead. + */ +void +ReleaseUndoBuffer(Buffer buffer) +{ + ReleaseBuffer(buffer); +} + +/* + * UnlockReleaseUndoBuffer + * Unlock and release an undo buffer in one call. + * + * Convenience function that combines UnlockReleaseBuffer() semantics + * for undo buffers. + */ +void +UnlockReleaseUndoBuffer(Buffer buffer) +{ + UnlockReleaseBuffer(buffer); +} + +/* + * MarkUndoBufferDirty + * Mark an undo buffer as needing write-back. + * + * The buffer must be exclusively locked when this is called. + * The dirty buffer will be written back during the next checkpoint + * or when evicted from the buffer pool. + */ +void +MarkUndoBufferDirty(Buffer buffer) +{ + MarkBufferDirty(buffer); +} + + +/* ---------------------------------------------------------------- + * Buffer invalidation + * ---------------------------------------------------------------- + */ + +/* + * InvalidateUndoBuffers + * Drop all shared buffers belonging to a given undo log. + * + * This is called when an undo log is fully discarded and no longer + * needed. All pages for the specified undo log number are removed + * from the shared buffer pool without being written back to disk, + * since the underlying undo log files are being removed. + * + * Uses DropRelationBuffers() which is the standard public API for + * dropping buffers belonging to a relation. We open an SMgrRelation + * for the virtual undo log locator and drop all buffers for the + * UndoLogForkNum fork starting from block 0. + * + * The caller must ensure that no other backend is concurrently + * accessing buffers for this undo log. + */ +void +InvalidateUndoBuffers(uint32 log_number) +{ + RelFileLocator rlocator; + SMgrRelation srel; + ForkNumber forknum = UndoLogForkNum; + BlockNumber firstDelBlock = 0; + + UndoLogGetRelFileLocator(log_number, &rlocator); + srel = smgropen(rlocator, INVALID_PROC_NUMBER); + + DropRelationBuffers(srel, &forknum, 1, &firstDelBlock); + + smgrclose(srel); +} + +/* + * InvalidateUndoBufferRange + * Drop shared buffers for a range of blocks in an undo log. + * + * This is called during undo log truncation when only a portion of + * the undo log is being discarded. Blocks starting from first_block + * onward are invalidated. + * + * Note: DropRelationBuffers drops all blocks >= firstDelBlock for the + * given fork, so we pass first_block as the starting block. The + * last_block parameter documents the intended range boundary but the + * buffer manager will drop any matching buffer with blockNum >= + * first_block. + * + * The caller must ensure that no other backend is concurrently + * accessing the buffers being invalidated. + */ +void +InvalidateUndoBufferRange(uint32 log_number, BlockNumber first_block, + BlockNumber last_block) +{ + RelFileLocator rlocator; + SMgrRelation srel; + ForkNumber forknum = UndoLogForkNum; + + Assert(first_block <= last_block); + + UndoLogGetRelFileLocator(log_number, &rlocator); + srel = smgropen(rlocator, INVALID_PROC_NUMBER); + + DropRelationBuffers(srel, &forknum, 1, &first_block); + + smgrclose(srel); +} diff --git a/src/backend/access/undo/undo_xlog.c b/src/backend/access/undo/undo_xlog.c new file mode 100644 index 0000000000000..cd5a5b9588b31 --- /dev/null +++ b/src/backend/access/undo/undo_xlog.c @@ -0,0 +1,1178 @@ +/*------------------------------------------------------------------------- + * + * 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); + + /* + * Extend the UNDO log file to the specified size. The file + * will be created if it doesn't exist. + * + * With append-only I/O, the smgr-managed file is no longer + * used -- UNDO data is written directly to the segment file. + */ + 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. Used only by nbtree/hash index + * INSERT undo, where btree binary search + * still reads 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 (FLUX). + * 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 deferred for async UNDO processing + * + * During crash recovery, if syscache isn't available, we skip UNDO application + * and defer the transaction for later processing by the logical revert worker. + */ +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 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); + total_records++; + + /* + * If any UNDO record was skipped (e.g., due to + * syscache not being initialized), mark this + * transaction for deferred processing by the + * logical revert worker. + */ + 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), track this transaction for + * deferred processing. We cannot apply it during recovery, so we add + * it to an in-memory list that will be applied after recovery + * completes (when InRedo is set to false). + * + * 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 deferred to " + "logical revert worker (syscache not ready)", + entry->xid))); + } + } + } + + if (total_xacts > 0) + { + if (deferred_xacts > 0) + ereport(LOG, + (errmsg("UNDO recovery complete: %d transactions processed, " + "%d records applied, %d transactions deferred to " + "logical revert worker", + 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 - Apply 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. Until an asynchronous applier exists, the deferred + * chains are reported and dropped here so recovery can complete; the WAL + * holding them is retained by UndoGetOldestBatchLSN's other checks. + */ +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 / logical revert + * worker). + */ + 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; +} + +/* + * UndoResetBatchReader - Free and NULL the cached WAL reader. + * + * Must be called after a PG_CATCH that could leave the static reader in + * an inconsistent state (stale segment FD, partial read buffer, etc.). + * The next call to UndoReadBatchFromWAL will reallocate a fresh reader. + */ +void +UndoResetBatchReader(void) +{ + if (undo_batch_reader != NULL) + { + XLogReaderFree(undo_batch_reader); + undo_batch_reader = NULL; + } +} 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..5a91908349a02 --- /dev/null +++ b/src/backend/access/undo/undolog.c @@ -0,0 +1,519 @@ +/*------------------------------------------------------------------------- + * + * 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; +} + +/* + * UndoLogShmemInit + * Initialize shared memory for UNDO log management + */ +void +UndoLogShmemInit(void) +{ + bool found; + + UndoLogShared = (UndoLogSharedData *) + ShmemInitStruct("UNDO Log Control", UndoLogShmemSize(), &found); + + if (!found) + { + 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); + } +} + +/* + * UndoLogGetOldestDiscardPtr + * Get the oldest UNDO discard pointer across all active logs. + * + * Used to determine WAL retention requirements for UNDO. + */ +UndoRecPtr +UndoLogGetOldestDiscardPtr(void) +{ + UndoRecPtr oldest = InvalidUndoRecPtr; + int i; + + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + UndoLogControl *log = &UndoLogShared->logs[i]; + + if (log->in_use) + { + if (!UndoRecPtrIsValid(oldest) || + log->discard_ptr < oldest) + oldest = log->discard_ptr; + } + } + + return oldest; +} + +/* + * 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)))); + } +} + +/* + * UndoGetDiscardHorizon + * Return the current UNDO discard horizon LSN. + * + * WAL segments containing data at or after this LSN must be retained + * because they contain UNDO records that may still be needed for + * rollback of in-progress transactions. + * + * Returns InvalidXLogRecPtr if no UNDO data exists (UNDO not in use + * or all transactions have committed). + */ +XLogRecPtr +UndoGetDiscardHorizon(void) +{ + if (UndoLogShared == NULL) + return InvalidXLogRecPtr; + + return (XLogRecPtr) pg_atomic_read_u64(&UndoLogShared->undo_discard_horizon); +} + +/* + * 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. ATM entries (aborted transactions awaiting Logical Revert) + * + * The ATM check is critical: once a transaction aborts, its per-backend + * slot is cleared by UndoClearBatchLSN(), but the logical revert worker + * still needs to read the UNDO batches from WAL. Without this check, + * checkpoints could recycle WAL segments containing needed UNDO data, + * causing the revert worker to crash (SIGBUS/SIGSEGV) or read garbage. + * + * 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; +} + +/* + * Legacy no-op stubs + * + * UNDO-in-WAL has no per-log segment files, no fd cache, and no + * per-backend write-pointer tracking: UNDO data lives in the WAL stream + * and durability/recycling are handled by WAL flush and the discard + * worker. The operations below are therefore inherently nothing-to-do in + * this mode, but they still have live callers in the shared transaction + * and discard paths (e.g. UndoLogCloseFiles / UndoFlushResetMaxWritePtr + * from xactundo.c, UndoLogDeleteSegmentFile from the discard worker, + * ExtendUndoLogFile from the undo_xlog.c redo path). We keep them as + * no-ops so those callers stay uniform across both UNDO modes rather than + * sprinkling mode checks at every call site; the no-op is the correct + * behaviour here, not a placeholder awaiting future work. + */ + +void +UndoLogSync(void) +{ + /* No-op: WAL sync handles durability */ +} + +void +UndoLogCloseFiles(void) +{ + /* No-op: no fd cache with UNDO-in-WAL */ +} + +void +UndoFlushResetMaxWritePtr(void) +{ + /* No-op: no per-backend write pointer tracking with UNDO-in-WAL */ +} + +UndoRecPtr +UndoFlushGetMaxWritePtr(void) +{ + /* No-op: no per-backend write pointer tracking with UNDO-in-WAL */ + return InvalidUndoRecPtr; +} + +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 */ +} + +bool +UndoLogTryPressureDiscard(void) +{ + /* No-op: no segment pressure with UNDO-in-WAL */ + return false; +} + +char * +UndoLogPath(uint32 log_number, char *path) +{ + /* Legacy: construct the path even though files no longer exist */ + snprintf(path, MAXPGPATH, "base/undo/%012u", log_number); + return path; +} + +void +ExtendUndoLogFile(uint32 log_number pg_attribute_unused(), + uint64 logical_end pg_attribute_unused()) +{ + /* No-op: no segment files with UNDO-in-WAL */ +} + +void +ExtendUndoLogSmgrFile(uint32 log_number pg_attribute_unused(), + uint64 logical_end pg_attribute_unused()) +{ + /* No-op: no smgr-managed UNDO files with UNDO-in-WAL */ +} + +UndoRecPtr +UndoLogGetInsertPtr(uint32 log_number) +{ + int i; + UndoRecPtr ptr = InvalidUndoRecPtr; + + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + UndoLogControl *log = &UndoLogShared->logs[i]; + + if (log->in_use && log->log_number == log_number) + { + ptr = pg_atomic_read_u64(&log->insert_ptr); + break; + } + } + + return ptr; +} + +UndoRecPtr +UndoLogGetDiscardPtr(uint32 log_number) +{ + int i; + UndoRecPtr ptr = InvalidUndoRecPtr; + + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + UndoLogControl *log = &UndoLogShared->logs[i]; + + if (log->in_use && log->log_number == log_number) + { + LWLockAcquire(&log->lock, LW_SHARED); + ptr = log->discard_ptr; + LWLockRelease(&log->lock); + break; + } + } + + return ptr; +} diff --git a/src/backend/access/undo/undorecord.c b/src/backend/access/undo/undorecord.c new file mode 100644 index 0000000000000..33df75369ec37 --- /dev/null +++ b/src/backend/access/undo/undorecord.c @@ -0,0 +1,415 @@ +/*------------------------------------------------------------------------- + * + * 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. + */ +Size +UndoRecordGetPayloadSize(Size payload_len) +{ + return SizeOfUndoRecordHeader + payload_len; +} + +/* + * UndoRecordSerialize - Serialize an UNDO record into a buffer + * + * The destination buffer must be large enough to hold the entire record. + * Use UndoRecordGetPayloadSize() to determine the required size. + */ +void +UndoRecordSerialize(char *dest, UndoRecordHeader *header, + const char *payload, Size payload_len) +{ + /* Copy header */ + memcpy(dest, header, SizeOfUndoRecordHeader); + + /* Copy payload if present */ + if (payload_len > 0 && payload != NULL) + { + memcpy(dest + SizeOfUndoRecordHeader, payload, payload_len); + } +} + +/* + * UndoRecordDeserialize - Deserialize an UNDO record from a buffer + * + * Reads the header and sets the payload pointer into the source buffer + * (zero-copy). Returns true on success, false on failure. + */ +bool +UndoRecordDeserialize(const char *src, UndoRecordHeader *header, + char **payload) +{ + if (src == NULL || header == NULL) + return false; + + /* Copy header */ + memcpy(header, src, SizeOfUndoRecordHeader); + + /* Set payload pointer if there is payload data */ + if (header->urec_payload_len > 0) + { + if (payload != NULL) + *payload = (char *) (src + SizeOfUndoRecordHeader); + } + else + { + if (payload != NULL) + *payload = NULL; + } + + return true; +} + +/* + * 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); + + 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); + + 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..a15c9e55b04cd --- /dev/null +++ b/src/backend/access/undo/undostats.c @@ -0,0 +1,352 @@ +/*------------------------------------------------------------------------- + * + * 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" + +PG_FUNCTION_INFO_V1(pg_stat_get_undo_logs); +PG_FUNCTION_INFO_V1(pg_stat_get_undo_buffers); +PG_FUNCTION_INFO_V1(pg_undo_force_discard); + +/* + * 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..0c29ab291b981 --- /dev/null +++ b/src/backend/access/undo/undoworker.c @@ -0,0 +1,628 @@ +/*------------------------------------------------------------------------- + * + * 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/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); +} + +/* + * UndoWorkerShmemInit - Initialize shared memory + */ +void +UndoWorkerShmemInit(void) +{ + bool found; + + UndoWorkerShmem = (UndoWorkerShmemData *) + ShmemInitStruct("UNDO Worker Data", + UndoWorkerShmemSize(), + &found); + + if (!found) + { + 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 or cleaning up retained sLog before-images). 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() and the sLog + * cleanup operate on shared memory and do 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); +} + +/* + * UndoWorkerRequestShutdown - Request worker to shut down + */ +void +UndoWorkerRequestShutdown(void) +{ + if (UndoWorkerShmem != NULL) + { + LWLockAcquire(&UndoWorkerShmem->lock, LW_EXCLUSIVE); + UndoWorkerShmem->shutdown_requested = true; + LWLockRelease(&UndoWorkerShmem->lock); + } +} diff --git a/src/backend/access/undo/xactundo.c b/src/backend/access/undo/xactundo.c new file mode 100644 index 0000000000000..401e748bfa66b --- /dev/null +++ b/src/backend/access/undo/xactundo.c @@ -0,0 +1,1459 @@ +/*------------------------------------------------------------------------- + * + * 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 */ +} + +/* + * PrepareXactUndoDataParts + * Like PrepareXactUndoData, but with scatter-gather payload. + * + * Used when the payload is in two non-contiguous pieces (e.g., a fixed + * header struct followed by variable-length tuple data). Avoids the + * need to assemble an intermediate contiguous buffer. + */ +UndoRecPtr +PrepareXactUndoDataParts(XactUndoContext *ctx, char persistence, + uint8 rmid, uint16 info, Oid reloid, + const char *part1, Size part1_len, + const char *part2, Size part2_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(); + + /* + * If we've entered a subtransaction deeper than what's currently tracked, + * push a new entry onto the subxact_stack. + */ + 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, stamped with the top-level xid (see + * PrepareXactUndoData). + */ + 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 using scatter-gather payload API. */ + UndoRecordAddPayloadParts(uset, rmid, info, reloid, + part1, part1_len, part2, part2_len); + + /* + * If this is the first undo for this persistence level in this + * subtransaction, record the start location. + */ + 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(); +} + + +/* + * GetCurrentXactUndoRecPtr + * Get the most recent undo record pointer for a persistence level. + */ +UndoRecPtr +GetCurrentXactUndoRecPtr(UndoPersistenceLevel plevel) +{ + return XactUndo.last_location[plevel]; +} + +/* + * 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]; +} + +/* + * XActUndoUpdateLastBatchLSN + * Record the LSN of an UNDO batch for the current transaction. + * + * Called from the heap DML code after writing an UNDO batch -- either + * embedded inside a heap WAL record (HAS_UNDO path) or as a standalone + * XLOG_UNDO_BATCH overflow record. Updates last_batch_lsn so that + * AtAbort_XactUndo() can find the head of the UNDO chain, and registers + * the batch LSN for WAL retention tracking on first call per transaction. + */ +void +XActUndoUpdateLastBatchLSN(XLogRecPtr lsn, UndoPersistenceLevel plevel) +{ + if (!XLogRecPtrIsValid(lsn) || plevel >= NUndoPersistenceLevels) + return; + + /* + * The pointer must be a valid LSN. Rmid validation is deferred to the + * rollback path; see comment in InsertXactUndoData for why we cannot call + * UndoValidateBatchLSN here. + */ + Assert(XLogRecPtrIsValid(lsn)); + + XactUndo.has_undo = true; + XactUndo.last_batch_lsn[plevel] = lsn; +} + +/* + * XactUndoHasUnrecoverableUndo + * Does the current transaction hold UNDO that has no working ROLLBACK + * PREPARED apply path yet? + * + * Both UNDO mechanisms are now recoverable across 2PC: + * + * - Cluster-wide UNDO (nbtree/hash and other cluster-wide consumers; + * last_batch_lsn): 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, the recovery UNDO + * phase, or (for a subxact-aborted chain) the revert worker can all 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 if inline UNDO or ATM + * holds LSN */ + + /* + * 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 + * every one of the aborted index entries it describes would be left for + * VACUUM -- 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; + + 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. + */ + if (!UndoValidateBatchLSN(perm_lsn)) + { + elog(DEBUG1, "inline UNDO: last_batch_lsn %X/%X is not " + "a valid UNDO batch", + LSN_FORMAT_ARGS(perm_lsn)); + undo_applied = false; + 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 per-backend UNDO LSN + * 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 (as PbuAtAbort_ApplyUndo + * does). 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; + } + } + + /* Close cached UNDO log fds. */ + UndoLogCloseFiles(); + + /* Reset per-backend write pointer tracking. */ + UndoFlushResetMaxWritePtr(); + + /* + * Release the WAL retention hold if nothing needs the UNDO records any + * more: inline UNDO completed, or the ATM recorded the transaction (the + * revert worker reads the chain from the ATM's copy of the LSN). + * + * 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 per-backend UNDO LSN slot (ATM and inline both 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 creates no + * top-level ATM entry, so nothing would ever revert its index entries -- + * 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 defer to + * the top-level ATM entry: a subtransaction can abort while its + * parent goes on to COMMIT, in which case no top-level ATM entry is + * ever created and the deferred 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; + } + } + + /* Close any cached UNDO log fds before process exit. */ + UndoLogCloseFiles(); + + /* Reset per-backend write pointer tracking. */ + UndoFlushResetMaxWritePtr(); + + /* 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 93ad49e59fdcd..2bfe90d1aaf39 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 3da2a90417fc6..d9d3e51396003 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 f81c7732e6899..ceb79d359b178 100644 --- a/src/backend/storage/buffer/bufmgr.c +++ b/src/backend/storage/buffer/bufmgr.c @@ -3220,6 +3220,68 @@ MarkBufferDirty(Buffer buffer) } } +/* + * MarkBufferDirtyShared + * + * Like MarkBufferDirty, but callable while holding only a SHARED content + * lock on the buffer. The BM_DIRTY bit is set atomically via CAS on the + * buffer header state word, which is safe regardless of lock mode. + * + * The caller MUST ensure that either: + * (a) a WAL record covering the modification has already been inserted + * (so the WAL flush in the checkpointer will find the record), or + * (b) full_page_writes will capture a consistent image (the modification + * is protected by a per-tuple lock that prevents torn pages). + * + * This is used by an in-place-update table AM's tuple-level CAS update path + * where per-tuple atomics protect individual tuple data under a shared page + * lock. + */ +void +MarkBufferDirtyShared(Buffer buffer) +{ + BufferDesc *bufHdr; + uint64 buf_state; + uint64 old_buf_state; + + if (!BufferIsValid(buffer)) + elog(ERROR, "bad buffer ID: %d", buffer); + + if (BufferIsLocal(buffer)) + { + MarkLocalBufferDirty(buffer); + return; + } + + bufHdr = GetBufferDescriptor(buffer - 1); + + Assert(BufferIsPinned(buffer)); + /* Caller holds at least BUFFER_LOCK_SHARE -- no exclusive assertion */ + + old_buf_state = pg_atomic_read_u64(&bufHdr->state); + for (;;) + { + if (old_buf_state & BM_LOCKED) + old_buf_state = WaitBufHdrUnlocked(bufHdr); + + buf_state = old_buf_state; + + Assert(BUF_STATE_GET_REFCOUNT(buf_state) > 0); + buf_state |= BM_DIRTY; + + if (pg_atomic_compare_exchange_u64(&bufHdr->state, &old_buf_state, + buf_state)) + break; + } + + if (!(old_buf_state & BM_DIRTY)) + { + pgBufferUsage.shared_blks_dirtied++; + if (VacuumCostActive) + VacuumCostBalance += VacuumCostPageDirty; + } +} + /* * ReleaseAndReadBuffer -- combine ReleaseBuffer() and ReadBuffer() * @@ -4902,6 +4964,38 @@ DropRelationBuffers(SMgrRelation smgr_reln, ForkNumber *forkNum, } } +/* --------------------------------------------------------------------- + * DropRelationBufferRange + * + * This function removes from the buffer pool the pages of one fork of + * a shared relation whose block numbers lie in [firstDelBlock, + * endBlock). Dirty pages are discarded without being written. + * + * DropRelationBuffers() drops everything at or beyond firstDelBlock, + * which suits truncation. A caller that retires the *head* of a + * relation while its tail is still live -- the UNDO log discarding + * consumed space below its insert point -- needs an upper bound, or + * it would lose the live tail. The caller must guarantee that no + * backend can need the dropped pages again, and must drop them before + * removing the file that holds them: a dirty page left behind would + * later be written to a file that no longer exists. + * + * Only shared (non-temporary) relations are handled. + * -------------------------------------------------------------------- + */ +void +DropRelationBufferRange(SMgrRelation smgr_reln, ForkNumber forkNum, + BlockNumber firstDelBlock, BlockNumber endBlock) +{ + Assert(!RelFileLocatorBackendIsTemp(smgr_reln->smgr_rlocator)); + + if (firstDelBlock >= endBlock) + return; + + FindAndDropRelationBuffers(smgr_reln->smgr_rlocator.locator, forkNum, + endBlock, firstDelBlock); +} + /* --------------------------------------------------------------------- * DropRelationsAllBuffers * @@ -5905,6 +5999,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..a81528df61f65 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,8 @@ 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." +UndoWorker "Waiting to access or modify UNDO worker shared memory queue." # 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 380679a76c096..fef22d15030d7 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.', @@ -3295,6 +3296,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 90971fbdac6f6..731634535ed4c 100644 --- a/src/bin/pg_dump/pg_dump.c +++ b/src/bin/pg_dump/pg_dump.c @@ -17398,6 +17398,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..d258c804e0151 --- /dev/null +++ b/src/include/access/undo.h @@ -0,0 +1,52 @@ +/*------------------------------------------------------------------------- + * + * 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_bufmgr.c - Buffer management via shared_buffers + * 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 Size UndoShmemSize(void); +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_bufmgr.h b/src/include/access/undo_bufmgr.h new file mode 100644 index 0000000000000..b0c3736b73122 --- /dev/null +++ b/src/include/access/undo_bufmgr.h @@ -0,0 +1,297 @@ +/*------------------------------------------------------------------------- + * + * undo_bufmgr.h + * UNDO log buffer management and file layout definitions + * + * UNDO-in-WAL architecture: + * + * - UNDO records are embedded in the WAL stream as XLOG_UNDO_BATCH + * records. There are no separate UNDO segment files. + * - Reads: UndoReadBatchFromWAL() reads UNDO batches from WAL via + * XLogReader (for rollback chain traversal). + * - Sync: WAL flush handles durability (standard XLogFlush path). + * - Retention: undo_discard_horizon prevents WAL recycling past + * oldest needed UNDO batch. + * + * This module retains virtual RelFileLocator mapping for: + * - Buffer invalidation during segment discard (InvalidateUndoBuffers) + * - Legacy backward compatibility + * + * Each undo log is mapped to a virtual relation: + * RelFileLocator = { + * spcOid = UNDO_DEFAULT_TABLESPACE_OID (pg_default, 1663) + * dbOid = UNDO_DB_OID (pseudo-database 9) + * relNumber = log_number (undo log number as RelFileNumber) + * } + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undo_bufmgr.h + * + *------------------------------------------------------------------------- + */ +#ifndef UNDO_BUFMGR_H +#define UNDO_BUFMGR_H + +#include "storage/block.h" +#include "storage/buf.h" +#include "storage/bufmgr.h" +#include "storage/relfilelocator.h" + +/* + * Pseudo-database OID used for undo log relations in the buffer pool. + * This matches ZHeap's UndoLogDatabaseOid convention. This OID must not + * collide with any real database OID; value 9 is reserved for this purpose. + */ +#define UNDO_DB_OID 9 + +/* + * Default tablespace OID for undo log buffers. This matches the + * pg_default tablespace (OID 1663 from pg_tablespace.dat). + * Eventually per-tablespace undo logs may be supported, but for now + * all undo data uses the default tablespace. + */ +#define UNDO_DEFAULT_TABLESPACE_OID 1663 + +/* + * Fork number used for undo log buffers in the shared buffer pool. + * + * Following ZHeap's convention (UndoLogForkNum = MAIN_FORKNUM), we use + * MAIN_FORKNUM for undo log buffer operations. Undo buffers are + * distinguished from regular relation data by the UNDO_DB_OID in the + * dbOid field of the BufferTag, not by a special fork number. + * + * Using MAIN_FORKNUM is necessary because the smgr layer sizes internal + * arrays to MAX_FORKNUM+1 entries. A fork number beyond that range + * would cause out-of-bounds accesses in smgr_cached_nblocks[] and + * similar arrays. + */ +#define UndoLogForkNum MAIN_FORKNUM + +/* + * UNDO_FORKNUM is reserved for future use when the smgr layer is + * extended to support undo-specific file management (Task #5). + * It is defined in buf_internals.h as a constant but not currently + * used in buffer operations. + */ + + +/* ---------------------------------------------------------------- + * Undo log to RelFileLocator mapping + * ---------------------------------------------------------------- + */ + +/* + * UndoLogGetRelFileLocator + * Build a virtual RelFileLocator for an undo log number. + * + * This mapping allows the standard buffer manager to identify undo log + * blocks using its existing BufferTag infrastructure. The resulting + * RelFileLocator does not correspond to any entry in pg_class; it is + * purely a buffer-pool-internal identifier. + * + * Parameters: + * log_number - the undo log number (0..16M) + * rlocator - output RelFileLocator to populate + */ +static inline void +UndoLogGetRelFileLocator(uint32 log_number, RelFileLocator *rlocator) +{ + rlocator->spcOid = UNDO_DEFAULT_TABLESPACE_OID; + rlocator->dbOid = UNDO_DB_OID; + rlocator->relNumber = (RelFileNumber) log_number; +} + +/* + * IsUndoRelFileLocator + * Check whether a RelFileLocator refers to an undo log. + * + * This is useful for code that needs to distinguish undo log locators + * from regular relation locators (e.g., in smgr dispatch, checkpoint + * logic, or buffer tag inspection). + */ +static inline bool +IsUndoRelFileLocator(const RelFileLocator *rlocator) +{ + return (rlocator->dbOid == UNDO_DB_OID); +} + +/* + * UNDO file layout: append-only + * + * UNDO log files use an append-only layout with NO PageHeaderData overhead. + * The logical byte offset in UndoRecPtr maps directly to the physical file + * offset. This eliminates the overhead of page headers, pd_lower tracking, + * LSN management, and full-page images for UNDO data. + * + * UNDO data is written via pwrite() and read via pread(), bypassing + * shared_buffers entirely for the write path. For reads, hot data is + * served from the kernel page cache (no I/O), while cold data requires + * sequential I/O on the pre-allocated file. + * + * The buffer pool integration (ReadUndoBuffer etc.) is retained only for + * the buffer invalidation API used during segment discard. + */ + +/* + * UndoRecPtrGetFileOffset + * Compute the physical file offset for an undo log logical byte offset. + * + * With the append-only layout, the logical offset IS the file offset. + */ +#define UndoRecPtrGetFileOffset(offset) ((uint64) (offset)) + +/* + * Legacy page-layout macros (retained for undo_bufmgr.c invalidation API). + * + * These are used only by buffer invalidation during discard, not by the + * write/read paths. The "block number" is conceptual, mapping the + * contiguous byte stream to BLCKSZ-aligned regions. + */ +#define UNDO_USABLE_BYTES_PER_PAGE BLCKSZ + +#define UndoRecPtrGetBlockNum(offset) \ + ((BlockNumber) ((offset) / BLCKSZ)) + +#define UndoRecPtrGetPageOffset(offset) \ + ((uint32) ((offset) % BLCKSZ)) + +/* + * UndoLogicalToFileSize + * Compute the physical file size needed for a given logical byte count. + * + * With append-only layout, physical size equals logical size (no headers). + * We round up to BLCKSZ alignment for pre-allocation. + */ +#define UndoLogicalToFileSize(logical_size) \ + ((uint64) (((logical_size) + BLCKSZ - 1) / BLCKSZ) * BLCKSZ) + + +/* ---------------------------------------------------------------- + * Buffer read/release API + * ---------------------------------------------------------------- + */ + +/* + * ReadUndoBuffer + * Read an undo log block into the shared buffer pool. + * + * This is the primary entry point for reading undo data. It translates + * the undo log number and block number into a virtual RelFileLocator and + * calls ReadBufferWithoutRelcache() to obtain a shared buffer. + * + * The returned Buffer must be released with ReleaseUndoBuffer() when the + * caller is done. The caller may also need to lock the buffer (via + * LockBuffer) depending on the access pattern. + * + * Parameters: + * log_number - undo log number + * block_number - block within the undo log + * mode - RBM_NORMAL, RBM_ZERO_AND_LOCK, etc. + * + * Returns: a valid Buffer handle. + */ +extern Buffer ReadUndoBuffer(uint32 log_number, BlockNumber block_number, + ReadBufferMode mode); + +/* + * ReadUndoBufferExtended + * Like ReadUndoBuffer but with explicit strategy control. + * + * Allows the caller to specify a buffer access strategy (e.g., for + * sequential undo log scans during discard or recovery). + */ +extern Buffer ReadUndoBufferExtended(uint32 log_number, + BlockNumber block_number, + ReadBufferMode mode, + BufferAccessStrategy strategy); + +/* + * ReleaseUndoBuffer + * Release a previously read undo buffer. + * + * This is a thin wrapper around ReleaseBuffer() for API symmetry. + * If the buffer was locked, it must be unlocked first (or use + * UnlockReleaseUndoBuffer). + */ +extern void ReleaseUndoBuffer(Buffer buffer); + +/* + * UnlockReleaseUndoBuffer + * Unlock and release an undo buffer in one call. + */ +extern void UnlockReleaseUndoBuffer(Buffer buffer); + +/* + * MarkUndoBufferDirty + * Mark an undo buffer as dirty. + * + * This is a thin wrapper around MarkBufferDirty() for API consistency. + */ +extern void MarkUndoBufferDirty(Buffer buffer); + + +/* ---------------------------------------------------------------- + * Buffer tag construction (requires buf_internals.h) + * ---------------------------------------------------------------- + */ + +/* + * UndoMakeBufferTag + * Initialize a BufferTag for an undo log block. + * + * This constructs the BufferTag that the shared buffer manager will use + * to identify this undo block in its hash table. It uses the virtual + * RelFileLocator mapping and UndoLogForkNum. + * + * Callers must include storage/buf_internals.h before this header to + * make these declarations visible. + */ +#ifdef BUFMGR_INTERNALS_H +extern void UndoMakeBufferTag(BufferTag *tag, uint32 log_number, + BlockNumber block_number); + +/* + * IsUndoBufferTag + * Check whether a BufferTag refers to an undo log buffer. + * + * Undo buffers are identified by the UNDO_DB_OID in the dbOid field + * of the buffer tag. + */ +static inline bool +IsUndoBufferTag(const BufferTag *tag) +{ + return (tag->dbOid == UNDO_DB_OID); +} +#endif /* BUFMGR_INTERNALS_H */ + + +/* ---------------------------------------------------------------- + * Invalidation + * ---------------------------------------------------------------- + */ + +/* + * InvalidateUndoBuffers + * Drop all shared buffers for a given undo log. + * + * Called when an undo log is discarded to remove stale entries from + * the shared buffer pool. This is analogous to DropRelationBuffers() + * for regular relations. + */ +extern void InvalidateUndoBuffers(uint32 log_number); + +/* + * InvalidateUndoBufferRange + * Drop shared buffers for a range of blocks in an undo log. + * + * Called during undo log truncation/discard to invalidate only the + * blocks that are being reclaimed. Blocks starting from first_block + * onward are invalidated. + */ +extern void InvalidateUndoBufferRange(uint32 log_number, + BlockNumber first_block, + BlockNumber last_block); + +#endif /* UNDO_BUFMGR_H */ diff --git a/src/include/access/undo_xlog.h b/src/include/access/undo_xlog.h new file mode 100644 index 0000000000000..327f67e489a7f --- /dev/null +++ b/src/include/access/undo_xlog.h @@ -0,0 +1,290 @@ +/*------------------------------------------------------------------------- + * + * 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 heap or index page during transaction rollback. + * + * 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 (FLUX) + * UNDO_CLR_LP_DEAD - Mark line pointer LP_DEAD (nbtree/hash 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); + +/* Two-phase commit support */ +extern void undo_twophase_recover(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); +extern void undo_twophase_postcommit(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); +extern void undo_twophase_postabort(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); + +/* + * 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); +extern void UndoResetBatchReader(void); + +/* + * 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..02ad54cdb934d --- /dev/null +++ b/src/include/access/undolog.h @@ -0,0 +1,196 @@ +/*------------------------------------------------------------------------- + * + * 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; + +/* + * 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; + +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_rollback_overflow_size; +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 UndoLogShmemInit(void); + +/* + * Discard, retention, and checkpoint + */ +extern void UndoLogDiscard(UndoRecPtr oldest_needed); +extern UndoRecPtr UndoLogGetOldestDiscardPtr(void); +extern void CheckPointUndoLog(void); + +/* WAL retention for UNDO: get/set the discard horizon */ +extern XLogRecPtr UndoGetDiscardHorizon(void); +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); + +/* + * Utility functions + */ +extern UndoRecPtr UndoLogGetInsertPtr(uint32 log_number); +extern UndoRecPtr UndoLogGetDiscardPtr(uint32 log_number); +extern char *UndoLogPath(uint32 log_number, char *path); + +/* + * Legacy no-op stubs -- retained for callers not yet fully updated. + * These are all no-ops in the UNDO-in-WAL architecture. + */ +extern void UndoLogSync(void); +extern void UndoLogCloseFiles(void); +extern void ExtendUndoLogFile(uint32 log_number, uint64 new_size); +extern void ExtendUndoLogSmgrFile(uint32 log_number, uint64 logical_end); +extern UndoRecPtr UndoFlushGetMaxWritePtr(void); +extern void UndoFlushResetMaxWritePtr(void); +extern void UndoLogSealAndRotate(uint8 trigger); +extern void UndoLogDeleteSegmentFile(uint32 log_number); +extern bool UndoLogTryPressureDiscard(void); + +#endif /* UNDOLOG_H */ diff --git a/src/include/access/undorecord.h b/src/include/access/undorecord.h new file mode 100644 index 0000000000000..c37ab2de6d6ec --- /dev/null +++ b/src/include/access/undorecord.h @@ -0,0 +1,222 @@ +/*------------------------------------------------------------------------- + * + * 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); +extern void UndoRecordSerialize(char *dest, UndoRecordHeader *header, + const char *payload, Size payload_len); +extern bool UndoRecordDeserialize(const char *src, UndoRecordHeader *header, + char **payload); + +/* 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..8933798553668 --- /dev/null +++ b/src/include/access/undormgr.h @@ -0,0 +1,118 @@ +/*------------------------------------------------------------------------- + * + * 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 and the built-in index-AM IDs. + * Every other 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 table 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 UNDO_RMID_NBTREE 1 +#define UNDO_RMID_HASH 3 +/* 2, 4, and 5 are reserved by out-of-core consumers (see their own headers) */ + +#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..9a5a55e3956b3 --- /dev/null +++ b/src/include/access/undostats.h @@ -0,0 +1,63 @@ +/*------------------------------------------------------------------------- + * + * 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); + +/* + * pg_undo_force_discard is declared via PG_FUNCTION_INFO_V1 in + * undostats.c. Do not redeclare it here: on Windows that emits a + * __declspec(dllimport) prototype that conflicts with the implicit + * dllexport from the V1 info macro. Catalog references go through + * pg_proc by name. + */ + +#endif /* UNDOSTATS_H */ diff --git a/src/include/access/undoworker.h b/src/include/access/undoworker.h new file mode 100644 index 0000000000000..87ec05ea8b1d2 --- /dev/null +++ b/src/include/access/undoworker.h @@ -0,0 +1,65 @@ +/*------------------------------------------------------------------------- + * + * 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; + +/* GUC parameters */ +extern int undo_worker_naptime; + +/* Shared memory functions */ +extern Size UndoWorkerShmemSize(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 UndoWorkerRequestShutdown(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..c0421bcba520d --- /dev/null +++ b/src/include/access/xactundo.h @@ -0,0 +1,131 @@ +/*------------------------------------------------------------------------- + * + * 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 UndoRecPtr PrepareXactUndoDataParts(XactUndoContext *ctx, + char persistence, + uint8 rmid, + uint16 info, + Oid reloid, + const char *part1, + Size part1_len, + const char *part2, + Size part2_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 UndoRecPtr GetCurrentXactUndoRecPtr(UndoPersistenceLevel plevel); +extern XLogRecPtr GetCurrentXactLastBatchLSN(UndoPersistenceLevel plevel); +extern void XActUndoUpdateLastBatchLSN(XLogRecPtr lsn, + 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 f46427258e3db..f07a2acca9638 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -12778,4 +12778,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/buf_internals.h b/src/include/storage/buf_internals.h index 726ab75dfe992..868f834246e1a 100644 --- a/src/include/storage/buf_internals.h +++ b/src/include/storage/buf_internals.h @@ -146,6 +146,20 @@ StaticAssertDecl(MAX_BACKENDS_BITS <= (BUF_LOCK_BITS - 2), StaticAssertDecl(BM_MAX_USAGE_COUNT < (UINT64CONST(1) << BUF_USAGECOUNT_BITS), "BM_MAX_USAGE_COUNT doesn't fit in BUF_USAGECOUNT_BITS bits"); +/* + * Reserved fork number for UNDO log buffers. + * + * This constant is reserved for future use when the smgr layer is extended + * to support undo-specific file management. Currently, undo buffers use + * MAIN_FORKNUM (following ZHeap's UndoLogForkNum convention) because the + * smgr layer sizes internal arrays to MAX_FORKNUM+1. Undo buffers are + * distinguished from regular relation data by using a pseudo-database OID + * (UNDO_DB_OID = 9) in the BufferTag's dbOid field. + * + * See src/include/access/undo_bufmgr.h for the undo buffer manager API. + */ +#define UNDO_FORKNUM 5 + /* * Buffer tag identifies which disk block the buffer contains. * diff --git a/src/include/storage/bufmgr.h b/src/include/storage/bufmgr.h index 6837b35fc6d0b..a8f765e692669 100644 --- a/src/include/storage/bufmgr.h +++ b/src/include/storage/bufmgr.h @@ -259,6 +259,16 @@ extern bool BufferIsLockedByMe(Buffer buffer); extern bool BufferIsLockedByMeInMode(Buffer buffer, BufferLockMode mode); extern bool BufferIsDirty(Buffer buffer); extern void MarkBufferDirty(Buffer buffer); + +/* + * MarkBufferDirtyShared -- mark buffer dirty while holding only BUFFER_LOCK_SHARE. + * + * Safe ONLY when the page modification is performed via an atomic CAS and the + * buffer's dirty bit is set atomically (no exclusive content lock needed). + * Currently used by an in-place-update table AM's CAS-update path where the + * tuple's writer field is modified atomically under shared buffer lock. + */ +extern void MarkBufferDirtyShared(Buffer buffer); extern void IncrBufferRefCount(Buffer buffer); extern void CheckBufferIsPinnedOnce(Buffer buffer); extern Buffer ReleaseAndReadBuffer(Buffer buffer, Relation relation, @@ -302,6 +312,10 @@ extern void FlushDatabaseBuffers(Oid dbid); extern void DropRelationBuffers(SMgrRelation smgr_reln, ForkNumber *forkNum, int nforks, BlockNumber *firstDelBlock); +extern void DropRelationBufferRange(SMgrRelation smgr_reln, + ForkNumber forkNum, + BlockNumber firstDelBlock, + BlockNumber endBlock); extern void DropRelationsAllBuffers(SMgrRelation *smgr_reln, int nlocators); extern void DropDatabaseBuffers(Oid dbid); @@ -321,6 +335,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..dfdd410d615f3 100644 --- a/src/include/storage/lwlocklist.h +++ b/src/include/storage/lwlocklist.h @@ -141,3 +141,5 @@ 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) +PG_LWLOCKTRANCHE(UNDO_WORKER, UndoWorker) 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 a7a8dbdd23f79..84af3bd8665ef 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 2e632c08072aa550ef551402cfc1b91240581e54 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 14 Sep 2026 13:03:40 +0000 Subject: [PATCH 04/22] 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. Uncouple FILEOPS from the FLUX build guard so it builds and registers on its own, ahead of the FLUX table AM. RM_FILEOPS_ID was registered inside the USE_FLUX rmgr block; introduce a dedicated USE_FILEOPS build flag (mirroring USE_FLUX) and move the FILEOPS PG_RMGR line into its own guarded block. The resource-manager ID order (and therefore every WAL rmgr ID) is unchanged in the final build; the only effect is that FILEOPS no longer depends on FLUX being compiled. Author: Greg Burd Changes since v2: - Report FILEOPS UNDO apply failures as failures [f3bd4a3cc7] - Remove obsolete UNDO file cleanup calls [fdf012acad] Tests: - Do not set logical_revert_naptime in 059 and 061 [3072609035] - Show that a crash right after commit loses deferred ops [b34b2463bb] --- meson.build | 11 + src/Makefile.global.in | 6 + 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 | 28 +- src/backend/access/transam/twophase_rmgr.c | 20 +- src/backend/access/undo/xactundo.c | 12 - src/backend/storage/file/Makefile | 2 + src/backend/storage/file/README | 202 ++ src/backend/storage/file/fileops.c | 2562 +++++++++++++++++ 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 | 202 ++ src/test/modules/test_fileops/Makefile | 17 + .../test_fileops/expected/test_fileops.out | 404 +++ .../test_fileops/expected/test_fileops_1.out | 323 +++ src/test/modules/test_fileops/meson.build | 39 + .../modules/test_fileops/sql/test_fileops.sql | 220 ++ .../t/001_fileops_apply_failure.pl | 49 + .../t/007_fileops_commit_crash.pl | 82 + .../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 + .../t/060_fileops_standby_divergence.pl | 121 + .../recovery/t/061_fileops_2pc_deferred.pl | 158 + src/test/recovery/t/062_fileops_recovery.pl | 435 +++ src/test/recovery/t/063_fileops_2pc.pl | 145 + .../recovery/t/072_perbackend_crash_undo.pl | 117 + src/tools/pgindent/typedefs.list | 26 + 43 files changed, 6965 insertions(+), 20 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/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/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/060_fileops_standby_divergence.pl 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/072_perbackend_crash_undo.pl diff --git a/meson.build b/meson.build index d05634fef0d8c..345ef815072d6 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..e33be14ba539d 100644 --- a/src/Makefile.global.in +++ b/src/Makefile.global.in @@ -256,6 +256,12 @@ PG_SYSROOT = @PG_SYSROOT@ override CPPFLAGS += $(ICU_CFLAGS) $(LIBNUMA_CFLAGS) $(LIBURING_CFLAGS) +# FILEOPS transactional filesystem operations (WAL rmgr RM_FILEOPS_ID) is +# always built. Its own guard, independent of USE_FLUX, so the FILEOPS WAL +# resource manager can be built and registered without the FLUX table AM. +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 dc477ecded6d0..61988c4c0a8b4 100644 --- a/src/backend/access/rmgrdesc/meson.build +++ b/src/backend/access/rmgrdesc/meson.build @@ -30,4 +30,10 @@ rmgr_desc_sources = files( # FLUX is always built; rmgrlist.h registers flux_desc/flux_identify +# 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 5a5ffd18c5510..0375244179764 100644 --- a/src/backend/access/transam/twophase.c +++ b/src/backend/access/transam/twophase.c @@ -99,6 +99,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" @@ -149,6 +150,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,14 @@ EndPrepare(GlobalTransaction gxact) StateFileChunk *record; bool replorigin; + /* + * FILEOPS: persist this transaction's pending file operations into the + * 2PC state file, for the same reasons the per-backend UNDO prepare hook + * does. 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/access/undo/xactundo.c b/src/backend/access/undo/xactundo.c index 401e748bfa66b..06900e5d6bc06 100644 --- a/src/backend/access/undo/xactundo.c +++ b/src/backend/access/undo/xactundo.c @@ -987,12 +987,6 @@ AtAbort_XactUndo(void) } } - /* Close cached UNDO log fds. */ - UndoLogCloseFiles(); - - /* Reset per-backend write pointer tracking. */ - UndoFlushResetMaxWritePtr(); - /* * Release the WAL retention hold if nothing needs the UNDO records any * more: inline UNDO completed, or the ATM recorded the transaction (the @@ -1263,12 +1257,6 @@ AtProcExit_XactUndo(void) } } - /* Close any cached UNDO log fds before process exit. */ - UndoLogCloseFiles(); - - /* Reset per-backend write pointer tracking. */ - UndoFlushResetMaxWritePtr(); - /* Release any WAL retention hold (in case process exits mid-transaction). */ UndoClearBatchLSN(); 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..cba6f1dd6233a --- /dev/null +++ b/src/backend/storage/file/README @@ -0,0 +1,202 @@ +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 / async revert (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, handed to the async revert + machinery via ATMAddAborted() in FinishPreparedTransaction() -- the same + path a non-prepared abort uses. Duplicating them in the 2PC record stream + would double-revert and race that async path. 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), +060_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..09d8ffe1ae769 --- /dev/null +++ b/src/backend/storage/file/fileops.c @@ -0,0 +1,2562 @@ +/*------------------------------------------------------------------------- + * + * 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 (ATMAddAborted() in + * FinishPreparedTransaction()). + */ +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, handed + * to the async revert machinery via ATMAddAborted() in + * FinishPreparedTransaction(). Serializing them here too would + * double-revert and race with that async path, 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: + { + xl_fileops_chown *xlrec = (xl_fileops_chown *) data; + const char *path = data + SizeOfFileOpsChown; + +#ifndef WIN32 + 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..5d8a92438c45a --- /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 hands the chain to the logical revert worker. 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..c7968c4af4ea4 --- /dev/null +++ b/src/port/pg_xattr.c @@ -0,0 +1,202 @@ +/*------------------------------------------------------------------------- + * + * 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 + +/* + * 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) + { + _dosmaperr(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; + + _dosmaperr(GetLastError()); + return -1; + +#else + (void) path; + (void) name; + errno = ENOTSUP; + return -1; +#endif +} diff --git a/src/test/modules/test_fileops/Makefile b/src/test/modules/test_fileops/Makefile new file mode 100644 index 0000000000000..70af37106ace4 --- /dev/null +++ b/src/test/modules/test_fileops/Makefile @@ -0,0 +1,17 @@ +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 +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..c3d4dc531fd87 --- /dev/null +++ b/src/test/modules/test_fileops/expected/test_fileops.out @@ -0,0 +1,404 @@ +-- +-- 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) +-- +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..c0a1721cc1f80 --- /dev/null +++ b/src/test/modules/test_fileops/expected/test_fileops_1.out @@ -0,0 +1,323 @@ +-- +-- 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) +-- +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/meson.build b/src/test/modules/test_fileops/meson.build new file mode 100644 index 0000000000000..0c7b1102dabd7 --- /dev/null +++ b/src/test/modules/test_fileops/meson.build @@ -0,0 +1,39 @@ +# 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': { + 'tests': [ + 't/001_fileops_apply_failure.pl', + 't/007_fileops_commit_crash.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..085b90257f610 --- /dev/null +++ b/src/test/modules/test_fileops/sql/test_fileops.sql @@ -0,0 +1,220 @@ +-- +-- 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) +-- + +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..969fbc6c053b0 --- /dev/null +++ b/src/test/modules/test_fileops/t/001_fileops_apply_failure.pl @@ -0,0 +1,49 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# A FILEOPS UNDO record that cannot be applied must be reported as a failure, +# so the abort hands the chain to the logical revert worker instead of +# treating it 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. No revert worker, so the +# hand-off stays observable in the ATM. +$node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +max_logical_revert_workers = 0 +}); +$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; + +is( $node->safe_psql( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), + 't', + 'the rollback whose FILEOPS UNDO failed was handed to the ATM'); +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..b01c61073ac84 --- /dev/null +++ b/src/test/modules/test_fileops/t/007_fileops_commit_crash.pl @@ -0,0 +1,82 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# A deferred commit-time file operation must survive a crash right after +# its transaction's commit record: the commit is durable, so the deletion, +# rename or rmdir it implies must happen too, on restart if not before. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('fileops_commit_crash'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +shared_preload_libraries = 'injection_points' +wal_level = replica +}); +$node->start; +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} +$node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); +my $datadir = $node->data_dir; + +# Commit $sql, crash right after the commit record, restart. +sub commit_and_crash +{ + my ($sql) = @_; + $node->safe_psql('postgres', + "SELECT injection_points_attach('fileops-commit-before-pending-ops', 'wait')"); + my $bg = $node->background_psql('postgres', on_error_stop => 0); + $bg->query_until(qr/start/, "\\echo start\n$sql\n"); + $node->wait_for_event('client backend', 'fileops-commit-before-pending-ops'); + $node->stop('immediate'); + $bg->{run}->finish; + $node->start; +} + +$node->safe_psql('postgres', "SELECT test_fileops_create_tempfile('del.dat')"); +$node->safe_psql('postgres', "SELECT test_fileops_create_tempfile('ren.dat')"); +$node->safe_psql('postgres', "SELECT test_fileops_mkdir('rmd', 448)"); +commit_and_crash(q{BEGIN; +SELECT test_fileops_delete('del.dat'); +SELECT test_fileops_rename('ren.dat', 'ren2.dat'); +SELECT test_fileops_rmdir('rmd'); +COMMIT;}); +TODO: +{ + # The deferred ops are WAL-logged only when executed, after the commit + # record, so this crash loses them. Their intent must be durable before + # the commit record and executed by commit redo. + local $TODO = 'commit-time FILEOPS ops are logged after the commit record'; + ok(!-e "$datadir/del.dat", + 'committed FileOpsDelete happened despite the crash'); + ok(!-e "$datadir/ren.dat" && -e "$datadir/ren2.dat", + 'committed FileOpsRename happened despite the crash'); + ok(!-e "$datadir/rmd", 'committed FileOpsRmdir happened despite the crash'); +} + +# DROP TABLESPACE is safe: its XLOG_TBLSPC_DROP precedes the commit record +# and its redo removes the directories. (In-place, so they are under +# pg_tblspc.) +$node->safe_psql('postgres', 'SET allow_in_place_tablespaces = on; CREATE TABLESPACE ts LOCATION \'\''); +my $tsoid = $node->safe_psql('postgres', "SELECT oid FROM pg_tablespace WHERE spcname = 'ts'"); +ok(-d "$datadir/pg_tblspc/$tsoid", 'tablespace directory exists'); +commit_and_crash('DROP TABLESPACE ts;'); +is($node->safe_psql('postgres', "SELECT count(*) FROM pg_tablespace WHERE spcname = 'ts'"), + '0', 'DROP TABLESPACE committed'); +ok(!-e "$datadir/pg_tblspc/$tsoid", + 'committed DROP TABLESPACE removed its directory despite the crash'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/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 e1cf57ff611a2..500aeeb768f05 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -68,6 +68,11 @@ tests += { 't/057_snapshot_commit_race.pl', 't/058_shutdown_crash_restart.pl', 't/059_remote_apply_status_interval.pl', + 't/060_fileops_standby_divergence.pl', + 't/061_fileops_2pc_deferred.pl', + 't/062_fileops_recovery.pl', + 't/063_fileops_2pc.pl', + 't/072_perbackend_crash_undo.pl', ], }, } diff --git a/src/test/recovery/t/060_fileops_standby_divergence.pl b/src/test/recovery/t/060_fileops_standby_divergence.pl new file mode 100644 index 0000000000000..36aa0906ba07c --- /dev/null +++ b/src/test/recovery/t/060_fileops_standby_divergence.pl @@ -0,0 +1,121 @@ +# 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/061_fileops_2pc_deferred.pl b/src/test/recovery/t/061_fileops_2pc_deferred.pl new file mode 100644 index 0000000000000..2317825297a35 --- /dev/null +++ b/src/test/recovery/t/061_fileops_2pc_deferred.pl @@ -0,0 +1,158 @@ +# 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'); some reversals run +# through the async cluster-wide UNDO revert worker, not synchronously. +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 via the async revert worker on ROLLBACK PREPARED, so poll. +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..f1e08b7869d14 --- /dev/null +++ b/src/test/recovery/t/062_fileops_recovery.pl @@ -0,0 +1,435 @@ +# 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')}); + is($mode_before, '420', 'Test 12: baseline mode is 0644'); + + $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)'); + + # 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'); + + # 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..23f242523d55e --- /dev/null +++ b/src/test/recovery/t/063_fileops_2pc.pl @@ -0,0 +1,145 @@ +# 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) +# hands that LSN to the async logical-revert machinery via ATMAddAborted(), +# 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; + +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 (async revert takes a moment) 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/072_perbackend_crash_undo.pl b/src/test/recovery/t/072_perbackend_crash_undo.pl new file mode 100644 index 0000000000000..68cc11cba43ba --- /dev/null +++ b/src/test/recovery/t/072_perbackend_crash_undo.pl @@ -0,0 +1,117 @@ +# 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, replayed like heap's own records -- NOT in any table-AM +# per-backend UNDO log. 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). +# No per-backend UNDO engine is involved. +# +# 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('pbu_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/pbu_crash_create.dat"; +my $chmod_path = "$datadir/pbu_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('pbu_crash_chmod.dat')}); +$node->safe_psql('postgres', qq{SELECT test_fileops_chmod('$chmod_path', 420)}); +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 per-backend 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/pbu_ready/, qq( +BEGIN; +SELECT test_fileops_create('$create_path', 384); -- 0600, new file +SELECT test_fileops_chmod('$chmod_path', 448); -- 0700, existing file +\\echo pbu_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)'); +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. 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). +# --------------------------------------------------------------------------- +$bg->{run}->kill_kill; +$node->stop('immediate'); +$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'); +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 and must NOT have engaged the per-backend UNDO engine +# for FILEOPS: the revert rides the cluster-wide UNDO engine on the common WAL. +my $rlog = slurp_file($node->logfile); +unlike($rlog, qr/per-backend UNDO recovery: rolling back transaction/, + 'FILEOPS revert did NOT go through the per-backend UNDO engine'); +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 per-backend crash-recovery undo'); + +$node->stop; +done_testing(); diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 84af3bd8665ef..e97b9252d8c89 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 ff17a214e431c548ea17a2ed394c9dcc58e0fc88 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 14 Sep 2026 13:03:40 +0000 Subject: [PATCH 05/22] 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 ++- 4 files changed, 271 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 e01fb2db91332..4ff673566d1c5 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 */ From f01f9ca4793393059237b1e3e81916527811d46b Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Sun, 27 Sep 2026 14:36:47 -0400 Subject: [PATCH 06/22] Add per-backend UNDO logs Add the per-backend UNDO log engine: UNDO records for a backend's own changes are written to dedicated undo-log segment files (base/undo, routed through a new SMGR_UNDO storage manager and SYNC_HANDLER_UNDO sync handler) rather than into the common WAL stream. This is the zheap-derived UNDO-log infrastructure, adapted to sit alongside the cluster-wide UNDO engine. * The engine itself lives under access/undo/perbackend/: log allocation and metadata (pbu_undolog.c), record assembly and insertion (pbu_undorecord.c, pbu_undoinsert.c), the undo-action apply machinery (pbu_undoaction.c, pbu_undoactionxlog.c), rollback-request queues (pbu_undorequest.c), discard (pbu_undodiscard.c), the discard worker and undo launcher (pbu_discardworker.c, pbu_undoworker.c), crash recovery (pbu_recovery.c), the segment-file smgr (pbu_undofile.c), and helpers/compat shims (pbu_helpers.c, pbu_compat.h). * Two new resource managers, UndoLog and UndoAction, with their WAL records described by pbu_undologdesc.c and pbu_undoactiondesc.c, and a two-phase resource manager (TWOPHASE_RM_PERBACKEND_ID) so a prepared transaction's per-backend undo chain-head is persisted at PREPARE and reverse-applied on ROLLBACK PREPARED / discarded on COMMIT PREPARED. * Integration: the per-backend engine's shmem, LWLock tranche (UNDO_LOG_PBU), workers, checkpoint snapshot (pg_undo/ via CheckPointUndoLogs / StartupUndoLogs), and crash-recovery apply (PbuPerformUndoRecovery) are wired into undo.c, xlog.c, xlogrecovery.c, xact.c, twophase.c, smgr.c, and sync.c. The default max_worker_processes is raised from 8 to 12 to leave parallel query its budget alongside the always-on per-backend UNDO workers. Based on the zheap undo work by Thomas Munro, Amit Kapila, Dilip Kumar, Kuntal Ghosh and others. 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 Changes since v2: - Apply the whole per-backend chain on ROLLBACK PREPARED [5fa0818211] - Pass a committed subtransaction's per-backend undo to its parent [f7c9badf81] - Key the per-backend rollback hash table on the full RollbackHashKey [313e36bf69] - Size per-backend undo log banks by logs per bank [62b66796e5] - Fix stale per-backend UNDO comments [15ab46b204] - Do not WAL-log undo apply progress for unlogged undo logs [c4ddfa2eaa] - Report a per-backend undo record with no handler as not applied [840d38aae8] - Tighten the recovery/075 header comment [2801df9130] - Never re-apply undo already applied by ROLLBACK TO SAVEPOINT [9f8e0fea0a] - Retry per-backend rollback requests an undo apply worker could not finish [ef5d7c2c13] - Always apply temporary undo in the aborting backend [e90258a472] - Discard temporary per-backend undo once its transaction ends [294017447a] - Do not buffer unlogged and temp undo pages as permanent [f6d492c687] - Empty unlogged and temp undo logs at the end of crash recovery [43b941aeb0] - Roll back every persistence level's per-backend undo chain [42c24e555f] - Retry savepoint undo once the subtransaction's catalog changes are undone [933792cf34] - Nothing-to-do worker requests are no longer retried (drop the TODO in recovery/085) [73784d8c6b] - Do not WAL-log discards and extensions of non-permanent undo logs [6aa1d5640c] - Per-backend UNDO redo: restore undo-page full-page images [0048aa395a] - Allow per-backend undo records to span three pages [c69a531178] - Allow WAL-logged amendments to owned undo payloads [1dbd03bbc8] - Remove the unused worker error-output helper [f4a909bbd1] - Preserve checksums across undo segment lifecycles [dc5d914e36] - Add a per-backend subtransaction undo dispatcher [a0185f0eb9] - Skip already-applied records in subtransaction undo dispatch [e423be2e75] - Skip completed undo records when retrying an outer abort [79f2f00a5a] - Start undo log recovery from a newer snapshot when pg_undo lacks one [ed83186497] - Make pbu_undoinsert.h self-contained [97a54337b0] - Make the undo discard worker unable to spin, and stop it on shutdown [07a7326b62] - Describe per-backend UNDO rollback, recovery, and a snapshot limit [694472a2a8] - Never WAL-log the meta of a non-permanent undo log [05c3499672] - Undo redo: do not rewrite an undo page that already holds the record [f6881ed0ba] - Recovery/075: wait for the worker to fail instead of sleeping [66863e4b21] - Per-backend UNDO: drop comments about zheap code that is not here [66414f6b47] - Per-backend UNDO: apply project formatting (6 files) [e93e8088de] - Per-backend UNDO: apply pgperltidy to its tests (11 files) [ea694d86a3] - Per-backend undo: finish an abort's rollback once its catalog changes are undone [fc5034ceb4] - Per-backend UNDO: add PbuPendingUndo to typedefs.list [c5a2e6e616] - Document the fixed startup cost of the UNDO background workers [627174dacb] Tests: - Roll back a row updated two and three times [4fecd09e0d] - One aborted transaction, every applier (recovery/084) [7fe8079db9] - ROLLBACK TO an empty savepoint reverts nothing (recovery/084) [2af8450b41] - Roll back changes to a table the transaction then rewrote [8b89a28a03] - Poll with poll_query_until instead of usleep loops [2d9d668c52] - Compare 089's marker row with only its CLOG hint bits masked [6413e9272b] - Reproduce subtransaction abort undo after storage removal [f42a116719] - Amcheck the index after UPDATE, REINDEX and rollback [31b45dc989] - Guard recovery against a missing undo-log xid mapping [da416a8db4] --- doc/src/sgml/config.sgml | 2 +- src/backend/access/rmgrdesc/Makefile | 2 + src/backend/access/rmgrdesc/meson.build | 2 + .../access/rmgrdesc/pbu_undoactiondesc.c | 73 + src/backend/access/rmgrdesc/pbu_undologdesc.c | 119 + src/backend/access/transam/rmgr.c | 2 + src/backend/access/transam/twophase.c | 19 + src/backend/access/transam/twophase_rmgr.c | 23 +- src/backend/access/transam/xact.c | 205 +- src/backend/access/transam/xlog.c | 42 + src/backend/access/transam/xlogrecovery.c | 20 + src/backend/access/undo/Makefile | 14 +- src/backend/access/undo/README | 87 + src/backend/access/undo/meson.build | 12 + .../undo/perbackend/pbu_discardworker.c | 201 + .../access/undo/perbackend/pbu_helpers.c | 86 + .../access/undo/perbackend/pbu_recovery.c | 538 +++ .../access/undo/perbackend/pbu_undoaction.c | 1423 ++++++++ .../undo/perbackend/pbu_undoactionxlog.c | 115 + .../access/undo/perbackend/pbu_undodiscard.c | 381 ++ .../access/undo/perbackend/pbu_undofile.c | 575 +++ .../access/undo/perbackend/pbu_undoinsert.c | 1780 +++++++++ .../access/undo/perbackend/pbu_undolog.c | 3224 +++++++++++++++++ .../access/undo/perbackend/pbu_undorecord.c | 516 +++ .../access/undo/perbackend/pbu_undorequest.c | 1721 +++++++++ .../access/undo/perbackend/pbu_undoworker.c | 908 +++++ src/backend/access/undo/undo.c | 79 +- src/backend/backup/basebackup.c | 10 +- src/backend/postmaster/bgworker.c | 15 + src/backend/postmaster/datachecksum_state.c | 5 + src/backend/storage/smgr/smgr.c | 42 +- src/backend/storage/sync/sync.c | 5 + src/backend/tcop/postgres.c | 3 + .../utils/activity/wait_event_names.txt | 1 + src/backend/utils/init/globals.c | 2 +- src/backend/utils/misc/guc_parameters.dat | 13 +- src/backend/utils/misc/postgresql.conf.sample | 5 +- src/bin/pg_checksums/pg_checksums.c | 14 +- src/bin/pg_rewind/filemap.c | 2 + src/bin/pg_waldump/rmgrdesc.c | 6 + src/bin/pg_waldump/t/001_basic.pl | 4 +- src/common/relpath.c | 26 + src/include/access/perbackend/pbu_compat.h | 185 + .../access/perbackend/pbu_discardworker.h | 24 + src/include/access/perbackend/pbu_helpers.h | 48 + .../access/perbackend/pbu_undoaction_xlog.h | 52 + .../access/perbackend/pbu_undodiscard.h | 24 + src/include/access/perbackend/pbu_undofile.h | 80 + .../access/perbackend/pbu_undoinsert.h | 80 + src/include/access/perbackend/pbu_undolog.h | 356 ++ .../access/perbackend/pbu_undolog_xlog.h | 71 + .../access/perbackend/pbu_undorecord.h | 215 ++ .../access/perbackend/pbu_undorequest.h | 166 + .../access/perbackend/pbu_undoworker.h | 29 + src/include/access/rmgrlist.h | 2 + src/include/access/twophase_rmgr.h | 3 +- src/include/access/xact.h | 22 + src/include/common/relpath.h | 4 + src/include/storage/lwlocklist.h | 1 + src/include/storage/sync.h | 1 + src/test/modules/test_checksums/meson.build | 1 + src/test/modules/test_checksums/t/025_undo.pl | 42 + src/test/recovery/meson.build | 12 + src/test/recovery/t/071_perbackend_undo.pl | 58 + .../recovery/t/073_perbackend_2pc_rollback.pl | 102 + src/test/recovery/t/074_perbackend_subxact.pl | 294 ++ .../recovery/t/075_perbackend_nonpermanent.pl | 273 ++ .../recovery/t/083_perbackend_multi_update.pl | 134 + .../recovery/t/084_perbackend_helping_once.pl | 227 ++ .../recovery/t/085_perbackend_worker_done.pl | 113 + .../t/089_perbackend_rewrite_rollback.pl | 124 + .../t/096_perbackend_redo_header_image.pl | 117 + .../recovery/t/114_undo_recovery_error.pl | 48 + .../115_flux_subxact_abort_dropped_storage.pl | 163 + .../recovery/t/117_undo_rewind_snapshot.pl | 116 + .../152_undo_attach_order_after_checkpoint.pl | 79 + src/tools/pgindent/typedefs.list | 36 + 77 files changed, 15598 insertions(+), 26 deletions(-) create mode 100644 src/backend/access/rmgrdesc/pbu_undoactiondesc.c create mode 100644 src/backend/access/rmgrdesc/pbu_undologdesc.c create mode 100644 src/backend/access/undo/perbackend/pbu_discardworker.c create mode 100644 src/backend/access/undo/perbackend/pbu_helpers.c create mode 100644 src/backend/access/undo/perbackend/pbu_recovery.c create mode 100644 src/backend/access/undo/perbackend/pbu_undoaction.c create mode 100644 src/backend/access/undo/perbackend/pbu_undoactionxlog.c create mode 100644 src/backend/access/undo/perbackend/pbu_undodiscard.c create mode 100644 src/backend/access/undo/perbackend/pbu_undofile.c create mode 100644 src/backend/access/undo/perbackend/pbu_undoinsert.c create mode 100644 src/backend/access/undo/perbackend/pbu_undolog.c create mode 100644 src/backend/access/undo/perbackend/pbu_undorecord.c create mode 100644 src/backend/access/undo/perbackend/pbu_undorequest.c create mode 100644 src/backend/access/undo/perbackend/pbu_undoworker.c create mode 100644 src/include/access/perbackend/pbu_compat.h create mode 100644 src/include/access/perbackend/pbu_discardworker.h create mode 100644 src/include/access/perbackend/pbu_helpers.h create mode 100644 src/include/access/perbackend/pbu_undoaction_xlog.h create mode 100644 src/include/access/perbackend/pbu_undodiscard.h create mode 100644 src/include/access/perbackend/pbu_undofile.h create mode 100644 src/include/access/perbackend/pbu_undoinsert.h create mode 100644 src/include/access/perbackend/pbu_undolog.h create mode 100644 src/include/access/perbackend/pbu_undolog_xlog.h create mode 100644 src/include/access/perbackend/pbu_undorecord.h create mode 100644 src/include/access/perbackend/pbu_undorequest.h create mode 100644 src/include/access/perbackend/pbu_undoworker.h create mode 100644 src/test/modules/test_checksums/t/025_undo.pl create mode 100644 src/test/recovery/t/071_perbackend_undo.pl create mode 100644 src/test/recovery/t/073_perbackend_2pc_rollback.pl create mode 100644 src/test/recovery/t/074_perbackend_subxact.pl create mode 100644 src/test/recovery/t/075_perbackend_nonpermanent.pl create mode 100644 src/test/recovery/t/083_perbackend_multi_update.pl create mode 100644 src/test/recovery/t/084_perbackend_helping_once.pl create mode 100644 src/test/recovery/t/085_perbackend_worker_done.pl create mode 100644 src/test/recovery/t/089_perbackend_rewrite_rollback.pl create mode 100644 src/test/recovery/t/096_perbackend_redo_header_image.pl create mode 100644 src/test/recovery/t/114_undo_recovery_error.pl create mode 100644 src/test/recovery/t/115_flux_subxact_abort_dropped_storage.pl create mode 100644 src/test/recovery/t/117_undo_rewind_snapshot.pl create mode 100644 src/test/recovery/t/152_undo_attach_order_after_checkpoint.pl diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index e537b3b0043f4..69851e43f22fb 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -3088,7 +3088,7 @@ include_dir 'conf.d' Sets the maximum number of background processes that the cluster can support. This parameter can only be set at server start. The - default is 8. + default is 12. diff --git a/src/backend/access/rmgrdesc/Makefile b/src/backend/access/rmgrdesc/Makefile index 5b0d8922ea272..efc715b789102 100644 --- a/src/backend/access/rmgrdesc/Makefile +++ b/src/backend/access/rmgrdesc/Makefile @@ -30,6 +30,8 @@ OBJS = \ spgdesc.o \ standbydesc.o \ tblspcdesc.o \ + pbu_undoactiondesc.o \ + pbu_undologdesc.o \ undodesc.o \ xactdesc.o \ xlogdesc.o diff --git a/src/backend/access/rmgrdesc/meson.build b/src/backend/access/rmgrdesc/meson.build index 61988c4c0a8b4..84dcf1b666739 100644 --- a/src/backend/access/rmgrdesc/meson.build +++ b/src/backend/access/rmgrdesc/meson.build @@ -22,6 +22,8 @@ rmgr_desc_sources = files( 'spgdesc.c', 'standbydesc.c', 'tblspcdesc.c', + 'pbu_undoactiondesc.c', + 'pbu_undologdesc.c', 'undodesc.c', 'xactdesc.c', 'xlogdesc.c', diff --git a/src/backend/access/rmgrdesc/pbu_undoactiondesc.c b/src/backend/access/rmgrdesc/pbu_undoactiondesc.c new file mode 100644 index 0000000000000..1456caea5b8c0 --- /dev/null +++ b/src/backend/access/rmgrdesc/pbu_undoactiondesc.c @@ -0,0 +1,73 @@ +/*------------------------------------------------------------------------- + * + * pbu_undoactiondesc.c + * rmgr descriptor routines for the per-backend UNDO action WAL + * (RM_UNDOACTION_ID). + * + * Per-backend UNDO engine derives from EnterpriseDB zheap + * (Amit Kapila, Dilip Kumar, Kuntal Ghosh, Mahendra Singh Thalor, + * Rafia Sabih, Thomas Munro et al.). + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/rmgrdesc/pbu_undoactiondesc.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/perbackend/pbu_undoaction_xlog.h" +#include "access/xlogreader.h" + +void +undoaction_desc(StringInfo buf, XLogReaderState *record) +{ + char *rec = XLogRecGetData(record); + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + switch (info) + { + case XLOG_UNDO_AMEND_RECORD: + appendStringInfo(buf, "blocks %d", XLogRecMaxBlockId(record) + 1); + break; + case XLOG_UNDO_APPLY_PROGRESS: + { + xl_undoapply_progress *xlrec = (xl_undoapply_progress *) rec; + + appendStringInfo(buf, "urec_ptr %016llX progress %u", + (unsigned long long) xlrec->urec_ptr, xlrec->progress); + } + break; + case XLOG_UNDO_APPLY_SUBXACT: + { + xl_undoapply_subxact *xlrec = (xl_undoapply_subxact *) rec; + + appendStringInfo(buf, "urec_ptr %016llX", + (unsigned long long) xlrec->urec_ptr); + } + break; + } +} + +const char * +undoaction_identify(uint8 info) +{ + const char *id = NULL; + + switch (info & ~XLR_INFO_MASK) + { + case XLOG_UNDO_AMEND_RECORD: + id = "AMEND_RECORD"; + break; + case XLOG_UNDO_APPLY_PROGRESS: + id = "APPLY_PROGRESS"; + break; + case XLOG_UNDO_APPLY_SUBXACT: + id = "APPLY_SUBXACT"; + break; + } + + return id; +} diff --git a/src/backend/access/rmgrdesc/pbu_undologdesc.c b/src/backend/access/rmgrdesc/pbu_undologdesc.c new file mode 100644 index 0000000000000..49192c03b3158 --- /dev/null +++ b/src/backend/access/rmgrdesc/pbu_undologdesc.c @@ -0,0 +1,119 @@ +/*------------------------------------------------------------------------- + * + * pbu_undologdesc.c + * rmgr descriptor routines for the per-backend UNDO log WAL + * (RM_UNDOLOG_ID). + * + * Per-backend UNDO engine derives from EnterpriseDB zheap + * (Amit Kapila, Dilip Kumar, Kuntal Ghosh, Mahendra Singh Thalor, + * Rafia Sabih, Thomas Munro et al.). + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/rmgrdesc/pbu_undologdesc.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/perbackend/pbu_undolog_xlog.h" +#include "access/xlogreader.h" + +void +undolog_desc(StringInfo buf, XLogReaderState *record) +{ + char *rec = XLogRecGetData(record); + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + switch (info) + { + case XLOG_UNDOLOG_CREATE: + { + xl_undolog_create *xlrec = (xl_undolog_create *) rec; + + appendStringInfo(buf, "logno %u", xlrec->logno); + } + break; + case XLOG_UNDOLOG_EXTEND: + { + xl_undolog_extend *xlrec = (xl_undolog_extend *) rec; + + appendStringInfo(buf, "logno %u end " UndoLogOffsetFormat, + xlrec->logno, xlrec->end); + } + break; + case XLOG_UNDOLOG_ATTACH: + { + xl_undolog_attach *xlrec = (xl_undolog_attach *) rec; + + appendStringInfo(buf, "xid %u logno %u dbid %u", + xlrec->xid, xlrec->logno, xlrec->dbid); + } + break; + case XLOG_UNDOLOG_DISCARD: + { + xl_undolog_discard *xlrec = (xl_undolog_discard *) rec; + + appendStringInfo(buf, + "logno %u discard " UndoLogOffsetFormat + " end " UndoLogOffsetFormat " latestxid %u", + xlrec->logno, xlrec->discard, xlrec->end, + xlrec->latestxid); + } + break; + case XLOG_UNDOLOG_REWIND: + { + xl_undolog_rewind *xlrec = (xl_undolog_rewind *) rec; + + appendStringInfo(buf, "logno %u insert " UndoLogOffsetFormat, + xlrec->logno, xlrec->insert); + } + break; + case XLOG_UNDOLOG_META: + { + xl_undolog_meta *xlrec = (xl_undolog_meta *) rec; + + appendStringInfo(buf, "logno %u xid %u", + xlrec->logno, xlrec->xid); + } + break; + case XLOG_UNDOLOG_SWITCH: + appendStringInfoString(buf, "switch"); + break; + } +} + +const char * +undolog_identify(uint8 info) +{ + const char *id = NULL; + + switch (info & ~XLR_INFO_MASK) + { + case XLOG_UNDOLOG_CREATE: + id = "CREATE"; + break; + case XLOG_UNDOLOG_EXTEND: + id = "EXTEND"; + break; + case XLOG_UNDOLOG_ATTACH: + id = "ATTACH"; + break; + case XLOG_UNDOLOG_DISCARD: + id = "DISCARD"; + break; + case XLOG_UNDOLOG_REWIND: + id = "REWIND"; + break; + case XLOG_UNDOLOG_META: + id = "META"; + break; + case XLOG_UNDOLOG_SWITCH: + id = "SWITCH"; + break; + } + + return id; +} diff --git a/src/backend/access/transam/rmgr.c b/src/backend/access/transam/rmgr.c index 7441cf2a716b9..f0c7809c6c2b8 100644 --- a/src/backend/access/transam/rmgr.c +++ b/src/backend/access/transam/rmgr.c @@ -42,6 +42,8 @@ #include "storage/standby.h" #include "utils/relmapper.h" #include "access/undo_xlog.h" +#include "access/perbackend/pbu_undolog_xlog.h" +#include "access/perbackend/pbu_undoaction_xlog.h" /* IWYU pragma: end_keep */ diff --git a/src/backend/access/transam/twophase.c b/src/backend/access/transam/twophase.c index 0375244179764..127343cc3fc81 100644 --- a/src/backend/access/transam/twophase.c +++ b/src/backend/access/transam/twophase.c @@ -159,6 +159,14 @@ int max_prepared_xacts = 0; */ extern void FileOpsAtPrepare(void); +/* + * Per-backend UNDO 2PC prepare hook (pbu_recovery.c). Declared locally by + * prototype: the per-backend engine headers conflict with the cluster-wide + * UNDO engine headers included here. Registers this transaction's per-backend + * undo chain-head into the 2PC state file at EndPrepare(). + */ +extern void PbuAtPrepare_Undo(void); + typedef struct GlobalTransactionData { GlobalTransaction next; /* list link for free list */ @@ -1195,6 +1203,17 @@ EndPrepare(GlobalTransaction gxact) StateFileChunk *record; bool replorigin; + /* + * Per-backend UNDO: persist this transaction's per-backend undo + * chain-head location into the 2PC state file (as a + * TWOPHASE_RM_PERBACKEND_ID record) so ROLLBACK PREPARED -- including + * from a different backend after a crash -- can reverse-apply it, and + * COMMIT PREPARED can discard it. Must run before the END sentinel while + * the record chain is still open. A no-op if this transaction produced + * no per-backend undo. + */ + PbuAtPrepare_Undo(); + /* * FILEOPS: persist this transaction's pending file operations into the * 2PC state file, for the same reasons the per-backend UNDO prepare hook diff --git a/src/backend/access/transam/twophase_rmgr.c b/src/backend/access/transam/twophase_rmgr.c index 687e77f7e6743..7ef5f7508dda2 100644 --- a/src/backend/access/transam/twophase_rmgr.c +++ b/src/backend/access/transam/twophase_rmgr.c @@ -28,6 +28,17 @@ * postabort performs the abort-time filesystem undo. */ +/* + * Per-backend UNDO 2PC callbacks (pbu_recovery.c). Declared locally by + * prototype: the per-backend engine headers cannot be included here (they + * redefine types that conflict with the cluster-wide UNDO engine). The + * callback signature is the generic TwoPhaseCallback. + */ +extern void pbu_twophase_postabort(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); +extern void pbu_twophase_postcommit(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); + const TwoPhaseCallback twophase_recover_callbacks[TWOPHASE_RM_MAX_ID + 1] = { @@ -36,7 +47,8 @@ const TwoPhaseCallback twophase_recover_callbacks[TWOPHASE_RM_MAX_ID + 1] = NULL, /* pgstat */ multixact_twophase_recover, /* MultiXact */ predicatelock_twophase_recover, /* PredicateLock */ - NULL /* FILEOPS (post-commit/abort only) */ + NULL, /* FILEOPS (post-commit/abort only) */ + NULL /* per-backend UNDO (postabort-only) */ }; const TwoPhaseCallback twophase_postcommit_callbacks[TWOPHASE_RM_MAX_ID + 1] = @@ -46,7 +58,8 @@ const TwoPhaseCallback twophase_postcommit_callbacks[TWOPHASE_RM_MAX_ID + 1] = pgstat_twophase_postcommit, /* pgstat */ multixact_twophase_postcommit, /* MultiXact */ NULL, /* PredicateLock */ - fileops_twophase_postcommit /* FILEOPS */ + fileops_twophase_postcommit, /* FILEOPS */ + pbu_twophase_postcommit /* per-backend UNDO */ }; const TwoPhaseCallback twophase_postabort_callbacks[TWOPHASE_RM_MAX_ID + 1] = @@ -56,7 +69,8 @@ const TwoPhaseCallback twophase_postabort_callbacks[TWOPHASE_RM_MAX_ID + 1] = pgstat_twophase_postabort, /* pgstat */ multixact_twophase_postabort, /* MultiXact */ NULL, /* PredicateLock */ - fileops_twophase_postabort /* FILEOPS */ + fileops_twophase_postabort, /* FILEOPS */ + pbu_twophase_postabort /* per-backend UNDO */ }; const TwoPhaseCallback twophase_standby_recover_callbacks[TWOPHASE_RM_MAX_ID + 1] = @@ -66,5 +80,6 @@ const TwoPhaseCallback twophase_standby_recover_callbacks[TWOPHASE_RM_MAX_ID + 1 NULL, /* pgstat */ NULL, /* MultiXact */ NULL, /* PredicateLock */ - NULL /* FILEOPS */ + NULL, /* FILEOPS */ + NULL /* per-backend UNDO */ }; diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c index a56c32aee0b28..979baf46f950b 100644 --- a/src/backend/access/transam/xact.c +++ b/src/backend/access/transam/xact.c @@ -223,9 +223,13 @@ typedef struct TransactionStateData 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 */ - uint64 pbuUndoStartPtr; /* per-backend UNDO: first record of this - * xact (0 = none) */ - uint64 pbuUndoLatestPtr; /* per-backend UNDO: most recent record */ + /* + * Per-backend UNDO: first and most recent record of this (sub)transaction + * in each persistence level's undo log (0 = none). A transaction writing + * permanent, unlogged and temporary tables has one chain in each. + */ + uint64 pbuUndoStartPtr[PBU_UNDO_PERSISTENCE_LEVELS]; + uint64 pbuUndoLatestPtr[PBU_UNDO_PERSISTENCE_LEVELS]; struct TransactionStateData *parent; /* back link to parent */ } TransactionStateData; @@ -376,6 +380,9 @@ static bool AbortCurrentTransactionInternal(void); static void StartSubTransaction(void); static void CommitSubTransaction(void); static void AbortSubTransaction(void); +static void AtSubCommit_PbuUndo(void); +static void AtSubAbort_PbuUndo(void); +static void AtSubAbort_PbuRetryUndo(void); static void CleanupSubTransaction(void); static void PushTransaction(void); static void PopTransaction(void); @@ -1203,6 +1210,161 @@ GetCurrentTransactionUndoRecPtr(void) return s->undoRecPtr; } +/* + * SetCurrentTransactionPbuUndoLocation + * Record a per-backend UNDO record pointer for the current transaction. + * + * Called from the per-backend UNDO engine (SetCurrentUndoLocation) after each + * InsertPreparedUndo. The first call in a transaction captures the chain + * start (used as the rollback request's start pointer at abort); every call + * updates the latest pointer. Kept separate from undoRecPtr so the + * per-relation and per-backend UNDO engines coexist without stepping on each + * other's transaction state. + */ +void +SetCurrentTransactionPbuUndoLocation(int level, uint64 urec_ptr) +{ + TransactionState s = CurrentTransactionState; + + Assert(level >= 0 && level < PBU_UNDO_PERSISTENCE_LEVELS); + if (s->pbuUndoStartPtr[level] == 0) + s->pbuUndoStartPtr[level] = urec_ptr; + s->pbuUndoLatestPtr[level] = urec_ptr; +} + +/* + * AtSubCommit_PbuUndo + * Hand the committing subtransaction's per-backend UNDO to its parent. + * + * Once a subtransaction commits (RELEASE SAVEPOINT, or a PL/pgSQL exception + * block that completes), its changes belong to the parent: rolling back the + * parent, or the whole transaction, must revert them too. The subtransaction + * wrote its records after any the parent wrote, so the parent keeps its own + * start pointer if it has one and takes the child's latest pointer. + */ +static void +AtSubCommit_PbuUndo(void) +{ + TransactionState s = CurrentTransactionState; + + Assert(s->parent != NULL); + + for (int level = 0; level < PBU_UNDO_PERSISTENCE_LEVELS; level++) + { + if (s->pbuUndoStartPtr[level] == 0) + continue; + + if (s->parent->pbuUndoStartPtr[level] == 0) + s->parent->pbuUndoStartPtr[level] = s->pbuUndoStartPtr[level]; + s->parent->pbuUndoLatestPtr[level] = s->pbuUndoLatestPtr[level]; + } +} + +/* + * AtSubAbort_PbuUndo + * Hand an aborted subtransaction's per-backend UNDO to its parent. + * + * The access methods have applied the subtransaction's records and marked + * them applied (PbuApplySubxactUndo), but the records stay in the chain, and + * the parent takes them over exactly as for a commit. If they are the first + * records of the transaction in an undo log, the first one carries the + * transaction header, which a rollback of the whole chain needs as its 'to' + * pointer. The latest pointer matters too: a record that could not be + * applied was left unmarked, and a rollback of the parent must still reach + * it; the marked records are skipped. + */ +static void +AtSubAbort_PbuUndo(void) +{ + AtSubCommit_PbuUndo(); +} + +/* + * AtSubAbort_PbuRetryUndo + * Apply the per-backend UNDO the SUBXACT_EVENT_ABORT_SUB callbacks could + * not, now that the subtransaction's catalog changes are rolled back. + * + * A subtransaction that updated a table and then truncated or dropped it + * still sees the table empty or gone when the callbacks run. Once its + * relcache and invalidations are rolled back, the parent sees the table as + * it was, so run the remaining records as the parent, before the + * subtransaction's locks are released. + */ +static void +AtSubAbort_PbuRetryUndo(void) +{ + TransactionState s = CurrentTransactionState; + ResourceOwner saved_owner = CurrentResourceOwner; + + if (!PbuSubxactUndoRetryPending() || s->parent->state != TRANS_INPROGRESS) + return; + + CurrentTransactionState = s->parent; + CurrentResourceOwner = s->parent->curTransactionOwner; + PG_TRY(); + { + PbuRetrySubxactUndo(); + } + PG_FINALLY(); + { + CurrentTransactionState = s; + CurrentResourceOwner = saved_owner; + } + PG_END_TRY(); +} + +/* + * GetCurrentTransactionPbuUndoStartAt / GetCurrentTransactionPbuUndoLatestAt + * The current (sub)transaction's first and most recent per-backend UNDO + * record in the given persistence level's undo log (0 if none there). + */ +uint64 +GetCurrentTransactionPbuUndoStartAt(int level) +{ + Assert(level >= 0 && level < PBU_UNDO_PERSISTENCE_LEVELS); + return CurrentTransactionState->pbuUndoStartPtr[level]; +} + +uint64 +GetCurrentTransactionPbuUndoLatestAt(int level) +{ + Assert(level >= 0 && level < PBU_UNDO_PERSISTENCE_LEVELS); + return CurrentTransactionState->pbuUndoLatestPtr[level]; +} + +/* + * GetCurrentTransactionPbuUndoStart / GetCurrentTransactionPbuUndoLatest + * The bracket of the first persistence level, in permanent, unlogged, + * temporary order, in which the current (sub)transaction wrote + * per-backend UNDO (0 if none). + * + * Only tells whether the (sub)transaction wrote any per-backend UNDO. A + * (sub)transaction that wrote undo of several persistence levels has a chain + * in each; walk them with the ...At() variants. + */ +static int +PbuUndoFirstLevel(void) +{ + for (int level = 0; level < PBU_UNDO_PERSISTENCE_LEVELS; level++) + { + if (CurrentTransactionState->pbuUndoLatestPtr[level] != 0) + return level; + } + return 0; +} + +uint64 +GetCurrentTransactionPbuUndoStart(void) +{ + return CurrentTransactionState->pbuUndoStartPtr[PbuUndoFirstLevel()]; +} + +uint64 +GetCurrentTransactionPbuUndoLatest(void) +{ + return CurrentTransactionState->pbuUndoLatestPtr[PbuUndoFirstLevel()]; +} + /* * CommandCounterIncrement */ @@ -2232,8 +2394,9 @@ StartTransaction(void) s->nChildXids = 0; s->maxChildXids = 0; s->undoRecPtr = 0; /* no UNDO records yet */ - s->pbuUndoStartPtr = 0; /* no per-backend UNDO records yet */ - s->pbuUndoLatestPtr = 0; + /* no per-backend UNDO records yet */ + memset(s->pbuUndoStartPtr, 0, sizeof(s->pbuUndoStartPtr)); + memset(s->pbuUndoLatestPtr, 0, sizeof(s->pbuUndoLatestPtr)); /* * Once the current user ID and the security context flags are fetched, @@ -2410,6 +2573,8 @@ CommitTransaction(void) * the transaction-abort path. */ + PbuAtPreCommit_Undo(); + CallXactCallbacks(is_parallel_worker ? XACT_EVENT_PARALLEL_PRE_COMMIT : XACT_EVENT_PRE_COMMIT); @@ -2693,6 +2858,8 @@ PrepareTransaction(void) break; } + PbuAtPreCommit_Undo(); + CallXactCallbacks(XACT_EVENT_PRE_PREPARE); /* @@ -3127,6 +3294,28 @@ AbortTransaction(void) /* Clean up transaction undo state (free per-persistence record sets) */ AtAbort_XactUndo(); + /* + * Roll back the per-backend UNDO engine's records for this transaction, + * if any. This engine coexists with the per-relation engine handled by + * AtAbort_XactUndo() above; the call is a no-op unless the per-backend + * engine produced UNDO for this xact (FLUX and RECNO tables do). + * UNDO appliers open relations, so present TRANS_INPROGRESS for the + * duration (all backing resources are still live at this point). + */ + { + int pbu_saved_state = EnterInlineUndoApplyState(); + + PG_TRY(); + { + PbuAtAbort_ApplyUndo(); + } + PG_FINALLY(); + { + LeaveInlineUndoApplyState(pbu_saved_state); + } + PG_END_TRY(); + } + /* * do abort processing */ @@ -3297,6 +3486,7 @@ StartTransactionCommand(void) * transaction. */ case TBLOCK_DEFAULT: + PbuApplyPendingUndo(); StartTransaction(); s->blockState = TBLOCK_STARTED; break; @@ -5376,6 +5566,7 @@ CommitSubTransaction(void) /* Post-commit cleanup */ if (FullTransactionIdIsValid(s->fullTransactionId)) AtSubCommit_childXids(); + AtSubCommit_PbuUndo(); AfterTriggerEndSubXact(true); AtSubCommit_Portals(s->subTransactionId, s->parent->subTransactionId, @@ -5573,6 +5764,9 @@ AbortSubTransaction(void) CallSubXactCallbacks(SUBXACT_EVENT_ABORT_SUB, s->subTransactionId, s->parent->subTransactionId); + /* The callbacks above applied and marked its per-backend UNDO. */ + AtSubAbort_PbuUndo(); + ResourceOwnerRelease(s->curTransactionOwner, RESOURCE_RELEASE_BEFORE_LOCKS, false, false); @@ -5582,6 +5776,7 @@ AbortSubTransaction(void) s->parent->subTransactionId); AtEOSubXact_TypeCache(); AtEOSubXact_Inval(false); + AtSubAbort_PbuRetryUndo(); ResourceOwnerRelease(s->curTransactionOwner, RESOURCE_RELEASE_LOCKS, false, false); diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c index 9a028e6fc95a3..da92c323a9724 100644 --- a/src/backend/access/transam/xlog.c +++ b/src/backend/access/transam/xlog.c @@ -756,6 +756,17 @@ static XLogRecPtr CreateOverwriteContrecordRecord(XLogRecPtr aborted_lsn, TimeLineID newTLI); static void CheckPointGuts(XLogRecPtr checkPointRedo, int flags); +/* + * Per-backend UNDO engine checkpoint/startup entry points. Declared locally + * (by prototype) rather than via access/perbackend/pbu_undolog.h, because that + * header redefines types (UndoRecPtr, UndoLogControl) that conflict with the + * cluster-wide UNDO engine headers already pulled in here. Both entry points + * take only XLogRecPtr arguments, so no pbu-specific type is needed. + */ +extern void CheckPointUndoLogs(XLogRecPtr checkPointRedo, + XLogRecPtr priorCheckPointRedo); +extern void StartupUndoLogs(XLogRecPtr checkPointRedo); +extern void ResetNonPermanentUndoLogs(void); static void KeepLogSeg(XLogRecPtr recptr, XLogSegNo *logSegNo); static void AdvanceXLInsertBuffer(XLogRecPtr upto, TimeLineID tli, @@ -6326,6 +6337,19 @@ StartupXLOG(void) */ StartupMultiXact(); + /* + * Rebuild the per-backend UNDO engine's UndoLogControl banks and + * freelists from the pg_undo/ snapshot written by + * CheckPointUndoLogs() at the checkpoint we are starting recovery from. + * This must precede WAL replay: redo of RM_UNDOLOG_ID records + * (META/ATTACH/EXTEND/DISCARD) advances the in-memory log metadata seeded + * here, and redo of registered undo pages reads/writes those logs' + * backing segments. If that snapshot is missing, as after pg_rewind or a + * base backup, a newer one is used and replay converges on it; with no + * snapshot at all, a cluster that has undo log segments fails to start. + */ + StartupUndoLogs(checkPoint.redo); + /* * Ditto for commit timestamps. Activate the facility if the setting is * enabled in the control file, as there should be no tracking of commit @@ -6623,8 +6647,13 @@ StartupXLOG(void) * end-of-recovery steps fail. */ if (InRecovery) + { ResetUnloggedRelations(UNLOGGED_RELATION_INIT); + /* Likewise empty the per-backend undo logs of non-permanent tables. */ + ResetNonPermanentUndoLogs(); + } + /* * Pre-scan prepared transactions to find out the range of XIDs present. * This information is not quite needed yet, but it is positioned here so @@ -8466,6 +8495,19 @@ CheckPointGuts(XLogRecPtr checkPointRedo, int flags) /* Persist UNDO log discard pointers and log statistics */ CheckPointUndoLog(); + /* + * Persist per-backend UNDO log metadata (the pg_undo/ snapshot) so + * the per-backend engine's UndoLogControl banks and freelists can be + * rebuilt by StartupUndoLogs() at recovery. The undo-page contents + * themselves are WAL-logged (attached to producer WAL records) and + * flushed by CheckPointBuffers() below; here we record the table of undo + * logs and their insert/discard pointers. priorCheckPointRedo is the + * previous checkpoint's redo point (ControlFile->checkPointCopy.redo is + * not yet advanced to the new checkpoint at this stage of + * CreateCheckPoint), used to clean up the now-obsolete snapshot file. + */ + CheckPointUndoLogs(checkPointRedo, ControlFile->checkPointCopy.redo); + /* Write out all dirty data in SLRUs and the main buffer pool */ TRACE_POSTGRESQL_BUFFER_CHECKPOINT_START(flags); CheckpointStats.ckpt_write_t = GetCurrentTimestamp(); diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index 9fd5dfd4c7f2f..77c231a681b01 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -75,6 +75,13 @@ #define RECOVERY_COMMAND_FILE "recovery.conf" #define RECOVERY_COMMAND_DONE "recovery.done" +/* + * Per-backend UNDO crash-recovery apply driver (pbu_recovery.c). Declared + * locally by prototype rather than via a pbu header, which would drag in the + * per-backend engine's conflicting type definitions. + */ +extern int PbuPerformUndoRecovery(void); + /* * GUC support */ @@ -1894,6 +1901,19 @@ PerformWalRecovery(void) (errmsg("undo phase complete"))); } + /* + * Reverse-apply per-backend UNDO for loser transactions. The + * per-backend engine's UndoLogControl banks were rebuilt by + * StartupUndoLogs() before redo, redo advanced them and rebuilt the + * xid->logno map, and the undo pages were replayed (they ride on + * their producers' WAL records). Now walk the recovered undo logs' + * transaction-header chains and roll back transactions that wrote + * per-backend undo but did not commit and are not prepared. Runs + * unconditionally for the same promoted-standby reason as + * PerformUndoRecovery() above. + */ + PbuPerformUndoRecovery(); + InRedo = false; } else diff --git a/src/backend/access/undo/Makefile b/src/backend/access/undo/Makefile index fc1c26953cd89..a34b24942cbcb 100644 --- a/src/backend/access/undo/Makefile +++ b/src/backend/access/undo/Makefile @@ -23,6 +23,18 @@ OBJS = \ undormgr.o \ undostats.o \ undoworker.o \ - xactundo.o + xactundo.o \ + perbackend/pbu_undolog.o \ + perbackend/pbu_undofile.o \ + perbackend/pbu_undorecord.o \ + perbackend/pbu_undoinsert.o \ + perbackend/pbu_undoaction.o \ + perbackend/pbu_undoactionxlog.o \ + perbackend/pbu_undorequest.o \ + perbackend/pbu_undodiscard.o \ + perbackend/pbu_discardworker.o \ + perbackend/pbu_undoworker.o \ + perbackend/pbu_recovery.o \ + perbackend/pbu_helpers.o include $(top_srcdir)/src/backend/common.mk diff --git a/src/backend/access/undo/README b/src/backend/access/undo/README index fe61d68fff9b6..aca848bc0837d 100644 --- a/src/backend/access/undo/README +++ b/src/backend/access/undo/README @@ -71,6 +71,56 @@ changes) writes one interleaved chain, and the single global horizon is the right granularity because such operations are not concentrated on any one relation. +### The UNDO Store: Per-Backend UNDO Logs + +Alongside the cluster-wide store, the per-backend UNDO logs +(access/undo/perbackend/) give each backend its own append-only log, so +the hot path has no shared insertion-point contention, and a discard +worker reclaims undo once no snapshot needs it. Records go to dedicated +segment files (base/undo, routed through the SMGR_UNDO storage manager +and the SYNC_HANDLER_UNDO sync handler) rather than into the common WAL +stream. This is the store a **high-churn in-place-update table AM** +uses, where UNDO volume is dominated by rows being updated repeatedly. + +The two stores share the UNDO record header format and the +rollback-dispatch machinery (undormgr.c); their storage and discard +paths are independent. The per-backend engine adds two resource managers +(UndoLog and UndoAction) and its own two-phase resource manager +(TWOPHASE_RM_PERBACKEND_ID) so a prepared transaction's per-backend +chain-head survives 2PC. + +At abort, PbuAtAbort_ApplyUndo() registers the transaction's per-backend +chain as a rollback request. A chain of at least +undo_rollback_overflow_size (default 16 MB) is handed to a per-backend undo +apply worker when the request queues have room, and ROLLBACK returns +without applying it. Otherwise (a smaller chain, full queues, temporary +UNDO, or single-user mode) the backend applies the chain itself before +ROLLBACK returns. The ATM is not used. + +On crash recovery, PbuPerformUndoRecovery() walks the recovered undo logs +after the redo pass, alongside the cluster-wide engine's +PerformUndoRecovery(). The table and index AM UNDO callbacks cannot run in +the startup process (they return UNDO_APPLY_SKIPPED while InRecovery is +set), so a loser transaction with such UNDO is registered as a rollback +request (PbuRegisterRecoveredRollbackReq) and an undo apply worker reverts +it after the database opens. Until then the loser's changes are still on +the pages, but readers do not see them, because visibility is resolved +through the writing transaction's status and that transaction did not +commit. + +Known limitation: StartupUndoLogs() starts from pg_undo/, the undo +log snapshot written by the checkpoint that redo starts from. A file-level +copy (pg_rewind, a base backup) can lack that snapshot. Startup then uses +the oldest snapshot after the redo point, or failing that the newest one +before it, and logs "undo log checkpoint snapshot for redo point ... is +missing". If DROP TABLESPACE removed an undo log between the redo point +and the snapshot used, redo of WAL that still refers to that log can fail +with an error such as "cannot find undo log number N for xid X", and the +server does not start. + +No in-tree access method writes per-backend UNDO yet; the engine compiles, +links, and registers, and its dispatch path is ready for a future consumer. + ## Recovery Model @@ -571,6 +621,43 @@ 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. +### Fixed Startup Cost + +"Zero overhead when disabled" is about the hot path, not startup. Enabling +the UNDO subsystem in a build adds a fixed per-cluster cost that is paid even +by a cluster that never creates a table in a UNDO-using access method: + + - Two extra static background workers over the cluster-wide UNDO worker and + the logical revert launcher: the per-backend discard worker + (perbackend/pbu_discardworker.c), which reclaims undo-log segment space, + and the undo launcher (perbackend/pbu_undoworker.c), which applies + rollbacks deferred by crash recovery. To seat them alongside parallel + query's full budget the default max_worker_processes is raised from 8 to + 12. + + - About 795 kB of shared memory over an upstream build at default settings + (measured as sum(allocated_size) from pg_shmem_allocations on a fresh + cluster, shared_buffers=128MB, max_connections=100): roughly 418 kB of + fixed UNDO control structures requested in UndoShmemRequest_internal() + (the per-backend rollback hash table and request queues, the undo log + directory and the launcher state), plus roughly 395 kB from the four + extra worker PGPROC slots the raised max_worker_processes adds (each + PGPROC and its fast-path lock and subtransaction arrays). The sLog + row-lock directory and tables add nothing at startup; they are allocated + from DSA on first use. + +The workers hibernate when there is no per-backend UNDO to process, so beyond +this fixed memory a never-UNDO cluster pays only for two idle, sleeping +processes. The workers are static because a static background worker must be +registered during the request_fn phase, before BackgroundWorkerShmemInit() +(see UndoShmemRequest_internal in undo.c), and the discard worker is required +for correctness: without it the undo-log segment files of any UNDO-using +table grow without bound. Launching these workers only on first use of a +UNDO-using access method -- and so avoiding the fixed cost on a cluster that +never uses one -- is left as a future improvement; it would require making +them dynamic (bgworker start requested at first undo allocation) rather than +static. + ### Overhead When Active INSERT: One UNDO record (header only, no payload). ~40 bytes diff --git a/src/backend/access/undo/meson.build b/src/backend/access/undo/meson.build index ca8fc14f29d2e..b21e198f88aa7 100644 --- a/src/backend/access/undo/meson.build +++ b/src/backend/access/undo/meson.build @@ -12,4 +12,16 @@ backend_sources += files( 'undostats.c', 'undoworker.c', 'xactundo.c', + 'perbackend/pbu_undolog.c', + 'perbackend/pbu_undofile.c', + 'perbackend/pbu_undorecord.c', + 'perbackend/pbu_undoinsert.c', + 'perbackend/pbu_undoaction.c', + 'perbackend/pbu_undoactionxlog.c', + 'perbackend/pbu_undorequest.c', + 'perbackend/pbu_undodiscard.c', + 'perbackend/pbu_discardworker.c', + 'perbackend/pbu_undoworker.c', + 'perbackend/pbu_recovery.c', + 'perbackend/pbu_helpers.c', ) diff --git a/src/backend/access/undo/perbackend/pbu_discardworker.c b/src/backend/access/undo/perbackend/pbu_discardworker.c new file mode 100644 index 0000000000000..709742e7ba874 --- /dev/null +++ b/src/backend/access/undo/perbackend/pbu_discardworker.c @@ -0,0 +1,201 @@ +/*------------------------------------------------------------------------- + * + * discardworker.c + * The undo discard worker for asynchronous undo management. + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/postmaster/discardworker.c + * + * The main responsibility of the discard worker is to discard the undo log + * of transactions that are committed and all-visible or are rolledback. It + * also registers the request for aborted transactions in the work queues. + * To know more about work queues, see undorequest.c. It iterates through all + * the active logs one-by-one and try to discard the transactions that are old + * enough to matter. + * + * For tranasctions that spans across multiple logs, the log for committed and + * all-visible transactions are discarded seprately for each log. This is + * possible as the transactions that span across logs have separate transaction + * header for each log. For aborted transactions, we try to process the actions + * of entire transaction at one-shot as we need to perform the actions starting + * from end location to start location. However, it is possbile that the later + * portion of transaction that is overflowed into a separate log can be processed + * separately if we encounter the corresponding log first. If we want we can + * combine the log for processing in that case as well, but there is no clear + * advantage of the same. + *------------------------------------------------------------------------- + */ + +#include "postgres.h" + +#include "access/perbackend/pbu_compat.h" +#include "access/perbackend/pbu_helpers.h" +#include + +#include "access/perbackend/pbu_undodiscard.h" +#include "access/perbackend/pbu_discardworker.h" +#include "miscadmin.h" +#include "pgstat.h" +#include "postmaster/bgworker.h" +#include "postmaster/interrupt.h" +#include "storage/ipc.h" +#include "storage/latch.h" +#include "storage/lwlock.h" +#include "storage/proc.h" +#include "storage/procarray.h" +#include "storage/shmem.h" +#include "tcop/tcopprot.h" +#include "utils/guc.h" +#include "utils/resowner.h" + +/* max sleep time between cycles (100 milliseconds) */ +#define MIN_NAPTIME_PER_CYCLE 100L +#define DELAYED_NAPTIME 10 * MIN_NAPTIME_PER_CYCLE +#define MAX_NAPTIME_PER_CYCLE 100 * MIN_NAPTIME_PER_CYCLE + +static bool hibernate = false; +static long wait_time = MIN_NAPTIME_PER_CYCLE; +static bool am_discard_worker = false; + +/* + * DiscardWorkerRegister -- Register a undo discard worker. + */ +void +DiscardWorkerRegister(void) +{ + BackgroundWorker bgw; + + /* The restart interval below is fixed rather than configurable. */ + + memset(&bgw, 0, sizeof(bgw)); + bgw.bgw_flags = BGWORKER_SHMEM_ACCESS | + BGWORKER_BACKEND_DATABASE_CONNECTION; + bgw.bgw_start_time = BgWorkerStart_RecoveryFinished; + snprintf(bgw.bgw_name, BGW_MAXLEN, "discard worker"); + sprintf(bgw.bgw_library_name, "postgres"); + sprintf(bgw.bgw_function_name, "DiscardWorkerMain"); + bgw.bgw_restart_time = 5; + bgw.bgw_notify_pid = 0; + bgw.bgw_main_arg = (Datum) 0; + + RegisterBackgroundWorker(&bgw); +} + +/* + * DiscardWorkerMain -- Main loop for the undo discard worker. + */ +pg_noreturn void +DiscardWorkerMain(Datum main_arg) +{ + ereport(LOG, + (errmsg("discard worker started"))); + + /* Establish signal handlers. */ + pqsignal(SIGTERM, SignalHandlerForShutdownRequest); + BackgroundWorkerUnblockSignals(); + + am_discard_worker = true; + + /* Establish connection to nailed catalogs. */ + BackgroundWorkerInitializeConnection(NULL, NULL, 0); + + /* + * Make it easy to identify our processes. This must run AFTER + * BackgroundWorkerInitializeConnection(): SetConfigOption(PGC_S_SESSION) + * reads CurrentUserId (via SetSessionAuthorization bookkeeping), which is + * only valid once the connection has established the session user. Doing + * it earlier trips Assert(OidIsValid(CurrentUserId)). + */ + SetConfigOption("application_name", MyBgworkerEntry->bgw_name, + PGC_USERSET, PGC_S_SESSION); + + /* Enter main loop */ + while (!ShutdownRequestPending) + { + int rc; + + TransactionId OldestXmin, + oldestXidHavingUndo; + + /* + * Absorb any pending ProcSignalBarrier. This worker is always + * running, so a barrier that it never absorbed would stall every + * WaitForProcSignalBarrier() caller in the cluster -- for example, + * enabling data checksums online. + */ + CHECK_FOR_INTERRUPTS(); + + /* + * It is okay to ignore vacuum transaction here, as we can discard the + * undo of the vacuuming transaction if the transaction is committed. + * We don't need to hold its undo for the visibility purpose. + */ + OldestXmin = GetOldestXmin(NULL, PROCARRAY_FLAGS_AUTOVACUUM | + PROCARRAY_FLAGS_VACUUM); + + oldestXidHavingUndo = GetXidFromEpochXid( + pg_atomic_read_u64(UndoOldestXidHavingUndo_pbu)); + + /* + * Call the discard routine if there oldestXidHavingUndo is lagging + * behind OldestXmin. + */ + if (OldestXmin != InvalidTransactionId && + TransactionIdPrecedes(oldestXidHavingUndo, OldestXmin)) + { + UndoDiscard(OldestXmin, &hibernate); + + /* + * If we got some undo logs to discard or discarded something, + * then reset the wait_time as we have got work to do. Note that + * if there are some undologs that cannot be discarded, then above + * condition will remain unsatisfied till oldestXmin remains + * unchanged and the wait_time will not reset in that case. + */ + if (!hibernate) + wait_time = MIN_NAPTIME_PER_CYCLE; + } + + /* Wait for more work. */ + rc = WaitLatch(&MyProc->procLatch, + WL_LATCH_SET | WL_TIMEOUT | WL_POSTMASTER_DEATH, + wait_time, + WAIT_EVENT_UNDO_DISCARD_WORKER_MAIN); + + ResetLatch(&MyProc->procLatch); + + /* + * Increase the wait_time based on the length of inactivity. If + * wait_time is within one second, then increment it by 100 ms at a + * time. Henceforth, increment it one second at a time, till it + * reaches ten seconds. Never increase the wait_time more than ten + * seconds, it will be too much of waiting otherwise. + */ + if (rc & WL_TIMEOUT && hibernate) + { + wait_time += (wait_time < DELAYED_NAPTIME ? + MIN_NAPTIME_PER_CYCLE : DELAYED_NAPTIME); + if (wait_time > MAX_NAPTIME_PER_CYCLE) + wait_time = MAX_NAPTIME_PER_CYCLE; + } + + /* emergency bailout if postmaster has died */ + if (rc & WL_POSTMASTER_DEATH) + proc_exit(1); + } + + /* we're done */ + ereport(LOG, + (errmsg("discard worker shutting down"))); + + proc_exit(0); +} + +bool +IsDiscardProcess(void) +{ + return am_discard_worker; +} diff --git a/src/backend/access/undo/perbackend/pbu_helpers.c b/src/backend/access/undo/perbackend/pbu_helpers.c new file mode 100644 index 0000000000000..03e1e178251f1 --- /dev/null +++ b/src/backend/access/undo/perbackend/pbu_helpers.c @@ -0,0 +1,86 @@ +/*------------------------------------------------------------------------- + * + * pbu_helpers.c + * small helpers for the per-backend UNDO engine (see pbu_helpers.h). + * + * These back the handful of zheap-private helpers the vendored engine calls. + * GetEpochForXid/dbid_exists are real implementations against master; + * SetCurrentUndoLocation records the undo location on the TransactionState + * (xact.c). + * + * Per-backend undo engine derives from EnterpriseDB zheap + * (Amit Kapila, Dilip Kumar, Kuntal Ghosh, Mahendra Singh Thalor, + * Rafia Sabih, Thomas Munro et al.). + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/perbackend/pbu_helpers.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/perbackend/pbu_compat.h" +#include "access/perbackend/pbu_helpers.h" +#include "access/transam.h" +#include "access/xact.h" +#include "utils/syscache.h" + +/* + * GetEpochForXid + * Return the epoch that the given plain TransactionId belongs to, + * relative to the current nextXid. zheap kept this in varsup.c; master + * tracks a FullTransactionId nextXid in TransamVariables, so we derive + * the epoch from it, accounting for xid wraparound relative to nextXid. + */ +uint32 +GetEpochForXid(TransactionId xid) +{ + FullTransactionId nextFullXid; + TransactionId nextXid; + uint32 epoch; + + nextFullXid = ReadNextFullTransactionId(); + nextXid = XidFromFullTransactionId(nextFullXid); + epoch = EpochFromFullTransactionId(nextFullXid); + + /* + * If xid is greater than the low 32 bits of nextXid, it must belong to + * the previous epoch (nextXid has wrapped past it). + */ + if (xid > nextXid) + epoch--; + + return epoch; +} + +/* + * dbid_exists + * True iff a database with the given OID currently exists. + */ +bool +dbid_exists(Oid dbid) +{ + return SearchSysCacheExists1(DATABASEOID, ObjectIdGetDatum(dbid)); +} + +/* + * SetCurrentUndoLocation + * Record the transaction's current per-backend undo insertion location so + * abort-time rollback can find the chain. Stored on the current + * TransactionState (kept separate from the cluster-wide engine's + * undoRecPtr) via xact.c; the first location recorded in a transaction is + * its rollback start point. + */ +void +SetCurrentUndoLocation(UndoRecPtr urec_ptr) +{ + UndoLogControl *log = UndoLogGet(UndoRecPtrGetLogNo(urec_ptr)); + + StaticAssertStmt(UndoPersistenceLevels == PBU_UNDO_PERSISTENCE_LEVELS, + "per-backend undo persistence levels out of sync"); + SetCurrentTransactionPbuUndoLocation(log->meta.persistence, + (uint64) urec_ptr); +} diff --git a/src/backend/access/undo/perbackend/pbu_recovery.c b/src/backend/access/undo/perbackend/pbu_recovery.c new file mode 100644 index 0000000000000..58b91eeb56981 --- /dev/null +++ b/src/backend/access/undo/perbackend/pbu_recovery.c @@ -0,0 +1,538 @@ +/*------------------------------------------------------------------------- + * + * pbu_recovery.c + * per-backend UNDO crash-recovery apply driver. + * + * After WAL redo reaches consistency, the ARIES-style undo phase must roll + * back transactions that produced per-backend UNDO but did not commit before + * the crash. StartupUndoLogs() has already rebuilt the UndoLogControl banks + * (insert/discard pointers) from the pg_undo/ checkpoint snapshot, and + * redo of the RM_UNDOLOG_ID records advanced them and rebuilt the xid->logno + * map; the undo-page contents themselves were made durable by riding on their + * producer's WAL record (RegisterUndoLogBuffers) and were replayed into the + * shared buffers. So the recovered undo logs contain, in insertion order, the + * transaction-header chain: each transaction's first record carries a + * UndoRecordTransaction header with the owning xid, its database, an + * apply-progress marker, and uur_next -- the start location of the next + * transaction. + * + * PbuPerformUndoRecovery() walks that chain across all active undo logs and, + * for every LOSER transaction (wrote undo, did not commit, and is not a + * prepared 2PC transaction awaiting explicit resolution), reverse-applies its + * undo via execute_undo_actions() -- the same apply path used on live abort + * (PbuAtAbort_ApplyUndo). execute_undo_actions() is idempotent: it consults + * the transaction header's uur_progress marker and returns early if the undo + * was already (fully) applied, so re-processing a chain (e.g. reached again + * from a second log) is harmless. + * + * IN-FLIGHT LOSERS AND DEFERRED COMPLETION + * + * The inline apply cannot finish the job for a table or index AM. Every AM + * undo callback (flux_undo_apply and the nbtree/hash callbacks) returns + * UNDO_APPLY_SKIPPED unconditionally while InRecovery is set: reverting a row + * needs the relcache/syscache to open the relation, and reversing the index + * side of an in-place indexed-column UPDATE needs the index AMs -- neither of + * which the startup process has (calling the index AMs from it crashes). So + * for a transaction that was an IN-FLIGHT LOSER at the crash instant -- its + * ROLLBACK had not been applied, and a CHECKPOINT had already made its + * post-update pages durable -- the inline apply reverts nothing at all. + * + * That outcome used to be indistinguishable from success, so such a transaction + * was silently abandoned in its post-update state: the heap kept the + * uncommitted value and the indexes kept the uncommitted key. + * + * execute_undo_actions() now reports whether every record was applied, and a + * transaction it could not finish is registered as a rollback request -- + * PbuRegisterRecoveredRollbackReq() -- in the same shared rollback hash table + * and xid/size queues a live abort uses. After recovery the undo launcher + * starts an undo apply worker for that database, which connects and calls + * execute_undo_actions() with relcache, syscache and the index AMs available: + * the same code path, and the same end state, as a live ROLLBACK. + * + * The chain also stays put until the deferred rollback has consumed it: the + * discard worker stops at the first transaction in a log that did not commit + * and whose transaction header does not yet record the undo as applied + * (uur_progress == 0; see UndoDiscardOneLog), and the progress marker is only + * set once the worker has applied every record. + * + * Between the end of recovery and the worker's pass, a loser transaction's rows + * are still physically present with their uncommitted values, but they are not + * visible: FLUX resolves visibility through the status of the writing xid, + * and that xid did not commit. Readers therefore see + * the pre-update row throughout. The deferred apply restores the before-image + * physically and repairs the index entries. + * + * If no undo apply worker can run (single-user mode) the request cannot be + * drained, so we report a WARNING naming the transaction instead of claiming a + * rollback that will not happen. + * + * Prepared (2PC) transactions are skipped here: their per-backend undo + * location is persisted in the 2PC state file (see twophase.c) and applied by + * pbu_twophase_postabort() on ROLLBACK PREPARED, or discarded on COMMIT + * PREPARED. + * + * This runs before WAL insertion is re-enabled, matching the cluster-wide + * engine's PerformUndoRecovery(); the apply itself writes no new WAL (durability of the + * revert is provided by the end-of-recovery checkpoint), and the apply + * callbacks that need syscache (e.g. nbtree) defer to the post-recovery undo + * apply worker via UNDO_APPLY_SKIPPED (they check InRecovery). FILEOPS undo + * apply is path-based and completes synchronously here. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/perbackend/pbu_recovery.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "utils/injection_point.h" + +#include "access/perbackend/pbu_compat.h" +#include "access/perbackend/pbu_undoinsert.h" +#include "access/perbackend/pbu_undolog.h" +#include "access/perbackend/pbu_undorecord.h" +#include "access/perbackend/pbu_undorequest.h" +#include "access/transam.h" +#include "access/twophase.h" +#include "access/twophase_rmgr.h" +#include "access/xact.h" +#include "miscadmin.h" +#include "postmaster/bgworker.h" +#include "utils/memutils.h" + +/* + * pbu_undoworker.h cannot be included here (its types collide with the ones + * pbu_compat.h pulls in), so declare the symbol we need from it. + */ +extern void PbuEnsureUndoLauncher(void); + +/* + * On-disk 2PC record for a per-backend UNDO chain. Persisted at EndPrepare() + * and consumed on COMMIT/ROLLBACK PREPARED. The owning full xid is supplied + * to the callbacks separately (TwoPhaseCallback fxid arg), so it is not stored + * here. + * + * Both ends of the chain are needed: the apply walks backward from the latest + * record to the first one. + */ +typedef struct PbuTwoPhaseUndoRecord +{ + uint64 start_urec_ptr; /* first UndoRecPtr of this xact */ + uint64 latest_urec_ptr; /* most recent UndoRecPtr of this xact */ + Oid dbid; /* database the undo belongs to */ +} PbuTwoPhaseUndoRecord; + +#define PBU_2PC_UNDO 0x0001 + +static bool PbuRegisterRecoveredRollbackReq(FullTransactionId full_xid, + UndoRecPtr start_urec_ptr, + UndoRecPtr end_urec_ptr, + Oid dbid, TransactionId xid); + +/* + * PbuRegisterRecoveredRollbackReq + * Queue a loser transaction whose undo could not be applied inline for + * completion by the undo apply worker after recovery finishes. + * + * Returns true if the request was queued (or was already queued by an earlier + * pass over the same chain), false if there is no way to drain it, in which + * case the caller must not count the transaction as rolled back. + * + * RegisterRollbackReq() enters the request in the shared rollback hash table + * and pushes it onto the xid and size queues. Two properties matter here: + * + * - The hash-table entry makes the discard worker leave this transaction's + * undo alone, so the chain survives until the deferred rollback consumes it. + * - The queue entry is what the undo launcher polls; it runs with + * bgw_start_time = BgWorkerStart_RecoveryFinished, so it begins draining + * exactly when the system opens for writes, which is the earliest moment the + * apply can legally run. + * + * We pass end_urec_ptr explicitly instead of letting RegisterRollbackReq derive + * it. The derivation (FindUndoEndLocationAndSize) resolves the chain end from + * the log's live insert pointer and asserts the transaction is the log's current + * owner; for a chain recovered from a crashed log neither holds, whereas our + * caller has just computed the exact boundary during the chain walk. Note the + * convention: end_urec_ptr is the transaction's LATEST undo record (inclusive), + * which is what UndoWorkerPerformRequest() hands to execute_undo_actions() as + * its 'from' pointer -- not the exclusive end of the chain's address range. + * + * RegisterRollbackReq() returns false in single-user mode (!IsUnderPostmaster), + * where its normal contract is "the calling backend should apply the undo + * itself". The startup process must not do that (no relcache, no index AMs), + * and in single-user mode there is no worker either, so that case is reported to + * the caller as un-drainable rather than silently dropped. + */ +static bool +PbuRegisterRecoveredRollbackReq(FullTransactionId full_xid, + UndoRecPtr start_urec_ptr, + UndoRecPtr end_urec_ptr, + Oid dbid, TransactionId xid) +{ + /* + * A dropped database leaves nothing to revert: the relations are gone, so + * the undo has no target. (RegisterRollbackReq asserts on InvalidOid.) + */ + if (!OidIsValid(dbid)) + return true; + + if (!IsUnderPostmaster) + { + ereport(WARNING, + (errmsg("per-backend UNDO recovery: rollback of transaction %u " + "cannot be completed in single-user mode", xid), + errdetail("The transaction did not commit, so its rows are not " + "visible, but restoring their before-images and " + "repairing index entries needs an undo apply worker."), + errhint("Restart the server normally to let the undo apply " + "worker complete this rollback."))); + return false; + } + + if (!RollbackHTIsFull()) + (void) RegisterRollbackReq(end_urec_ptr, start_urec_ptr, dbid, full_xid); + else + { + ereport(WARNING, + (errmsg("per-backend UNDO recovery: rollback request table is " + "full; rollback of transaction %u is still outstanding", + xid))); + return false; + } + + /* + * The queued request is drained by an undo apply worker, started by the + * undo launcher once recovery has finished. Warn if the configuration + * leaves no launcher to do that. + */ + PbuEnsureUndoLauncher(); + + ereport(LOG, + (errmsg("per-backend UNDO recovery: queued rollback of transaction " + "%u for the undo apply worker", xid))); + + return true; +} + +/* + * PbuPerformUndoRecovery + * Reverse-apply the per-backend UNDO of loser transactions at crash + * recovery. + * + * Called from the recovery driver (PerformWalRecovery in xlogrecovery.c) after + * the redo loop finishes, alongside the cluster-wide engine's + * PerformUndoRecovery(). + * Returns the number of transactions whose undo was applied or queued for + * deferred completion. + * + * A transaction with table or index undo cannot be reverted here (the AM + * callbacks all skip while InRecovery); it is queued for the undo apply worker + * instead. See the file header. + */ +int +PbuPerformUndoRecovery(void) +{ + UndoLogControl *log = NULL; + int applied = 0; + int deferred = 0; + int abandoned = 0; + MemoryContext oldctx; + MemoryContext workctx; + + /* + * Do everything in a scratch context so the per-record pallocs from the + * chain walk / bulk fetch don't accumulate in whatever context recovery + * left us in. + */ + workctx = AllocSetContextCreate(CurrentMemoryContext, + "PbuUndoRecovery", + ALLOCSET_DEFAULT_SIZES); + oldctx = MemoryContextSwitchTo(workctx); + + /* + * Visit every undo log currently known to the engine. For each log with + * live (non-discarded) data, walk its transaction-header chain from the + * oldest valid record forward and roll back the losers. + */ + while ((log = UndoLogNext(log)) != NULL) + { + UndoRecPtr cur; + UndoLogNumber logno = log->logno; + + /* + * Unlogged and temporary undo did not survive the crash: its pages + * were never WAL-logged, and the tables it belongs to are being + * reset. StartupXLOG() empties those logs + * (ResetNonPermanentUndoLogs). + */ + if (log->meta.persistence != UNDO_PERMANENT) + continue; + + /* First non-discarded record in this log, if any. */ + cur = UndoLogGetFirstValidRecord(logno); + + while (UndoRecPtrIsValid(cur)) + { + UnpackedUndoRecord *uur; + FullTransactionId full_xid; + TransactionId xid; + Oid dbid; + UndoRecPtr txn_start = cur; + UndoRecPtr next_txn; + + CHECK_FOR_INTERRUPTS(); + + /* + * Only follow the chain within a log while the pointer stays in a + * live (allocated, non-discarded) region. A pointer into an + * already-discarded range means that transaction's undo was fully + * applied and discarded before the crash. + */ + if (UndoLogIsDiscarded(cur)) + break; + + uur = UndoFetchRecord(txn_start, InvalidBlockNumber, + InvalidOffsetNumber, InvalidTransactionId, + NULL, NULL); + if (uur == NULL) + break; /* discarded/rewound concurrently */ + + /* + * The first record of a transaction in a log carries the + * transaction header (xid, dbid, uur_next). If this record is + * not a transaction header we have lost the chain anchor; stop + * walking this log. + */ + if (!(uur->uur_info & UREC_INFO_TRANSACTION)) + { + UndoRecordRelease(uur); + break; + } + + xid = uur->uur_xid; + dbid = uur->uur_dbid; + full_xid = FullTransactionIdFromEpochAndXid(uur->uur_xidepoch, xid); + next_txn = uur->uur_next; + UndoRecordRelease(uur); + uur = NULL; + + /* + * Decide whether this transaction is a loser that must be rolled + * back. Committed transactions keep their effects. Prepared 2PC + * transactions stay prepared (their undo is owned by the 2PC + * state and applied on ROLLBACK PREPARED). Everything else -- an + * in-flight transaction that crashed, or one already marked + * aborted but not yet fully undone -- is a loser. + */ + if (TransactionIdIsValid(xid) && + !TransactionIdDidCommit(xid) && + !RecoveryTransactionIdIsPrepared(xid)) + { + UndoRecPtr from_ptr; + UndoRecPtr end_ptr; + + ereport(LOG, + (errmsg("per-backend UNDO recovery: rolling back " + "transaction %u (log %d)", xid, logno))); + + /* + * Compute the LATEST (newest) undo record of this + * transaction: execute_undo_actions()/UndoRecordBulkFetch + * walk the chain BACKWARD from `from` (latest) down to `to` + * (txn_start), so we must start at the last record, not the + * first. The transaction's undo occupies [txn_start, end): + * end is the next transaction's start when it is in this same + * log, otherwise the log's current insert pointer. The + * latest record pointer is the record immediately before + * `end`. + */ + if (UndoRecPtrIsValid(next_txn) && + UndoRecPtrGetLogNo(next_txn) == logno) + end_ptr = next_txn; + else + { + UndoLogOffset next_insert; + + next_insert = (UndoLogOffset) MakeUndoRecPtr(logno, + log->meta.insert); + + /* + * If the insert pointer sits at the very start of a page + * (just past the block header), step back over the header + * so UndoGetPrevUndoRecptr finds the last record on the + * previous page (mirrors FindUndoEndLocationAndSize). + */ + if (UndoRecPtrGetPageOffset(next_insert) == UndoLogBlockHeaderSize) + next_insert -= UndoLogBlockHeaderSize; + end_ptr = next_insert; + } + + from_ptr = UndoGetPrevUndoRecptr(end_ptr, InvalidUndoRecPtr, NULL); + if (!UndoRecPtrIsValid(from_ptr)) + from_ptr = txn_start; + + /* + * Reverse-apply the whole chain for this transaction inline, + * and queue what the inline apply could not finish. + * + * execute_undo_actions(from=latest, to=txn_start) walks the + * chain backward from the newest record to the transaction + * start, applying each record's compensating action in LIFO + * order; nopartial = true (complete transaction). It returns + * false if any record came back UNDO_APPLY_SKIPPED -- which + * is what every AM callback does while InRecovery -- or + * errored, meaning the transaction's heap and index effects + * are still in place and the rollback has to be completed + * after recovery. + */ + + /* + * Like redo, an ERROR here must fail startup and retry next + * time. + */ + INJECTION_POINT("pbu-recovery-error", NULL); + if (execute_undo_actions(full_xid, from_ptr, txn_start, true)) + applied++; + else if (PbuRegisterRecoveredRollbackReq(full_xid, txn_start, + from_ptr, dbid, xid)) + deferred++; + else + abandoned++; + } + + /* + * Advance to the next transaction in insertion order. Stop if + * there is none, if it loops back, or if it leaves the set of + * active logs we are iterating (it will be reached when we visit + * that log). + */ + if (!UndoRecPtrIsValid(next_txn) || next_txn == cur) + break; + if (UndoRecPtrGetLogNo(next_txn) != logno) + break; + cur = next_txn; + + /* Keep scratch memory bounded across a long chain. */ + MemoryContextReset(workctx); + MemoryContextSwitchTo(workctx); + } + } + + MemoryContextSwitchTo(oldctx); + MemoryContextDelete(workctx); + + if (applied > 0) + ereport(LOG, + (errmsg("per-backend UNDO recovery: rolled back %d " + "transaction(s)", applied))); + if (deferred > 0) + ereport(LOG, + (errmsg("per-backend UNDO recovery: queued %d transaction(s) for " + "rollback by the undo apply worker", deferred))); + if (abandoned > 0) + ereport(WARNING, + (errmsg("per-backend UNDO recovery: %d transaction(s) could not " + "be rolled back and could not be queued", abandoned))); + + return applied + deferred; +} + +/* + * PbuAtPrepare_Undo + * Persist the current transaction's per-backend UNDO chain bounds into + * the 2PC state file, one record per persistence level's undo log. + * + * Called from EndPrepare() (twophase.c), between StartPrepare() and the END + * sentinel, where RegisterTwoPhaseRecord() is valid. If this transaction + * produced per-backend UNDO (its TransactionState carries a valid + * pbuUndoStartPtr set by SetCurrentUndoLocation), record the first and the + * latest record locations and the database so COMMIT/ROLLBACK PREPARED can + * find the chain. No-op otherwise. + * + * Mirrors the cluster-wide engine's automatic 2PC capture (gxact->undo_batch_lsn set + * in MarkAsPreparing), but for the per-backend engine and via an explicit + * two-phase record so the location survives a crash in the GID state file. + */ +void +PbuAtPrepare_Undo(void) +{ + /* One record per persistence level's chain; none if no per-backend UNDO. */ + for (int level = 0; level < UndoPersistenceLevels; level++) + { + UndoRecPtr start_urec_ptr; + PbuTwoPhaseUndoRecord rec; + + start_urec_ptr = (UndoRecPtr) GetCurrentTransactionPbuUndoStartAt(level); + if (!UndoRecPtrIsValid(start_urec_ptr)) + continue; + + rec.start_urec_ptr = (uint64) start_urec_ptr; + rec.latest_urec_ptr = GetCurrentTransactionPbuUndoLatestAt(level); + rec.dbid = MyDatabaseId; + + RegisterTwoPhaseRecord(TWOPHASE_RM_PERBACKEND_ID, PBU_2PC_UNDO, + &rec, sizeof(PbuTwoPhaseUndoRecord)); + } +} + +/* + * pbu_twophase_postabort + * ROLLBACK PREPARED resolution for a per-backend UNDO chain head. + * + * Reverse-apply the prepared transaction's per-backend undo. Reached from the + * finishing backend's own live transaction (ROLLBACK PREPARED), including + * after a crash+restart where the 2PC state file was reloaded, so the apply + * runs with syscache available (path-based FILEOPS undo, and any future + * relation-based undo, both work here). + * + * fxid is the prepared transaction's full xid, supplied by the 2PC dispatch. + * execute_undo_actions() is idempotent (checks uur_progress), so a retry after + * a crash mid-rollback is safe. + */ +void +pbu_twophase_postabort(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len) +{ + PbuTwoPhaseUndoRecord *rec = (PbuTwoPhaseUndoRecord *) recdata; + UndoRecPtr start_urec_ptr; + UndoRecPtr latest_urec_ptr; + + Assert(info == PBU_2PC_UNDO); + Assert(len == sizeof(PbuTwoPhaseUndoRecord)); + + start_urec_ptr = (UndoRecPtr) rec->start_urec_ptr; + if (!UndoRecPtrIsValid(start_urec_ptr)) + return; + + latest_urec_ptr = (UndoRecPtr) rec->latest_urec_ptr; + if (!UndoRecPtrIsValid(latest_urec_ptr)) + latest_urec_ptr = start_urec_ptr; + + /* + * execute_undo_actions() walks the chain backward from 'from' and stops + * after 'to', so 'from' must be the latest record and 'to' the first. + * nopartial = true: this is the complete transaction. + */ + execute_undo_actions(fxid, latest_urec_ptr, start_urec_ptr, true); +} + +/* + * pbu_twophase_postcommit + * COMMIT PREPARED resolution for a per-backend UNDO chain head. + * + * The transaction committed, so its per-backend undo is not applied; the undo + * pages age out and are discarded by the normal discard machinery. Nothing to + * do here. + */ +void +pbu_twophase_postcommit(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len) +{ + (void) fxid; + (void) info; + (void) recdata; + (void) len; +} diff --git a/src/backend/access/undo/perbackend/pbu_undoaction.c b/src/backend/access/undo/perbackend/pbu_undoaction.c new file mode 100644 index 0000000000000..d1ec3b12111fe --- /dev/null +++ b/src/backend/access/undo/perbackend/pbu_undoaction.c @@ -0,0 +1,1423 @@ +/*------------------------------------------------------------------------- + * + * undoaction.c + * execute undo actions + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/backend/access/undo/undoaction.c + * + *------------------------------------------------------------------------- + */ + +#include "postgres.h" + +#include "access/perbackend/pbu_compat.h" + +#include "access/table.h" +#include "access/perbackend/pbu_undoaction_xlog.h" +#include "access/perbackend/pbu_undolog.h" +#include "access/perbackend/pbu_undorequest.h" +#include "access/xact.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "access/xlog_internal.h" +#include "nodes/pg_list.h" +#include "pgstat.h" +#include "storage/block.h" +#include "storage/buf.h" +#include "storage/bufmgr.h" +#include "storage/smgr.h" +#include "utils/relfilenumbermap.h" +#include "utils/injection_point.h" +#include "utils/syscache.h" +#include "utils/memutils.h" +#include "miscadmin.h" +#include "storage/shmem.h" +#include "access/perbackend/pbu_undodiscard.h" +#include "access/undormgr.h" + +/* + * PrefetchUndoPages - Prefetch undo pages + * + * Prefetch undo pages, if prefetch_pages are behind prefetch_target + */ +static void +PrefetchUndoPages(RelFileNode rnode, int prefetch_target, int *prefetch_pages, + BlockNumber to_blkno, BlockNumber from_blkno, + char persistence) +{ + int nprefetch; + BlockNumber startblock; + BlockNumber lastprefetched; + + /* Calculate last prefetched page in the previous iteration. */ + lastprefetched = from_blkno - *prefetch_pages; + + /* We have already prefetched all the pages of the transaction's undo. */ + if (lastprefetched <= to_blkno) + return; + + /* Calculate number of blocks to be prefetched. */ + nprefetch = + Min(prefetch_target - *prefetch_pages, lastprefetched - to_blkno); + + /* Where to start prefetch. */ + startblock = lastprefetched - nprefetch; + + while (nprefetch--) + { + PrefetchSharedBuffer(smgropen(rnode, INVALID_PROC_NUMBER), + MAIN_FORKNUM, startblock++); + (*prefetch_pages)++; + } +} + +/* + * UndoRecordBulkFetch - Read undo records in bulk + * + * Read undo records between from_urecptr and to_urecptr until we exhaust the + * the memory size specified by undo_apply_size. If we could not read all the + * records till to_urecptr then the caller should consume current set of records + * and call this function again. + * + * from_urecptr - Where to start fetching the undo records. If we can not + * read all the records because of memory limit then this + * will be set to the previous undo record pointer from where + * we need to start fetching on next call. Otherwise it will + * be set to InvalidUndoRecPtr. + * to_urecptr - Last undo record pointer to be fetched. + * undo_apply_size - Memory segment limit to collect undo records. + * nrecords - Number of undo records read. + * one_page - Caller is applying undo only for one block not for + * complete transaction. If this is set true then instead of + * following transaction undo chain using prevlen we will + * follow the block prev chain of the block so that we can + * avoid reading many unnecessary undo records of the + * transaction. + */ +UndoRecInfo * +UndoRecordBulkFetch(UndoRecPtr *from_urecptr, UndoRecPtr to_urecptr, + int undo_apply_size, int *nrecords, bool one_page) +{ + RelFileNode rnode; + UndoRecPtr urecptr, + prev_urec_ptr; + BlockNumber blkno; + BlockNumber to_blkno; + Buffer buffer = InvalidBuffer; + UnpackedUndoRecord *uur = NULL; + UndoRecInfo *urp_array; + int urp_array_size = 1024; + int urp_index = 0; + int prefetch_target = 0; + int prefetch_pages = 0; + Size total_size = 0; + TransactionId xid = InvalidTransactionId; + + /* + * In one_page mode we are fetching undo only for one page instead of + * fetching all the undo of the transaction. Basically, we are fetching + * interleaved undo records. So it does not make sense to do any prefetch + * in that case. + */ + if (!one_page) + prefetch_target = target_prefetch_pages; + + /* + * Allocate initial memory to hold the undo record info, we can expand it + * if needed. + */ + urp_array = (UndoRecInfo *) palloc(sizeof(UndoRecInfo) * urp_array_size); + urecptr = *from_urecptr; + + prev_urec_ptr = InvalidUndoRecPtr; + *from_urecptr = InvalidUndoRecPtr; + + /* Read undo chain backward until we reach to the first undo record. */ + do + { + BlockNumber from_blkno; + UndoLogControl *log; + UndoPersistence persistence; + int size; + int logno; + + logno = UndoRecPtrGetLogNo(urecptr); + log = UndoLogGet(logno); + persistence = log->meta.persistence; + + UndoRecPtrAssignRelFileNode(rnode, urecptr); + to_blkno = UndoRecPtrGetBlockNum(to_urecptr); + from_blkno = UndoRecPtrGetBlockNum(urecptr); + + /* Allocate memory for next undo record. */ + uur = palloc0(sizeof(UnpackedUndoRecord)); + + /* + * If next undo record pointer to be fetched is not on the same block + * then release the old buffer and reduce the prefetch_pages count by + * one as we have consumed one page. Otherwise, just set the old + * buffer into the new undo record so that UndoGetOneRecord don't read + * the buffer again. + */ + blkno = UndoRecPtrGetBlockNum(urecptr); + if (!UndoRecPtrIsValid(prev_urec_ptr) || + UndoRecPtrGetLogNo(prev_urec_ptr) != logno || + UndoRecPtrGetBlockNum(prev_urec_ptr) != blkno) + { + /* Release the previous buffer */ + if (BufferIsValid(buffer)) + { + UnlockReleaseBuffer(buffer); + buffer = InvalidBuffer; + } + + if (prefetch_pages > 0) + prefetch_pages--; + } + else + uur->uur_buffer = buffer; + + /* + * If prefetch_pages are half of the prefetch_target then it's time to + * prefetch again. + */ + if (prefetch_pages < prefetch_target / 2) + PrefetchUndoPages(rnode, prefetch_target, &prefetch_pages, to_blkno, + from_blkno, persistence); + + /* + * In one_page mode it's possible that the undo of the transaction + * might have been applied by worker and undo got discarded. Prevent + * discard worker from discarding undo data while we are reading it. + * See detail comment in UndoFetchRecord. In normal mode we are + * holding transaction undo action lock so it can not be discarded. + */ + if (one_page) + { + LWLockAcquire(&log->discard_lock, LW_SHARED); + + if (!UndoRecordIsValid(urecptr)) + break; + + /* Read the undo record. */ + UndoGetOneRecord(uur, urecptr, rnode, persistence, true); + LWLockRelease(&log->discard_lock); + } + else + UndoGetOneRecord(uur, urecptr, rnode, persistence, true); + + /* + * Remember the buffer, so that next time we can call UndoGetOneRecord + * with the same buffer if we are reading the undo from the same + * buffer. + */ + buffer = uur->uur_buffer; + uur->uur_buffer = InvalidBuffer; + + /* + * As soon as the transaction id is changed we can stop fetching the + * undo record. Ideally, to_urecptr should control this but while + * reading undo only for a page we don't know what is the end undo + * record pointer for the transaction. + */ + if (one_page) + { + if (!TransactionIdIsValid(xid)) + xid = uur->uur_xid; + else if (xid != uur->uur_xid) + break; + } + + /* Remember the previous undo record pointer. */ + prev_urec_ptr = urecptr; + + /* + * Calculate the previous undo record pointer of the transaction. If + * we are reading undo only for a page then follow the blkprev chain + * of the page. Otherwise, calculate the previous undo record pointer + * using transaction's current undo record pointer and the prevlen. + */ + if (one_page) + urecptr = uur->uur_blkprev; + else if (prev_urec_ptr == to_urecptr || uur->uur_info & UREC_INFO_TRANSACTION) + urecptr = InvalidUndoRecPtr; + else + urecptr = UndoGetPrevUndoRecptr(prev_urec_ptr, uur->uur_prevurp, + &buffer); + + /* We have consumed all elements of the urp_array so expand its size. */ + if (urp_index >= urp_array_size) + { + urp_array_size *= 2; + urp_array = + repalloc(urp_array, sizeof(UndoRecInfo) * urp_array_size); + } + + /* Add entry in the urp_array */ + urp_array[urp_index].index = urp_index; + urp_array[urp_index].urp = prev_urec_ptr; + urp_array[urp_index].uur = uur; + urp_index++; + + /* We have fetched all the undo records for the transaction. */ + if (!UndoRecPtrIsValid(urecptr) || (prev_urec_ptr == to_urecptr)) + break; + + /* + * Including current record, if we have crossed the memory limit then + * stop processing more records. Remember to set the from_urecptr so + * that on next call we can resume fetching undo records where we left + * it. + */ + size = UnpackedUndoRecordSize(uur); + total_size += size; + + if (total_size >= undo_apply_size) + { + *from_urecptr = urecptr; + break; + } + } while (true); + + /* Release the last buffer. */ + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + + *nrecords = urp_index; + + return urp_array; +} + +/* + * undo_record_comparator + * + * qsort comparator to handle undo record for applying undo actions of the + * transaction. + */ +static int +undo_record_comparator(const void *left, const void *right) +{ + UnpackedUndoRecord *luur = ((UndoRecInfo *) left)->uur; + UnpackedUndoRecord *ruur = ((UndoRecInfo *) right)->uur; + + if (luur->uur_rmid < ruur->uur_rmid) + return -1; + else if (luur->uur_rmid > ruur->uur_rmid) + return 1; + else if (luur->uur_reloid < ruur->uur_reloid) + return -1; + else if (luur->uur_reloid > ruur->uur_reloid) + return 1; + else if (luur->uur_block == ruur->uur_block) + { + /* + * If records are for the same block then maintain their existing + * order by comparing their index in the array. Because for single + * block we need to maintain the order for applying undo action. + */ + if (((UndoRecInfo *) left)->index < ((UndoRecInfo *) right)->index) + return -1; + else + return 1; + } + else if (luur->uur_block < ruur->uur_block) + return -1; + else + return 1; +} + +/* + * PbuMarkRollbackDone + * Record that the rollback of the transaction whose undo chain starts at + * start_urec_ptr is complete, and forget its rollback request. + * + * The transaction header's progress marker makes a later retry a no-op and + * lets the discard worker discard the transaction's undo. + * + * If nothing was prepared there is no transaction header to mark: the undo + * log is temporary (discarded at transaction end) or already gone. In that + * case skip the update and its CLR entirely -- temp undo is not WAL-logged, + * and UndoRecordUpdateTransInfo() would be reading an xact_urec_info[] slot + * that was never filled in. + * + * Only permanent undo logs are WAL-logged. The contents of an unlogged log + * never reach the WAL, so redo would find no record to patch; its header is + * updated in place, like any unlogged page. + */ +void +PbuMarkRollbackDone(FullTransactionId full_xid, UndoRecPtr start_urec_ptr) +{ + if (PrepareUpdateUndoActionProgress(NULL, start_urec_ptr, 1)) + { + UndoLogControl *log = UndoLogGet(UndoRecPtrGetLogNo(start_urec_ptr)); + + START_CRIT_SECTION(); + + /* Update the progress in the transaction header. */ + UndoRecordUpdateTransInfo(0); + + /* WAL log the undo apply progress. */ + if (log->meta.persistence == UNDO_PERMANENT) + { + XLogRecPtr lsn; + xl_undoapply_progress xlrec; + + xlrec.urec_ptr = start_urec_ptr; + xlrec.progress = 1; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xlrec)); + + RegisterUndoLogBuffers(2); + lsn = XLogInsert(RM_UNDOACTION_ID, XLOG_UNDO_APPLY_PROGRESS); + UndoLogBuffersSetLSN(lsn); + } + + END_CRIT_SECTION(); + } + UnlockReleaseUndoBuffers(); + + RollbackHTRemoveEntry(full_xid, start_urec_ptr); +} + +/* + * execute_undo_actions - Execute the undo actions + * + * xid - Transaction id that is getting rolled back. + * from_urecptr - undo record pointer from where to start applying undo action. + * to_urecptr - undo record pointer upto which point apply undo action. + * nopartial - true if rollback is for complete transaction. + * + * Returns true if every record in the requested range was applied, and false + * if at least one record was skipped (UNDO_APPLY_SKIPPED) or failed + * (UNDO_APPLY_ERROR), so the transaction's rollback is NOT complete. A false + * return obliges the caller to arrange for the remaining work to be done + * later: the AM apply callbacks skip unconditionally while InRecovery is set + * (they need syscache/relcache and the index AMs, which the startup process + * does not have), so the crash-recovery driver must re-queue the transaction + * for a post-recovery worker rather than treat the chain as reverted. See + * PbuPerformUndoRecovery(). + * + * A return of true after an already-applied transaction header short-circuit + * (uur_progress != 0) is correct: the rollback is complete, just not by us. + */ +bool +execute_undo_actions(FullTransactionId full_xid, UndoRecPtr from_urecptr, + UndoRecPtr to_urecptr, bool nopartial) +{ + UnpackedUndoRecord *uur = NULL; + UndoRecInfo *urp_array; + UndoRecPtr urec_ptr; + ForkNumber prev_fork = InvalidForkNumber; + BlockNumber prev_block = InvalidBlockNumber; + int undo_apply_size = maintenance_work_mem * 1024L; + bool all_applied = true; + TransactionId xid PG_USED_FOR_ASSERTS_ONLY = XidFromFullTransactionId(full_xid); + + /* 'from' and 'to' pointers must be valid. */ + Assert(from_urecptr != InvalidUndoRecPtr); + Assert(to_urecptr != InvalidUndoRecPtr); + + urec_ptr = from_urecptr; + + if (nopartial) + { + /* + * It is important here to fetch the latest undo record and validate + * if the actions are already executed. The reason is that it is + * possible that discard worker or backend might try to execute the + * rollback request which is already executed. For ex., after discard + * worker fetches the record and found that this transaction need to + * be rolledback, backend might concurrently execute the actions and + * remove the request from rollback hash table. The similar problem + * can happen if the discard worker first pushes the request, the undo + * worker processed it and backend tries to process it some later + * point. + */ + uur = UndoFetchRecord(to_urecptr, InvalidBlockNumber, InvalidOffsetNumber, + InvalidTransactionId, NULL, NULL); + + /* already processed. */ + if (uur == NULL) + return true; + + /* + * We don't need to execute the undo actions if they are already + * executed. + */ + if (uur->uur_progress != 0) + { + UndoRecordRelease(uur); + return true; + } + + Assert(xid == uur->uur_xid); + + UndoRecordRelease(uur); + uur = NULL; + } + + /* + * Fetch the multiple undo records which can fit into uur_segment; sort + * them in order of reloid and block number then apply them together + * page-wise. Repeat this until we get invalid undo record pointer. + */ + do + { + int prev_rmid = -1; + Oid prev_reloid = InvalidOid; + bool blk_chain_complete; + int i; + int nrecords; + int last_index = 0; + int prefetch_pages = 0; + + /* + * If urec_ptr is not valid means we have complete all undo actions + * for this transaction, otherwise we need to fetch the next batch of + * the undo records. + */ + if (!UndoRecPtrIsValid(urec_ptr)) + break; + + /* + * Fetch multiple undo record in bulk. This will return the array of + * undo record which will holds undo record pointers and the pointers + * to the actual unpacked undo record. This will also update the + * number of undo records it has copied in the urp_array. Also, for + * prefetching the target block ahead of applying undo actions it will + * update undo_blkinfo which will contains the information of the data + * blocks for which undo actions are going to applied for this undo + * record batch. + */ + urp_array = UndoRecordBulkFetch(&urec_ptr, to_urecptr, undo_apply_size, + &nrecords, false); + if (nrecords == 0) + break; + + Assert(TransactionIdEquals(xid, urp_array[0].uur->uur_xid)); + + /* Sort the undo record array in order of target blocks. */ + qsort((void *) urp_array, nrecords, sizeof(UndoRecInfo), + undo_record_comparator); + + if (nopartial && !UndoRecPtrIsValid(urec_ptr)) + blk_chain_complete = true; + else + blk_chain_complete = false; + + /* + * Now we have urp_array which is sorted in the block order so + * traverse this array and apply the undo action block by block. + */ + for (i = last_index; i < nrecords; i++) + { + UnpackedUndoRecord *cur_uur = urp_array[i].uur; + + /* + * If this undo is not for the same block then apply all undo + * actions for the previous block. + */ + if (prev_rmid >= 0 && + (prev_rmid != cur_uur->uur_rmid || + prev_reloid != cur_uur->uur_reloid || + prev_fork != cur_uur->uur_fork || + prev_block != cur_uur->uur_block)) + { + if (!execute_undo_actions_page(urp_array, last_index, i - 1, + prev_reloid, full_xid, prev_block, + blk_chain_complete)) + all_applied = false; + last_index = i; + + /* We have consumed one prefetched page. */ + if (prefetch_pages > 0) + prefetch_pages--; + } + + prev_rmid = cur_uur->uur_rmid; + prev_reloid = cur_uur->uur_reloid; + prev_fork = cur_uur->uur_fork; + prev_block = cur_uur->uur_block; + } + + /* Apply the last set of the actions. */ + if (!execute_undo_actions_page(urp_array, last_index, i - 1, + prev_reloid, full_xid, prev_block, + blk_chain_complete)) + all_applied = false; + + /* Free all undo records. */ + for (i = 0; i < nrecords; i++) + UndoRecordRelease(urp_array[i].uur); + + /* + * Free urp array and undo_blkinfo array for the current batch of undo + * records. + */ + pfree(urp_array); + } while (true); + + /* + * Set undo action apply progress as completed in the transaction header + * if this is a main transaction. + */ + if (nopartial) + { + /* + * During crash recovery (the ARIES undo phase), the whole transaction + * chain is applied once, before WAL insertion is re-enabled and + * before the shared rollback hash table is populated for this xid. + * The transaction-header progress marker and its CLR + * (XLOG_UNDO_APPLY_PROGRESS) exist to make an interrupted LIVE abort + * idempotent and to coordinate with the discard worker; neither + * applies here. Attempting the progress update in recovery is also + * unsafe: it would take the InRecovery undo-buffer read path (no + * exclusive buffer lock) and then MarkBufferDirty, tripping the + * buffer-lock assert, and it would emit WAL and touch the RollbackHT + * that has no entry for this xid. So in recovery we skip the + * progress/CLR bookkeeping entirely; the per-record apply above + * already reverted the change, and the end-of-recovery checkpoint + * provides durability. + * + * Note that in recovery the per-record apply above has typically NOT + * reverted anything: the AM callbacks return UNDO_APPLY_SKIPPED while + * InRecovery is set, which all_applied has recorded. Marking + * progress here would therefore be a lie as well as unsafe; the + * caller uses our false return to re-queue the transaction for + * post-recovery apply, which then does reach the progress/CLR + * bookkeeping below. + */ + if (InRecovery) + return all_applied; + + /* + * Do not mark the transaction header as fully applied when some + * record was skipped or failed: the marker is what makes a later + * retry a no-op, so setting it now would strand the un-reverted + * remainder. The caller decides whether to re-queue. + */ + if (!all_applied) + return false; + + Assert(TransactionIdIsValid(xid)); + PbuMarkRollbackDone(full_xid, to_urecptr); + } + + return all_applied; +} + +/* + * PbuAtAbort_ApplyUndo + * Roll back the current transaction's per-backend UNDO at abort. + * + * Called from AbortTransaction() (xact.c) in the inline-undo-apply window, + * alongside (but independent of) the cluster-wide engine's AtAbort_XactUndo(). + * The two engines coexist: this path only fires when the per-backend engine + * produced UNDO for this transaction, which is recorded separately on the + * TransactionState (pbuUndoStartPtr) via SetCurrentUndoLocation(). FLUX + * writes per-backend UNDO; a transaction that touched no FLUX table has an + * invalid pbuUndoStartPtr and returns immediately. + * + * The rollback is registered in the shared rollback hash table so the discard + * worker will not race us. If the request cannot be pushed to a background + * undo worker (e.g. single-user mode, or workers disabled), the backend + * applies its own UNDO synchronously via execute_undo_actions(). + */ +static void PbuRememberPendingUndo(FullTransactionId full_xid, + UndoRecPtr from_urec_ptr, + UndoRecPtr start_urec_ptr, bool temp); + +void +PbuAtAbort_ApplyUndo(void) +{ + FullTransactionId full_xid; + bool any = false; + + for (int level = 0; level < UndoPersistenceLevels; level++) + { + if (GetCurrentTransactionPbuUndoStartAt(level) != 0) + any = true; + } + + /* Nothing to do if this transaction produced no per-backend UNDO. */ + if (!any) + return; + + full_xid = GetTopFullTransactionIdIfAny(); + if (!FullTransactionIdIsValid(full_xid)) + return; + + /* + * Release any buffer content locks this transaction still holds before + * applying UNDO. + * + * A statement that errors out mid-update can unwind while still holding a + * buffer content lock -- e.g. flux_tuple_update_inplace() calls + * CheckForSerializableConflictIn() under the tuple's exclusive buffer + * lock, and that ereports on an SSI failure. Resource-owner cleanup only + * releases those locks later, in ResourceOwnerRelease(RESOURCE_RELEASE_ + * LOCKS), which runs AFTER this abort-time UNDO application. The UNDO + * appliers re-lock the very same buffer (flux_undo_apply -> + * BufferLockAcquire), so without this the backend deadlocks against its + * own held lock and the abort never completes (observed as a permanently + * hung backend after "could not serialize access ..." in the total-cash + * isolation test). + * + * This mirrors what the cluster-wide engine already does around inline + * UNDO application in xactundo.c. We are in an error-recovery path, so + * dropping content locks here is safe -- no live statement can still be + * relying on them. + */ + BufferLockReleaseAll(); + + /* + * The transaction has one chain in each persistence level's undo log it + * wrote to. Each is a separate rollback request, keyed by its start. + */ + for (int level = 0; level < UndoPersistenceLevels; level++) + { + UndoRecPtr start_urec_ptr; + UndoRecPtr from_urec_ptr; + bool pushed; + + start_urec_ptr = (UndoRecPtr) GetCurrentTransactionPbuUndoStartAt(level); + if (!UndoRecPtrIsValid(start_urec_ptr)) + continue; + from_urec_ptr = (UndoRecPtr) GetCurrentTransactionPbuUndoLatestAt(level); + if (!UndoRecPtrIsValid(from_urec_ptr)) + from_urec_ptr = start_urec_ptr; + + /* + * Register the rollback request. RegisterRollbackReq computes the + * chain's end pointer from start_urec_ptr, records the request so the + * discard worker skips it, and returns true if it was pushed to a + * background undo worker. It returns false in single-user mode and + * for temporary undo. + */ + pushed = RegisterRollbackReq(InvalidUndoRecPtr, start_urec_ptr, + MyDatabaseId, full_xid); + + /* + * If the request was not handed to a background worker, this backend + * must apply the UNDO itself, synchronously, before the transaction + * finishes aborting. from = latest record, to = chain start, + * nopartial = true (whole transaction). + */ + if (!pushed && + !execute_undo_actions(full_xid, from_urec_ptr, start_urec_ptr, + true)) + PbuRememberPendingUndo(full_xid, from_urec_ptr, start_urec_ptr, + level == UNDO_TEMP); + } +} + +/* + * Rollbacks that PbuAtAbort_ApplyUndo() could not complete, for + * PbuApplyPendingUndo(); allocated in TopMemoryContext. + */ +typedef struct PbuPendingUndo +{ + FullTransactionId full_xid; + UndoRecPtr from_urec_ptr; + UndoRecPtr start_urec_ptr; + bool temp; + int nfailures; +} PbuPendingUndo; + +static List *pbu_pending_undo = NIL; +static bool pbu_applying_pending_undo = false; + +/* Attempts PbuApplyPendingUndo() makes at a rollback before giving up. */ +#define PBU_PENDING_UNDO_MAX_ATTEMPTS 3 + +static void +PbuRememberPendingUndo(FullTransactionId full_xid, UndoRecPtr from_urec_ptr, + UndoRecPtr start_urec_ptr, bool temp) +{ + MemoryContext oldcxt = MemoryContextSwitchTo(TopMemoryContext); + PbuPendingUndo *p = palloc0_object(PbuPendingUndo); + + p->full_xid = full_xid; + p->from_urec_ptr = from_urec_ptr; + p->start_urec_ptr = start_urec_ptr; + p->temp = temp; + pbu_pending_undo = lappend(pbu_pending_undo, p); + MemoryContextSwitchTo(oldcxt); +} + +/* + * PbuApplyPendingUndo + * Finish the rollbacks that had to wait for the end of the transaction. + * + * The inline rollback in AbortTransaction() runs before the transaction's + * catalog changes are rolled back. If the transaction itself truncated, + * rewrote or dropped a relation its undo records name, or dropped or rebuilt + * an index they change, the relcache does not show that relation or file yet, + * and the access method leaves the record unapplied (UNDO_APPLY_SKIPPED). + * Once the transaction is over the catalog is as it was before it, so apply + * the chain again, in a transaction of its own, as an undo apply worker + * would. Records already applied are found no longer current and skipped. + * + * No worker can do this for temporary undo, which only this backend can + * read, nor in single-user mode. Until it is done, readers walk past the + * aborted versions and writers revert them first, as for any rollback left + * to a worker; PbuTempUndoPending() keeps the temporary undo they need. + * + * Called with no transaction open: before the next one starts and before + * the backend reports ready for query. An error is reported as a WARNING + * and the rollback is tried again next time, a few times at most. Then it + * is given up: what is left stays in place, for writers to revert while the + * undo lasts, as an undo worker leaves a request it gives up on. + */ +void +PbuApplyPendingUndo(void) +{ + List *pending; + ListCell *lc; + + if (pbu_pending_undo == NIL || pbu_applying_pending_undo || + IsTransactionOrTransactionBlock()) + return; + + pbu_applying_pending_undo = true; + pending = pbu_pending_undo; + pbu_pending_undo = NIL; + + foreach(lc, pending) + { + PbuPendingUndo *p = (PbuPendingUndo *) lfirst(lc); + MemoryContext oldcxt = CurrentMemoryContext; + bool done = false; + + PG_TRY(); + { + StartTransactionCommand(); + done = execute_undo_actions(p->full_xid, p->from_urec_ptr, + p->start_urec_ptr, true); + CommitTransactionCommand(); + } + PG_CATCH(); + { + ErrorData *edata; + + MemoryContextSwitchTo(oldcxt); + edata = CopyErrorData(); + FlushErrorState(); + AbortOutOfAnyTransaction(); + ereport(WARNING, + (errmsg("could not finish rolling back transaction %llu: %s", + (unsigned long long) U64FromFullTransactionId(p->full_xid), + edata->message))); + FreeErrorData(edata); + } + PG_END_TRY(); + MemoryContextSwitchTo(oldcxt); + + if (done) + pfree(p); + else if (++p->nfailures < PBU_PENDING_UNDO_MAX_ATTEMPTS) + { + MemoryContextSwitchTo(TopMemoryContext); + pbu_pending_undo = lappend(pbu_pending_undo, p); + MemoryContextSwitchTo(oldcxt); + } + else + { + ereport(WARNING, + (errmsg("giving up on rolling back transaction %llu", + (unsigned long long) U64FromFullTransactionId(p->full_xid)), + errdetail("Some of its per-backend undo could not be applied; those changes stay in place."))); + pfree(p); + } + } + list_free(pending); + pbu_applying_pending_undo = false; +} + +/* + * Must this backend keep its temporary undo? See PbuApplyPendingUndo(). + */ +bool +PbuTempUndoPending(void) +{ + ListCell *lc; + + if (pbu_applying_pending_undo) + return true; + foreach(lc, pbu_pending_undo) + { + if (((PbuPendingUndo *) lfirst(lc))->temp) + return true; + } + return false; +} + +/* + * MarkUndoRecordApplied + * Mark one undo record as already applied. + * + * The record's rmid is replaced by UNDO_RMID_INVALID. The rmid is the first + * byte of the record, which always lies on the page that urp addresses. The + * record's other contents, including the trailing length that the backward + * chain walk uses, are left alone, so the chain stays walkable and a reader + * following a version pointer into it still finds the same bytes. + */ +static void +MarkUndoRecordApplied(UndoRecPtr urp) +{ + UndoLogControl *log = UndoLogGet(UndoRecPtrGetLogNo(urp)); + RelFileNode rnode; + Buffer buffer; + Page page; + + if (log == NULL) + return; /* discarded: nothing left to apply */ + + UndoRecPtrAssignRelFileNode(rnode, urp); + buffer = ReadBufferWithoutRelcache(rnode, UndoLogForkNum, + UndoRecPtrGetBlockNum(urp), + RBM_NORMAL, NULL, + UndoPersistenceIsPermanent(log->meta.persistence)); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + START_CRIT_SECTION(); + + ((char *) page)[UndoRecPtrGetPageOffset(urp) + + offsetof(UndoRecordHeader, urec_rmid)] = UNDO_RMID_INVALID; + MarkBufferDirty(buffer); + + /* Only permanent undo logs are WAL-logged; see execute_undo_actions(). */ + if (log->meta.persistence == UNDO_PERMANENT) + { + xl_undoapply_subxact xlrec; + + xlrec.urec_ptr = urp; + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xlrec)); + XLogRegisterBuffer(0, buffer, 0); + PageSetLSN(page, XLogInsert(RM_UNDOACTION_ID, XLOG_UNDO_APPLY_SUBXACT)); + } + + END_CRIT_SECTION(); + + UnlockReleaseBuffer(buffer); +} + +/* + * Patch a reserved part of the current writer's payload without changing the + * record size. The caller must own the transaction, so discard and rollback + * cannot race this change. All affected pages are locked before changing any + * bytes, and a single WAL record makes a cross-page patch atomic at replay. + */ +void +PbuAmendPayload(UndoRecPtr urp, Size offset, const char *data, Size len) +{ + UndoLogControl *log = UndoLogGet(UndoRecPtrGetLogNo(urp)); + UnpackedUndoRecord *urec; + RelFileNode rnode; + UndoLogOffset pos; + Buffer buffers[UINT16_MAX / UndoLogUsableBytesPerPage + 2]; + uint16 offsets[lengthof(buffers)]; + uint16 lengths[lengthof(buffers)]; + Size done = 0; + int nbufs = 0; + int i; + + if (log == NULL || len == 0 || len > UINT16_MAX) + elog(ERROR, "invalid undo payload amendment"); + urec = UndoFetchRecord(urp, InvalidBlockNumber, InvalidOffsetNumber, + InvalidTransactionId, NULL, NULL); + if (urec == NULL) + elog(ERROR, "undo record missing during payload amendment"); + if (!TransactionIdIsCurrentTransactionId(urec->uur_xid) || + offset > urec->uur_payload.len || len > urec->uur_payload.len - offset) + elog(ERROR, "undo payload amendment outside owned record"); + + /* Expected size includes tuple bytes and the trailing record length. */ + offset += UndoRecordExpectedSize(urec) - urec->uur_payload.len - + urec->uur_tuple.len - sizeof(uint16); + UndoRecordRelease(urec); + pos = UndoLogOffsetPlusUsableBytes(UndoRecPtrGetOffset(urp), offset); + UndoRecPtrAssignRelFileNode(rnode, urp); + XLogEnsureRecordSpace(lengthof(buffers), 2 * lengthof(buffers)); + + while (done < len) + { + offsets[nbufs] = pos % BLCKSZ; + lengths[nbufs] = Min(len - done, BLCKSZ - offsets[nbufs]); + buffers[nbufs] = ReadBufferWithoutRelcache(rnode, UndoLogForkNum, + pos / BLCKSZ, RBM_NORMAL, NULL, + UndoPersistenceIsPermanent(log->meta.persistence)); + LockBuffer(buffers[nbufs], BUFFER_LOCK_EXCLUSIVE); + done += lengths[nbufs]; + pos = UndoLogOffsetPlusUsableBytes(pos, lengths[nbufs]); + nbufs++; + } + + START_CRIT_SECTION(); + done = 0; + if (log->meta.persistence == UNDO_PERMANENT) + XLogBeginInsert(); + for (i = 0; i < nbufs; i++) + { + memcpy((char *) BufferGetPage(buffers[i]) + offsets[i], + data + done, lengths[i]); + MarkBufferDirty(buffers[i]); + if (log->meta.persistence == UNDO_PERMANENT) + { + XLogRegisterBuffer(i, buffers[i], 0); + XLogRegisterBufData(i, (char *) &offsets[i], sizeof(uint16)); + XLogRegisterBufData(i, (char *) data + done, lengths[i]); + } + done += lengths[i]; + } + if (log->meta.persistence == UNDO_PERMANENT) + { + XLogRecPtr lsn = XLogInsert(RM_UNDOACTION_ID, XLOG_UNDO_AMEND_RECORD); + + for (i = 0; i < nbufs; i++) + PageSetLSN(BufferGetPage(buffers[i]), lsn); + } + END_CRIT_SECTION(); + for (i = 0; i < nbufs; i++) + UnlockReleaseBuffer(buffers[i]); +} + +/* Set while PbuApplySubxactUndo() runs; see PbuInSubxactApply(). */ +static bool pbu_in_subxact_apply = false; + +/* Set while PbuRetrySubxactUndo() runs; see PbuInSubxactRetry(). */ +static bool pbu_in_subxact_retry = false; + +/* + * The top-level transaction in which ROLLBACK TO SAVEPOINT left an undo + * record unapplied, if any. See PbuAtPreCommit_Undo(). + */ +static FullTransactionId pbu_subxact_undo_incomplete; + +/* + * Records of the aborting subtransaction that PbuApplySubxactUndo() could not + * apply yet, for PbuRetrySubxactUndo(); allocated in TopTransactionContext of + * the transaction pbu_retry_fxid. + */ +static FullTransactionId pbu_retry_fxid; +static UndoRecPtr *pbu_retry_urps; +static int pbu_retry_n; +static int pbu_retry_size; + +static void +PbuRememberSubxactRetry(FullTransactionId full_xid, UndoRecPtr urp) +{ + if (!FullTransactionIdEquals(pbu_retry_fxid, full_xid)) + { + pbu_retry_fxid = full_xid; + pbu_retry_size = 16; + pbu_retry_urps = MemoryContextAlloc(TopTransactionContext, + pbu_retry_size * sizeof(UndoRecPtr)); + pbu_retry_n = 0; + } + else if (pbu_retry_n == pbu_retry_size) + { + pbu_retry_size *= 2; + pbu_retry_urps = repalloc(pbu_retry_urps, + pbu_retry_size * sizeof(UndoRecPtr)); + } + pbu_retry_urps[pbu_retry_n++] = urp; +} + +/* Is anything left for PbuRetrySubxactUndo()? */ +bool +PbuSubxactUndoRetryPending(void) +{ + FullTransactionId full_xid = GetTopFullTransactionIdIfAny(); + + return FullTransactionIdIsValid(full_xid) && + FullTransactionIdEquals(full_xid, pbu_retry_fxid) && pbu_retry_n > 0; +} + +/* + * PbuInSubxactApply + * Is the undo being applied for ROLLBACK TO SAVEPOINT? + * + * The access methods' undo callbacks drop the top transaction's sLog marker + * of a restored tuple once its rollback is done. For a subtransaction that + * would drop markers other (sibling, parent) subtransactions still need; the + * access methods' SUBXACT_EVENT_ABORT_SUB handlers clean up per + * subtransaction instead. + */ +bool +PbuInSubxactApply(void) +{ + return pbu_in_subxact_apply; +} + +/* + * PbuInSubxactRetry + * Is the undo being applied by PbuRetrySubxactUndo()? + * + * Then the subtransaction's catalog changes are rolled back, so the relcache + * maps every relation to the file the parent sees. An undo callback that + * found its relation mapped to another file during the walk can return + * UNDO_APPLY_SKIPPED there and rely on the retry seeing the right one. + */ +bool +PbuInSubxactRetry(void) +{ + return pbu_in_subxact_retry; +} + +/* + * PbuApplySubxactUndo + * ROLLBACK TO SAVEPOINT: reverse-apply the aborting subtransaction's + * per-backend undo records of one undo resource manager. + * + * Called before the subtransaction abort record, so that access methods + * can perform their subtransaction-scoped cleanup afterwards. + * CurrentTransactionState is still the aborting subtransaction, + * so the ...At() brackets hold exactly its records, one chain per persistence + * level it wrote to. Every level is applied: a change left in place is kept + * by a COMMIT, as an aborted subtransaction's image that readers can walk past + * only until the transaction's undo is discarded. + * + * Each record that was applied is also marked so. The records stay in the transaction's + * undo chain, between records of the parent, so a later rollback of the + * parent or of the whole transaction would walk over them and apply them a + * second time. By then the subtransaction's row locks are gone and another + * transaction may have updated the same row and committed; re-applying the + * before-image would overwrite that committed value. Every applier skips + * marked records: execute_undo_actions_page() here, which serves the aborting + * backend, the undo apply workers, crash recovery and ROLLBACK PREPARED, and + * this walk, which dispatches records by resource manager. + * + * zheap solved the same problem by rewinding the undo insert pointer to the + * start of the subtransaction. That would let a later write overwrite + * records that a concurrent reader may still be reaching through a tuple's + * version pointer, so we mark instead of rewinding. + * + * A record that could not be applied is left unmarked and remembered for + * PbuRetrySubxactUndo(): typically the subtransaction itself truncated or + * dropped the record's relation, which makes it look empty or gone until + * AbortSubTransaction() has rolled back the subtransaction's catalog changes. + * The older records of the same relation are remembered with it, unapplied: + * records of one row must be applied newest first, and an older one applied + * now would find its row still carrying the newer, unapplied change. + * + * This runs inside AbortSubTransaction(), so an ERROR here (for instance an + * I/O error reading an undo page) is an error during abort. + */ +static void +PbuApplySubxactUndoInternal(int rmid) +{ + FullTransactionId full_xid = GetTopFullTransactionIdIfAny(); + TransactionId xid; + int saved_trans_state; + List *deferred = NIL; + + if (!FullTransactionIdIsValid(full_xid)) + return; + xid = XidFromFullTransactionId(full_xid); + + /* + * We are in TRANS_ABORT, but relcache and locks are still live: present + * TRANS_INPROGRESS for the relation opens the appliers perform. + */ + saved_trans_state = EnterInlineUndoApplyState(); + pbu_in_subxact_apply = true; + PG_TRY(); + { + for (int level = 0; level < UndoPersistenceLevels; level++) + { + UndoRecPtr start = (UndoRecPtr) GetCurrentTransactionPbuUndoStartAt(level); + UndoRecPtr urp = (UndoRecPtr) GetCurrentTransactionPbuUndoLatestAt(level); + + if (!UndoRecPtrIsValid(start)) + continue; + + while (UndoRecPtrIsValid(urp)) + { + UnpackedUndoRecord *uur; + UndoRecPtr prev; + Buffer buffer = InvalidBuffer; + bool applied = false; + + uur = UndoFetchRecord(urp, InvalidBlockNumber, + InvalidOffsetNumber, + InvalidTransactionId, NULL, NULL); + if (uur == NULL) + break; /* discarded: nothing left to apply */ + + if (rmid < 0 || uur->uur_rmid == rmid) + { + const UndoRmgrData *rmgr = GetUndoRmgr(uur->uur_rmid); + + applied = !list_member_oid(deferred, uur->uur_reloid) && + rmgr != NULL && rmgr->rm_undo != NULL && + rmgr->rm_undo(uur->uur_rmid, uur->uur_type, xid, + uur->uur_reloid, + uur->uur_payload.data, + uur->uur_payload.len, urp) == + UNDO_APPLY_SUCCESS; + if (!applied) + { + PbuRememberSubxactRetry(full_xid, urp); + deferred = list_append_unique_oid(deferred, + uur->uur_reloid); + } + } + + if (urp == start || + ((uur->uur_info & UREC_INFO_TRANSACTION) && + !UndoRecPtrIsValid(uur->uur_prevurp))) + prev = InvalidUndoRecPtr; + else + prev = UndoGetPrevUndoRecptr(urp, uur->uur_prevurp, + &buffer); + + UndoRecordRelease(uur); + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + + if (applied) + MarkUndoRecordApplied(urp); + urp = prev; + } + } + } + PG_FINALLY(); + { + pbu_in_subxact_apply = false; + LeaveInlineUndoApplyState(saved_trans_state); + } + PG_END_TRY(); + list_free(deferred); +} + +/* Apply one resource manager's subtransaction records. */ +void +PbuApplySubxactUndo(uint8 rmid) +{ + PbuApplySubxactUndoInternal(rmid); +} + +/* + * Apply all subtransaction records in chain order, before its abort record + * can drop their target files. Dispatch each record to its resource manager. + */ +void +PbuApplySubxactUndoAll(void) +{ + PbuApplySubxactUndoInternal(-1); +} + +/* + * PbuRetrySubxactUndo + * Apply the records PbuApplySubxactUndo() could not. + * + * Called by AbortSubTransaction() once the subtransaction's relcache and + * catalog changes are rolled back, with the parent made the current + * transaction, so relations look as the parent sees them. If a record's + * relation or block is still gone, nothing is left to undo: the relation was + * created in the subtransaction, or is gone for good. A record that fails + * here stays unmarked, in the chain the parent takes over, and the + * transaction can no longer commit; see PbuAtPreCommit_Undo(). + */ +void +PbuRetrySubxactUndo(void) +{ + FullTransactionId full_xid = GetTopFullTransactionIdIfAny(); + TransactionId xid = XidFromFullTransactionId(full_xid); + int n; + + if (!PbuSubxactUndoRetryPending()) + return; + n = pbu_retry_n; + pbu_retry_n = 0; + + pbu_in_subxact_apply = true; + pbu_in_subxact_retry = true; + PG_TRY(); + { + /* Same order as the walk: newest first. */ + for (int i = 0; i < n; i++) + { + UndoRecPtr urp = pbu_retry_urps[i]; + UnpackedUndoRecord *uur; + const UndoRmgrData *rmgr; + UndoApplyResult result = UNDO_APPLY_ERROR; + + uur = UndoFetchRecord(urp, InvalidBlockNumber, InvalidOffsetNumber, + InvalidTransactionId, NULL, NULL); + if (uur == NULL) + continue; + /* An outer subtransaction's walk may already have applied it. */ + if (uur->uur_rmid == UNDO_RMID_INVALID) + { + UndoRecordRelease(uur); + continue; + } + rmgr = GetUndoRmgr(uur->uur_rmid); + if (rmgr != NULL && rmgr->rm_undo != NULL) + result = rmgr->rm_undo(uur->uur_rmid, uur->uur_type, xid, + uur->uur_reloid, + uur->uur_payload.data, + uur->uur_payload.len, urp); + UndoRecordRelease(uur); + + if (result == UNDO_APPLY_ERROR) + pbu_subxact_undo_incomplete = full_xid; + else + MarkUndoRecordApplied(urp); + } + } + PG_FINALLY(); + { + pbu_in_subxact_apply = false; + pbu_in_subxact_retry = false; + } + PG_END_TRY(); +} + +/* + * PbuAtPreCommit_Undo + * Refuse to commit or prepare a transaction in which ROLLBACK TO + * SAVEPOINT left per-backend undo unapplied. + * + * Committing would keep the rolled-back change on the page as the image of an + * aborted subtransaction. Readers walk past it to the committed version only + * while the undo exists; once it is discarded, that version is lost. Rolling + * back instead applies the record with the rest of the transaction. + */ +void +PbuAtPreCommit_Undo(void) +{ + FullTransactionId full_xid = GetTopFullTransactionIdIfAny(); + + if (PbuSubxactUndoRetryPending() || + (FullTransactionIdIsValid(full_xid) && + FullTransactionIdEquals(full_xid, pbu_subxact_undo_incomplete))) + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("cannot commit a transaction whose rollback to a savepoint was incomplete"), + errdetail("An undo record of a rolled-back subtransaction could not be applied."))); +} + +/* + * execute_undo_actions_page - Execute the undo actions for a page + * + * urp_array - array of undo records (along with their location) for which undo + * action needs to be applied. + * first_idx - index in the urp_array of the first undo action to be applied + * last_idx - index in the urp_array of the first undo action to be applied + * reloid - OID of relation on which undo actions needs to be applied. + * blkno - block number on which undo actions needs to be applied. + * blk_chain_complete - indicates whether the undo chain for block is + * complete. + * + * returns true if every record in [first_idx, last_idx] was applied, false if + * any record was skipped (UNDO_APPLY_SKIPPED) or errored. A false return means + * the rollback of those records is still outstanding; the caller must not treat + * the transaction as reverted. Unlike the previous behaviour, an + * UNDO_APPLY_ERROR does not stop the loop: the remaining records of the batch + * are still attempted (they may target unrelated pages), and the false return + * carries the incompleteness up. + */ +bool +execute_undo_actions_page(UndoRecInfo *urp_array, int first_idx, int last_idx, + Oid reloid, FullTransactionId full_xid, BlockNumber blkno, + bool blk_chain_complete) +{ + int i; + bool all_applied = true; + TransactionId xid = XidFromFullTransactionId(full_xid); + + Assert(urp_array != NULL); + + (void) reloid; /* per-record uur_reloid is used instead */ + (void) blkno; + (void) blk_chain_complete; + + /* + * Dispatch each record to the owning AM's undo-apply callback via the + * pluggable UNDO resource-manager API (access/undormgr.h). Every AM or + * subsystem that writes per-backend UNDO registers an UndoRmgrData with + * an rm_undo callback keyed by the rmid stored in each record's header; + * the callback opens the target relation, reapplies the compensating + * change to the page and emits its own CLR WAL. + * + * The record subtype the callback switches on lives in uur_type, NOT + * uur_info (the latter is the UREC_INFO_* structural flag bits); we + * therefore pass rec->uur_type as the callback's "info" argument. Passing + * uur_info here would be a dispatch bug. + */ + for (i = first_idx; i <= last_idx; i++) + { + UnpackedUndoRecord *rec = urp_array[i].uur; + const UndoRmgrData *undormgr; + const char *payload; + Size payload_len; + UndoApplyResult result; + + /* Already applied by ROLLBACK TO SAVEPOINT. */ + if (rec->uur_rmid == UNDO_RMID_INVALID) + continue; + + undormgr = GetUndoRmgr(rec->uur_rmid); + if (undormgr == NULL || undormgr->rm_undo == NULL) + { + /* + * No registered handler for this rmid. Every producer registers + * its rmgr at startup, so this should not happen. The record's + * change is still in place, so the transaction is not reverted: + * report that rather than counting the record as applied. + */ + ereport(WARNING, + (errmsg("no per-backend UNDO handler for resource manager %u; " + "undo record at " UndoRecPtrFormat " not applied", + rec->uur_rmid, + (unsigned long long) urp_array[i].urp))); + all_applied = false; + continue; + } + + payload = rec->uur_payload.data; + payload_len = rec->uur_payload.len; + + /* Lets tests make a record fail to apply without an error. */ + if (IS_INJECTION_POINT_ATTACHED("pbu-undo-apply-skip")) + result = UNDO_APPLY_SKIPPED; + else + result = undormgr->rm_undo(rec->uur_rmid, + rec->uur_type, + xid, + rec->uur_reloid, + payload, + payload_len, + urp_array[i].urp); + + /* + * Both SKIPPED and ERROR leave this record's change in place, so the + * transaction is not fully reverted either way. Report that to the + * caller instead of silently returning success. + * + * SKIPPED is the interesting case for crash recovery: every AM undo + * callback returns it unconditionally while InRecovery is set, so a + * whole loser transaction comes back SKIPPED from the ARIES undo + * phase and must be re-queued (PbuPerformUndoRecovery). Outside + * recovery, a record whose relation or block is gone for good counts + * as applied (the AM callback returns SUCCESS); SKIPPED then means + * the record must be tried again later, e.g. because the aborting + * transaction's own DROP or TRUNCATE has not been rolled back yet + * (PbuApplyPendingUndo). An undo apply worker retries an incomplete + * request a bounded number of times (UNDO_WORKER_MAX_ATTEMPTS). + */ + if (result != UNDO_APPLY_SUCCESS) + all_applied = false; + } + + return all_applied; +} diff --git a/src/backend/access/undo/perbackend/pbu_undoactionxlog.c b/src/backend/access/undo/perbackend/pbu_undoactionxlog.c new file mode 100644 index 0000000000000..c2b13dbe3f118 --- /dev/null +++ b/src/backend/access/undo/perbackend/pbu_undoactionxlog.c @@ -0,0 +1,115 @@ +/*------------------------------------------------------------------------- + * + * undoactionxlog.c + * WAL replay logic for undo actions. + * + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/undoactionxlog.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/perbackend/pbu_compat.h" + +#include "access/perbackend/pbu_undoaction_xlog.h" +#include "access/perbackend/pbu_undoinsert.h" +#include "access/perbackend/pbu_undorecord.h" +#include "access/undormgr.h" +#include "access/xlog.h" +#include "access/xlogutils.h" + +/* + * Replay of undo apply progress. + */ +static void +undo_xlog_apply_progress(XLogReaderState *record) +{ + xl_undoapply_progress *xlrec = (xl_undoapply_progress *) XLogRecGetData(record); + + /* Update the progress in the transaction header. */ + if (PrepareUpdateUndoActionProgress(record, xlrec->urec_ptr, xlrec->progress)) + UndoRecordUpdateTransInfo(0); + UnlockReleaseUndoBuffers(); +} + +/* + * Replay the marking of an undo record as applied by ROLLBACK TO SAVEPOINT. + * Setting the rmid is idempotent, so a page that already has it is harmless. + */ +static void +undo_xlog_apply_subxact(XLogReaderState *record) +{ + xl_undoapply_subxact *xlrec = (xl_undoapply_subxact *) XLogRecGetData(record); + Buffer buffer; + + if (XLogReadBufferForRedo(record, 0, &buffer) == BLK_NEEDS_REDO) + { + Page page = BufferGetPage(buffer); + + ((char *) page)[UndoRecPtrGetPageOffset(xlrec->urec_ptr) + + offsetof(UndoRecordHeader, urec_rmid)] = UNDO_RMID_INVALID; + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); +} + +/* Replay each piece of a payload patch, including pieces on later pages. */ +static void +undo_xlog_amend_record(XLogReaderState *record) +{ + int i; + + for (i = 0; i <= XLogRecMaxBlockId(record); i++) + { + Buffer buffer; + + if (XLogReadBufferForRedo(record, i, &buffer) == BLK_NEEDS_REDO) + { + Size len; + char *data = XLogRecGetBlockData(record, i, &len); + uint16 offset; + Page page = BufferGetPage(buffer); + + if (len < sizeof(offset)) + elog(PANIC, "truncated undo amendment"); + memcpy(&offset, data, sizeof(offset)); + if (offset < UndoLogBlockHeaderSize || + offset > BLCKSZ || len - sizeof(offset) > BLCKSZ - offset) + elog(PANIC, "invalid undo amendment offset"); + memcpy((char *) page + offset, data + sizeof(offset), + len - sizeof(offset)); + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +void +undoaction_redo(XLogReaderState *record) +{ + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + switch (info) + { + case XLOG_UNDO_AMEND_RECORD: + undo_xlog_amend_record(record); + break; + case XLOG_UNDO_APPLY_PROGRESS: + undo_xlog_apply_progress(record); + break; + case XLOG_UNDO_APPLY_SUBXACT: + undo_xlog_apply_subxact(record); + break; + default: + elog(PANIC, "undoaction_redo: unknown op code %u", info); + } +} diff --git a/src/backend/access/undo/perbackend/pbu_undodiscard.c b/src/backend/access/undo/perbackend/pbu_undodiscard.c new file mode 100644 index 0000000000000..3218fdf06ba65 --- /dev/null +++ b/src/backend/access/undo/perbackend/pbu_undodiscard.c @@ -0,0 +1,381 @@ +/*------------------------------------------------------------------------- + * + * undodiscard.c + * discard undo records + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/backend/access/undo/undodiscard.c + * + *------------------------------------------------------------------------- + */ + +#include "postgres.h" + +#include "access/perbackend/pbu_compat.h" +#include "access/perbackend/pbu_helpers.h" + +#include "access/xact.h" +#include "access/xlog.h" +#include "access/perbackend/pbu_undolog.h" +#include "access/perbackend/pbu_undodiscard.h" +#include "access/perbackend/pbu_undorequest.h" +#include "catalog/pg_tablespace.h" +#include "miscadmin.h" +#include "postmaster/interrupt.h" +#include "storage/block.h" +#include "storage/buf.h" +#include "storage/bufmgr.h" +#include "storage/shmem.h" +#include "storage/proc.h" +#include "utils/resowner.h" + +/* + * Discard the undo for the given log + * + * Search the undo log, get the start record for each transaction until we get + * the transaction with xid >= xmin or an invalid xid. Then call undolog + * routine to discard upto that point and update the memory structure for the + * log slot. We set the hibernate flag if we do not have any undo data that + * can be discarded, this flag is passed to the discard worker wherein it + * determines if system is idle and it should sleep for sometime. + * + * Return the oldest xid remaining in this undo log (which should be >= xmin, + * since we'll discard everything older). Return InvalidTransactionId if the + * undo log is empty. + */ +static FullTransactionId +UndoDiscardOneLog(UndoLogControl *log, TransactionId xmin, bool *hibernate) +{ + UndoRecPtr undo_recptr, + next_insert; + UndoRecPtr next_urecptr = InvalidUndoRecPtr; + UnpackedUndoRecord *uur = NULL; + bool need_discard = false; + bool log_complete = false; + TransactionId undoxid = InvalidTransactionId; + TransactionId latest_discardxid = InvalidTransactionId; + uint32 epoch = 0; + + /* There might not be any undo log and hibernation might be needed. */ + *hibernate = true; + + /* + * A discard pointer past the insert pointer means the log's state is not + * one WAL replay can have produced from a consistent log: there is no + * record at the discard pointer to read. Leave such a log alone rather + * than walk it. + */ + LWLockAcquire(&log->mutex, LW_SHARED); + if (log->meta.discard > log->meta.insert) + { + UndoLogOffset discard = log->meta.discard; + UndoLogOffset insert = log->meta.insert; + + LWLockRelease(&log->mutex); + if (!log->discard_corrupt_reported) + { + log->discard_corrupt_reported = true; + ereport(WARNING, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("undo log %u has discard pointer " UndoRecPtrFormat " past its insert pointer " UndoRecPtrFormat ", not discarding it", + log->logno, + (unsigned long long) MakeUndoRecPtr(log->logno, discard), + (unsigned long long) MakeUndoRecPtr(log->logno, insert)))); + } + return InvalidFullTransactionId; + } + LWLockRelease(&log->mutex); + + if (UndoRecPtrIsValid(log->oldest_data)) + undo_recptr = log->oldest_data; + else + undo_recptr = UndoLogGetFirstValidRecord(log->logno); + + StartTransactionCommand(); + + /* Loop until we run out of discardable transactions in the given log. */ + do + { + bool pending_abort = false; + + /* + * A log with many transactions takes a while to walk: let a shutdown + * request end the pass. The next pass starts again from the shared + * oldest_data, which only moves forward. + */ + CHECK_FOR_INTERRUPTS(); + if (ShutdownRequestPending) + break; + + next_insert = UndoLogGetNextInsertPtr(log->logno, InvalidTransactionId); + + if (next_insert == undo_recptr) + { + /* + * The caller of this function must have ensured that there is + * something to discard. + */ + Assert(undo_recptr != log->oldest_data); + + /* Indicate that we have processed all the log. */ + log_complete = true; + } + else + { + /* Fetch the undo record for given undo_recptr. */ + uur = UndoFetchRecord(undo_recptr, InvalidBlockNumber, + InvalidOffsetNumber, InvalidTransactionId, + NULL, NULL); + + if (uur != NULL) + { + /* + * Add the aborted transaction to the rollback request queues. + * + * If the undo actions for the aborted transaction is already + * applied then continue discarding the undo log, otherwise, + * discard till current point and stop processing this undo + * log. + * + * We can ignore the abort for transactions whose + * corresponding database doesn't exist. + * + * Note: We've added the transaction-in-progress check to + * avoid xids of in-progress autovacuum. Note that, while + * calculating xmin, we ignore the autovacuum xids in + * DiscardWorkerMain, so an aborted-looking xid below xmin may + * belong to an autovacuum that is still running. Counting + * autovacuum xids in xmin would avoid that, but a + * long-running autovacuum would then hold back discard. + */ + if (!TransactionIdDidCommit(uur->uur_xid) && + !TransactionIdIsInProgress(uur->uur_xid) && + TransactionIdPrecedes(uur->uur_xid, xmin) && + uur->uur_progress == 0 && + dbid_exists(uur->uur_dbid)) + { + FullTransactionId full_xid; + + full_xid = FullTransactionIdFromEpochAndXid(uur->uur_xidepoch, + uur->uur_xid); + (void) RegisterRollbackReq(InvalidUndoRecPtr, + undo_recptr, + uur->uur_dbid, + full_xid); + + pending_abort = true; + } + + next_urecptr = uur->uur_next; + undoxid = uur->uur_xid; + epoch = uur->uur_xidepoch; + + UndoRecordRelease(uur); + uur = NULL; + } + } + + /* + * We can discard upto this point when one of following conditions is + * met: (a) the next transaction is not all-visible. (b) there is no + * more log to process. (c) the transaction undo in current log is + * finished. (d) there is a pending abort. + */ + if ((TransactionIdIsValid(undoxid) && + TransactionIdFollowsOrEquals(undoxid, xmin)) || + next_urecptr == InvalidUndoRecPtr || + log_complete || + UndoRecPtrGetLogNo(next_urecptr) != log->logno || + pending_abort) + { + /* Hey, I got some undo log to discard, can not hibernate now. */ + *hibernate = false; + + /* + * If the transaction id is smaller than the xmin, it means this + * must be the last transaction in this undo log, so we need to + * get the last insert point in this undo log and discard till + * that point. + * + * Also, if the transaction has pending abort, stop discarding + * further. + */ + if (TransactionIdPrecedes(undoxid, xmin) && !pending_abort) + { + UndoRecPtr blk_next_insert = InvalidUndoRecPtr; + + /* + * If the more undo has been inserted since last we checked, + * then we can process that as well. + */ + blk_next_insert = UndoLogGetNextInsertPtr(log->logno, undoxid); + + /* + * Another transaction has attached to the log since we read + * the record: its undo follows, and the next pass will find + * it. End this pass here; retrying now would reread the same + * state and never get further. + */ + if (!UndoRecPtrIsValid(blk_next_insert)) + break; + + undo_recptr = blk_next_insert; + need_discard = true; + epoch = 0; + latest_discardxid = undoxid; + undoxid = InvalidTransactionId; + } + + /* Update the shared memory state. */ + LWLockAcquire(&log->discard_lock, LW_EXCLUSIVE); + + /* + * If no more pending undo logs then set the oldest transaction to + * InvalidTransactionId. + */ + if (log_complete) + { + log->oldest_xid = InvalidTransactionId; + log->oldest_xidepoch = 0; + } + else + { + log->oldest_xid = undoxid; + log->oldest_xidepoch = epoch; + } + + log->oldest_data = undo_recptr; + + LWLockRelease(&log->discard_lock); + + if (need_discard) + { + LWLockAcquire(&log->discard_update_lock, LW_EXCLUSIVE); + PbuUndoLogDiscard(undo_recptr, latest_discardxid); + LWLockRelease(&log->discard_update_lock); + } + + break; + } + + /* + * This transaction is smaller than the xmin so lets jump to the next + * transaction. + */ + undo_recptr = next_urecptr; + latest_discardxid = undoxid; + + Assert(uur == NULL); + + need_discard = true; + } while (true); + + CommitTransactionCommand(); + + return FullTransactionIdFromEpochAndXid(epoch, undoxid); +} + +/* + * Discard the undo for all the transactions whose xid is smaller than + * oldestXmin + */ +void +UndoDiscard(TransactionId oldestXmin, bool *hibernate) +{ + FullTransactionId oldestXidHavingUndo; + UndoLogControl *log = NULL; + uint32 epoch; + + /* + * If all the undo logs are discarded, then oldestXidHavingUndo should be + * oldestXmin. As of now, we don't allow more than 2 billion xids in the + * system, so we can rely on the epoch retrieved with GetEpochForXid. + */ + epoch = GetEpochForXid(oldestXmin); + oldestXidHavingUndo = FullTransactionIdFromEpochAndXid(epoch, oldestXmin); + + /* + * Iterate through all the active logs and one-by-one try to discard the + * transactions that are old enough to matter. + * + * Note: Ideally we can arrange undo logs so that we can efficiently find + * those with oldest_xid < oldestXmin, but for now we'll just scan all of + * them. + */ + while ((log = UndoLogNext(log))) + { + FullTransactionId oldest_xid = InvalidFullTransactionId; + + /* + * If the log is already discarded, then we are done. It is important + * to first check this to ensure that tablespace containing this log + * doesn't get dropped concurrently. + */ + LWLockAcquire(&log->mutex, LW_SHARED); + if (log->meta.discard == log->meta.insert) + { + LWLockRelease(&log->mutex); + continue; + } + LWLockRelease(&log->mutex); + + /* We can't process temporary undo logs. */ + if (log->meta.persistence == UNDO_TEMP) + continue; + + /* + * If the first xid of the undo log is smaller than the xmin the try + * to discard the undo log. + */ + if (!TransactionIdIsValid(log->oldest_xid) || + TransactionIdPrecedes(log->oldest_xid, oldestXmin)) + { + /* Process the undo log. */ + oldest_xid = UndoDiscardOneLog(log, oldestXmin, hibernate); + } + + /* If oldestXidHavingUndo is not yet initialized, initialize it. */ + if (!FullTransactionIdIsValid(oldestXidHavingUndo)) + oldestXidHavingUndo = oldest_xid; + else if (FullTransactionIdIsValid(oldest_xid) && + FullTransactionIdPrecedes(oldest_xid, oldestXidHavingUndo)) + oldestXidHavingUndo = oldest_xid; + } + + /* + * Update the oldestXidWithEpochHavingUndo in the shared memory. + * + * Note: In future if multiple worker can perform discard then we may need + * to use compare and swap for updating the shared memory value. + */ + if (FullTransactionIdIsValid(oldestXidHavingUndo)) + pg_atomic_write_u64(UndoOldestXidHavingUndo_pbu, + U64FromFullTransactionId(oldestXidHavingUndo)); +} + +/* + * To discard all the logs. Particularly required in single user mode. + * At the commit time, discard all the undo logs. + */ +void +UndoLogDiscardAll(void) +{ + UndoLogControl *log = NULL; + + Assert(!IsUnderPostmaster); + + while ((log = UndoLogNext(log))) + { + /* + * Process the undo log. No locks are required for discard, since + * this called only in single-user mode. Similarly, no transaction id + * is required here because WAL-logging the xid till where the undo is + * discarded will not be required for single user mode. + */ + PbuUndoLogDiscard(MakeUndoRecPtr(log->logno, log->meta.insert), + InvalidTransactionId); + } + +} diff --git a/src/backend/access/undo/perbackend/pbu_undofile.c b/src/backend/access/undo/perbackend/pbu_undofile.c new file mode 100644 index 0000000000000..6421ed501833c --- /dev/null +++ b/src/backend/access/undo/perbackend/pbu_undofile.c @@ -0,0 +1,575 @@ +/*------------------------------------------------------------------------- + * + * pbu_undofile.c + * management of file-backed segments for per-backend undo logs + * + * PostgreSQL undo file manager. This module provides an SMGR-compatible + * interface to the files that back per-backend undo logs on the file system, + * so that undo log data can use the shared buffer pool. Other aspects of + * undo log management are provided by pbu_undolog.c, so the SMGR interfaces + * not directly concerned with reading, writing and flushing data are + * unimplemented. + * + * It is vendored/adapted from EnterpriseDB zheap's storage/smgr/undofile.c + * and reconciled to the current smgr API: + * - the f_smgr vtable moved from read/write to readv/writev buffer vectors + * plus zeroextend/maxcombine/fd/startreadv entry points, + * - RelFileNode became RelFileLocator (smgr_rlocator), and + * - SMgrRelationData no longer carries a private_data pointer, so the + * recently-used segment File is cached in a small backend-local MRU + * table keyed by (logno, tablespace, segno) instead of on the reln. + * + * The undo smgr is registered as smgrsw[SMGR_UNDO] in smgr.c and selected by + * smgropen() for rlocators whose dbOid is UndoLogDatabaseOid (the pseudo-oid + * used for undo logs). Its checkpoint sync path is registered as + * SYNC_HANDLER_UNDO in sync.c (undofile_syncfiletag). + * + * Per-backend undo engine derives from EnterpriseDB zheap + * (Amit Kapila, Dilip Kumar, Kuntal Ghosh, Mahendra Singh Thalor, + * Rafia Sabih, Thomas Munro et al.). + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/perbackend/pbu_undofile.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/perbackend/pbu_compat.h" +#include "access/perbackend/pbu_undofile.h" +#include "access/perbackend/pbu_undolog.h" +#include "access/xlog.h" +#include "miscadmin.h" +#include "storage/aio.h" +#include "storage/aio_types.h" +#include "storage/fd.h" +#include "storage/smgr.h" +#include "storage/sync.h" +#include "utils/memutils.h" + +/* + * While md.c expects random access and has a small number of huge segments, + * undofile.c manages a potentially very large number of smaller segments and + * has a less random access pattern. Rather than keep a per-fork array of + * vfds we keep the single most-recently-used segment File in a backend-local + * cache keyed by (logno, tablespace, segno). Undo access within a log is + * largely sequential, so an MRU of one is sufficient here. A workload that + * thrashes across segments would benefit from promoting this to an LRU of N. + */ +typedef struct UndoFileMru +{ + bool valid; + Oid relNode; /* undo log number */ + Oid spcNode; /* tablespace */ + int segno; + File file; +} UndoFileMru; + +static UndoFileMru undofile_mru = {false, 0, 0, 0, 0}; + +static MemoryContext UndoFileCxt = NULL; + +static File undofile_open_segment_file(Oid relNode, Oid spcNode, int segno, + bool missing_ok); +static File undofile_get_segment_file(SMgrRelation reln, int segno); + +/* Populate a file tag describing an undofile.c segment file. */ +static inline void +INIT_UNDOFILETAG(FileTag *tag, Oid xx_logno, Oid xx_tbspc, uint64 xx_segno) +{ + memset(tag, 0, sizeof(FileTag)); + tag->handler = SYNC_HANDLER_UNDO; + tag->forknum = UndoLogForkNum; + tag->rlocator.spcOid = xx_tbspc; + tag->rlocator.dbOid = UndoLogDatabaseOid; + tag->rlocator.relNumber = xx_logno; + tag->segno = xx_segno; +} + +void +undofile_init(void) +{ + if (UndoFileCxt == NULL) + UndoFileCxt = AllocSetContextCreate(TopMemoryContext, + "UndoFileSmgr", + ALLOCSET_DEFAULT_SIZES); +} + +void +undofile_open(SMgrRelation reln) +{ + /* No per-reln state to set up; segment Files are cached in undofile_mru. */ +} + +void +undofile_shutdown(void) +{ +} + +void +undofile_close(SMgrRelation reln, ForkNumber forknum) +{ +} + +void +undofile_create(SMgrRelation reln, ForkNumber forknum, bool isRedo) +{ + /* + * File creation is managed by pbu_undolog.c, but xlogutils.c likes to + * call this just in case. Ignore. + */ +} + +bool +undofile_exists(SMgrRelation reln, ForkNumber forknum) +{ + /* + * During recovery, xlogutils.c (via smgrexists) and the buffer manager + * probe whether an undo segment exists before reading/extending it. A + * segment file is created lazily by undofile_get_segment_file() the first + * time a block is touched, so "exists" here means "the backing segment 0 + * file is present, or can be created". We report the log as existing so + * that redo of a registered undo buffer routes through the read path, + * which tolerates and (re)creates a missing segment during recovery. + * + * Outside recovery this smgr is only reached through the per-backend undo + * engine's own read/write paths, which never call smgrexists(); an + * unexpected caller there is a bug, so keep it loud. + */ + if (InRecovery) + return true; + + elog(ERROR, "undofile_exists is not supported outside recovery"); + return false; /* keep compiler quiet */ +} + +void +undofile_unlink(RelFileLocatorBackend rlocator, ForkNumber forknum, bool isRedo) +{ + /* + * Undo segment files are removed by pbu_undolog.c's discard path + * (replayed via undolog_xlog_discard), not through the smgr unlink API. + * xlogutils.c never unlinks undo logs, so a call here is unexpected. + */ + elog(ERROR, "undofile_unlink is not supported"); +} + +void +undofile_extend(SMgrRelation reln, ForkNumber forknum, + BlockNumber blocknum, const void *buffer, + bool skipFsync) +{ + /* + * Undo segments are pre-allocated full-size by pbu_undolog.c, so the + * normal engine never extends via the smgr. During recovery, however, + * ExtendBufferedRelTo() (reached from XLogReadBufferExtended when a + * registered undo page lies beyond the current segment) may extend one + * block at a time. Since undofile_nblocks() reports the log as maximal, + * the buffer-manager "page exists" test is always true and this path is + * not normally reached; treat a single-block extend during recovery as an + * initialized write of that block so it is idempotent with the read path. + */ + if (InRecovery) + { + undofile_writev(reln, forknum, blocknum, &buffer, 1, skipFsync); + return; + } + + elog(ERROR, "undofile_extend is not supported outside recovery"); +} + +void +undofile_zeroextend(SMgrRelation reln, ForkNumber forknum, + BlockNumber blocknum, int nblocks, bool skipFsync) +{ + /* + * See undofile_extend: recovery may zero-extend a registered undo page + * that is past the (pre-allocated) segment boundary. Write zeroed blocks + * so the page is initialized, matching md.c's zeroextend semantics. The + * segment file itself is (re)created on demand by + * undofile_get_segment_file() during recovery. + */ + if (InRecovery) + { + char *zerobuf = palloc0(BLCKSZ); + const void *buffers[1]; + int i; + + buffers[0] = zerobuf; + for (i = 0; i < nblocks; i++) + undofile_writev(reln, forknum, blocknum + i, buffers, 1, skipFsync); + pfree(zerobuf); + return; + } + + elog(ERROR, "undofile_zeroextend is not supported outside recovery"); +} + +bool +undofile_prefetch(SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, + int nblocks) +{ +#ifdef USE_PREFETCH + Assert(forknum == MAIN_FORKNUM); + + while (nblocks > 0) + { + File file; + off_t seekpos; + int nthis; + + file = undofile_get_segment_file(reln, blocknum / UNDOSEG_SIZE); + seekpos = (off_t) BLCKSZ * (blocknum % ((BlockNumber) UNDOSEG_SIZE)); + Assert(seekpos < (off_t) BLCKSZ * UNDOSEG_SIZE); + + nthis = Min(nblocks, UNDOSEG_SIZE - (blocknum % UNDOSEG_SIZE)); + + (void) FilePrefetch(file, seekpos, (off_t) BLCKSZ * nthis, + WAIT_EVENT_DATA_FILE_PREFETCH); + + blocknum += nthis; + nblocks -= nthis; + } +#endif /* USE_PREFETCH */ + return true; +} + +/* + * Undo I/O never combines across segments (a request is always a single + * block, and segments are separate files), so report the maximum combine as + * the remaining blocks in the current segment. + */ +uint32 +undofile_maxcombine(SMgrRelation reln, ForkNumber forknum, + BlockNumber blocknum) +{ + BlockNumber segoff = blocknum % ((BlockNumber) UNDOSEG_SIZE); + + return UNDOSEG_SIZE - segoff; +} + +void +undofile_readv(SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, + void **buffers, BlockNumber nblocks) +{ + Assert(forknum == MAIN_FORKNUM); + + while (nblocks > 0) + { + File file; + off_t seekpos; + struct iovec iov; + ssize_t nbytes; + + file = undofile_get_segment_file(reln, blocknum / UNDOSEG_SIZE); + seekpos = (off_t) BLCKSZ * (blocknum % ((BlockNumber) UNDOSEG_SIZE)); + Assert(seekpos < (off_t) BLCKSZ * UNDOSEG_SIZE); + + iov.iov_base = buffers[0]; + iov.iov_len = BLCKSZ; + + nbytes = FileReadV(file, &iov, 1, seekpos, WAIT_EVENT_DATA_FILE_READ); + if (nbytes != BLCKSZ) + { + if (nbytes < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not read block %u in file \"%s\": %m", + blocknum, FilePathName(file)))); + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("could not read block %u in file \"%s\": read only %zd of %d bytes", + blocknum, FilePathName(file), + nbytes, BLCKSZ))); + } + + blocknum++; + buffers++; + nblocks--; + } +} + +void +undofile_startreadv(PgAioHandle *ioh, + SMgrRelation reln, ForkNumber forknum, + BlockNumber blocknum, + void **buffers, BlockNumber nblocks) +{ + /* + * Asynchronous read of undo pages. Reached during recovery when the + * buffer manager prefetches/reads registered undo buffers via the AIO + * path (e.g. XLogReadBufferExtended -> ReadBufferWithoutRelcache under an + * AIO io_method). Mirrors mdstartreadv(): a single undo segment file, + * one FileStartReadV, reusing the generic md readv completion callback + * (it only interprets the byte count into blocks and does not touch + * md-private state, so it is correct for undo too). + */ + File file; + off_t seekpos; + struct iovec *iov; + int iovcnt; + int i; + int ret; + BlockNumber nblocks_this_segment; + + Assert(forknum == MAIN_FORKNUM); + + nblocks_this_segment = Min(nblocks, + UNDOSEG_SIZE - (blocknum % UNDOSEG_SIZE)); + if (nblocks_this_segment != nblocks) + elog(ERROR, "undo read crossing segment boundary"); + + file = undofile_get_segment_file(reln, blocknum / UNDOSEG_SIZE); + seekpos = (off_t) BLCKSZ * (blocknum % ((BlockNumber) UNDOSEG_SIZE)); + Assert(seekpos < (off_t) BLCKSZ * UNDOSEG_SIZE); + + iovcnt = pgaio_io_get_iovec(ioh, &iov); + Assert(nblocks <= iovcnt); + + for (i = 0; i < nblocks_this_segment; i++) + { + iov[i].iov_base = buffers[i]; + iov[i].iov_len = BLCKSZ; + } + iovcnt = nblocks_this_segment; + + pgaio_io_set_flag(ioh, PGAIO_HF_BUFFERED); + + pgaio_io_set_target_smgr(ioh, reln, forknum, blocknum, nblocks, false); + pgaio_io_register_callbacks(ioh, PGAIO_HCB_MD_READV, 0); + + ret = FileStartReadV(ioh, file, iovcnt, seekpos, WAIT_EVENT_DATA_FILE_READ); + if (ret != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not start reading blocks %u..%u in file \"%s\": %m", + blocknum, blocknum + nblocks_this_segment - 1, + FilePathName(file)))); +} + +void +undofile_writev(SMgrRelation reln, ForkNumber forknum, + BlockNumber blocknum, const void **buffers, + BlockNumber nblocks, bool skipFsync) +{ + Assert(forknum == MAIN_FORKNUM); + + while (nblocks > 0) + { + File file; + off_t seekpos; + struct iovec iov; + ssize_t nbytes; + + file = undofile_get_segment_file(reln, blocknum / UNDOSEG_SIZE); + seekpos = (off_t) BLCKSZ * (blocknum % ((BlockNumber) UNDOSEG_SIZE)); + Assert(seekpos < (off_t) BLCKSZ * UNDOSEG_SIZE); + + iov.iov_base = unconstify(void *, buffers[0]); + iov.iov_len = BLCKSZ; + + nbytes = FileWriteV(file, &iov, 1, seekpos, WAIT_EVENT_DATA_FILE_WRITE); + if (nbytes != BLCKSZ) + { + if (nbytes < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write block %u in file \"%s\": %m", + blocknum, FilePathName(file)))); + + /* + * short write: unexpected, because this should be overwriting an + * entirely pre-allocated segment file + */ + ereport(ERROR, + (errcode(ERRCODE_DISK_FULL), + errmsg("could not write block %u in file \"%s\": wrote only %zd of %d bytes", + blocknum, FilePathName(file), + nbytes, BLCKSZ))); + } + + /* Tell checkpointer this file is dirty. */ + if (!skipFsync && !SmgrIsTemp(reln)) + { + FileTag tag; + + INIT_UNDOFILETAG(&tag, + reln->smgr_rlocator.locator.relNumber, + reln->smgr_rlocator.locator.spcOid, + blocknum / UNDOSEG_SIZE); + + if (!RegisterSyncRequest(&tag, SYNC_REQUEST, false /* retryOnError */ )) + { + if (FileSync(file, WAIT_EVENT_DATA_FILE_SYNC) < 0) + ereport(data_sync_elevel(ERROR), + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\": %m", + FilePathName(file)))); + } + } + + blocknum++; + buffers++; + nblocks--; + } +} + +void +undofile_writeback(SMgrRelation reln, ForkNumber forknum, + BlockNumber blocknum, BlockNumber nblocks) +{ + while (nblocks > 0) + { + File file; + int nflush; + + file = undofile_get_segment_file(reln, blocknum / UNDOSEG_SIZE); + + /* compute number of desired writes within the current segment */ + nflush = Min(nblocks, + 1 + UNDOSEG_SIZE - (blocknum % UNDOSEG_SIZE)); + + FileWriteback(file, + (off_t) (blocknum % UNDOSEG_SIZE) * BLCKSZ, + (off_t) nflush * BLCKSZ, WAIT_EVENT_DATA_FILE_FLUSH); + + nblocks -= nflush; + blocknum += nflush; + } +} + +BlockNumber +undofile_nblocks(SMgrRelation reln, ForkNumber forknum) +{ + /* + * xlogutils.c likes to call this to decide whether to read or extend; for + * now we lie and say the relation is as big as possible. + */ + return UndoLogMaxSize / BLCKSZ; +} + +void +undofile_truncate(SMgrRelation reln, ForkNumber forknum, + BlockNumber old_blocks, BlockNumber nblocks) +{ + elog(ERROR, "undofile_truncate is not supported"); +} + +void +undofile_immedsync(SMgrRelation reln, ForkNumber forknum) +{ + elog(ERROR, "undofile_immedsync is not supported"); +} + +void +undofile_registersync(SMgrRelation reln, ForkNumber forknum) +{ + elog(ERROR, "undofile_registersync is not supported"); +} + +int +undofile_fd(SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, + uint32 *off) +{ + File file; + + file = undofile_get_segment_file(reln, blocknum / UNDOSEG_SIZE); + *off = (uint32) ((off_t) BLCKSZ * (blocknum % ((BlockNumber) UNDOSEG_SIZE))); + + return FileGetRawDesc(file); +} + +static File +undofile_open_segment_file(Oid relNode, Oid spcNode, int segno, + bool missing_ok) +{ + File file; + char path[MAXPGPATH]; + + UndoLogSegmentPath(relNode, segno, spcNode, path); + file = PathNameOpenFile(path, O_RDWR | PG_BINARY); + + if (file <= 0 && (!missing_ok || errno != ENOENT)) + elog(ERROR, "cannot open undo segment file \"%s\": %m", path); + + return file; +} + +/* + * Get a File for a particular segment of a SMgrRelation representing an undo + * log. The result is cached in a backend-local MRU of one. + */ +static File +undofile_get_segment_file(SMgrRelation reln, int segno) +{ + Oid relNode = reln->smgr_rlocator.locator.relNumber; + Oid spcNode = reln->smgr_rlocator.locator.spcOid; + + /* If we have the right file open already, reuse it. */ + if (undofile_mru.valid && + undofile_mru.file > 0 && + undofile_mru.relNode == relNode && + undofile_mru.spcNode == spcNode && + undofile_mru.segno == segno) + return undofile_mru.file; + + /* Otherwise close whatever we had open. */ + if (undofile_mru.valid && undofile_mru.file > 0) + FileClose(undofile_mru.file); + undofile_mru.valid = false; + undofile_mru.file = 0; + + undofile_mru.file = + undofile_open_segment_file(relNode, spcNode, segno, InRecovery); + if (InRecovery && undofile_mru.file <= 0) + { + /* + * If in recovery, we may be trying to access a file that will later + * be unlinked. Tolerate missing files, creating a new zero-filled + * file as required. + */ + UndoLogNewSegment(relNode, spcNode, segno); + undofile_mru.file = + undofile_open_segment_file(relNode, spcNode, segno, false); + Assert(undofile_mru.file > 0); + } + + undofile_mru.relNode = relNode; + undofile_mru.spcNode = spcNode; + undofile_mru.segno = segno; + undofile_mru.valid = true; + + return undofile_mru.file; +} + +int +undofile_syncfiletag(const FileTag *tag, char *path) +{ + SMgrRelation reln = smgropen(tag->rlocator, INVALID_PROC_NUMBER); + File file; + + UndoLogSegmentPath(tag->rlocator.relNumber, tag->segno, + tag->rlocator.spcOid, path); + + file = undofile_get_segment_file(reln, tag->segno); + if (file <= 0) + { + /* errno set by undofile_get_segment_file() */ + return -1; + } + + return FileSync(file, WAIT_EVENT_DATA_FILE_SYNC); +} + +void +undofile_forget_sync(UndoLogNumber logno, BlockNumber segno, Oid tablespace) +{ + FileTag tag; + + INIT_UNDOFILETAG(&tag, logno, tablespace, segno); + + (void) RegisterSyncRequest(&tag, SYNC_FORGET_REQUEST, true /* retryOnError */ ); +} diff --git a/src/backend/access/undo/perbackend/pbu_undoinsert.c b/src/backend/access/undo/perbackend/pbu_undoinsert.c new file mode 100644 index 0000000000000..d220abce09d2d --- /dev/null +++ b/src/backend/access/undo/perbackend/pbu_undoinsert.c @@ -0,0 +1,1780 @@ +/*------------------------------------------------------------------------- + * + * undoinsert.c + * entry points for inserting undo records + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/backend/access/undo/undoinsert.c + * + * NOTES: + * Undo record layout: + * + * Undo records are stored in sequential order in the undo log. Each undo + * record consists of a variable length header, tuple data, and payload + * information. The first undo record of each transaction contains a + * transaction header that points to the next transaction's start header. + * This allows us to discard the entire transaction's log at one-shot rather + * than record-by-record. The callers are not aware of transaction header, + * this is entirely maintained and used by undo record layer. See + * undorecord.h for detailed information about undo record header. + * + * Multiple logs: + * + * It is possible that the undo records for a transaction spans across + * multiple undo logs. We need some special handling while inserting them to + * ensure that discard and rollbacks can work sanely. + * + * When the undorecord for a transaction gets inserted in the next log then we + * insert a transaction header for the first record in the new log and update + * the transaction header with this new logs location. We will also keep + * a back pointer to the last undo record of previous log in the first record + * of new log, so that we can traverse the previous record during rollback. + * In case, this is not the first record in new log (aka new log already + * contains some other transactions data), we also update that transactions + * next start header with this new undo records location. This will allow us + * to connect transaction's undo records across logs when the same transaction + * span across log. + * + * There is some difference in the way the rollbacks work when the undo for + * same transaction spans across multiple logs depending on which log is + * processed first by the discard worker. If it processes the first log which + * contains the transactions first record, then it can get the last record + * of that transaction even if it is in different log and then processes all + * the undo records from last to first. OTOH, if the next log get processed + * first, we don't need to trace back the actual start pointer of the + * transaction, rather we only execute the undo actions from the current log + * and avoid re-executing them next time. There is a possibility that after + * executing the undo actions, the undo got discarded, now in later stage while + * processing the previous log, it might try to fetch the undo record in the + * discarded log while chasing the transaction header chain which can cause + * trouble. We avoid this situation by first checking if the next_urec of + * the transaction is already discarded and if so, we start executing from + * the last undo record in the current log. + * + *------------------------------------------------------------------------- + */ + +#include "postgres.h" + +#include "access/perbackend/pbu_compat.h" +#include "access/perbackend/pbu_helpers.h" + +#include "access/subtrans.h" +#include "access/xact.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "access/perbackend/pbu_undorecord.h" +#include "access/perbackend/pbu_undoinsert.h" +#include "access/perbackend/pbu_undolog_xlog.h" +#include "access/xact.h" +#include "access/xlog.h" +#include "access/xlogutils.h" +#include "catalog/pg_tablespace.h" +#include "storage/block.h" +#include "storage/buf.h" +#include "storage/buf_internals.h" +#include "storage/bufmgr.h" +#include "miscadmin.h" + +/* + * This defines the number of undo records that can be prepared before + * calling insert by default. If you need to prepare more than + * MAX_PREPARED_UNDO undo records, then you must call UndoSetPrepareSize + * first. + */ +#define MAX_PREPARED_UNDO 2 + +/* + * This defines the max number of previous xact infos we need to update. + * Usually it's 1 for updating next link of previous transaction's header + * if we are starting a new transaction. But, in some cases where the same + * transaction is spilled to the next log, we update our own transaction's + * header in previous undo log as well as the header of the previous + * transaction in the new log. + */ +#define MAX_XACT_UNDO_INFO 2 + +/* + * Consider buffers needed for updating previous transaction's + * starting undo record as well. + */ +#define MAX_UNDO_BUFFERS (MAX_PREPARED_UNDO + MAX_XACT_UNDO_INFO) * MAX_BUFFER_PER_UNDO + +/* + * Previous top transaction id which inserted the undo. Whenever a new main + * transaction try to prepare an undo record we will check if its txid not the + * same as prev_txid then we will insert the start undo record. + */ +static TransactionId prev_txid[UndoPersistenceLevels] = {0}; + +/* Undo block number to buffer mapping. */ +typedef struct UndoBuffers +{ + UndoLogNumber logno; /* Undo log number */ + BlockNumber blk; /* block number */ + Buffer buf; /* buffer allocated for the block */ + bool zero; /* new block full of zeroes */ + bool done; /* in redo: page already holds this record */ +} UndoBuffers; + +static UndoBuffers def_buffers[MAX_UNDO_BUFFERS]; +static int buffer_idx; + +/* + * Structure to hold the prepared undo information. + */ +typedef struct PreparedUndoSpace +{ + UndoRecPtr urp; /* undo record pointer */ + UnpackedUndoRecord *urec; /* undo record */ + int undo_buffer_idx[MAX_BUFFER_PER_UNDO]; /* undo_buffer array + * index */ +} PreparedUndoSpace; + +static PreparedUndoSpace def_prepared[MAX_PREPARED_UNDO]; +static int prepare_idx; +static int max_prepared_undo = MAX_PREPARED_UNDO; +static UndoRecPtr prepared_urec_ptr = InvalidUndoRecPtr; + +/* + * By default prepared_undo and undo_buffer points to the static memory. + * In case caller wants to support more than default max_prepared undo records + * then the limit can be increased by calling UndoSetPrepareSize function. + * Therein, dynamic memory will be allocated and prepared_undo and undo_buffer + * will start pointing to newly allocated memory, which will be released by + * UnlockReleaseUndoBuffers and these variables will again set back to their + * default values. + */ +static PreparedUndoSpace *prepared_undo = def_prepared; +static UndoBuffers *undo_buffer = def_buffers; + +/* + * Structure to hold the previous transaction's undo update information. This + * is populated while current transaction is updating its undo record pointer + * in previous transactions first undo record. + */ +typedef struct XactUndoRecordInfo +{ + UndoRecPtr urecptr; /* txn's start urecptr */ + int idx_undo_buffers[MAX_BUFFER_PER_UNDO]; + UnpackedUndoRecord uur; /* undo record header */ +} XactUndoRecordInfo; + +static XactUndoRecordInfo xact_urec_info[MAX_XACT_UNDO_INFO]; +static int xact_urec_info_idx; + +/* Prototypes for static functions. */ +static void UndoRecordPrepareTransInfo(XLogReaderState *xlog_record, + UndoRecPtr urecptr, UndoRecPtr xact_urp); +static int UndoGetBufferSlot(RelFileNode rnode, BlockNumber blk, + ReadBufferMode rbm, + UndoPersistence persistence, + XLogReaderState *xlog_record); +static uint16 UndoGetPrevRecordLen(UndoRecPtr urp, Buffer *input_buffer); +static int UndoRedoBlockId(XLogReaderState *xlog_record, RelFileNode rnode, + BlockNumber blk); + +/* + * The block id under which the WAL record being replayed registered the given + * undo page, or -1 if it did not register it. + */ +static int +UndoRedoBlockId(XLogReaderState *xlog_record, RelFileNode rnode, + BlockNumber blk) +{ + if (xlog_record == NULL) + return -1; + + for (int id = 0; id <= XLogRecMaxBlockId(xlog_record); id++) + { + RelFileLocator blk_rlocator; + ForkNumber blk_forknum; + BlockNumber blk_blkno; + + if (XLogRecGetBlockTagExtended(xlog_record, id, &blk_rlocator, + &blk_forknum, &blk_blkno, NULL) && + RelFileLocatorEquals(blk_rlocator, rnode) && + blk_forknum == UndoLogForkNum && blk_blkno == blk) + return id; + } + return -1; +} + +/* + * Check whether the undo record is discarded or not. If it's already discarded + * return false otherwise return true. + * + * Caller must hold lock on log->discard_lock. This function will release the + * lock if return false otherwise lock will be held on return and the caller + * need to release it. + */ +bool +UndoRecordIsValid(UndoRecPtr urp) +{ + UndoLogControl *log = UndoLogGet(UndoRecPtrGetLogNo(urp)); + + /* + * No log means the record is gone, which is exactly the "already + * discarded" answer: return false. Per this function's contract the + * discard_lock is released on a false return -- but with no log there is + * no lock to release, and the caller could not have acquired one either, + * so simply return. + */ + if (log == NULL) + return false; + + Assert(LWLockHeldByMeInMode(&log->discard_lock, LW_SHARED)); + + if (log->oldest_data == InvalidUndoRecPtr) + { + /* + * oldest_data is only initialized when the DiscardWorker first time + * attempts to discard undo logs so we can not rely on this value to + * identify whether the undo record pointer is already discarded or + * not so we can check it by calling undo log routine. If its not yet + * discarded then we have to reacquire the log->discard_lock so that + * the doesn't get discarded concurrently. + */ + LWLockRelease(&log->discard_lock); + if (UndoLogIsDiscarded(urp)) + return false; + LWLockAcquire(&log->discard_lock, LW_SHARED); + } + + /* Check again if it's already discarded. */ + if (urp < log->oldest_data) + { + LWLockRelease(&log->discard_lock); + return false; + } + + return true; +} + +/* + * Prepare to update the previous transaction's next undo pointer to maintain + * the transaction chain in the undo. This will read the header of the first + * undo record of the previous transaction and lock the necessary buffers. + * The actual update will be done by UndoRecordUpdateTransInfo under the + * critical section. + */ +static void +UndoRecordPrepareTransInfo(XLogReaderState *xlog_record, UndoRecPtr urecptr, + UndoRecPtr xact_urp) +{ + Buffer buffer = InvalidBuffer; + BlockNumber cur_blk; + RelFileNode rnode; + UndoLogControl *log; + Page page; + int already_decoded = 0; + int starting_byte; + int bufidx; + int index = 0; + + /* + * The absence of previous transaction's undo indicate that this backend + * is preparing its first undo in which case we have nothing to update. + */ + if (!UndoRecPtrIsValid(xact_urp)) + return; + + log = UndoLogGet(UndoRecPtrGetLogNo(xact_urp)); + + /* + * The log is gone (already discarded, or xact_urp is stale): nothing to + * update. UndoLogGet() returns NULL rather than handing back an + * uninitialised UndoLogControl, so this must be checked before any + * dereference. + */ + if (log == NULL) + return; + + /* + * Temporary undo logs are discarded on transaction commit so we don't + * need to do anything. + */ + if (log->meta.persistence == UNDO_TEMP) + return; + + /* + * Acquire the discard lock before accessing the undo record so that + * discard worker doesn't remove the record while we are in process of + * reading it. + */ + LWLockAcquire(&log->discard_lock, LW_SHARED); + + /* + * The absence of previous transaction's undo indicate that this backend + * is preparing its first undo in which case we have nothing to update. + * UndoRecordIsValid will release the lock if it returns false. + */ + if (!UndoRecordIsValid(xact_urp)) + return; + + UndoRecPtrAssignRelFileNode(rnode, xact_urp); + cur_blk = UndoRecPtrGetBlockNum(xact_urp); + starting_byte = UndoRecPtrGetPageOffset(xact_urp); + + /* + * In redo, update the header only if DO did. DO registers the header's + * page in its WAL record exactly when it got here, and may have skipped + * the header as discarded on the discard worker's word (oldest_data) + * before the discard reached the WAL, which redo cannot see. A header + * that DO did update has its page restored from the record if needed. + */ + if (InRecovery && xlog_record != NULL && + UndoRedoBlockId(xlog_record, rnode, cur_blk) < 0) + { + LWLockRelease(&log->discard_lock); + return; + } + + /* + * Read undo record header in by calling UnpackUndoRecord, if the undo + * record header is split across buffers then we need to read the complete + * header by invoking UnpackUndoRecord multiple times. + */ + while (true) + { + bufidx = UndoGetBufferSlot(rnode, cur_blk, + RBM_NORMAL, + log->meta.persistence, + xlog_record); + + xact_urec_info[xact_urec_info_idx].idx_undo_buffers[index++] = bufidx; + buffer = undo_buffer[bufidx].buf; + + /* + * UndoRecordIsValid() says the header has not been discarded, so its + * page must be there. Only redo can fail to find it, and then the + * undo the header chains is lost: refuse to go on. + */ + if (!BufferIsValid(buffer)) + elog(PANIC, "undo page %u of undo log %u, holding the transaction header at " + UndoRecPtrFormat ", is missing", + cur_blk, (unsigned int) UndoRecPtrGetLogNo(xact_urp), + (unsigned long long) xact_urp); + page = BufferGetPage(buffer); + + if (UnpackUndoRecord(&xact_urec_info[xact_urec_info_idx].uur, page, + starting_byte, &already_decoded, true, false)) + break; + + /* Could not fetch the complete header so go to the next block. */ + starting_byte = UndoLogBlockHeaderSize; + cur_blk++; + } + + xact_urec_info[xact_urec_info_idx].uur.uur_next = urecptr; + xact_urec_info[xact_urec_info_idx].urecptr = xact_urp; + xact_urec_info_idx++; + LWLockRelease(&log->discard_lock); +} + +/* + * Update the progress of the undo record in the transaction header. + * + * Returns true if an entry was pushed onto xact_urec_info[] (so the caller + * must follow up with UndoRecordUpdateTransInfo() to write it out), false if + * there is nothing to update. Callers MUST honour the false return: calling + * UndoRecordUpdateTransInfo() anyway would dereference an xact_urec_info[] + * slot we never filled in, which crashes on the stale/invalid log number + * (seen as a segfault in LWLockAcquire(&log->discard_update_lock) when a temp + * relation's undo is rolled back). + */ +bool +PrepareUpdateUndoActionProgress(XLogReaderState *xlog_record, + UndoRecPtr urecptr, int progress) +{ + Buffer buffer = InvalidBuffer; + BlockNumber cur_blk; + RelFileNode rnode; + UndoLogNumber logno = UndoRecPtrGetLogNo(urecptr); + UndoLogControl *log; + Page page; + int already_decoded = 0; + int starting_byte; + int bufidx; + int index = 0; + + log = UndoLogGet(logno); + + /* + * The log is gone (already discarded, or urecptr is stale): nothing to + * update. Every other UndoLogGet() caller treats NULL this way. + */ + if (log == NULL) + return false; + + /* + * Temporary undo logs are discarded on transaction end, so there is no + * transaction header worth updating. + */ + if (log->meta.persistence == UNDO_TEMP) + return false; + + UndoRecPtrAssignRelFileNode(rnode, urecptr); + cur_blk = UndoRecPtrGetBlockNum(urecptr); + starting_byte = UndoRecPtrGetPageOffset(urecptr); + + while (true) + { + bufidx = UndoGetBufferSlot(rnode, cur_blk, + RBM_NORMAL, + log->meta.persistence, + xlog_record); + + xact_urec_info[xact_urec_info_idx].idx_undo_buffers[index++] = bufidx; + buffer = undo_buffer[bufidx].buf; + + /* As in UndoRecordPrepareTransInfo(). */ + if (!BufferIsValid(buffer)) + elog(PANIC, "undo page %u of undo log %u, holding the transaction header at " + UndoRecPtrFormat ", is missing", + cur_blk, logno, (unsigned long long) urecptr); + page = BufferGetPage(buffer); + + if (UnpackUndoRecord(&xact_urec_info[xact_urec_info_idx].uur, page, starting_byte, + &already_decoded, true, false)) + break; + + starting_byte = UndoLogBlockHeaderSize; + cur_blk++; + } + + xact_urec_info[xact_urec_info_idx].urecptr = urecptr; + xact_urec_info[xact_urec_info_idx].uur.uur_progress = progress; + xact_urec_info_idx++; + + return true; +} + +/* + * Overwrite the first undo record of the previous transaction to update its + * next pointer. This will just insert the already prepared record by + * UndoRecordPrepareTransInfo. This must be called under the critical section. + * This will just overwrite the undo header not the data. + */ +void +UndoRecordUpdateTransInfo(int idx) +{ + UndoLogNumber logno = UndoRecPtrGetLogNo(xact_urec_info[idx].urecptr); + Page page = NULL; + int starting_byte; + int already_written = 0; + int i = 0; + UndoRecPtr urec_ptr = InvalidUndoRecPtr; + UndoLogControl *log; + uint16 remaining_bytes; + + /* + * Nothing was prepared for this slot (the matching Prepare* call returned + * early, e.g. for a temp undo log), so there is nothing to write out. + * Guard here as well as at the call sites so a future caller that forgets + * to check gets a no-op instead of a wild pointer. + */ + if (idx >= xact_urec_info_idx || + !UndoRecPtrIsValid(xact_urec_info[idx].urecptr)) + return; + + log = UndoLogGet(logno); + if (log == NULL) + return; + + urec_ptr = xact_urec_info[idx].urecptr; + + /* + * Acquire the discard_update_lock before accessing the undo record so + * that discard worker can't remove the record while we are in process of + * reading it. + */ + LWLockAcquire(&log->discard_update_lock, LW_SHARED); + /* Check if it is already discarded. */ + if (UndoLogIsDiscarded(urec_ptr)) + { + /* Release lock and return. */ + LWLockRelease(&log->discard_update_lock); + return; + } + + /* + * Update the next transactions start urecptr in the transaction header. + */ + starting_byte = UndoRecPtrGetPageOffset(urec_ptr); + remaining_bytes = sizeof(UndoRecPtr); + + do + { + Buffer buffer; + int buf_idx; + + buf_idx = xact_urec_info[idx].idx_undo_buffers[i]; + buffer = undo_buffer[buf_idx].buf; + + if (BufferIsValid(buffer) && !undo_buffer[buf_idx].done) + { + page = BufferGetPage(buffer); + + /* Overwrite the previously written undo. */ + if (InsertUndoRecord(&xact_urec_info[idx].uur, page, starting_byte, + &already_written, 0, 0, true)) + { + MarkBufferDirty(buffer); + break; + } + + MarkBufferDirty(buffer); + } + else + { + /* + * During recovery, there might be some blocks which are already + * removed by discard process, or that already hold this update + * (BLK_DONE), so we can just skip inserting into those blocks. + */ + Assert(InRecovery); + page = NULL; + + /* + * Block is not valid so we can not write to the current block but + * we might need to insert remaining partial record to the next + * block so set proper value for already_written variable to jump + * to the undo record offset from which we want to insert into + * next block. + */ + if (InsertUndoRecord(&xact_urec_info[idx].uur, page, starting_byte, + &already_written, remaining_bytes, 0, true)) + break; + else + remaining_bytes -= (BLCKSZ - starting_byte); + } + starting_byte = UndoLogBlockHeaderSize; + i++; + + Assert(idx < MAX_BUFFER_PER_UNDO); + } while (true); + + LWLockRelease(&log->discard_update_lock); +} + +/* + * Find the block number in undo buffer array, if it's present then just return + * its index otherwise search the buffer and insert an entry and lock the buffer + * in exclusive mode. + * + * Undo log insertions are append-only. If the caller is writing new data + * that begins exactly at the beginning of a page, then there cannot be any + * useful data after that point. In that case RBM_ZERO can be passed in as + * rbm so that we can skip a useless read of a disk block. In all other + * cases, RBM_NORMAL should be passed in, to read the page in if it doesn't + * happen to be already in the buffer pool. + */ +static int +UndoGetBufferSlot(RelFileNode rnode, + BlockNumber blk, + ReadBufferMode rbm, + UndoPersistence persistence, + XLogReaderState *xlog_record) +{ + int i; + Buffer buffer; + XLogRedoAction action = BLK_NEEDS_REDO; + + /* Don't do anything, if we already have a buffer pinned for the block. */ + for (i = 0; i < buffer_idx; i++) + { + /* + * It's not enough to just compare the block number because the + * undo_buffer might holds the undo from different undo logs (e.g when + * previous transaction start header is in previous undo log) so + * compare (logno + blkno). + */ + if ((blk == undo_buffer[i].blk) && + (undo_buffer[i].logno == rnode.relNumber)) + { + /* caller must hold exclusive lock on buffer */ + Assert(BufferIsLocal(undo_buffer[i].buf) || + BufferIsLockedByMeInMode(undo_buffer[i].buf, + BUFFER_LOCK_EXCLUSIVE)); + break; + } + } + + /* + * We did not find the block so allocate the buffer and insert into the + * undo buffer array + */ + if (i == buffer_idx) + { + /* + * Fetch the buffer in which we want to insert the undo record. + */ + if (InRecovery) + { + int block_id = UndoRedoBlockId(xlog_record, rnode, blk); + + /* + * An undo page is a block reference of the WAL record whose undo + * insert this is, so it may carry a full-page image: the first + * change to the page since the checkpoint always does. The image + * must be restored. The page on disk can be from the future -- a + * discard replayed before the crash may have removed its segment + * file, which then reads back as zeroes -- or torn. zheap found + * the block by its tag (XLogReadBufferForRedoBlock); do the same + * and let XLogReadBufferForRedoExtended() restore the image, or + * else read and lock the page and compare LSNs. + */ + if (block_id >= 0) + action = XLogReadBufferForRedoExtended(xlog_record, block_id, + rbm, false, &buffer); + else + { + /* + * Not a block of this record: read it the same way, so undo + * redo is idempotent. An invalid buffer means the page is + * not there (BLK_NOTFOUND); a page LSN at or past this record + * means it is already applied (BLK_DONE). Either way nothing + * is written into it: a page written out after a later + * LSN-gated change to these bytes (an amendment, a + * transaction header update) would lose that change. + */ + buffer = XLogReadBufferExtended(rnode, UndoLogForkNum, blk, + rbm, InvalidBuffer); + if (!BufferIsValid(buffer)) + action = BLK_NOTFOUND; + else + { + /* + * XLogReadBufferExtended() returns the buffer PINNED but + * not locked. The undo redo path (InsertPreparedUndo / + * UndoRecordUpdateTransInfo) writes into and + * MarkBufferDirty()s this buffer, which asserts an + * exclusive lock, so take it here -- matching the + * exclusive lock the non-recovery branch below acquires. + */ + if (rbm != RBM_ZERO_AND_LOCK) + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + + if (xlog_record != NULL && + xlog_record->EndRecPtr <= PageGetLSN(BufferGetPage(buffer))) + action = BLK_DONE; + else + action = BLK_NEEDS_REDO; + } + } + } + else + { + buffer = ReadBufferWithoutRelcache(rnode, + UndoLogForkNum, + blk, + rbm, + NULL, + UndoPersistenceIsPermanent(persistence)); + + /* Lock the buffer (RBM_ZERO_AND_LOCK already locked it) */ + if (rbm != RBM_ZERO_AND_LOCK) + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + } + + if (action == BLK_NOTFOUND) + { + undo_buffer[buffer_idx].buf = InvalidBuffer; + undo_buffer[buffer_idx].blk = InvalidBlockNumber; + } + else + { + undo_buffer[buffer_idx].buf = buffer; + undo_buffer[buffer_idx].blk = blk; + undo_buffer[buffer_idx].logno = rnode.relNumber; + undo_buffer[buffer_idx].zero = rbm == RBM_ZERO; + undo_buffer[buffer_idx].done = action == BLK_DONE; + } + buffer_idx++; + } + + return i; +} + +/* + * Calculate total size required by nrecords and allocate them in bulk. This is + * required for some operation which can allocate multiple undo record in one + * WAL operation e.g multi-insert. If we don't allocate undo space for all the + * record (which are inserted under one WAL) together than there is possibility + * that both of them go under different undo log. And, currently during + * recovery we don't have mechanism to map xid to multiple log number during one + * WAL operation. So in short all the operation under one WAL must allocate + * their undo from the same undo log. + */ +static UndoRecPtr +UndoRecordAllocate(UnpackedUndoRecord *undorecords, int nrecords, + FullTransactionId fxid, UndoPersistence upersistence, + XLogReaderState *xlog_record, + xl_undolog_meta *undometa) +{ + UnpackedUndoRecord *urec = NULL; + UndoLogControl *log; + UndoRecordSize size; + UndoRecPtr urecptr; + TransactionId txid = XidFromFullTransactionId(fxid); + UndoRecPtr prevlogurp = InvalidUndoRecPtr; + UndoLogNumber prevlogno = InvalidUndoLogNumber; + bool need_xact_hdr = false; + bool log_switched = false; + int i; + + /* There must be at least one undo record. */ + if (nrecords <= 0) + elog(ERROR, "cannot allocate space for zero undo records"); + + /* Is this the first undo record of the transaction? */ + if ((InRecovery && IsTransactionFirstRec(txid)) || + (!InRecovery && prev_txid[upersistence] != txid)) + need_xact_hdr = true; + +resize: + size = 0; + + for (i = 0; i < nrecords; i++) + { + urec = undorecords + i; + + /* + * Prepare the transacion header for the first undo record of + * transaction. + * + * Note: There is also an option that instead of adding the + * information to this record we can prepare a new record which only + * contain transaction informations, but we can't see any clear + * advantage of the same. + */ + if (need_xact_hdr && i == 0) + { + urec->uur_next = InvalidUndoRecPtr; + urec->uur_xidepoch = EpochFromFullTransactionId(fxid); + urec->uur_progress = 0; + + if (log_switched) + { + uint16 prevlen; + UndoRecPtr prevloginserturp; + + /* + * If undo log is switched then during rollback we can not go + * to the previous undo record of the transaction by prevlen + * so we store the previous undo record pointer in the + * transaction header. + */ + Assert(prevlogno != InvalidUndoLogNumber); + log = UndoLogGet(prevlogno); + + /* Compute insert undo pointer of the previous undo log. */ + prevloginserturp = MakeUndoRecPtr(prevlogno, log->meta.insert); + + /* Fetch length of the last undo record of the previous log. */ + prevlen = UndoGetPrevRecordLen(prevloginserturp, NULL); + + /* Compute the last record's undo record pointer. */ + urec->uur_prevurp = + MakeUndoRecPtr(prevlogno, log->meta.insert - prevlen); + } + else + urec->uur_prevurp = InvalidUndoRecPtr; + + /* During recovery, get the database id from the undo log state. */ + if (InRecovery) + urec->uur_dbid = UndoLogStateGetDatabaseId(); + else + urec->uur_dbid = MyDatabaseId; + + /* Set uur_info to include the transaction header. */ + urec->uur_info |= UREC_INFO_TRANSACTION; + } + else + { + /* + * It is okay to initialize these variables with invalid values as + * these are used only with the first record of transaction. + */ + urec->uur_next = InvalidUndoRecPtr; + urec->uur_xidepoch = 0; + urec->uur_dbid = 0; + urec->uur_progress = 0; + urec->uur_prevurp = InvalidUndoRecPtr; + } + + /* Calculate the size of the undo record based on the info required. */ + UndoRecordSetInfo(urec); + size += UndoRecordExpectedSize(urec); + } + + /* + * Check whether the undo log got switched while we are in a transaction. + */ + if (InRecovery) + { + /* + * During recovery we can identify the log switch by checking the + * prevlogurp from the MyUndoLogState. The WAL replay action for log + * switch would have set the value and we need to clear it after + * retrieving the latest value. + */ + prevlogurp = UndoLogStateGetAndClearPrevLogXactUrp(); + urecptr = UndoLogAllocateInRecovery(txid, size, upersistence); + if (UndoRecPtrIsValid(prevlogurp)) + { + prevlogno = UndoRecPtrGetLogNo(prevlogurp); + log_switched = true; + } + } + else + { + /* + * Check whether the current log is switched after allocation. We can + * determine that by simply checking to which log we are attached + * before and after allocation. + */ + prevlogno = UndoLogAmAttachedTo(upersistence); + urecptr = UndoLogAllocate(size, upersistence); + if (!need_xact_hdr && + prevlogno != InvalidUndoLogNumber && + prevlogno != UndoRecPtrGetLogNo(urecptr)) + { + log = UndoLogGet(prevlogno); + prevlogurp = MakeUndoRecPtr(prevlogno, log->meta.last_xact_start); + log_switched = true; + } + } + + log = UndoLogGet(UndoRecPtrGetLogNo(urecptr)); + + /* + * By now, we must be attached to some undo log unless we are in recovery. + */ + Assert(AmAttachedToUndoLog(log) || InRecovery); + + /* + * If we've rewound all the way back to the start of the transaction by + * rolling back the first subtransaction (which we can't detect until + * after we've allocated some space) or the undo log got switched, we'll + * need a new transaction header. If we weren't already generating one, + * then do it now. + */ + if (!need_xact_hdr && + (log->meta.insert == log->meta.last_xact_start || log_switched)) + { + need_xact_hdr = true; + urec->uur_info = 0; /* force recomputation of info bits */ + goto resize; + } + + /* Copy undometa before advancing the insert location. */ + if (undometa) + { + undometa->meta = log->meta; + undometa->logno = log->logno; + undometa->xid = log->xid; + } + + /* Update the previous transaction's start undo record, if required. */ + if (need_xact_hdr || log_switched) + { + /* + * If the undo log is switched then we need to update our own + * transaction header in the previous log as well as the previous + * transaction's header in the new log. Read detail comments for + * multi-log handling atop this file. + */ + if (log_switched) + UndoRecordPrepareTransInfo(xlog_record, urecptr, prevlogurp); + + /* Don't update our own start header. */ + if (log->meta.last_xact_start != log->meta.insert) + UndoRecordPrepareTransInfo(xlog_record, urecptr, + MakeUndoRecPtr(log->logno, log->meta.last_xact_start)); + + /* Remember the current transaction's xid. */ + prev_txid[upersistence] = txid; + + /* Store the current transaction's start undorecptr in the undo log. */ + UndoLogSetLastXactStartPoint(urecptr); + } + + UndoLogAdvance(urecptr, size, upersistence); + + /* + * Write WAL for log switch. This is required to identify the log switch + * during recovery. + */ + if (!InRecovery && log_switched && upersistence == UNDO_PERMANENT) + { + XLogBeginInsert(); + XLogRegisterData((char *) &prevlogurp, sizeof(UndoRecPtr)); + XLogInsert(RM_UNDOLOG_ID, XLOG_UNDOLOG_SWITCH); + } + + return urecptr; +} + +/* + * Call UndoSetPrepareSize to set the value of how many undo records can be + * prepared before we can insert them. If the size is greater than + * MAX_PREPARED_UNDO then it will allocate extra memory to hold the extra + * prepared undo. + * + * This is normally used when more than one undo record needs to be prepared. + */ +void +UndoSetPrepareSize(UnpackedUndoRecord *undorecords, int nrecords, + FullTransactionId fxid, UndoPersistence upersistence, + XLogReaderState *xlog_record, + xl_undolog_meta *undometa) +{ + FullTransactionId txid; + + /* Get the top transaction id. */ + if (!FullTransactionIdIsValid(fxid)) + { + Assert(!InRecovery); + txid = GetTopFullTransactionId(); + } + else + { + Assert(InRecovery || + FullTransactionIdEquals(fxid, GetTopFullTransactionId())); + txid = fxid; + } + + if (nrecords > MAX_PREPARED_UNDO) + { + prepared_undo = palloc0(nrecords * sizeof(PreparedUndoSpace)); + + /* + * Consider buffers needed for updating previous transaction's + * starting undo record. Hence increased by 1. + */ + undo_buffer = palloc0((nrecords + 1) * MAX_BUFFER_PER_UNDO * + sizeof(UndoBuffers)); + max_prepared_undo = nrecords; + } + + prepared_urec_ptr = UndoRecordAllocate(undorecords, nrecords, txid, + upersistence, xlog_record, + undometa); +} + +/* + * Call PrepareUndoInsert to tell the undo subsystem about the undo record you + * intended to insert. Upon return, the necessary undo buffers are pinned and + * locked. + * + * This should be done before any critical section is established, since it + * can fail. + * + * In recovery, 'xid' refers to the transaction id stored in WAL, otherwise, + * it refers to the top transaction id because undo log only stores mapping + * for the top most transactions. + */ +UndoRecPtr +PrepareUndoInsert(UnpackedUndoRecord *urec, FullTransactionId fxid, + UndoPersistence upersistence, + XLogReaderState *xlog_record, + xl_undolog_meta *undometa) +{ + UndoRecordSize size; + UndoRecPtr urecptr; + RelFileNode rnode; + UndoRecordSize cur_size = 0; + BlockNumber cur_blk; + FullTransactionId txid; + int starting_byte; + int index = 0; + int bufidx; + ReadBufferMode rbm; + + /* Already reached maximum prepared limit. */ + if (prepare_idx == max_prepared_undo) + elog(ERROR, "already reached the maximum prepared limit"); + + + if (!FullTransactionIdIsValid(fxid)) + { + /* During recovery, we must have a valid transaction id. */ + Assert(!InRecovery); + txid = GetTopFullTransactionId(); + } + else + { + /* + * Assign the top transaction id because undo log only stores mapping + * for the top most transactions. + */ + Assert(InRecovery || + FullTransactionIdEquals(fxid, GetTopFullTransactionId())); + txid = fxid; + } + + if (!UndoRecPtrIsValid(prepared_urec_ptr)) + urecptr = UndoRecordAllocate(urec, 1, txid, upersistence, + xlog_record, undometa); + else + urecptr = prepared_urec_ptr; + + /* advance the prepared ptr location for next record. */ + size = UndoRecordExpectedSize(urec); + if (UndoRecPtrIsValid(prepared_urec_ptr)) + { + UndoLogOffset insert = UndoRecPtrGetOffset(prepared_urec_ptr); + + insert = UndoLogOffsetPlusUsableBytes(insert, size); + prepared_urec_ptr = MakeUndoRecPtr(UndoRecPtrGetLogNo(urecptr), insert); + } + + cur_blk = UndoRecPtrGetBlockNum(urecptr); + UndoRecPtrAssignRelFileNode(rnode, urecptr); + starting_byte = UndoRecPtrGetPageOffset(urecptr); + + /* + * If we happen to be writing the very first byte into this page, then + * there is no need to read from disk. + */ + if (starting_byte == UndoLogBlockHeaderSize) + rbm = RBM_ZERO; + else + rbm = RBM_NORMAL; + + do + { + bufidx = UndoGetBufferSlot(rnode, cur_blk, rbm, upersistence, + xlog_record); + if (cur_size == 0) + cur_size = BLCKSZ - starting_byte; + else + cur_size += BLCKSZ - UndoLogBlockHeaderSize; + + /* undo record can't use buffers more than MAX_BUFFER_PER_UNDO. */ + Assert(index < MAX_BUFFER_PER_UNDO); + + /* Keep the track of the buffers we have pinned and locked. */ + prepared_undo[prepare_idx].undo_buffer_idx[index++] = bufidx; + + /* + * If we need more pages they'll be all new so we can definitely skip + * reading from disk. + */ + rbm = RBM_ZERO; + cur_blk++; + } while (cur_size < size); + + /* + * Save the undo record information to be later used by InsertPreparedUndo + * to insert the prepared record. + */ + prepared_undo[prepare_idx].urec = urec; + prepared_undo[prepare_idx].urp = urecptr; + prepare_idx++; + + return urecptr; +} + +/* + * Insert a previously-prepared undo record. This will write the actual undo + * record into the buffers already pinned and locked in PreparedUndoInsert, + * and mark them dirty. This step should be performed after entering a + * criticalsection; it should never fail. + */ +void +InsertPreparedUndo(void) +{ + Page page = NULL; + int starting_byte; + int already_written; + int bufidx = 0; + int idx; + uint16 undo_len = 0; + uint16 remaining_bytes; + UndoRecPtr urp; + UnpackedUndoRecord *uur; + + /* There must be atleast one prepared undo record. */ + Assert(prepare_idx > 0); + + /* + * This must be called under a critical section or we must be in recovery. + */ + Assert(InRecovery || CritSectionCount > 0); + + for (idx = 0; idx < prepare_idx; idx++) + { + uur = prepared_undo[idx].urec; + urp = prepared_undo[idx].urp; + + already_written = 0; + bufidx = 0; + starting_byte = UndoRecPtrGetPageOffset(urp); + + undo_len = remaining_bytes = UndoRecordExpectedSize(uur); + + do + { + PreparedUndoSpace undospace = prepared_undo[idx]; + Buffer buffer; + + buffer = undo_buffer[undospace.undo_buffer_idx[bufidx]].buf; + + if (BufferIsValid(buffer) && + !undo_buffer[undospace.undo_buffer_idx[bufidx]].done) + { + page = BufferGetPage(buffer); + + /* + * Initialize the page whenever we try to write the first + * record in page. We start writing immediately after the + * block header. + */ + if (starting_byte == UndoLogBlockHeaderSize) + PageInit(page, BLCKSZ, 0); + + /* + * Try to insert the record into the current page. If it + * doesn't succeed then recall the routine with the next page. + */ + if (InsertUndoRecord(uur, page, starting_byte, &already_written, + 0, undo_len, false)) + { + MarkBufferDirty(buffer); + break; + } + + MarkBufferDirty(buffer); + } + else + { + /* + * During recovery, there might be some blocks which are + * already deleted due to some discard command, or that + * already hold this record (BLK_DONE), so we can just skip + * inserting into those blocks. + */ + Assert(InRecovery); + page = NULL; + + /* + * Block is not valid so we can not write to the current block + * but we might need to insert remaining partial record to the + * next block so set proper value for already_written variable + * to jump to the undo record offset from which we want to + * insert into next block. InsertUndoRecord will not write + * anything if the input page is NULL, it will just update the + * already_written count and local work header. + */ + if (InsertUndoRecord(uur, page, starting_byte, &already_written, + remaining_bytes, undo_len, false)) + break; + else + remaining_bytes -= (BLCKSZ - starting_byte); + } + + /* + * If we are switching to the next block then consider the header + * in total undo length. + */ + starting_byte = UndoLogBlockHeaderSize; + bufidx++; + + /* undo record can't use buffers more than MAX_BUFFER_PER_UNDO. */ + Assert(bufidx < MAX_BUFFER_PER_UNDO); + } while (true); + + /* + * Set the current undo location for a transaction. This is required + * to perform rollback during abort of transaction. + */ + SetCurrentUndoLocation(urp); + } + + /* Update previously prepared transaction headers. */ + if (xact_urec_info_idx > 0) + { + int i = 0; + + for (i = 0; i < xact_urec_info_idx; i++) + UndoRecordUpdateTransInfo(i); + } + +} + +/* + * UndoGetOneRecord It will fetch the undo record pointed + * by urp and unpack the record into urec. This function will not release the + * pin on the buffer if complete record is fetched from one buffer, so caller + * can reuse the same urec to fetch the another undo record which is on the + * same block. Caller will be responsible to release the buffer inside urec + * and set it to invalid if it wishes to fetch the record from another block. + * + * keep_buffer - if this flag is set then it will keep the buffer pin on the + * first buffer of the undo record. This is used by bulk fetch who want to read + * multiple record of the transaction. So keeping buffer pin will ensure that + * we don't need to read the buffer multiple times while fetching the previous + * record of the transaction as there might be multiple undo record on the same + * buffer. This will also make sure that payload and data part are always + * copied in separate allocated memory instead of pointing into buffer. + */ +void +UndoGetOneRecord(UnpackedUndoRecord *urec, UndoRecPtr urp, RelFileNode rnode, + UndoPersistence persistence, bool keep_buffer) +{ + Buffer buffer = urec->uur_buffer; + Page page; + int starting_byte = UndoRecPtrGetPageOffset(urp); + int already_decoded = 0; + BlockNumber cur_blk; + bool is_undo_rec_split = false; + + cur_blk = UndoRecPtrGetBlockNum(urp); + + /* If we already have a buffer pin then no need to allocate a new one. */ + if (!BufferIsValid(buffer)) + { + buffer = ReadBufferWithoutRelcache(rnode, UndoLogForkNum, cur_blk, + RBM_NORMAL, NULL, + UndoPersistenceIsPermanent(persistence)); + + urec->uur_buffer = buffer; + LockBuffer(buffer, BUFFER_LOCK_SHARE); + } + else if (!keep_buffer) + LockBuffer(buffer, BUFFER_LOCK_SHARE); + + while (true) + { + page = BufferGetPage(buffer); + + /* + * Note: This can be optimized to just fetch header first and only if + * matches with block number and offset then fetch the complete + * record. + */ + if (UnpackUndoRecord(urec, page, starting_byte, &already_decoded, + false, keep_buffer)) + break; + + /* Leave room for the next page of this record. */ + Assert(cur_blk - UndoRecPtrGetBlockNum(urp) + 1 < + MAX_BUFFER_PER_UNDO); + + starting_byte = UndoLogBlockHeaderSize; + is_undo_rec_split = true; + + /* + * The record spans more than a page so we would have copied it (see + * UnpackUndoRecord). In such cases, we can release the buffer. If + * keep_buffer is set then don't release the first buffer of the undo + * record. + */ + if (!keep_buffer || cur_blk != UndoRecPtrGetBlockNum(urp)) + { + UnlockReleaseBuffer(buffer); + if (!keep_buffer) + urec->uur_buffer = InvalidBuffer; + } + + /* Go to next block. */ + cur_blk++; + buffer = ReadBufferWithoutRelcache(rnode, UndoLogForkNum, cur_blk, + RBM_NORMAL, NULL, + UndoPersistenceIsPermanent(persistence)); + LockBuffer(buffer, BUFFER_LOCK_SHARE); + } + + /* + * If we have copied the data then release the buffer, otherwise, just + * unlock it. + */ + if (is_undo_rec_split) + UnlockReleaseBuffer(buffer); + else if (!keep_buffer) + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); +} + +/* + * ResetUndoRecord - Helper function for UndoFetchRecord to reset the current + * record. + */ +static void +ResetUndoRecord(UnpackedUndoRecord *urec, UndoRecPtr urp, RelFileNode * rnode, + RelFileNode * prevrec_rnode) +{ + /* + * If we have a valid buffer pinned then just ensure that we want to find + * the next tuple from the same block. Otherwise release the buffer and + * set it invalid + */ + if (BufferIsValid(urec->uur_buffer)) + { + /* + * Undo buffer will be changed if the next undo record belongs to a + * different block or undo log. + */ + if ((UndoRecPtrGetBlockNum(urp) != + BufferGetBlockNumber(urec->uur_buffer)) || + (prevrec_rnode->relNumber != rnode->relNumber)) + { + ReleaseBuffer(urec->uur_buffer); + urec->uur_buffer = InvalidBuffer; + } + } + else + { + /* + * If there is not a valid buffer in urec->uur_buffer that means we + * had copied the payload data and tuple data so free them. + */ + if (urec->uur_payload.data) + pfree(urec->uur_payload.data); + if (urec->uur_tuple.data) + pfree(urec->uur_tuple.data); + } + + /* Reset the urec before fetching the tuple */ + urec->uur_tuple.data = NULL; + urec->uur_tuple.len = 0; + urec->uur_payload.data = NULL; + urec->uur_payload.len = 0; +} + +/* + * Fetch the next undo record for given blkno, offset and transaction id (if + * valid). The same tuple can be modified by multiple transactions, so during + * undo chain traversal sometimes we need to distinguish based on transaction + * id. Callers that don't have any such requirement can pass + * InvalidTransactionId. + * + * Start the search from urp. Caller need to call UndoRecordRelease to release the + * resources allocated by this function. + * + * urec_ptr_out is undo record pointer of the qualified undo record if valid + * pointer is passed. + * + * callback function decides whether particular undo record satisfies the + * condition of caller. + * + * Returns the required undo record if found, otherwise, return NULL which + * means either the record is already discarded or there is no such record + * in the undo chain. + */ +UnpackedUndoRecord * +UndoFetchRecord(UndoRecPtr urp, BlockNumber blkno, OffsetNumber offset, + TransactionId xid, UndoRecPtr *urec_ptr_out, + SatisfyUndoRecordCallback callback) +{ + RelFileNode rnode, + prevrec_rnode = {0}; + UnpackedUndoRecord *urec = NULL; + int logno; + + if (urec_ptr_out) + *urec_ptr_out = InvalidUndoRecPtr; + + urec = palloc0(sizeof(UnpackedUndoRecord)); + UndoRecPtrAssignRelFileNode(rnode, urp); + + /* Find the undo record pointer we are interested in. */ + while (true) + { + UndoLogControl *log; + + logno = UndoRecPtrGetLogNo(urp); + log = UndoLogGet(logno); + if (log == NULL) + { + if (BufferIsValid(urec->uur_buffer)) + ReleaseBuffer(urec->uur_buffer); + return NULL; + } + + /* + * Prevent UndoDiscardOneLog() from discarding data while we try to + * read it. Usually we would acquire log->mutex to read log->meta + * members, but in this case we know that discard can't move without + * also holding log->discard_lock. + */ + LWLockAcquire(&log->discard_lock, LW_SHARED); + if (!UndoRecordIsValid(urp)) + { + if (BufferIsValid(urec->uur_buffer)) + ReleaseBuffer(urec->uur_buffer); + return NULL; + } + + /* Fetch the current undo record. */ + UndoGetOneRecord(urec, urp, rnode, log->meta.persistence, false); + LWLockRelease(&log->discard_lock); + + if (blkno == InvalidBlockNumber) + break; + + /* Check whether the undorecord satisfies conditions */ + if (callback(urec, blkno, offset, xid)) + break; + + urp = urec->uur_blkprev; + prevrec_rnode = rnode; + + /* Get rnode for the current undo record pointer. */ + UndoRecPtrAssignRelFileNode(rnode, urp); + + /* Reset the current undorecord before fetching the next. */ + ResetUndoRecord(urec, urp, &rnode, &prevrec_rnode); + } + + if (urec_ptr_out) + *urec_ptr_out = urp; + return urec; +} + +/* + * UndoGetPrevRecordLen - read length of the previous undo record. + * + * This function will take an undo record pointer as an input and read the + * length of the previous undo record which is stored at the end of the previous + * undo record. If the previous undo record is split then this will add the + * undo block header size in the total length. + */ +static uint16 +UndoGetPrevRecordLen(UndoRecPtr urp, Buffer *input_buffer) +{ + UndoLogControl *log; + UndoLogNumber logno = UndoRecPtrGetLogNo(urp); + UndoLogOffset page_offset = UndoRecPtrGetPageOffset(urp); + BlockNumber cur_blk = UndoRecPtrGetBlockNum(urp); + Buffer buffer; + char *page; + char prevlen[2]; + RelFileNode rnode; + int byte_to_read = sizeof(uint16); + bool permanent; + uint16 prev_rec_len = 0; + bool release_buffer = false; + + /* Get relfilenode and undo persistence */ + logno = UndoRecPtrGetLogNo(urp); + log = UndoLogGet(logno); + UndoRecPtrAssignRelFileNode(rnode, urp); + permanent = UndoPersistenceIsPermanent(log->meta.persistence); + + /* + * If caller has passed invalid buffer then read the buffer. + */ + if (input_buffer == NULL || !BufferIsValid(*input_buffer)) + { + buffer = ReadBufferWithoutRelcache(rnode, UndoLogForkNum, cur_blk, + RBM_NORMAL, NULL, permanent); + + LockBuffer(buffer, BUFFER_LOCK_SHARE); + release_buffer = true; + } + else + buffer = *input_buffer; + + /* Get page from buffer. */ + page = (char *) BufferGetPage(buffer); + + /* + * Length if the previous undo record is store at the end of that record + * so just fetch last 2 bytes. + */ + while (byte_to_read > 0) + { + /* + * Read the next prevlen byte from the current page while one is left + * above this page's block header; otherwise move to the previous + * page. + * + * The bound is tested BEFORE the decrement because page_offset is + * unsigned. A page_offset of 0 is a legitimate input: it addresses + * the position just past the last data byte of the preceding page, + * which is what a caller gets by stepping an insert pointer back over + * the block header after a record ended flush against the page + * boundary. Decrementing first would wrap 0 around to SIZE_MAX, + * satisfy the bound and read a garbage length from outside the page. + */ + if (page_offset > UndoLogBlockHeaderSize) + { + page_offset -= 1; + prevlen[byte_to_read - 1] = page[page_offset]; + byte_to_read -= 1; + } + else + { + /* + * Unlock the previous buffer before locking the next buffer to + * avoid the deadlock. + */ + UnlockReleaseBuffer(buffer); + release_buffer = true; + cur_blk -= 1; + buffer = ReadBufferWithoutRelcache(rnode, UndoLogForkNum, cur_blk, + RBM_NORMAL, NULL, + permanent); + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page_offset = BLCKSZ; + page = (char *) BufferGetPage(buffer); + } + } + + prev_rec_len = *(uint16 *) (prevlen); + + /* + * The trailer counts usable bytes only. Include every intervening page + * header when converting it to a physical distance back from urp. A page + * offset of zero denotes the end of the preceding page. + */ + { + UndoLogOffset page_span = UndoRecPtrGetPageOffset(urp); + + if (page_span == 0) + page_span = BLCKSZ; + page_span -= UndoLogBlockHeaderSize; + + if (prev_rec_len > page_span) + prev_rec_len += ((prev_rec_len - page_span + + UndoLogUsableBytesPerPage - 1) / + UndoLogUsableBytesPerPage) * UndoLogBlockHeaderSize; + } + + /* Release the buffer if we have locally read it. */ + if (release_buffer) + { + UnlockReleaseBuffer(buffer); + + /* + * As we already released the previous buffer, so settiing it as + * invalid so that caller should not try to release again. + */ + if (input_buffer != NULL) + *input_buffer = InvalidBuffer; + } + + return prev_rec_len; +} + +/* + * Return the previous undo record pointer. + * + * A valid value of prevurp indicates that the previous undo record + * pointer is in some other log and caller can directly use that. + * Otherwise this will calculate the previous undo record pointer + * by using current urp and the prevlen. + */ +UndoRecPtr +UndoGetPrevUndoRecptr(UndoRecPtr urp, UndoRecPtr prevurp, Buffer *buffer) +{ + if (UndoRecPtrIsValid(prevurp)) + return prevurp; + else + { + UndoLogNumber logno = UndoRecPtrGetLogNo(urp); + UndoLogOffset offset = UndoRecPtrGetOffset(urp); + uint16 prevlen; + + /* Read length of the previous undo record. */ + prevlen = UndoGetPrevRecordLen(urp, buffer); + + /* calculate the previous undo record pointer */ + return MakeUndoRecPtr(logno, offset - prevlen); + } +} + +/* + * Release the resources allocated by UndoFetchRecord. + */ +void +UndoRecordRelease(UnpackedUndoRecord *urec) +{ + /* + * If the undo record has a valid buffer then just release the buffer + * otherwise free the tuple and payload data. + */ + if (BufferIsValid(urec->uur_buffer)) + { + ReleaseBuffer(urec->uur_buffer); + } + else + { + if (urec->uur_payload.data) + pfree(urec->uur_payload.data); + if (urec->uur_tuple.data) + pfree(urec->uur_tuple.data); + } + + pfree(urec); +} + +/* + * RegisterUndoLogBuffers - Register the undo buffers. + */ +void +RegisterUndoLogBuffers(uint8 first_block_id) +{ + int idx; + int flags; + + for (idx = 0; idx < buffer_idx; idx++) + { + flags = undo_buffer[idx].zero ? REGBUF_WILL_INIT : 0; + XLogRegisterBuffer(first_block_id + idx, undo_buffer[idx].buf, flags); + } +} + +/* + * RegisterUndoLogBuffersWithData - Register the undo buffers and attach + * caller-supplied bytes as block data on the FIRST of them. + * + * Used by producers whose redo handler re-runs the undo insert and therefore + * needs the undo record's own description to travel with the buffers (see + * flux_pbu.c). Attaching the bytes to an undo block rather than to the main + * data channel makes the region self-locating on the redo side: it is found via + * XLogRecGetBlockData() on whichever block id the undo pages landed at, so the + * producer's per-opcode main-data layout is unaffected. + * + * REGBUF_KEEP_DATA is essential: without it the block data is dropped whenever + * the page also carries a full-page image (needs_data = !needs_backup in + * XLogRecordAssemble), and redo would then be unable to replay the insert -- + * leaving the undo log's insert pointer un-advanced and every later record in + * that log misplaced. + * + * Returns the number of undo buffers registered. + */ +int +RegisterUndoLogBuffersWithData(uint8 first_block_id, + const char *data, uint32 datalen) +{ + int idx; + int flags; + + for (idx = 0; idx < buffer_idx; idx++) + { + flags = REGBUF_KEEP_DATA; + if (undo_buffer[idx].zero) + flags |= REGBUF_WILL_INIT; + XLogRegisterBuffer(first_block_id + idx, undo_buffer[idx].buf, flags); + } + + if (buffer_idx > 0 && data != NULL && datalen > 0) + XLogRegisterBufData(first_block_id, data, datalen); + + return buffer_idx; +} + +/* + * RegisterUndoLogBuffersForImage - Register the undo buffers forcing a + * full-page image of each into the WAL record. + * + * Used by producers (e.g. FILEOPS) that do NOT re-run the undo-insert during + * their own redo handler. Forcing a full-page image makes the undo page + * content self-contained in the producer's WAL record, so a plain + * XLogReadBufferForRedo() in the producer's redo restores the exact undo-page + * bytes -- no undo-insert replay logic is needed on the redo side. Zeroed + * (freshly initialized) pages are also forced-imaged (REGBUF_WILL_INIT alone + * would omit the image and require redo-side reconstruction). + */ +void +RegisterUndoLogBuffersForImage(uint8 first_block_id) +{ + int idx; + + /* + * Force a full-page image unconditionally. REGBUF_WILL_INIT is + * deliberately NOT set even for freshly-zeroed pages: WILL_INIT means "no + * image, redo rebuilds the page" and is mutually exclusive with + * REGBUF_FORCE_IMAGE (see the assert in XLogRegisterBuffer). We want the + * image so the producer's redo can restore the exact undo-page bytes + * without re-running the undo insert. + */ + for (idx = 0; idx < buffer_idx; idx++) + XLogRegisterBuffer(first_block_id + idx, undo_buffer[idx].buf, + REGBUF_FORCE_IMAGE); +} + +/* + * UndoLogBuffersSetLSN - Set LSN on undo page. + */ +void +UndoLogBuffersSetLSN(XLogRecPtr recptr) +{ + int idx; + + for (idx = 0; idx < buffer_idx; idx++) + PageSetLSN(BufferGetPage(undo_buffer[idx].buf), recptr); +} + +/* + * Reset the global variables related to undo buffers. This is required at the + * transaction abort and while releasing the undo buffers. + */ +void +ResetUndoBuffers(void) +{ + int i; + + for (i = 0; i < buffer_idx; i++) + { + undo_buffer[i].blk = InvalidBlockNumber; + undo_buffer[i].buf = InvalidBuffer; + } + + for (i = 0; i < xact_urec_info_idx; i++) + xact_urec_info[i].urecptr = InvalidUndoRecPtr; + + /* Reset the prepared index. */ + prepare_idx = 0; + buffer_idx = 0; + xact_urec_info_idx = 0; + prepared_urec_ptr = InvalidUndoRecPtr; + + /* + * max_prepared_undo limit is changed so free the allocated memory and + * reset all the variable back to their default value. + */ + if (max_prepared_undo > MAX_PREPARED_UNDO) + { + pfree(undo_buffer); + pfree(prepared_undo); + undo_buffer = def_buffers; + prepared_undo = def_prepared; + max_prepared_undo = MAX_PREPARED_UNDO; + } +} + +/* + * Unlock and release the undo buffers. This step must be performed after + * exiting any critical section where we have perfomed undo actions. + */ +void +UnlockReleaseUndoBuffers(void) +{ + int i; + + for (i = 0; i < buffer_idx; i++) + { + if (BufferIsValid(undo_buffer[i].buf)) + UnlockReleaseBuffer(undo_buffer[i].buf); + } + + ResetUndoBuffers(); +} + +/* + * Called whenever we attach to a new undo log, so that we forget about our + * translation-unit private state relating to the log we were last attached + * to. + */ +void +UndoRecordOnUndoLogChange(UndoPersistence persistence) +{ + prev_txid[persistence] = InvalidTransactionId; +} diff --git a/src/backend/access/undo/perbackend/pbu_undolog.c b/src/backend/access/undo/perbackend/pbu_undolog.c new file mode 100644 index 0000000000000..94c402219f64b --- /dev/null +++ b/src/backend/access/undo/perbackend/pbu_undolog.c @@ -0,0 +1,3224 @@ +/*------------------------------------------------------------------------- + * + * undolog.c + * management of undo logs + * + * PostgreSQL undo log manager. This module is responsible for lifecycle + * management of undo logs and backing files, associating undo logs with + * backends, allocating and managing space within undo logs. + * + * For the code that reads and writes blocks of data, see undofile.c. + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/backend/access/undo/undolog.c + * + *------------------------------------------------------------------------- + */ + +#include "postgres.h" + +#include "access/perbackend/pbu_compat.h" + +#include "access/transam.h" +#include "access/perbackend/pbu_undolog.h" +#include "access/perbackend/pbu_undolog_xlog.h" +#include "access/xact.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "access/xlogreader.h" +#include "catalog/catalog.h" +#include "catalog/pg_tablespace.h" +#include "commands/tablespace.h" +#include "funcapi.h" +#include "miscadmin.h" +#include "nodes/execnodes.h" +#include "pgstat.h" +#include "storage/buf.h" +#include "storage/bufmgr.h" +#include "storage/buf_internals.h" +#include "storage/dsm.h" +#include "storage/fd.h" +#include "storage/ipc.h" +#include "storage/lwlock.h" +#include "storage/procarray.h" +#include "storage/shmem.h" +#include "storage/standby.h" +#include "access/perbackend/pbu_undofile.h" +#include "utils/builtins.h" +#include "utils/tuplestore.h" +#include "utils/guc.h" +#include "utils/memutils.h" +#include "utils/varlena.h" + +#include +#include + +/* + * Number of bits of an undo log number used to identify a bank of + * UndoLogControl objects. This allows us to break up our array of + * UndoLogControl objects into many smaller arrays, called banks, and find our + * way to an UndoLogControl object in O(1) complexity in two steps. + */ +#define UndoLogBankBits 14 +#define UndoLogBanks (1 << UndoLogBankBits) + +/* Number of UndoLogControl objects in one bank (the remaining low bits). */ +#define UndoLogsPerBank (1 << (UndoLogNumberBits - UndoLogBankBits)) + +/* Extract the undo bank number from an undo log number (upper bits). */ +#define UndoLogNoGetBankNo(logno) \ + ((logno) >> (UndoLogNumberBits - UndoLogBankBits)) + +/* Extract the slot within a bank from an undo log number (lower bits). */ +#define UndoLogNoGetSlotNo(logno) \ + ((logno) & (UndoLogsPerBank - 1)) + +/* + * During recovery we maintain a mapping of transaction ID to undo logs + * numbers. We do this with another two-level array, so that we use memory + * only for chunks of the array that overlap with the range of active xids. + */ +#define UndoLogXidLowBits 16 + +/* + * Number of high bits. + */ +#define UndoLogXidHighBits \ + (sizeof(TransactionId) * CHAR_BIT - UndoLogXidLowBits) + +/* Extract the upper bits of an xid, for undo log mapping purposes. */ +#define UndoLogGetXidHigh(xid) ((xid) >> UndoLogXidLowBits) + +/* Extract the lower bits of an xid, for undo log mapping purposes. */ +#define UndoLogGetXidLow(xid) ((xid) & ((1 << UndoLogXidLowBits) - 1)) + +/* + * Main control structure for undo log management in shared memory. + */ +typedef struct UndoLogSharedData +{ + UndoLogNumber free_lists[UndoPersistenceLevels]; + int low_bankno; /* the lowest bank */ + int high_bankno; /* one past the highest bank */ + UndoLogNumber low_logno; /* the lowest logno */ + UndoLogNumber high_logno; /* one past the highest logno */ + + /* + * Array of DSM handles pointing to the arrays of UndoLogControl objects. + * We don't expect there to be many banks active at a time -- usually 1 or + * 2, but we need random access by log number so we arrange them into + * 'banks'. + */ + dsm_handle banks[UndoLogBanks]; +} UndoLogSharedData; + +/* + * Per-backend state for the undo log module. + * Backend-local pointers to undo subsystem state in shared memory. + */ +static struct +{ + UndoLogSharedData *shared; + + /* + * The control object for the undo logs that this backend is currently + * attached to at each persistence level. + */ + UndoLogControl *logs[UndoPersistenceLevels]; + + /* The DSM segments used to hold banks of control objects. */ + dsm_segment *bank_segments[UndoLogBanks]; + + /* + * The address where each bank of control objects is mapped into memory in + * this backend. We map banks into memory on demand, and they stay mapped + * in until every backend that mapped them exits. + */ + UndoLogControl *banks[UndoLogBanks]; + + /* + * The lowest log number that might currently be mapped into this backend. + */ + int low_logno; + + /* + * If the undo_tablespaces GUC changes we'll remember to examine it and + * attach to a new undo log using this flag. + */ + bool need_to_choose_tablespace; + + /* + * During recovery, the startup process maintains a mapping of xid to undo + * log number, instead of using 'log' above. This is not used in regular + * backends and can be in backend-private memory so long as recovery is + * single-process. This map references UNDO_PERMANENT logs only, since + * temporary and unlogged relations don't have WAL to replay. + */ + UndoLogNumber **xid_map; + + /* + * The slot for the oldest xids still running. We advance this during + * checkpoints to free up chunks of the map. + */ + uint16 xid_map_oldest_chunk; + + /* Current dbid. Used during recovery. */ + Oid dbid; + + /* + * Transaction's start header undo record pointer in the previous undo log + * when transaction spills across multiple undo log. This is used for + * identifying the log switch during recovery and updating the transaction + * header in the previous log. + */ + UndoRecPtr prevlogurp; +} MyUndoLogState; + +/* GUC variables */ +static char *undo_tablespaces = NULL; + +static UndoLogControl *get_undo_log_by_number(UndoLogNumber logno); +static void ensure_undo_log_number(UndoLogNumber logno); +static void attach_undo_log(UndoPersistence level, Oid tablespace); +static void detach_current_undo_log(UndoPersistence level, bool exhausted); +static void extend_undo_log(UndoLogNumber logno, UndoLogOffset new_end); +static void undo_log_before_exit(int code, Datum value); +static void forget_undo_buffers(int logno, UndoLogOffset old_discard, + UndoLogOffset new_discard, + bool drop_tail); +static bool choose_undo_tablespace(bool force_detach, Oid *oid); +static void undolog_xid_map_gc(void); +static void undolog_bank_gc(void); + +/* + * pbu: singleton lock guarding the undo-log freelist / bank arrays. zheap + * used a built-in named LWLock (UndoLogLock); to avoid touching the shared + * lwlocklist.h we allocate our own here and register a tranche. + */ +LWLock *UndoLogLock_pbu = NULL; +LWLock *RollbackRequestLock_pbu = NULL; +LWLock *UndoWorkerLock_pbu = NULL; +pg_atomic_uint64 *UndoOldestXidHavingUndo_pbu = NULL; + +PG_FUNCTION_INFO_V1(pbu_pg_stat_get_undo_logs); + +/* + * Return the amount of traditional smhem required for undo log management. + * Extra shared memory will be managed using DSM segments. + */ +Size +PbuUndoLogShmemSize(void) +{ + /* + * Layout: UndoLogSharedData, then the 8-byte atomic + * oldestXidWithEpochHavingUndo, then the three singleton LWLocks. The + * atomic must be 8-byte aligned (pg_atomic_init_u64 asserts this), so it + * is placed first at a MAXALIGN'd offset (ShmemInitStruct returns a + * MAXALIGN'd base) and each region is padded with MAXALIGN. + */ + return add_size(MAXALIGN(sizeof(UndoLogSharedData)), + add_size(MAXALIGN(sizeof(pg_atomic_uint64)), + mul_size(3, sizeof(LWLock)))); +} + +/* + * Initialize the undo log subsystem. Called in each backend. + */ +void +PbuUndoLogShmemRequest(void) +{ + /* + * Reserve the shared struct in the request phase so the framework sizes + * the segment for it and populates MyUndoLogState.shared before + * PbuUndoLogShmemInit() runs (mirroring the sLog subsystem's pattern). + * The layout after the struct is: the 8-byte oldest-xid atomic (placed + * first, at a MAXALIGN'd offset, because pg_atomic_init_u64 requires + * 8-byte alignment) followed by the three singleton LWLocks; see + * PbuUndoLogShmemSize(). + */ + ShmemRequestStruct(.name = "UndoLogShared", + .size = PbuUndoLogShmemSize(), + .ptr = (void **) &MyUndoLogState.shared); +} + +void +PbuUndoLogShmemInit(void) +{ + UndoLogSharedData *shared = MyUndoLogState.shared; + + /* + * The framework has already allocated the "UndoLogShared" region and set + * MyUndoLogState.shared (see PbuUndoLogShmemRequest()). Carve the + * singleton locks + oldest-xid atomic out of the region after the shared + * struct. + */ + UndoOldestXidHavingUndo_pbu = (pg_atomic_uint64 *) + (((char *) shared) + MAXALIGN(sizeof(UndoLogSharedData))); + UndoLogLock_pbu = (LWLock *) + (((char *) UndoOldestXidHavingUndo_pbu) + + MAXALIGN(sizeof(pg_atomic_uint64))); + RollbackRequestLock_pbu = UndoLogLock_pbu + 1; + UndoWorkerLock_pbu = UndoLogLock_pbu + 2; + + if (!IsUnderPostmaster) + { + int i; + + LWLockInitialize(UndoLogLock_pbu, LWTRANCHE_UNDO_LOG_PBU); + LWLockInitialize(RollbackRequestLock_pbu, LWTRANCHE_UNDO_LOG_PBU); + LWLockInitialize(UndoWorkerLock_pbu, LWTRANCHE_UNDO_LOG_PBU); + pg_atomic_init_u64(UndoOldestXidHavingUndo_pbu, 0); + + /* + * We start with no undo logs. StartUpUndoLogs() will recreate undo + * logs that were known at last checkpoint. + */ + memset(shared, 0, sizeof(*shared)); + for (i = 0; i < UndoPersistenceLevels; ++i) + shared->free_lists[i] = InvalidUndoLogNumber; + shared->low_bankno = 0; + shared->high_bankno = 0; + + /* + * Undo log numbers double as the RelFileNumber of the undo log's + * pseudo-relation in the undo SMGR (UndoRecPtrGetRelNode == logno, + * and the on-disk segment path is derived from that same relNumber). + * A RelFileNumber of 0 is InvalidRelFileNumber and trips + * RelFileNumberIsValid() in smgropen(), so undo log 0 can never be + * used. Start numbering at 1. (zheap addressed undo logs in a + * private space where 0 was fine; on master they are ordinary SMGR + * relations, so the first valid number is 1.) FILEOPS is the first + * producer to actually allocate an undo log, which is why this only + * bites now. + */ + shared->low_logno = 1; + shared->high_logno = 1; + } +} + +void +UndoLogInit(void) +{ + before_shmem_exit(undo_log_before_exit, 0); +} + +/* + * Figure out which directory holds an undo log based on tablespace. + */ +static void +UndoLogDirectory(Oid tablespace, char *dir) +{ + if (tablespace == DEFAULTTABLESPACE_OID || + tablespace == InvalidOid) + snprintf(dir, MAXPGPATH, "base/undo"); + else + snprintf(dir, MAXPGPATH, "pg_tblspc/%u/%s/undo", + tablespace, TABLESPACE_VERSION_DIRECTORY); + + /* Note: Should we use UndoLogDatabaseOid (9) instead of "undo"? */ + + /* + * Note: Should we add an extra directory between log number and segment + * files? If all undo logs are in the same directory then + * fsync(directory) may create contention in the OS between unrelated + * backends that as they rotate segment files. + */ +} + +/* + * Compute the pathname to use for an undo log segment file. + */ +void +UndoLogSegmentPath(UndoLogNumber logno, int segno, Oid tablespace, char *path) +{ + char dir[MAXPGPATH]; + + /* Figure out which directory holds the segment, based on tablespace. */ + UndoLogDirectory(tablespace, dir); + + /* + * Build the path from log number and offset. The pathname is the + * UndoRecPtr of the first byte in the segment in hexadecimal, with a + * period inserted between the components. + */ + snprintf(path, MAXPGPATH, "%s/%06X.%010zX", dir, logno, + segno * UndoLogSegmentSize); +} + +/* + * Iterate through the set of currently active logs. + * + * This scans every log in [low_logno, high_logno); the cost is linear in + * the range of log numbers, including inactive logs. + */ +UndoLogControl * +UndoLogNext(UndoLogControl *log) +{ + UndoLogSharedData *shared = MyUndoLogState.shared; + + if (log == NULL) + { + UndoLogNumber low_logno; + + LWLockAcquire(UndoLogLock, LW_SHARED); + low_logno = shared->low_logno; + LWLockRelease(UndoLogLock); + + return get_undo_log_by_number(low_logno); + } + else + { + UndoLogNumber high_logno; + + LWLockAcquire(UndoLogLock, LW_SHARED); + high_logno = shared->high_logno; + LWLockRelease(UndoLogLock); + + if (log->logno + 1 == high_logno) + return NULL; + + return get_undo_log_by_number(log->logno + 1); + } +} + +/* + * Check if an undo log position has been discarded. 'point' must be an undo + * log pointer that was allocated at some point in the past, otherwise the + * result is undefined. + */ +bool +UndoLogIsDiscarded(UndoRecPtr point) +{ + UndoLogControl *log = get_undo_log_by_number(UndoRecPtrGetLogNo(point)); + bool result; + + /* + * If we don't recognize the log number, it's either entirely discarded or + * it's never been allocated (ie from the future) and our result is + * undefined. + */ + if (log == NULL) + return true; + + /* + * Note: For a super cheap locked operation, it's better to use + * LW_EXLUSIVE even though we don't need exclusivity, right? + */ + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + result = UndoRecPtrGetOffset(point) < log->meta.discard; + LWLockRelease(&log->mutex); + + return result; +} + +/* + * Store latest transaction's start undo record point in undo meta data. It + * will fetched by the backend when it's reusing the undo log and preparing + * its first undo. + */ +void +UndoLogSetLastXactStartPoint(UndoRecPtr point) +{ + UndoLogNumber logno = UndoRecPtrGetLogNo(point); + UndoLogControl *log = get_undo_log_by_number(logno); + + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + log->meta.last_xact_start = UndoRecPtrGetOffset(point); + LWLockRelease(&log->mutex); +} + +/* + * Fetch the previous transaction's start undo record point. Return Invalid + * undo pointer if backend is not attached to any log. + */ +UndoRecPtr +UndoLogGetLastXactStartPoint(UndoLogNumber logno) +{ + UndoLogControl *log = get_undo_log_by_number(logno); + uint64 last_xact_start = 0; + + if (unlikely(log == NULL)) + return InvalidUndoRecPtr; + + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + last_xact_start = log->meta.last_xact_start; + LWLockRelease(&log->mutex); + + if (last_xact_start == 0) + return InvalidUndoRecPtr; + + return MakeUndoRecPtr(logno, last_xact_start); +} + +/* + * Is this record is the first record for any transaction. + */ +bool +IsTransactionFirstRec(TransactionId xid) +{ + uint16 high_bits = UndoLogGetXidHigh(xid); + uint16 low_bits = UndoLogGetXidLow(xid); + UndoLogNumber logno; + UndoLogControl *log; + + Assert(InRecovery); + + if (MyUndoLogState.xid_map == NULL) + elog(ERROR, "xid to undo log number map not initialized"); + if (MyUndoLogState.xid_map[high_bits] == NULL) + elog(ERROR, "cannot find undo log number for xid %u", xid); + + logno = MyUndoLogState.xid_map[high_bits][low_bits]; + + log = get_undo_log_by_number(logno); + if (log == NULL) + elog(ERROR, "cannot find undo log number %d for xid %u", logno, xid); + + return log->meta.is_first_rec; +} + +/* + * Detach from the undo log we are currently attached to, returning it to the + * free list if it still has space. + */ +static void +detach_current_undo_log(UndoPersistence persistence, bool exhausted) +{ + UndoLogSharedData *shared = MyUndoLogState.shared; + UndoLogControl *log = MyUndoLogState.logs[persistence]; + + Assert(log != NULL); + + MyUndoLogState.logs[persistence] = NULL; + + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + log->procno = INVALID_PROC_NUMBER; + log->pid = InvalidPid; + log->xid = InvalidTransactionId; + if (exhausted) + log->meta.status = UNDO_LOG_STATUS_EXHAUSTED; + LWLockRelease(&log->mutex); + + /* Push back onto the appropriate freelist. */ + if (!exhausted) + { + LWLockAcquire(UndoLogLock, LW_EXCLUSIVE); + log->next_free = shared->free_lists[persistence]; + shared->free_lists[persistence] = log->logno; + LWLockRelease(UndoLogLock); + } +} + +static void +undo_log_before_exit(int code, Datum arg) +{ + int i; + + for (i = 0; i < UndoPersistenceLevels; ++i) + { + if (MyUndoLogState.logs[i] != NULL) + detach_current_undo_log(i, false); + } +} + +/* + * Create a fully allocated empty segment file on disk for the byte starting + * at 'end'. + */ +static void +allocate_empty_undo_segment(UndoLogNumber logno, Oid tablespace, + UndoLogOffset end) +{ + struct stat stat_buffer; + off_t size; + char path[MAXPGPATH]; + void *zeroes; + size_t nzeroes = 8192; + int fd; + + UndoLogSegmentPath(logno, end / UndoLogSegmentSize, tablespace, path); + + /* + * Create and fully allocate a new file. If we crashed and recovered then + * the file might already exist, so use flags that tolerate that. It's + * also possible that it exists but is too short, in which case we'll + * write the rest. We don't really care what's in the file, we just want + * to make sure that the filesystem has allocated physical blocks for it, + * so that non-COW filesystems will report ENOSPC now rather than later + * when the space is needed and we'll avoid creating files with holes. + */ + fd = OpenTransientFile(path, O_RDWR | O_CREAT | PG_BINARY); + if (fd < 0 && tablespace != 0) + { + char undo_path[MAXPGPATH]; + + /* Try creating the undo directory for this tablespace. */ + UndoLogDirectory(tablespace, undo_path); + if (mkdir(undo_path, S_IRWXU) != 0 && errno != EEXIST) + { + char *parentdir; + + if (errno != ENOENT || !InRecovery) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create directory \"%s\": %m", + undo_path))); + + /* + * In recovery, it's possible that the tablespace directory + * doesn't exist because a later WAL record removed the whole + * tablespace. In that case we create a regular directory to + * stand in for it. This is similar to the logic in + * TablespaceCreateDbspace(). + */ + + /* create two parents up if not exist */ + parentdir = pstrdup(undo_path); + get_parent_directory(parentdir); + get_parent_directory(parentdir); + /* Can't create parent and it doesn't already exist? */ + if (mkdir(parentdir, S_IRWXU) < 0 && errno != EEXIST) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create directory \"%s\": %m", + parentdir))); + pfree(parentdir); + + /* create one parent up if not exist */ + parentdir = pstrdup(undo_path); + get_parent_directory(parentdir); + /* Can't create parent and it doesn't already exist? */ + if (mkdir(parentdir, S_IRWXU) < 0 && errno != EEXIST) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create directory \"%s\": %m", + parentdir))); + pfree(parentdir); + + if (mkdir(undo_path, S_IRWXU) != 0 && errno != EEXIST) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create directory \"%s\": %m", + undo_path))); + } + + fd = OpenTransientFile(path, O_RDWR | O_CREAT | PG_BINARY); + } + if (fd < 0) + elog(ERROR, "could not create new file \"%s\": %m", path); + if (fstat(fd, &stat_buffer) < 0) + elog(ERROR, "could not stat \"%s\": %m", path); + size = stat_buffer.st_size; + + /* A buffer full of zeroes we'll use to fill up new segment files. */ + zeroes = palloc0(nzeroes); + + while (size < UndoLogSegmentSize) + { + ssize_t written; + + written = write(fd, zeroes, Min(nzeroes, UndoLogSegmentSize - size)); + if (written < 0) + elog(ERROR, "cannot initialize undo log segment file \"%s\": %m", + path); + size += written; + } + + /* Flush the contents of the file to disk. */ + if (pg_fsync(fd) != 0) + elog(ERROR, "cannot fsync file \"%s\": %m", path); + CloseTransientFile(fd); + + pfree(zeroes); + +} + +/* + * Create a new undo segment, when it is unexpectedly not present. + */ +void +UndoLogNewSegment(UndoLogNumber logno, Oid tablespace, int segno) +{ + Assert(InRecovery); + allocate_empty_undo_segment(logno, tablespace, segno * UndoLogSegmentSize); +} + +/* + * Create and zero-fill a new segment for the undo log we are currently + * attached to. + */ +static void +extend_undo_log(UndoLogNumber logno, UndoLogOffset new_end) +{ + UndoLogControl *log; + char dir[MAXPGPATH]; + size_t end; + + log = get_undo_log_by_number(logno); + + Assert(log != NULL); + Assert(log->meta.end % UndoLogSegmentSize == 0); + Assert(new_end % UndoLogSegmentSize == 0); + Assert(MyUndoLogState.logs[log->meta.persistence] == log || InRecovery); + + /* + * Create all the segments needed to increase 'end' to the requested size. + * This is quite expensive, so we will try to avoid it completely by + * renaming files into place in PbuUndoLogDiscard instead. + */ + end = log->meta.end; + while (end < new_end) + { + allocate_empty_undo_segment(logno, log->meta.tablespace, end); + end += UndoLogSegmentSize; + } + + /* + * Flush the parent dir so that the directory metadata survives a crash + * after this point. + */ + UndoLogDirectory(log->meta.tablespace, dir); + fsync_fname(dir, true); + + /* + * If we're not in recovery, we need to WAL-log the creation of the new + * file(s). We do that after the above filesystem modifications, in + * violation of the data-before-WAL rule as exempted by + * src/backend/access/transam/README. This means that it's possible for + * us to crash having made some or all of the filesystem changes but + * before WAL logging, but in that case we'll eventually try to create the + * same segment(s) again which is tolerated. + * + * Unlogged and temporary undo logs are not WAL-logged: their contents + * never reach the WAL, crash recovery empties them + * (ResetNonPermanentUndoLogs), and a standby has no use for their + * segments. The same goes for their discards; see PbuUndoLogDiscard(). + */ + if (!InRecovery && log->meta.persistence == UNDO_PERMANENT) + { + xl_undolog_extend xlrec; + XLogRecPtr ptr; + + xlrec.logno = logno; + xlrec.end = end; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xlrec)); + ptr = XLogInsert(RM_UNDOLOG_ID, XLOG_UNDOLOG_EXTEND); + XLogFlush(ptr); + } + + /* + * We didn't need to acquire the mutex to read 'end' above because only we + * write to it. But we need the mutex to update it, because the + * checkpointer might read it concurrently. + * + * Note: It's possible for meta.end to be higher already during recovery, + * because of the timing of a checkpoint; in that case we did nothing + * above and we shouldn't update shmem here. That interaction needs more + * analysis. + */ + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + if (log->meta.end < end) + log->meta.end = end; + LWLockRelease(&log->mutex); +} + +/* + * Get an insertion point that is guaranteed to be backed by enough space to + * hold 'size' bytes of data. To actually write into the undo log, client + * code should call this first and then use bufmgr routines to access buffers + * and provide WAL logs and redo handlers. In other words, while this module + * looks after making sure the undo log has sufficient space and the undo meta + * data is crash safe, the *contents* of the undo log and (indirectly) the + * insertion point are the responsibility of client code. + * + * Note: As an optimization, we could take a third argument 'discard_last'. If + * the caller knows that the last transaction it committed is all visible and + * has its undo pointer, it could supply that value. Then while we hold + * log->mutex we could check if log->meta.discard == discard_last, and if it's + * in the same undo log segment as the current insert then it could cheaply + * update it in shmem and include the value in the existing + * XLOG_UNDOLOG_ATTACH WAL record. We'd be leaving the heavier lifting of + * dealing with segment roll-over to undo workers, but avoiding work for undo + * workers by folding a super cheap common case into the next foreground xact. + * (Not sure how we actually avoid waking up the undo work though...) + * + * Note: Problem: if foreground processes can move the discard pointer as well + * as background processes (undo workers), then how is the undo worker + * supposed to access the undo data pointed to by the discard pointer so that + * it can read the xid? We certainly don't want to hold the undo log lock + * while doing stuff like that, because it would interfere with read-only + * sessions that need to check the discard pointer. Possible solution: we may + * need a way to 'pin' the discard pointer while the undo worker is + * considering what to do. If we add 'discard_last' as described in the + * previous paragraph, that optimisation would need to be skipped if the + * foreground process running UndoLogAllocate sees that the discard pointer is + * currently pinned by a background worker. Going to sit on this thought for + * a little while before writing any code... need to contemplate undo workers + * some more. + * + * Returns an undo log insertion point that can be converted to a buffer tag + * and an insertion point within a buffer page using the macros above. + */ +UndoRecPtr +UndoLogAllocate(size_t size, UndoPersistence persistence) +{ + UndoLogControl *log = MyUndoLogState.logs[persistence]; + UndoLogOffset new_insert; + TransactionId logxid; + + /* + * We may need to attach to an undo log, either because this is the first + * time this backend as needed to write to an undo log at all or because + * the undo_tablespaces GUC was changed. When doing that, we'll need + * interlocking against tablespaces being concurrently dropped. + */ + +retry: + /* See if we need to check the undo_tablespaces GUC. */ + if (unlikely(MyUndoLogState.need_to_choose_tablespace || log == NULL)) + { + Oid tablespace; + bool need_to_unlock; + + need_to_unlock = + choose_undo_tablespace(MyUndoLogState.need_to_choose_tablespace, + &tablespace); + attach_undo_log(persistence, tablespace); + if (need_to_unlock) + LWLockRelease(TablespaceCreateLock); + log = MyUndoLogState.logs[persistence]; + MyUndoLogState.need_to_choose_tablespace = false; + } + + /* + * If this is the first time we've allocated undo log space in this + * transaction, we'll record the xid->undo log association so that it can + * be replayed correctly. Before that, we set the first record flag to + * false. + */ + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + log->meta.is_first_rec = false; + logxid = log->xid; + + if (logxid != GetTopTransactionId()) + { + xl_undolog_attach xlrec; + + /* + * While we have the lock, check if we have been forcibly detached by + * DROP TABLESPACE. That can only happen between transactions (see + * DetachUndoLogsInsTablespace()) so we only have to check for it in + * this branch. + */ + if (log->procno == INVALID_PROC_NUMBER) + { + LWLockRelease(&log->mutex); + log = NULL; + goto retry; + } + log->xid = GetTopTransactionId(); + log->meta.is_first_rec = true; + LWLockRelease(&log->mutex); + + /* + * Temporary undo is needed only until the transaction that wrote it + * has ended: only the backend that owns a temporary table can read + * it, and that backend has applied or no longer needs the undo of its + * earlier transactions. A log taken over from a backend that exited + * holds nothing anyone needs either. So before the first temporary + * undo record of a transaction, discard everything that came before. + * (The discard worker never processes temporary logs.) Not while a + * rollback of this backend is still to be finished, though: see + * PbuApplyPendingUndo(). + */ + if (persistence == UNDO_TEMP && log->meta.discard < log->meta.insert && + !PbuTempUndoPending()) + { + LWLockAcquire(&log->discard_update_lock, LW_EXCLUSIVE); + PbuUndoLogDiscard(MakeUndoRecPtr(log->logno, log->meta.insert), + InvalidTransactionId); + LWLockRelease(&log->discard_update_lock); + } + + /* Skip the attach record for unlogged and temporary tables. */ + if (persistence == UNDO_PERMANENT) + { + xlrec.xid = GetTopTransactionId(); + xlrec.logno = log->logno; + xlrec.dbid = MyDatabaseId; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xlrec)); + XLogInsert(RM_UNDOLOG_ID, XLOG_UNDOLOG_ATTACH); + } + } + else + { + LWLockRelease(&log->mutex); + } + + /* + * 'size' is expressed in usable non-header bytes. Figure out how far we + * have to move insert to create space for 'size' usable bytes (stepping + * over any intervening headers). + */ + Assert(log->meta.insert % BLCKSZ >= UndoLogBlockHeaderSize); + new_insert = UndoLogOffsetPlusUsableBytes(log->meta.insert, size); + Assert(new_insert % BLCKSZ >= UndoLogBlockHeaderSize); + + /* + * We don't need to acquire log->mutex to read log->meta.insert and + * log->meta.end, because this backend is the only one that can modify + * them. + */ + if (unlikely(new_insert > log->meta.end)) + { + if (new_insert > UndoLogMaxSize) + { + log = NULL; + detach_current_undo_log(persistence, true); + goto retry; + } + + /* + * Extend the end of this undo log to cover new_insert (in other words + * round up to the segment size). + */ + extend_undo_log(log->logno, + new_insert + UndoLogSegmentSize - + new_insert % UndoLogSegmentSize); + Assert(new_insert <= log->meta.end); + } + + return MakeUndoRecPtr(log->logno, log->meta.insert); +} + +/* + * In recovery, we expect the xid to map to a known log which already has + * enough space in it. + */ +UndoRecPtr +UndoLogAllocateInRecovery(TransactionId xid, size_t size, + UndoPersistence level) +{ + uint16 high_bits = UndoLogGetXidHigh(xid); + uint16 low_bits = UndoLogGetXidLow(xid); + UndoLogNumber logno; + UndoLogControl *log; + + /* + * The sequence of calls to UndoLogAllocateRecovery during REDO (recovery) + * must match the sequence of calls to UndoLogAllocate during DO, for any + * given session. The XXX_redo code for any UNDO-generating operation + * must use UndoLogAllocateRecovery rather than UndoLogAllocate, because + * it must supply the extra 'xid' argument so that we can find out which + * undo log number to use. During DO, that's tracked per-backend, but + * during REDO the original backends/sessions are lost and we have only + * the Xids. + */ + Assert(InRecovery); + + /* + * Look up the undo log number for this xid. The mapping must already + * have been created by an XLOG_UNDOLOG_ATTACH record emitted during the + * first call to UndoLogAllocate for this xid after the most recent + * checkpoint. + */ + if (MyUndoLogState.xid_map == NULL) + elog(ERROR, "xid to undo log number map not initialized"); + if (MyUndoLogState.xid_map[high_bits] == NULL) + elog(ERROR, "cannot find undo log number for xid %u", xid); + logno = MyUndoLogState.xid_map[high_bits][low_bits]; + + if (logno == InvalidUndoLogNumber) + elog(ERROR, "cannot find undo log number for xid %u", xid); + + /* + * This log must already have been created by XLOG_UNDOLOG_CREATE records + * emitted by UndoLogAllocate. + */ + log = get_undo_log_by_number(logno); + if (log == NULL) + elog(ERROR, "cannot find undo log number %d for xid %u", logno, xid); + + /* + * This log must already have been extended to cover the requested size by + * XLOG_UNDOLOG_EXTEND records emitted by UndoLogAllocate, or by + * XLOG_UNDLOG_DISCARD records recycling segments. + */ + if (log->meta.end < UndoLogOffsetPlusUsableBytes(log->meta.insert, size)) + elog(ERROR, + "unexpectedly couldn't allocate %zu bytes in undo log number %d", + size, logno); + + /* + * By this time we have allocated a undo log in transaction so after this + * it will not be first undo record for the transaction. + */ + log->meta.is_first_rec = false; + + return MakeUndoRecPtr(logno, log->meta.insert); +} + +/* + * Advance the insertion pointer by 'size' usable (non-header) bytes. + * + * Caller must WAL-log this operation first, and must replay it during + * recovery. + */ +void +UndoLogAdvance(UndoRecPtr insertion_point, size_t size, UndoPersistence persistence) +{ + UndoLogControl *log = NULL; + UndoLogNumber logno = UndoRecPtrGetLogNo(insertion_point); + + /* + * During recovery, MyUndoLogState is uninitialized. Hence, we need to + * work more. + */ + log = (InRecovery) ? get_undo_log_by_number(logno) + : MyUndoLogState.logs[persistence]; + + Assert(log != NULL); + Assert(InRecovery || logno == log->logno); + Assert(UndoRecPtrGetOffset(insertion_point) == log->meta.insert); + + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + log->meta.insert = UndoLogOffsetPlusUsableBytes(log->meta.insert, size); + LWLockRelease(&log->mutex); +} + +/* + * Advance the discard pointer in one undo log, discarding all undo data + * relating to one or more whole transactions. The passed in undo pointer is + * the address of the oldest data that the called would like to keep, and the + * affected undo log is implied by this pointer, ie + * UndoRecPtrGetLogNo(discard_pointer). + * + * The caller asserts that there will be no attempts to access the undo log + * region being discarded after this moment. This operation will cause the + * relevant buffers to be dropped immediately, without writing any data out to + * disk. Any attempt to read the buffers (except a partial buffer at the end + * of this range which will remain) may result in IO errors, because the + * underlying segment file may physically removed. + * + * Only one backend should call this for a given undo log concurrently, or + * data structures will become corrupted. It is expected that the caller will + * be an undo worker; only one undo worker should be working on a given undo + * log at a time. + * + * Note: Special case for when we wrapped past the end of an undo log, spilling + * into a new one. How do we discard that? Essentially we'll be discarding + * the whole undo log, but not sure how the caller should know that or deal + * with it and how this code should handle it. + */ +void +PbuUndoLogDiscard(UndoRecPtr discard_point, TransactionId xid) +{ + UndoLogNumber logno = UndoRecPtrGetLogNo(discard_point); + UndoLogControl *log = get_undo_log_by_number(logno); + UndoLogOffset old_discard; + UndoLogOffset discard = UndoRecPtrGetOffset(discard_point); + UndoLogOffset end; + int segno; + int new_segno; + bool need_to_flush_wal = false; + + if (log == NULL) + elog(ERROR, "cannot advance discard pointer for unknown undo log %d", + logno); + + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + if (discard > log->meta.insert) + elog(ERROR, "cannot move discard point past insert point"); + old_discard = log->meta.discard; + if (discard < old_discard) + elog(ERROR, "cannot move discard pointer backwards"); + end = log->meta.end; + LWLockRelease(&log->mutex); + + /* + * Drop all buffers holding this undo data out of the buffer pool (except + * the last one, if the new location is in the middle of it somewhere), so + * that the contained data doesn't ever touch the disk. The caller + * promises that this data will not be needed again. We have to drop the + * buffers from the buffer pool before removing files, otherwise a + * concurrent session might try to write the block to evict the buffer. + */ + forget_undo_buffers(logno, old_discard, discard, false); + + /* + * Check if we crossed a segment boundary and need to do some synchronous + * filesystem operations. + */ + segno = old_discard / UndoLogSegmentSize; + new_segno = discard / UndoLogSegmentSize; + if (segno < new_segno) + { + int recycle; + UndoLogOffset pointer; + + /* + * We WAL-log the discards of permanent undo logs, but we only need to + * flush the WAL if we have performed a filesystem operation. + */ + need_to_flush_wal = true; + + /* + * Note: When we rename or unlink a file, it's possible that some + * backend still has it open because it has recently read a page from + * it. smgr/undofile.c in any such backend will eventually close it, + * because it considers that fd to belong to the file with the name + * that we're unlinking or renaming and it doesn't like to keep more + * than one open at a time. No backend should ever try to read from + * such a file descriptor; that is what it means when we say that the + * caller of PbuUndoLogDiscard() asserts that there will be no + * attempts to access the discarded range of undo log! In the case of + * a rename, if a backend were to attempt to read undo data in the + * range being discarded, it would read entirely the wrong data. + * + * Note: What defenses could we build against that happening due to + * bugs/corruption? One way would be for undofile.c to refuse to read + * buffers from before the current discard point, but currently + * undofile.c doesn't need to deal with shmem/locks. That may be + * false economy, but we really don't want reader to have to wait to + * acquire the undo log lock just to read undo data while we are doing + * filesystem stuff in here. + */ + + /* Replace one segment unless there is already a spare one. */ + if (log->meta.end - log->meta.insert < UndoLogSegmentSize) + recycle = 1; + else + recycle = 0; + + /* Rewind to the start of the segment. */ + pointer = segno * UndoLogSegmentSize; + + while (pointer < new_segno * UndoLogSegmentSize) + { + char discard_path[MAXPGPATH]; + + /* Tell the checkpointer that the file is going away. */ + undofile_forget_sync(log->logno, pointer / UndoLogSegmentSize, + log->meta.tablespace); + + UndoLogSegmentPath(logno, pointer / UndoLogSegmentSize, + log->meta.tablespace, discard_path); + + /* + * Never rename pages whose checksums include their old block + * numbers. + */ + if (recycle > 0) + { + /* + * End points one byte past the end of the current undo space, + * ie to the first byte of the segment file we want to create. + */ + if (unlink(discard_path) == 0) + { + allocate_empty_undo_segment(logno, log->meta.tablespace, end); + end += UndoLogSegmentSize; + --recycle; + } + else + { + elog(ERROR, "could not remove \"%s\": %m", + discard_path); + } + } + else + { + if (unlink(discard_path) != 0) + elog(ERROR, "could not unlink \"%s\": %m", discard_path); + } + pointer += UndoLogSegmentSize; + } + } + + /* + * WAL-log the discard of a permanent undo log. Unlogged and temporary + * undo logs are not WAL-logged at all (see extend_undo_log()): a temp log + * is discarded at the first temp write of each transaction, and + * WAL-logging that would cost every such transaction a WAL record, and at + * a segment boundary a WAL flush, for nothing. + */ + if (log->meta.persistence == UNDO_PERMANENT) + { + xl_undolog_discard xlrec; + XLogRecPtr ptr; + + xlrec.logno = logno; + xlrec.discard = discard; + xlrec.end = end; + xlrec.latestxid = xid; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xlrec)); + ptr = XLogInsert(RM_UNDOLOG_ID, XLOG_UNDOLOG_DISCARD); + + if (need_to_flush_wal) + XLogFlush(ptr); + } + + /* Update shmem to show the new discard and end pointers. */ + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + log->meta.discard = discard; + log->meta.end = end; + LWLockRelease(&log->mutex); +} + +Oid +UndoRecPtrGetTablespace(UndoRecPtr ptr) +{ + UndoLogNumber logno = UndoRecPtrGetLogNo(ptr); + UndoLogControl *log = get_undo_log_by_number(logno); + + /* + * Note: What should the behaviour of this function be if you ask for the + * tablespace of a discarded log, where even the shmem bank is gone? + */ + + /* + * No need to acquire log->mutex, because log->meta.tablespace is constant + * for the lifetime of the log. + */ + if (log != NULL) + return log->meta.tablespace; + else + return InvalidOid; +} + +/* + * Return first valid UndoRecPtr for a given undo logno. If logno is invalid + * then return InvalidUndoRecPtr. + */ +UndoRecPtr +UndoLogGetFirstValidRecord(UndoLogNumber logno) +{ + UndoLogControl *log = get_undo_log_by_number(logno); + + if (log == NULL || log->meta.discard == log->meta.insert) + return InvalidUndoRecPtr; + + return MakeUndoRecPtr(logno, log->meta.discard); +} + +/* + * Return the ext insert location. This will also validate the input xid + * if latest insert point is not for the same transaction id then this will + * return Invalid Undo pointer. + */ +UndoRecPtr +UndoLogGetNextInsertPtr(UndoLogNumber logno, TransactionId xid) +{ + UndoLogControl *log = get_undo_log_by_number(logno); + TransactionId logxid; + UndoRecPtr insert; + + LWLockAcquire(&log->mutex, LW_SHARED); + insert = log->meta.insert; + logxid = log->xid; + LWLockRelease(&log->mutex); + + if (TransactionIdIsValid(logxid) && !TransactionIdEquals(logxid, xid)) + return InvalidUndoRecPtr; + + return MakeUndoRecPtr(logno, insert); +} + +/* + * Rewind the undo log insert position also set the prevlen in the mata + */ +void +UndoLogRewind(UndoRecPtr insert_urp) +{ + UndoLogNumber logno = UndoRecPtrGetLogNo(insert_urp); + UndoLogControl *log = get_undo_log_by_number(logno); + UndoLogOffset insert = UndoRecPtrGetOffset(insert_urp); + + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + log->meta.insert = insert; + + /* + * Force the wal log on next undo allocation. So that during recovery undo + * insert location is consistent with normal allocation. + */ + log->need_attach_wal_record = true; + LWLockRelease(&log->mutex); + + /* WAL log the rewind. */ + { + xl_undolog_rewind xlrec; + + xlrec.logno = logno; + xlrec.insert = insert; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xlrec)); + XLogInsert(RM_UNDOLOG_ID, XLOG_UNDOLOG_REWIND); + } +} + +/* + * Delete unreachable files under pg_undo. Any files corresponding to LSN + * positions before the previous checkpoint are no longer needed. + */ +static void +CleanUpUndoCheckPointFiles(XLogRecPtr checkPointRedo) +{ + DIR *dir; + struct dirent *de; + char path[MAXPGPATH]; + char oldest_path[MAXPGPATH]; + + /* + * Keep only those >= the previous checkpoint's redo point. A file-level + * copy (base backup, pg_rewind) can therefore lack the snapshot for the + * redo point it restarts from; StartupUndoLogs() copes with that. + */ + snprintf(oldest_path, MAXPGPATH, "%016llX", + (unsigned long long) checkPointRedo); + dir = AllocateDir("pg_undo"); + while ((de = ReadDir(dir, "pg_undo")) != NULL) + { + /* + * Assume that fixed width uppercase hex strings sort the same way as + * the values they represent, so we can use strcmp to identify undo + * log snapshot files corresponding to checkpoints that we don't need + * anymore. This assumption holds for ASCII. + */ + if (!(strlen(de->d_name) == UNDO_CHECKPOINT_FILENAME_LENGTH)) + continue; + + if (UndoCheckPointFilenamePrecedes(de->d_name, oldest_path)) + { + snprintf(path, MAXPGPATH, "pg_undo/%s", de->d_name); + if (unlink(path) != 0) + elog(ERROR, "could not unlink file \"%s\": %m", path); + } + } + FreeDir(dir); +} + +/* + * Write out the undo log meta data to the pg_undo directory. The actual + * contents of undo logs is in shared buffers and therefore handled by + * CheckPointBuffers(), but here we record the table of undo logs and their + * properties. + */ +void +CheckPointUndoLogs(XLogRecPtr checkPointRedo, XLogRecPtr priorCheckPointRedo) +{ + UndoLogSharedData *shared = MyUndoLogState.shared; + UndoLogMetaData *serialized = NULL; + UndoLogNumber low_logno; + UndoLogNumber high_logno; + UndoLogNumber logno; + size_t serialized_size = 0; + char *data; + char path[MAXPGPATH]; + int num_logs; + int fd; + pg_crc32c crc; + + /* + * Take this opportunity to check if we can free up any DSM segments and + * also some entries in the checkpoint file by forgetting about entirely + * discarded undo logs. Otherwise both would eventually grow large. + */ + LWLockAcquire(UndoLogLock, LW_EXCLUSIVE); + while (shared->low_logno < shared->high_logno) + { + UndoLogControl *log; + + log = get_undo_log_by_number(shared->low_logno); + if (log->meta.status != UNDO_LOG_STATUS_DISCARDED) + break; + + /* + * If this was the last slot in a bank, the bank is no longer needed. + * The shared memory will be given back to the operating system once + * every attached backend runs undolog_bank_gc(). + */ + if (UndoLogNoGetSlotNo(shared->low_logno + 1) == 0) + shared->banks[UndoLogNoGetBankNo(shared->low_logno)] = + DSM_HANDLE_INVALID; + + ++shared->low_logno; + } + LWLockRelease(UndoLogLock); + + /* Detach from any banks that we don't need if low_logno advanced. */ + undolog_bank_gc(); + + /* + * We acquire UndoLogLock to prevent any undo logs from being created or + * discarded while we build a snapshot of them. This isn't expected to + * take long on a healthy system because the number of active logs should + * be around the number of backends. Holding this lock won't prevent + * concurrent access to the undo log, except when segments need to be + * added or removed. + */ + LWLockAcquire(UndoLogLock, LW_SHARED); + + low_logno = shared->low_logno; + high_logno = shared->high_logno; + num_logs = high_logno - low_logno; + + /* + * Rather than doing the file IO while we hold the lock, we'll copy it + * into a palloc'd buffer. + */ + if (num_logs > 0) + { + serialized_size = sizeof(UndoLogMetaData) * num_logs; + serialized = (UndoLogMetaData *) palloc0(serialized_size); + + for (logno = low_logno; logno != high_logno; ++logno) + { + UndoLogControl *log; + + log = get_undo_log_by_number(logno); + if (log == NULL) /* can this happen? */ + continue; + + /* Capture snapshot while holding the mutex. */ + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + log->need_attach_wal_record = true; + memcpy(&serialized[logno - low_logno], &log->meta, + sizeof(UndoLogMetaData)); + LWLockRelease(&log->mutex); + } + } + + LWLockRelease(UndoLogLock); + + /* Dump into a file under pg_undo. */ + + /* + * Ensure the pg_undo directory exists. initdb does not create it (the + * per-backend engine is optional), so create it lazily and idempotently + * the first time a checkpoint persists undo-log metadata. + */ + (void) MakePGDirectory("pg_undo"); + + snprintf(path, MAXPGPATH, "pg_undo/%016" INT64_MODIFIER "X", + (unsigned long long) checkPointRedo); + pgstat_report_wait_start(WAIT_EVENT_UNDO_CHECKPOINT_WRITE); + fd = OpenTransientFile(path, O_RDWR | O_CREAT | PG_BINARY); + if (fd < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create file \"%s\": %m", path))); + + /* Compute header checksum. */ + INIT_CRC32C(crc); + COMP_CRC32C(crc, &low_logno, sizeof(low_logno)); + COMP_CRC32C(crc, &high_logno, sizeof(high_logno)); + FIN_CRC32C(crc); + + /* Write out range of active log numbers + crc. */ + if ((write(fd, &low_logno, sizeof(low_logno)) != sizeof(low_logno)) || + (write(fd, &high_logno, sizeof(high_logno)) != sizeof(high_logno)) || + (write(fd, &crc, sizeof(crc)) != sizeof(crc))) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write to file \"%s\": %m", path))); + + /* Write out the meta data for all undo logs in that range. */ + data = (char *) serialized; + INIT_CRC32C(crc); + while (serialized_size > 0) + { + ssize_t written; + + written = write(fd, data, serialized_size); + if (written < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write to file \"%s\": %m", path))); + COMP_CRC32C(crc, data, written); + serialized_size -= written; + data += written; + } + FIN_CRC32C(crc); + + if (write(fd, &crc, sizeof(crc)) != sizeof(crc)) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write to file \"%s\": %m", path))); + + + /* Flush file and directory entry. */ + pgstat_report_wait_start(WAIT_EVENT_UNDO_CHECKPOINT_SYNC); + pg_fsync(fd); + CloseTransientFile(fd); + fsync_fname("pg_undo", true); + pgstat_report_wait_end(); + + if (serialized) + pfree(serialized); + + CleanUpUndoCheckPointFiles(priorCheckPointRedo); + undolog_xid_map_gc(); +} + +/* + * Does directory 'path' hold any file? A missing directory holds none. + */ +static bool +undo_dir_has_files(const char *path) +{ + DIR *dir = AllocateDir(path); + struct dirent *de; + bool found = false; + + if (dir == NULL) + { + if (errno != ENOENT) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not open directory \"%s\": %m", path))); + return false; + } + while (!found && (de = ReadDir(dir, path)) != NULL) + found = strcmp(de->d_name, ".") != 0 && strcmp(de->d_name, "..") != 0; + FreeDir(dir); + return found; +} + +/* + * Has this cluster ever written an undo log segment, in any tablespace? + */ +static bool +undo_segments_exist(void) +{ + DIR *dir; + struct dirent *de; + char path[MAXPGPATH]; + bool found = undo_dir_has_files("base/undo"); + + dir = AllocateDir(PG_TBLSPC_DIR); + while (!found && (de = ReadDir(dir, PG_TBLSPC_DIR)) != NULL) + { + if (de->d_name[0] == '.') + continue; + snprintf(path, MAXPGPATH, "%s/%s/%s/undo", PG_TBLSPC_DIR, de->d_name, + TABLESPACE_VERSION_DIRECTORY); + found = undo_dir_has_files(path); + } + FreeDir(dir); + return found; +} + +/* + * pg_undo/ is missing: pick the snapshot to start from instead, and + * return false if pg_undo holds none. + * + * File-level copies lose the exact snapshot. pg_rewind copies pg_undo/ from + * a source whose later checkpoints have already pruned the snapshot for the + * common checkpoint the target restarts from; a base backup misses its start + * checkpoint's snapshot if two checkpoints complete before pg_undo/ is + * copied. The copy then holds a snapshot named after a LATER redo point of + * the same history, which the WAL to be replayed passes through. Start from + * the oldest such snapshot, as CLOG copied "from the future" is used: undo + * log redo is idempotent against a newer state (see undolog_redo_get_log()). + * + * Failing that, use the newest older snapshot. That is only right when the + * undo logs did not change after it, which is what pg_resetwal leaves behind: + * a new redo point past the clean-shutdown checkpoint that wrote the + * snapshot. + */ +static bool +choose_undo_snapshot(XLogRecPtr redo, char *path) +{ + DIR *dir; + struct dirent *de; + uint64 newer = 0; + uint64 older = 0; + + dir = AllocateDir("pg_undo"); + if (dir == NULL) + { + if (errno != ENOENT) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not open directory \"%s\": %m", "pg_undo"))); + return false; + } + while ((de = ReadDir(dir, "pg_undo")) != NULL) + { + uint64 lsn; + + if (strlen(de->d_name) != UNDO_CHECKPOINT_FILENAME_LENGTH || + strspn(de->d_name, "0123456789ABCDEF") != UNDO_CHECKPOINT_FILENAME_LENGTH) + continue; + lsn = strtou64(de->d_name, NULL, 16); + if (lsn > redo && (newer == 0 || lsn < newer)) + newer = lsn; + else if (lsn < redo && lsn > older) + older = lsn; + } + FreeDir(dir); + + if (newer == 0 && older == 0) + return false; + snprintf(path, MAXPGPATH, "pg_undo/%016llX", + (unsigned long long) (newer != 0 ? newer : older)); + ereport(LOG, + (errmsg("undo log checkpoint snapshot for redo point %X/%08X is missing, using \"%s\"", + LSN_FORMAT_ARGS(redo), path))); + return true; +} + +void +StartupUndoLogs(XLogRecPtr checkPointRedo) +{ + UndoLogSharedData *shared = MyUndoLogState.shared; + char path[MAXPGPATH]; + int logno; + int fd; + pg_crc32c crc; + pg_crc32c new_crc; + + /* If initdb is calling, there is no file to read yet. */ + if (IsBootstrapProcessingMode()) + return; + + /* Open the pg_undo file corresponding to the given checkpoint. */ + snprintf(path, MAXPGPATH, "pg_undo/%016" INT64_MODIFIER "X", + (unsigned long long) checkPointRedo); + fd = OpenTransientFile(path, O_RDONLY | PG_BINARY); + if (fd < 0 && errno == ENOENT) + { + if (!choose_undo_snapshot(checkPointRedo, path)) + { + /* + * No snapshot at all. That is only valid for a cluster that has + * no undo logs, and those leave no segment files. Otherwise + * starting with an empty inventory would lose the undo logs, and + * with them the history of every version chain pointing into + * them. + */ + if (undo_segments_exist()) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("undo log checkpoint snapshot \"%s\" is missing", path), + errdetail("Undo log segment files exist, but directory \"%s\" holds no snapshot of the undo log inventory.", + "pg_undo"), + errhint("Restore directory \"%s\" from the backup or server this data directory was copied from.", + "pg_undo"))); + return; + } + fd = OpenTransientFile(path, O_RDONLY | PG_BINARY); + } + if (fd < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not open file \"%s\": %m", path))); + pgstat_report_wait_start(WAIT_EVENT_UNDO_CHECKPOINT_READ); + + /* Read the active log number range. */ + if ((read(fd, &shared->low_logno, sizeof(shared->low_logno)) + != sizeof(shared->low_logno)) || + (read(fd, &shared->high_logno, sizeof(shared->high_logno)) + != sizeof(shared->high_logno)) || + (read(fd, &crc, sizeof(crc)) != sizeof(crc))) + elog(ERROR, "pg_undo file \"%s\" is corrupted", path); + + /* Verify the header checksum. */ + INIT_CRC32C(new_crc); + COMP_CRC32C(new_crc, &shared->low_logno, sizeof(shared->low_logno)); + COMP_CRC32C(new_crc, &shared->high_logno, sizeof(shared->high_logno)); + FIN_CRC32C(new_crc); + + if (crc != new_crc) + elog(ERROR, + "pg_undo file \"%s\" has incorrect checksum", path); + + /* Initialize all the logs and set up the freelist. */ + INIT_CRC32C(new_crc); + for (logno = shared->low_logno; logno < shared->high_logno; ++logno) + { + UndoLogControl *log; + + /* Get a zero-initialized control objects. */ + ensure_undo_log_number(logno); + log = get_undo_log_by_number(logno); + + /* Read in the meta data for this undo log. */ + if (read(fd, &log->meta, sizeof(log->meta)) != sizeof(log->meta)) + elog(ERROR, "corrupted pg_undo meta data in file \"%s\": %m", + path); + COMP_CRC32C(new_crc, &log->meta, sizeof(log->meta)); + + /* + * At normal start-up, or during recovery, all active undo logs start + * out on the appropriate free list. + */ + log->procno = INVALID_PROC_NUMBER; + log->pid = InvalidPid; + log->xid = InvalidTransactionId; + if (log->meta.status == UNDO_LOG_STATUS_ACTIVE) + { + log->next_free = shared->free_lists[log->meta.persistence]; + shared->free_lists[log->meta.persistence] = logno; + } + } + FIN_CRC32C(new_crc); + + /* Verify body checksum. */ + if (read(fd, &crc, sizeof(crc)) != sizeof(crc)) + elog(ERROR, "pg_undo file \"%s\" is corrupted", path); + if (crc != new_crc) + elog(ERROR, + "pg_undo file \"%s\" has incorrect checksum", path); + + CloseTransientFile(fd); + pgstat_report_wait_end(); +} + +/* + * WAL-LOG undo log meta data information before inserting the first WAL after + * the checkpoint for any undo log. + * + * Only the permanent undo log is replayed. The meta of an unlogged or + * temporary undo log must never reach the WAL: replay would map the + * transaction to that log, and the replay of the transaction's next permanent + * undo record would then allocate in the wrong log. It would also stamp the + * permanent log's LSN (NeedUndoMetaLog), suppressing the permanent log's own + * first-after-checkpoint record. + */ +void +LogUndoMetaData(xl_undolog_meta *xlrec) +{ + XLogRecPtr RedoRecPtr; + XLogRecPtr recptr; + + if (xlrec->meta.persistence != UNDO_PERMANENT) + return; + + /* + * Use the current redo point, not GetFullPageWriteInfo()'s backend-local + * cached copy. That cached copy is refreshed only inside + * XLogInsertRecord, so between a backend's WAL inserts it can lag an + * intervening checkpoint. NeedUndoMetaLog() would then compare log->lsn + * against a stale (older) redo point, wrongly conclude the meta was + * already logged this checkpoint cycle, and skip it -- leaving the undo + * record that follows as the first undo record after the new redo point + * with no XLOG_UNDOLOG_ATTACH/META before it. Recovery replaying from + * that redo point would then meet an undo record for an xid its + * xid->logno map was never told about and die with "xid to undo log + * number map not initialized". GetRedoRecPtr() reads the shared redo + * point and refreshes the local copy, so the decision is made against the + * checkpoint actually in effect. + */ + RedoRecPtr = GetRedoRecPtr(); + + if (NeedUndoMetaLog(RedoRecPtr)) + { + XLogBeginInsert(); + XLogRegisterData((char *) xlrec, sizeof(xl_undolog_meta)); + recptr = XLogInsert(RM_UNDOLOG_ID, XLOG_UNDOLOG_META); + UndoLogSetLSN(recptr); + } +} + +/* + * CaptureCurrentUndoLogMeta - snapshot the CURRENT (post-insert-advance) meta + * of the permanent undo log this backend is attached to. + * + * LogUndoMetaData()/PrepareUndoInsert capture the meta BEFORE the insert + * pointer is advanced (undometa->meta = log->meta then UndoLogAdvance). A + * producer that makes its undo crash-safe via a full-page image of the undo + * page (rather than by re-running the undo insert during its own redo) must + * also replay the advanced insert pointer, or the recovered log looks empty + * (insert == discard) even though the page holds data. Call this AFTER + * InsertPreparedUndo() to capture the advanced insert, then LogUndoMetaDataNow() + * to WAL-log it so undolog_xlog_meta() restores the correct insert pointer. + * + * Returns false (leaving *xlrec untouched) if this backend is not attached to + * a permanent undo log. + */ +bool +CaptureCurrentUndoLogMeta(xl_undolog_meta *xlrec) +{ + UndoLogControl *log = MyUndoLogState.logs[UNDO_PERMANENT]; + + if (log == NULL) + return false; + + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + xlrec->meta = log->meta; + xlrec->logno = log->logno; + xlrec->xid = log->xid; + LWLockRelease(&log->mutex); + + return true; +} + +/* + * LogUndoMetaDataNow - unconditionally WAL-log the given undo-log meta. + * + * Unlike LogUndoMetaData(), this is not gated on NeedUndoMetaLog(): it always + * emits the XLOG_UNDOLOG_META record. Used by FPI-based producers to record + * the post-advance insert pointer for EVERY undo record they write (see + * CaptureCurrentUndoLogMeta), so recovery restores the correct insert pointer + * regardless of checkpoint boundaries. + */ +void +LogUndoMetaDataNow(xl_undolog_meta *xlrec) +{ + XLogRecPtr recptr; + + XLogBeginInsert(); + XLogRegisterData((char *) xlrec, sizeof(xl_undolog_meta)); + recptr = XLogInsert(RM_UNDOLOG_ID, XLOG_UNDOLOG_META); + UndoLogSetLSN(recptr); +} + +/* + * Check whether we need to log undolog meta or not. + */ +bool +NeedUndoMetaLog(XLogRecPtr redo_point) +{ + UndoLogControl *log = MyUndoLogState.logs[UNDO_PERMANENT]; + + /* + * If the current session is not attached to any undo log then we don't + * need to log meta. It is quite possible that some operations skip + * writing undo, so those won't be attached to any undo log. + */ + if (log == NULL) + return false; + + Assert(AmAttachedToUndoLog(log)); + + if (log->lsn <= redo_point) + return true; + + return false; +} + +/* + * Update the WAL lsn in the undo. This is to test whether we need to include + * the xid to logno mapping information in the next WAL or not. + */ +void +UndoLogSetLSN(XLogRecPtr lsn) +{ + UndoLogControl *log = MyUndoLogState.logs[UNDO_PERMANENT]; + + Assert(AmAttachedToUndoLog(log)); + log->lsn = lsn; +} + +/* + * Get an UndoLogControl pointer for a given logno. This may require + * attaching to a DSM segment if it isn't already attached in this backend. + * Return NULL if there is no such logno because it has been entirely + * discarded. + */ +static UndoLogControl * +get_undo_log_by_number(UndoLogNumber logno) +{ + UndoLogSharedData *shared = MyUndoLogState.shared; + int bankno; + int slotno; + + /* + * Undo log numbers start at 1 (shared->low_logno is initialised to 1); + * logno 0 and InvalidUndoLogNumber (-1) are never allocated. Without + * this guard, a stale UndoRecPtr whose log number decodes to 0 (e.g. a + * slot's urec_ptr that a concurrent transaction reset while we held only + * a shared content lock) would index banks[0][0] and hand back an + * uninitialised UndoLogControl. Reading undo from it derives + * RelFileNumber 0 and trips the RelFileNumberIsValid() assert in + * smgropen() (seen from GetLockerTransInfo -> UndoFetchRecord under a + * 16-way concurrent load). Return NULL so every caller's existing "log + * == NULL -> record is gone" path handles it safely. + */ + if (logno < 1) + return NULL; + + bankno = UndoLogNoGetBankNo(logno); + slotno = UndoLogNoGetSlotNo(logno); + + /* See if we need to attach to the bank that holds logno. */ + if (unlikely(MyUndoLogState.banks[bankno] == NULL)) + { + dsm_segment *segment; + + if (shared->banks[bankno] != DSM_HANDLE_INVALID) + { + segment = dsm_attach(shared->banks[bankno]); + if (segment != NULL) + { + MyUndoLogState.bank_segments[bankno] = segment; + MyUndoLogState.banks[bankno] = dsm_segment_address(segment); + + dsm_pin_mapping(segment); + } + } + + if (unlikely(MyUndoLogState.banks[bankno] == NULL)) + return NULL; + } + + return &MyUndoLogState.banks[bankno][slotno]; +} + +UndoLogControl * +UndoLogGet(UndoLogNumber logno) +{ + return get_undo_log_by_number(logno); +} + +/* + * We write the undo log number into each UndoLogControl object. + */ +static void +initialize_undo_log_bank(int bankno, UndoLogControl *bank) +{ + int i; + + for (i = 0; i < UndoLogsPerBank; ++i) + { + bank[i].logno = UndoLogsPerBank * bankno + i; + + /* + * The bank arrives zeroed, and 0 is a valid ProcNumber, so procno + * must be set to the unattached sentinel explicitly. Without this an + * untouched slot would claim to be owned by the executor in PGPROC + * slot 0. + */ + bank[i].procno = INVALID_PROC_NUMBER; + bank[i].pid = InvalidPid; + + LWLockInitialize(&bank[i].mutex, LWTRANCHE_UNDO_LOG_PBU); + LWLockInitialize(&bank[i].discard_lock, LWTRANCHE_UNDO_LOG_PBU); + LWLockInitialize(&bank[i].discard_update_lock, LWTRANCHE_UNDO_LOG_PBU); + } +} + +/* + * Create shared memory space for a given undo log number, if it doesn't exist + * already. + */ +static void +ensure_undo_log_number(UndoLogNumber logno) +{ + UndoLogSharedData *shared = MyUndoLogState.shared; + int bankno = UndoLogNoGetBankNo(logno); + + /* In single-user mode, we have to use backend-private memory. */ + if (!IsUnderPostmaster) + { + if (MyUndoLogState.banks[bankno] == NULL) + { + size_t size; + + size = sizeof(UndoLogControl) * UndoLogsPerBank; + MyUndoLogState.banks[bankno] = + MemoryContextAllocZero(TopMemoryContext, size); + + initialize_undo_log_bank(bankno, MyUndoLogState.banks[bankno]); + } + return; + } + + /* Do we need to create a bank in shared memory for this undo log number? */ + if (shared->banks[bankno] == DSM_HANDLE_INVALID) + { + dsm_segment *segment; + size_t size; + + size = sizeof(UndoLogControl) * UndoLogsPerBank; + segment = dsm_create(size, 0); + dsm_pin_mapping(segment); + dsm_pin_segment(segment); + memset(dsm_segment_address(segment), 0, size); + shared->banks[bankno] = dsm_segment_handle(segment); + MyUndoLogState.banks[bankno] = dsm_segment_address(segment); + + initialize_undo_log_bank(bankno, MyUndoLogState.banks[bankno]); + } +} + +/* + * Attach to an undo log, possibly creating or recycling one. + */ +static void +attach_undo_log(UndoPersistence persistence, Oid tablespace) +{ + UndoLogSharedData *shared = MyUndoLogState.shared; + UndoLogControl *log = NULL; + UndoLogNumber logno; + UndoLogNumber *place; + + Assert(!InRecovery); + Assert(MyUndoLogState.logs[persistence] == NULL); + + LWLockAcquire(UndoLogLock, LW_EXCLUSIVE); + + /* + * Unattached logs form a linked list per persistence level. Finding a + * matching tablespace requires a linear scan; with one tablespace the + * first entry suffices. + */ + place = &shared->free_lists[persistence]; + while (*place != InvalidUndoLogNumber) + { + UndoLogControl *candidate = get_undo_log_by_number(*place); + + if (candidate == NULL) + elog(ERROR, "corrupted undo log freelist"); + if (candidate->meta.tablespace == tablespace) + { + logno = *place; + log = candidate; + *place = candidate->next_free; + break; + } + place = &candidate->next_free; + } + + /* + * All existing undo logs for this tablespace and persistence level are + * busy, so we'll have to create a new one. + */ + if (log == NULL) + { + if (shared->high_logno > (1 << UndoLogNumberBits)) + { + /* + * You've used up all 16 exabytes of undo log addressing space. + * This is a difficult state to reach using only 16 exabytes of + * WAL. + */ + elog(ERROR, "cannot create new undo log"); + } + + logno = shared->high_logno; + ensure_undo_log_number(logno); + + /* Get new zero-filled UndoLogControl object. */ + log = get_undo_log_by_number(logno); + + Assert(log->meta.persistence == 0); + Assert(log->meta.tablespace == InvalidOid); + Assert(log->meta.discard == 0); + Assert(log->meta.insert == 0); + Assert(log->meta.end == 0); + Assert(log->procno == INVALID_PROC_NUMBER); + Assert(log->pid == InvalidPid); + Assert(log->xid == 0); + + /* + * The insert and discard pointers start after the first block's + * header. That means that insert is > end for a short time in a + * newly created undo log. Is there any problem with that? + */ + log->meta.insert = UndoLogBlockHeaderSize; + log->meta.discard = UndoLogBlockHeaderSize; + + log->meta.tablespace = tablespace; + log->meta.persistence = persistence; + log->meta.status = UNDO_LOG_STATUS_ACTIVE; + + /* Move the high log number pointer past this one. */ + ++shared->high_logno; + + /* WAL-log the creation of this new undo log. */ + { + xl_undolog_create xlrec; + + xlrec.logno = logno; + xlrec.tablespace = log->meta.tablespace; + xlrec.persistence = log->meta.persistence; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xlrec)); + XLogInsert(RM_UNDOLOG_ID, XLOG_UNDOLOG_CREATE); + } + + /* + * This undo log has no segments. UndoLogAllocate will create the + * first one on demand. + */ + } + LWLockRelease(UndoLogLock); + + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + log->procno = MyProcNumber; + log->pid = MyProcPid; + log->xid = InvalidTransactionId; + log->need_attach_wal_record = true; + LWLockRelease(&log->mutex); + + MyUndoLogState.logs[persistence] = log; +} + +/* + * Free chunks of the xid/undo log map that relate to transactions that are no + * longer running. This is run at each checkpoint. + */ +static void +undolog_xid_map_gc(void) +{ + UndoLogNumber **xid_map = MyUndoLogState.xid_map; + TransactionId oldest_xid; + uint16 new_oldest_chunk; + uint16 oldest_chunk; + + if (xid_map == NULL) + return; + + /* + * During crash recovery, it may not be possible to call GetOldestXmin() + * yet because latestCompletedXid is invalid. + */ + if (!FullTransactionIdIsNormal(ShmemVariableCache->latestCompletedXid)) + return; + + oldest_xid = GetOldestXmin(NULL, PROCARRAY_FLAGS_DEFAULT); + new_oldest_chunk = UndoLogGetXidHigh(oldest_xid); + oldest_chunk = MyUndoLogState.xid_map_oldest_chunk; + + while (oldest_chunk != new_oldest_chunk) + { + if (xid_map[oldest_chunk]) + { + pfree(xid_map[oldest_chunk]); + xid_map[oldest_chunk] = NULL; + } + oldest_chunk = (oldest_chunk + 1) % (1 << UndoLogXidHighBits); + } + MyUndoLogState.xid_map_oldest_chunk = new_oldest_chunk; +} + +/* + * Detach from shared memory banks that are no longer needed because they hold + * undo logs that are entirely discarded. This should ideally be called + * periodically in any backend that accesses undo data, so that they have a + * chance to detach from DSM segments that hold banks of entirely discarded + * undo log control objects. + */ +static void +undolog_bank_gc(void) +{ + UndoLogSharedData *shared = MyUndoLogState.shared; + UndoLogNumber low_logno = shared->low_logno; + + if (unlikely(MyUndoLogState.low_logno < low_logno)) + { + int low_bank = UndoLogNoGetBankNo(low_logno); + int bank = UndoLogNoGetBankNo(MyUndoLogState.low_logno); + + while (bank < low_bank) + { + Assert(shared->banks[bank] == DSM_HANDLE_INVALID); + if (MyUndoLogState.banks[bank] != NULL) + { + dsm_detach(MyUndoLogState.bank_segments[bank]); + MyUndoLogState.bank_segments[bank] = NULL; + MyUndoLogState.banks[bank] = NULL; + } + ++bank; + } + } + + MyUndoLogState.low_logno = low_logno; +} + +/* + * Associate a xid with an undo log, during recovery. In a primary server, + * this isn't necessary because backends know which undo log they're attached + * to. During recovery, the natural association between backends and xids is + * lost, so we need to manage that explicitly. + */ +static void +undolog_xid_map_add(TransactionId xid, UndoLogNumber logno) +{ + uint16 high_bits; + uint16 low_bits; + + high_bits = UndoLogGetXidHigh(xid); + low_bits = UndoLogGetXidLow(xid); + + if (unlikely(MyUndoLogState.xid_map == NULL)) + { + /* First time through. Create mapping array. */ + MyUndoLogState.xid_map = + MemoryContextAllocZero(TopMemoryContext, + sizeof(UndoLogNumber *) * + (1 << (32 - UndoLogXidLowBits))); + MyUndoLogState.xid_map_oldest_chunk = high_bits; + } + + if (unlikely(MyUndoLogState.xid_map[high_bits] == NULL)) + { + /* This bank of mappings doesn't exist yet. Create it. */ + MyUndoLogState.xid_map[high_bits] = + MemoryContextAllocZero(TopMemoryContext, + sizeof(UndoLogNumber) * + (1 << UndoLogXidLowBits)); + } + + /* Associate this xid with this undo log number. */ + MyUndoLogState.xid_map[high_bits][low_bits] = logno; +} + +/* + * Seed the recovery xid->logno map for an undo record whose own WAL carries + * the log number. + * + * The mapping is normally created by replaying an XLOG_UNDOLOG_ATTACH (first + * undo record of a transaction) or XLOG_UNDOLOG_META (first undo record of a + * transaction after a checkpoint). The META is emitted on the primary only + * when LogUndoMetaData() judges, from the redo point then in effect, that it + * has not been logged since the last checkpoint. That judgement races a + * concurrent checkpoint: the forward undo record can be assigned an LSN just + * after a new redo point while its META was skipped, so a crash that replays + * from that redo point meets the forward record before any ATTACH/META for its + * xid and dies with "xid to undo log number map not initialized". + * + * A producer whose forward record carries both the top-level xid and the undo + * record pointer (hence the log number) resolves this unambiguously: the record + * names the exact log DO wrote into. Call this from such a producer's redo, + * before PrepareUndoInsert(), so IsTransactionFirstRec() and + * UndoLogAllocateInRecovery() find the log regardless of whether the META was + * emitted. Writing the value an ATTACH/META would also write is harmless, and + * when the transaction's undo spans logs the current record's log number is the + * right one for this record. log->meta (including is_first_rec and the insert + * pointer) still comes from the checkpoint snapshot and the replayed records, + * exactly as without this call, so replayed record sizes and offsets are + * unchanged. + */ +void +UndoLogRecoverySetXidMap(TransactionId xid, UndoLogNumber logno) +{ + Assert(InRecovery); + undolog_xid_map_add(xid, logno); +} + +/* assign_hook: do extra actions as needed */ +void +assign_undo_tablespaces(const char *newval, void *extra) +{ + /* + * This is normally called only when GetTopTransactionIdIfAny() == + * InvalidTransactionId (because you can't change undo_tablespaces in the + * middle of a transaction that's been asigned an xid), but we can't + * assert that because it's also called at the end of a transaction that's + * rolling back, to reset the GUC if it was set inside the transaction. + */ + + /* Tell UndoLogAllocate() to reexamine undo_tablespaces. */ + MyUndoLogState.need_to_choose_tablespace = true; +} + +static bool +choose_undo_tablespace(bool force_detach, Oid *tablespace) +{ + char *rawname; + List *namelist; + bool need_to_unlock; + int length; + int i; + + /* + * We need a modifiable copy of string. + * + * undo_tablespaces is never registered as a GUC in this build (the + * per-backend engine's GUCs are not wired up until FLUX migrates to it), + * so it stays at its C initializer NULL. A NULL here means "no + * tablespace configured", which is exactly the empty-string case handled + * below (DEFAULTTABLESPACE_OID); treat it as such rather than + * pstrdup(NULL). FILEOPS is the first producer to actually allocate an + * undo log, so it is the first to reach this path. + */ + rawname = pstrdup(undo_tablespaces != NULL ? undo_tablespaces : ""); + + /* Break string into list of identifiers. */ + if (!SplitIdentifierString(rawname, ',', &namelist)) + elog(ERROR, "undo_tablespaces is unexpectedly malformed"); + + length = list_length(namelist); + if (length == 0 || + (length == 1 && ((char *) linitial(namelist))[0] == '\0')) + { + /* + * If it's an empty string, then we'll use the default tablespace. No + * locking is required because it can't be dropped. + */ + *tablespace = DEFAULTTABLESPACE_OID; + need_to_unlock = false; + } + else + { + /* + * Choose an OID using our pid, so that if several backends have the + * same multi-tablespace setting they'll spread out. We could easily + * do better than this if more serious load balancing is judged + * useful. + */ + int index = MyProcNumber % length; + int first_index = index; + Oid oid = InvalidOid; + + /* + * Take the tablespace create/drop lock while we look the name up. + * This prevents the tablespace from being dropped while we're trying + * to resolve the name, or while the called is trying to create an + * undo log in it. The caller will have to release this lock. + */ + LWLockAcquire(TablespaceCreateLock, LW_EXCLUSIVE); + for (;;) + { + const char *name = list_nth(namelist, index); + + oid = get_tablespace_oid(name, true); + if (oid == InvalidOid) + { + /* Unknown tablespace, try the next one. */ + index = (index + 1) % length; + + /* + * But if we've tried them all, it's time to complain. We'll + * arbitrarily complain about the last one we tried in the + * error message. + */ + if (index == first_index) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("tablespace \"%s\" does not exist", name), + errhint("Create the tablespace or set undo_tablespaces to a valid or empty list."))); + continue; + } + if (oid == GLOBALTABLESPACE_OID) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("undo logs cannot be placed in pg_global tablespace"))); + /* If we got here we succeeded in finding one. */ + break; + } + + Assert(oid != InvalidOid); + *tablespace = oid; + need_to_unlock = true; + } + + /* + * If we came here because the user changed undo_tablesaces, then detach + * from any undo logs we happen to be attached to. + */ + if (force_detach) + { + for (i = 0; i < UndoPersistenceLevels; ++i) + { + UndoLogControl *log = MyUndoLogState.logs[i]; + UndoLogSharedData *shared = MyUndoLogState.shared; + + if (log != NULL) + { + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + log->procno = INVALID_PROC_NUMBER; + log->pid = InvalidPid; + log->xid = InvalidTransactionId; + LWLockRelease(&log->mutex); + + LWLockAcquire(UndoLogLock, LW_EXCLUSIVE); + log->next_free = shared->free_lists[i]; + shared->free_lists[i] = log->logno; + LWLockRelease(UndoLogLock); + + MyUndoLogState.logs[i] = NULL; + } + } + } + + return need_to_unlock; +} + +bool +DropUndoLogsInTablespace(Oid tablespace) +{ + DIR *dir; + char undo_path[MAXPGPATH]; + UndoLogNumber low_logno; + UndoLogNumber high_logno; + UndoLogNumber logno; + UndoLogSharedData *shared = MyUndoLogState.shared; + int i; + + Assert(LWLockHeldByMe(TablespaceCreateLock)); + Assert(tablespace != DEFAULTTABLESPACE_OID); + + LWLockAcquire(UndoLogLock, LW_SHARED); + low_logno = shared->low_logno; + high_logno = shared->high_logno; + LWLockRelease(UndoLogLock); + + /* First, try to kick everyone off any undo logs in this tablespace. */ + for (logno = low_logno; logno < high_logno; ++logno) + { + UndoLogControl *log = get_undo_log_by_number(logno); + bool ok; + bool return_to_freelist = false; + + /* Skip undo logs in other tablespaces. */ + if (log->meta.tablespace != tablespace) + continue; + + /* Check if this undo log can be forcibly detached. */ + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + if (log->meta.discard == log->meta.insert && + (log->xid == InvalidTransactionId || + !TransactionIdIsInProgress(log->xid))) + { + log->xid = InvalidTransactionId; + if (log->procno != INVALID_PROC_NUMBER) + { + log->procno = INVALID_PROC_NUMBER; + log->pid = InvalidPid; + return_to_freelist = true; + } + ok = true; + } + else + { + /* + * There is data we need in this undo log. We can't force it to + * be detached. + */ + ok = false; + } + LWLockRelease(&log->mutex); + + /* If we failed, then give up now and report failure. */ + if (!ok) + return false; + + /* + * Put this undo log back on the appropriate free-list. No one can + * attach to it while we hold TablespaceCreateLock, but if we return + * earlier in a future go around this loop, we need the undo log to + * remain usable. We'll remove all appropriate logs from the + * free-lists in a separate step below. + */ + if (return_to_freelist) + { + LWLockAcquire(UndoLogLock, LW_EXCLUSIVE); + log->next_free = shared->free_lists[log->meta.persistence]; + shared->free_lists[log->meta.persistence] = logno; + LWLockRelease(UndoLogLock); + } + } + + /* + * We detached all backends from undo logs in this tablespace, and no one + * can attach to any non-default-tablespace undo logs while we hold + * TablespaceCreateLock. We can now drop the undo logs. + */ + for (logno = low_logno; logno < high_logno; ++logno) + { + UndoLogControl *log = get_undo_log_by_number(logno); + + /* Skip undo logs in other tablespaces. */ + if (log->meta.tablespace != tablespace) + continue; + + /* + * Make sure no buffers remain. When that is done by UndoDiscard(), + * the final page is left in shared_buffers because it may contain + * data, or at least be needed again very soon. Here we need to drop + * even that page from the buffer pool. + */ + forget_undo_buffers(logno, log->meta.discard, log->meta.discard, true); + + /* + * Dropping the log permanently discards its remaining address space; + * log numbers are not reused in another tablespace. + */ + + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + log->meta.status = UNDO_LOG_STATUS_DISCARDED; + LWLockRelease(&log->mutex); + } + + /* Unlink all undo segment files in this tablespace. */ + UndoLogDirectory(tablespace, undo_path); + + dir = AllocateDir(undo_path); + if (dir != NULL) + { + struct dirent *de; + + while ((de = ReadDirExtended(dir, undo_path, LOG)) != NULL) + { + char segment_path[MAXPGPATH]; + + if (strcmp(de->d_name, ".") == 0 || + strcmp(de->d_name, "..") == 0) + continue; + snprintf(segment_path, sizeof(segment_path), "%s/%s", + undo_path, de->d_name); + if (unlink(segment_path) < 0) + elog(LOG, "couldn't unlink file \"%s\": %m", segment_path); + } + FreeDir(dir); + } + + /* Remove all dropped undo logs from the free-lists. */ + LWLockAcquire(UndoLogLock, LW_EXCLUSIVE); + for (i = 0; i < UndoPersistenceLevels; ++i) + { + UndoLogControl *log; + UndoLogNumber *place; + + place = &shared->free_lists[i]; + while (*place != InvalidUndoLogNumber) + { + log = get_undo_log_by_number(*place); + if (log->meta.status == UNDO_LOG_STATUS_DISCARDED) + *place = log->next_free; + else + place = &log->next_free; + } + } + LWLockRelease(UndoLogLock); + + return true; +} + +/* + * Rewrite all allocated undo pages after the checksum-enable barrier. + * Unlike relations, undo logs are shared by all databases and have no catalog + * entries. Include discarded prefixes of partial segments, since offline + * verification sees those too. New segments are zero-filled and all writers + * already generate checksums, so a snapshot of each log's extent suffices. + */ +void +UndoLogEnableChecksums(void) +{ + UndoLogControl *log = NULL; + + /* Keep DROP TABLESPACE from removing logs during the scan. */ + LWLockAcquire(TablespaceCreateLock, LW_SHARED); + while ((log = UndoLogNext(log)) != NULL) + { + UndoLogMetaData meta; + RelFileLocator rlocator; + BlockNumber first; + BlockNumber end; + + /* Discard can remove files, or invalidate buffers we just dirtied. */ + LWLockAcquire(&log->discard_update_lock, LW_SHARED); + LWLockAcquire(&log->mutex, LW_SHARED); + meta = log->meta; + LWLockRelease(&log->mutex); + if (meta.status != UNDO_LOG_STATUS_ACTIVE && + meta.status != UNDO_LOG_STATUS_EXHAUSTED) + { + LWLockRelease(&log->discard_update_lock); + continue; + } + + rlocator.spcOid = meta.tablespace; + rlocator.dbOid = UndoLogDatabaseOid; + rlocator.relNumber = log->logno; + first = (meta.discard / UndoLogSegmentSize) * UNDOSEG_SIZE; + end = meta.end / BLCKSZ; + for (BlockNumber block = first; block < end; block++) + { + Buffer buffer; + + CHECK_FOR_INTERRUPTS(); + buffer = ReadBufferWithoutRelcache(rlocator, UndoLogForkNum, + block, RBM_NORMAL, NULL, + UndoPersistenceIsPermanent(meta.persistence)); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + if (!PageIsNew(BufferGetPage(buffer))) + { + START_CRIT_SECTION(); + MarkBufferDirty(buffer); + if (meta.persistence == UNDO_PERMANENT) + log_newpage_buffer(buffer, false); + END_CRIT_SECTION(); + } + UnlockReleaseBuffer(buffer); + } + LWLockRelease(&log->discard_update_lock); + } + LWLockRelease(TablespaceCreateLock); +} + +void +ResetUndoLogs(UndoPersistence persistence) +{ + UndoLogControl *log = NULL; + + while ((log = UndoLogNext(log)) != NULL) + { + DIR *dir; + struct dirent *de; + char undo_path[MAXPGPATH]; + char segment_prefix[MAXPGPATH]; + size_t segment_prefix_size; + UndoLogOffset seg; + + if (log->meta.status != UNDO_LOG_STATUS_ACTIVE || + log->meta.persistence != persistence) + continue; + + /* + * Nothing in the buffer pool may be written back to the files we are + * about to remove, and the checkpointer must not try to fsync them. + */ + forget_undo_buffers(log->logno, 0, 0, true); + for (seg = log->meta.discard - log->meta.discard % UndoLogSegmentSize; + seg < log->meta.end; seg += UndoLogSegmentSize) + undofile_forget_sync(log->logno, seg / UndoLogSegmentSize, + log->meta.tablespace); + + /* Scan the directory for files belonging to this undo log. */ + snprintf(segment_prefix, sizeof(segment_prefix), "%06X.", log->logno); + segment_prefix_size = strlen(segment_prefix); + UndoLogDirectory(log->meta.tablespace, undo_path); + dir = AllocateDir(undo_path); + if (dir != NULL) + { + while ((de = ReadDirExtended(dir, undo_path, LOG)) != NULL) + { + char segment_path[MAXPGPATH]; + + if (strncmp(de->d_name, segment_prefix, segment_prefix_size) != 0) + continue; + snprintf(segment_path, sizeof(segment_path), "%s/%s", + undo_path, de->d_name); + if (unlink(segment_path) < 0) + elog(LOG, "could not unlink file \"%s\": %m", segment_path); + } + FreeDir(dir); + } + + /* + * The log is now empty. Place its insert and discard pointers at the + * first usable byte of the segment the next allocation will create at + * 'end', exactly as for a newly created log, so that no later discard + * can see them move backwards. No transaction header precedes the + * insert point any more. + */ + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + log->meta.insert = log->meta.discard = log->meta.end + + UndoLogBlockHeaderSize; + log->meta.last_xact_start = log->meta.insert; + log->meta.is_first_rec = false; + log->xid = InvalidTransactionId; + log->oldest_xid = InvalidTransactionId; + log->oldest_data = InvalidUndoRecPtr; + LWLockRelease(&log->mutex); + } +} + +/* + * ResetNonPermanentUndoLogs + * Empty every unlogged and temporary undo log after a crash. + * + * The pages of those logs are neither WAL-logged nor written at a regular + * checkpoint, so after a crash their files hold whatever happened to reach + * disk, while the logs' insert and discard pointers come from the checkpoint + * snapshot and from replayed WAL. The tables they belong to are being reset + * as well (unlogged tables to their init forks; temporary tables are gone), so + * none of that undo is needed. Called at the end of crash recovery, next to + * ResetUnloggedRelations(UNLOGGED_RELATION_INIT). + */ +void +ResetNonPermanentUndoLogs(void) +{ + ResetUndoLogs(UNDO_UNLOGGED); + ResetUndoLogs(UNDO_TEMP); +} + +Datum +pbu_pg_stat_get_undo_logs(PG_FUNCTION_ARGS) +{ +#define PG_STAT_GET_UNDO_LOGS_COLS 9 + ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; + TupleDesc tupdesc; + Tuplestorestate *tupstore; + MemoryContext per_query_ctx; + MemoryContext oldcontext; + UndoLogNumber low_logno; + UndoLogNumber high_logno; + UndoLogNumber logno; + UndoLogSharedData *shared = MyUndoLogState.shared; + char *tablespace_name = NULL; + Oid last_tablespace = InvalidOid; + + /* check to see if caller supports us returning a tuplestore */ + if (rsinfo == NULL || !IsA(rsinfo, ReturnSetInfo)) + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("set-valued function called in context that cannot accept a set"))); + if (!(rsinfo->allowedModes & SFRM_Materialize)) + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("materialize mode required, but it is not " \ + "allowed in this context"))); + + /* Build a tuple descriptor for our result type */ + if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE) + elog(ERROR, "return type must be a row type"); + + per_query_ctx = rsinfo->econtext->ecxt_per_query_memory; + oldcontext = MemoryContextSwitchTo(per_query_ctx); + + tupstore = tuplestore_begin_heap(true, false, work_mem); + rsinfo->returnMode = SFRM_Materialize; + rsinfo->setResult = tupstore; + rsinfo->setDesc = tupdesc; + + MemoryContextSwitchTo(oldcontext); + + /* Find the range of active log numbers. */ + LWLockAcquire(UndoLogLock, LW_SHARED); + low_logno = shared->low_logno; + high_logno = shared->high_logno; + LWLockRelease(UndoLogLock); + + /* Scan all undo logs to build the results. */ + for (logno = low_logno; logno < high_logno; ++logno) + { + UndoLogControl *log = get_undo_log_by_number(logno); + char buffer[17]; + Datum values[PG_STAT_GET_UNDO_LOGS_COLS]; + bool nulls[PG_STAT_GET_UNDO_LOGS_COLS] = {false}; + Oid tablespace; + + if (log == NULL) + continue; + + /* + * This won't be a consistent result overall, but the values for each + * log will be consistent because we'll take the per-log lock while + * copying them. + */ + LWLockAcquire(&log->mutex, LW_SHARED); + + if (log->meta.status == UNDO_LOG_STATUS_DISCARDED) + { + LWLockRelease(&log->mutex); + continue; + } + + values[0] = ObjectIdGetDatum((Oid) logno); + values[1] = CStringGetTextDatum( + log->meta.persistence == UNDO_PERMANENT ? "permanent" : + log->meta.persistence == UNDO_UNLOGGED ? "unlogged" : + log->meta.persistence == UNDO_TEMP ? "temporary" : ""); + tablespace = log->meta.tablespace; + + snprintf(buffer, sizeof(buffer), UndoRecPtrFormat, + (unsigned long long) MakeUndoRecPtr(logno, log->meta.discard)); + values[3] = CStringGetTextDatum(buffer); + snprintf(buffer, sizeof(buffer), UndoRecPtrFormat, + (unsigned long long) MakeUndoRecPtr(logno, log->meta.insert)); + values[4] = CStringGetTextDatum(buffer); + snprintf(buffer, sizeof(buffer), UndoRecPtrFormat, + (unsigned long long) MakeUndoRecPtr(logno, log->meta.end)); + values[5] = CStringGetTextDatum(buffer); + if (log->xid == InvalidTransactionId) + nulls[6] = true; + else + values[6] = TransactionIdGetDatum(log->xid); + if (log->procno == INVALID_PROC_NUMBER) + nulls[7] = true; + else + values[7] = Int32GetDatum((int64) log->pid); + LWLockRelease(&log->mutex); + + /* + * Deal with potentially slow tablespace name lookup without the lock. + * Avoid making multiple calls to that expensive function for the + * common case of repeating tablespace. + */ + if (tablespace != last_tablespace) + { + if (tablespace_name) + pfree(tablespace_name); + tablespace_name = get_tablespace_name(tablespace); + last_tablespace = tablespace; + } + if (tablespace_name) + { + values[2] = CStringGetTextDatum(tablespace_name); + nulls[2] = false; + } + else + nulls[2] = true; + + tuplestore_putvalues(tupstore, tupdesc, values, nulls); + } + if (tablespace_name) + pfree(tablespace_name); + + return (Datum) 0; +} + +/* + * Find the undo log an UndoLog record refers to during redo. + * + * Redo can start from a snapshot NEWER than its redo point (see + * choose_undo_snapshot()), so records before the snapshot meet a later state + * of the same history. A log below low_logno has since been dropped: + * return NULL and the caller skips the record. A log at or above high_logno + * was never created as far as this inventory knows, which means the + * inventory is wrong: fail rather than lose the log. + */ +static UndoLogControl * +undolog_redo_get_log(UndoLogNumber logno) +{ + UndoLogSharedData *shared = MyUndoLogState.shared; + UndoLogControl *log; + + if (logno < shared->low_logno) + return NULL; + log = logno < shared->high_logno ? get_undo_log_by_number(logno) : NULL; + if (log == NULL) + elog(ERROR, "unknown undo log %d", logno); + return log; +} + +/* + * replay the creation of a new undo log + */ +static void +undolog_xlog_create(XLogReaderState *record) +{ + xl_undolog_create *xlrec = (xl_undolog_create *) XLogRecGetData(record); + UndoLogControl *log; + UndoLogSharedData *shared = MyUndoLogState.shared; + + /* + * The log already exists, or is already gone, in a starting state newer + * than this record: keep that state. Its persistence and tablespace are + * the ones recorded here, and the first undo insert into a new log is + * always preceded by an XLOG_UNDOLOG_META that sets its pointers. + */ + if (xlrec->logno < shared->low_logno) + return; + if (xlrec->logno < shared->high_logno && + (log = get_undo_log_by_number(xlrec->logno)) != NULL && + log->meta.status != UNDO_LOG_STATUS_UNUSED) + return; + + /* Create meta-data space in shared memory. */ + ensure_undo_log_number(xlrec->logno); + + log = get_undo_log_by_number(xlrec->logno); + log->meta.status = UNDO_LOG_STATUS_ACTIVE; + log->meta.persistence = xlrec->persistence; + log->meta.tablespace = xlrec->tablespace; + log->meta.insert = UndoLogBlockHeaderSize; + log->meta.discard = UndoLogBlockHeaderSize; + + LWLockAcquire(UndoLogLock, LW_SHARED); + shared->high_logno = Max(xlrec->logno + 1, shared->high_logno); + LWLockRelease(UndoLogLock); +} + +/* + * replay the addition of a new segment to an undo log + */ +static void +undolog_xlog_extend(XLogReaderState *record) +{ + xl_undolog_extend *xlrec = (xl_undolog_extend *) XLogRecGetData(record); + + if (undolog_redo_get_log(xlrec->logno) == NULL) + return; + + /* + * Extend exactly as we would during DO phase. This never shrinks the + * log: an end at or below the current one creates nothing. + */ + extend_undo_log(xlrec->logno, xlrec->end); +} + +/* + * replay the association of an xid with a specific undo log + */ +static void +undolog_xlog_attach(XLogReaderState *record) +{ + xl_undolog_attach *xlrec = (xl_undolog_attach *) XLogRecGetData(record); + UndoLogControl *log; + + undolog_xid_map_add(xlrec->xid, xlrec->logno); + + /* Restore current dbid */ + MyUndoLogState.dbid = xlrec->dbid; + + /* + * Whatever follows is the first record for this transaction, so + * PrepareUndoInsert() adds the transaction header (UREC_INFO_TRANSACTION) + * to it. + */ + log = undolog_redo_get_log(xlrec->logno); + if (log == NULL) + return; + log->meta.is_first_rec = true; + log->xid = xlrec->xid; +} + +/* + * replay the undo-log switch wal. Store the transaction's undo record + * pointer of the previous log in MyUndoLogState temporarily, which will + * be reset after reading first time. + */ +static void +undolog_xlog_switch(XLogReaderState *record) +{ + UndoRecPtr prevlogurp = *((UndoRecPtr *) XLogRecGetData(record)); + + MyUndoLogState.prevlogurp = prevlogurp; +} + +/* + * replay undo log meta-data image + */ +static void +undolog_xlog_meta(XLogReaderState *record) +{ + xl_undolog_meta *xlrec = (xl_undolog_meta *) XLogRecGetData(record); + UndoLogControl *log; + UndoLogOffset end; + UndoLogStatus status; + + undolog_xid_map_add(xlrec->xid, xlrec->logno); + + log = undolog_redo_get_log(xlrec->logno); + if (log == NULL) + return; + + /* + * Install the log's meta-data as of this record. While this races + * against a checkpoint, XLOG_UNDOLOG_META always wins because it must be + * correct for any subsequent data appended by this transaction. That + * holds even when redo started from a newer snapshot: every later change + * to the log is itself in the WAL replayed after this record, so the log + * converges on the state the snapshot recorded. Field by field: + * + * insert, is_first_rec, last_xact_start: describe the undo insert that + * follows, whose redo must land exactly at 'insert' (FluxPbuRedoUndo + * checks). insert also legitimately moves back (UndoLogRewind), so these + * are never kept from a newer state. + * + * discard: the value DO had here. It may move back from a newer starting + * state, together with insert, so that the undo inserts replayed next are + * not taken as discarded. The DISCARD records after this one then remove + * again any segment their replay recreated. + * + * end: only grows. Keep a larger one: its segments exist, or recovery + * creates them on first access. + * + * persistence, tablespace: fixed when the log is created. + * + * status: only ever advances (active, exhausted, discarded) and an + * exhaustion is not WAL-logged, so keep a later status. + */ + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + end = log->meta.end; + status = log->meta.status; + log->meta = xlrec->meta; + log->meta.end = Max(end, xlrec->meta.end); + log->meta.status = Max(status, xlrec->meta.status); + log->xid = xlrec->xid; + log->procno = MyProcNumber; + log->pid = MyProcPid; /* show as recovery process */ + LWLockRelease(&log->mutex); +} + +/* + * Drop from shared buffers the pages of an undo log that are being discarded, + * dirty or not, so that none is later written to a segment file that no + * longer exists. + * + * Normally only [old_discard, new_discard) is dropped: the page holding + * new_discard, and everything above it, may still hold live undo. If + * drop_tail is true the whole log is being removed (DROP TABLESPACE) and + * every page from old_discard onward is dropped. + */ +static void +forget_undo_buffers(int logno, UndoLogOffset old_discard, + UndoLogOffset new_discard, bool drop_tail) +{ + BlockNumber old_blockno; + BlockNumber new_blockno; + RelFileNode rnode; + SMgrRelation smgr; + ForkNumber fork = UndoLogForkNum; + + UndoRecPtrAssignRelFileNode(rnode, MakeUndoRecPtr(logno, old_discard)); + + /* + * Keep dirty pages in the retained partial segment, even below its + * discard point. Online checksum enablement may have rewritten them; + * dropping them would leave stale checksums on disk. This also applies + * during replay of the checksum full-page images followed by discard. + */ + old_blockno = (old_discard / UndoLogSegmentSize) * UNDOSEG_SIZE; + new_blockno = (new_discard / UndoLogSegmentSize) * UNDOSEG_SIZE; + + /* + * A discarded page may be dirty: undo pages are never modified after they + * are written, but one that was dirty when discarded stays dirty. Left + * in shared buffers, it would later be written by the checkpointer or by + * eviction -- into a segment file the caller is about to rename or + * unlink, failing the checkpoint. So it must be dropped here, before any + * file is. + * + * For a partial discard the range needs an upper bound: the live tail at + * and above new_blockno may also be dirty, and dropping it would lose + * undo. DropRelationBuffers() drops everything from its start block + * onward, so it is only safe when the whole log is going away. + */ + smgr = smgropen(rnode, INVALID_PROC_NUMBER); + if (drop_tail) + { + /* + * The whole log is going away (DROP TABLESPACE) and every segment + * file will be unlinked, so everything at or beyond old_blockno must + * go -- including any pre-extended pages above the insert point. + */ + DropRelationBuffers(smgr, &fork, 1, &old_blockno); + } + else if (old_blockno < new_blockno) + DropRelationBufferRange(smgr, fork, old_blockno, new_blockno); +} + +/* + * replay an undo segment discard record + */ +static void +undolog_xlog_discard(XLogReaderState *record) +{ + xl_undolog_discard *xlrec = (xl_undolog_discard *) XLogRecGetData(record); + UndoLogControl *log; + UndoLogOffset discard; + UndoLogOffset end; + UndoLogOffset old_segment_begin; + UndoLogOffset new_segment_begin; + RelFileNode rnode = {0}; + char dir[MAXPGPATH]; + + /* + * A log below low_logno has been dropped since; see + * undolog_redo_get_log(). + */ + log = undolog_redo_get_log(xlrec->logno); + if (log == NULL) + return; + + /* + * We're about to discard undologs. In Hot Standby mode, ensure that + * there's no queries running which need to get tuple from discarded undo. + * + * Note: we are passing empty rnode to the conflict function so that it + * can check conflict in all the backend regardless of which database the + * backend is connected. + */ + if (InHotStandby && TransactionIdIsValid(xlrec->latestxid)) + ResolveRecoveryConflictWithSnapshot(xlrec->latestxid, false, rnode); + + /* + * See if we need to unlink or rename any files, but don't consider it an + * error if we find that files are missing. Since PbuUndoLogDiscard() + * performs filesystem operations before WAL logging or updating shmem + * which could be checkpointed, a crash could have left files already + * deleted, but we could replay WAL that expects the files to be there. + */ + + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + discard = log->meta.discard; + end = log->meta.end; + LWLockRelease(&log->mutex); + + /* + * Both pointers only advance. If neither would here, the starting state + * is newer than this record (or the record is a repeat): the segments it + * removes are gone already, and the ones it adds exist. + */ + if (xlrec->discard <= discard && xlrec->end <= end) + return; + + /* Drop buffers before we remove/recycle any files. */ + forget_undo_buffers(xlrec->logno, discard, xlrec->discard, false); + + /* Rewind to the start of the segment. */ + old_segment_begin = discard - discard % UndoLogSegmentSize; + new_segment_begin = xlrec->discard - xlrec->discard % UndoLogSegmentSize; + + /* Unlink or rename segments that are no longer in range. */ + while (old_segment_begin < new_segment_begin) + { + char discard_path[MAXPGPATH]; + + /* Tell the checkpointer that the file is going away. */ + undofile_forget_sync(log->logno, old_segment_begin / UndoLogSegmentSize, + log->meta.tablespace); + + UndoLogSegmentPath(xlrec->logno, old_segment_begin / UndoLogSegmentSize, + log->meta.tablespace, discard_path); + + /* Never rename pages whose checksums include their old block numbers. */ + if (end < xlrec->end) + { + if (unlink(discard_path) == 0) + { + allocate_empty_undo_segment(xlrec->logno, log->meta.tablespace, end); + end += UndoLogSegmentSize; + } + else + { + elog(LOG, "could not remove \"%s\": %m", + discard_path); + } + } + else + { + if (unlink(discard_path) != 0) + elog(LOG, "could not unlink \"%s\": %m", discard_path); + } + old_segment_begin += UndoLogSegmentSize; + } + + /* Create any further new segments that are needed the slow way. */ + while (end < xlrec->end) + { + allocate_empty_undo_segment(xlrec->logno, log->meta.tablespace, end); + end += UndoLogSegmentSize; + } + + /* Flush the directory entries. */ + UndoLogDirectory(log->meta.tablespace, dir); + fsync_fname(dir, true); + + /* Update shmem. The discard pointer never moves back here. */ + LWLockAcquire(&log->mutex, LW_EXCLUSIVE); + log->meta.discard = Max(discard, xlrec->discard); + log->meta.end = end; + LWLockRelease(&log->mutex); +} + +/* + * replay the rewind of a undo log + */ +static void +undolog_xlog_rewind(XLogReaderState *record) +{ + xl_undolog_rewind *xlrec = (xl_undolog_rewind *) XLogRecGetData(record); + UndoLogControl *log; + + log = undolog_redo_get_log(xlrec->logno); + if (log != NULL) + log->meta.insert = xlrec->insert; +} + +void +undolog_redo(XLogReaderState *record) +{ + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + switch (info) + { + case XLOG_UNDOLOG_CREATE: + undolog_xlog_create(record); + break; + case XLOG_UNDOLOG_EXTEND: + undolog_xlog_extend(record); + break; + case XLOG_UNDOLOG_ATTACH: + undolog_xlog_attach(record); + break; + case XLOG_UNDOLOG_DISCARD: + undolog_xlog_discard(record); + break; + case XLOG_UNDOLOG_REWIND: + undolog_xlog_rewind(record); + break; + case XLOG_UNDOLOG_META: + undolog_xlog_meta(record); + break; + case XLOG_UNDOLOG_SWITCH: + undolog_xlog_switch(record); + break; + default: + elog(PANIC, "undo_redo: unknown op code %u", info); + } +} + +/* + * For assertions only. + */ +bool +AmAttachedToUndoLog(UndoLogControl *log) +{ + int i; + + for (i = 0; i < UndoPersistenceLevels; ++i) + { + if (MyUndoLogState.logs[i] == log) + return true; + } + return false; +} + +/* + * Fetch database id from the undo log state + */ +Oid +UndoLogStateGetDatabaseId(void) +{ + Assert(InRecovery); + return MyUndoLogState.dbid; +} + +/* + * Get transaction start header in the previous log + * + * This should be only called during recovery. The value of prevlogurp + * is restored in MyUndoLogState while replying the UNDOLOG_XLOG_SWITCH + * wal and it will be cleared in this function. + */ +UndoRecPtr +UndoLogStateGetAndClearPrevLogXactUrp(void) +{ + UndoRecPtr prevlogurp; + + Assert(InRecovery); + prevlogurp = MyUndoLogState.prevlogurp; + MyUndoLogState.prevlogurp = InvalidUndoRecPtr; + + return prevlogurp; +} + +/* + * Get the undo log number my backend is attached to + */ +UndoLogNumber +UndoLogAmAttachedTo(UndoPersistence persistence) +{ + if (MyUndoLogState.logs[persistence] == NULL) + return InvalidUndoLogNumber; + return MyUndoLogState.logs[persistence]->logno; +} diff --git a/src/backend/access/undo/perbackend/pbu_undorecord.c b/src/backend/access/undo/perbackend/pbu_undorecord.c new file mode 100644 index 0000000000000..02bc633bb830a --- /dev/null +++ b/src/backend/access/undo/perbackend/pbu_undorecord.c @@ -0,0 +1,516 @@ +/*------------------------------------------------------------------------- + * + * undorecord.c + * encode and decode undo records + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/backend/access/undo/undorecord.c + *------------------------------------------------------------------------- + */ + +#include "postgres.h" + +#include "access/perbackend/pbu_compat.h" + +#include "access/subtrans.h" +#include "access/perbackend/pbu_undorecord.h" +#include "catalog/pg_tablespace.h" +#include "storage/block.h" + +/* Workspace for InsertUndoRecord and UnpackUndoRecord. */ +static UndoRecordHeader work_hdr; +static UndoRecordRelationDetails work_rd; +static UndoRecordBlock work_blk; +static UndoRecordTransaction work_txn; +static UndoRecordPayload work_payload; + +/* Prototypes for static functions. */ +static bool InsertUndoBytes(char *sourceptr, int sourcelen, + char **writeptr, char *endptr, + int *my_bytes_written, int *total_bytes_written); +static bool ReadUndoBytes(char *destptr, int readlen, + char **readptr, char *endptr, + int *my_bytes_read, int *total_bytes_read, bool nocopy); + +/* + * Compute and return the expected size of an undo record. + */ +Size +UndoRecordExpectedSize(UnpackedUndoRecord *uur) +{ + Size size; + + size = SizeOfUndoRecordHeader + sizeof(uint16); + if ((uur->uur_info & UREC_INFO_RELATION_DETAILS) != 0) + size += SizeOfUndoRecordRelationDetails; + if ((uur->uur_info & UREC_INFO_BLOCK) != 0) + size += SizeOfUndoRecordBlock; + if ((uur->uur_info & UREC_INFO_TRANSACTION) != 0) + size += SizeOfUndoRecordTransaction; + if ((uur->uur_info & UREC_INFO_PAYLOAD) != 0) + { + size += SizeOfUndoRecordPayload; + size += uur->uur_payload.len; + size += uur->uur_tuple.len; + } + + return size; +} + +/* + * Compute size of the Unpacked undo record in memory + */ +Size +UnpackedUndoRecordSize(UnpackedUndoRecord *uur) +{ + Size size; + + size = sizeof(UnpackedUndoRecord); + + /* Add payload size if record contains payload data. */ + if ((uur->uur_info & UREC_INFO_PAYLOAD) != 0) + { + size += uur->uur_payload.len; + size += uur->uur_tuple.len; + } + + return size; +} + +/* + * To insert an undo record, call InsertUndoRecord() repeatedly until it + * returns true. + * + * Insert as much of an undo record as will fit in the given page. + * starting_byte is the byte within the give page at which to begin writing, + * while *already_written is the number of bytes written to previous pages. + * + * Returns true if the remainder of the record was written and false if more + * bytes remain to be written; in either case, *already_written is set to the + * number of bytes written thus far. + * + * This function assumes that if *already_written is non-zero on entry, the + * same UnpackedUndoRecord is passed each time. It also assumes that + * UnpackUndoRecord is not called between successive calls to InsertUndoRecord + * for the same UnpackedUndoRecord. + * + * If this function is called again to continue writing the record, the + * previous value for *already_written should be passed again, and + * starting_byte should be passed as sizeof(PageHeaderData) (since the record + * will continue immediately following the page header). + * + * remaining_bytes number of bytes to be written yet. This value is only + * considered when page is NULL and that is required when caller just wanted + * the local work_hdr and the already_written variable to get updated but + * don't want to insert actual data in current block and work_hdr should be + * updated so that we can insert the remaining partial record in the next + * valid block. + */ +bool +InsertUndoRecord(UnpackedUndoRecord *uur, Page page, + int starting_byte, int *already_written, int remaining_bytes, + uint16 undo_len, bool header_only) +{ + char *writeptr = (char *) page + starting_byte; + char *endptr = (char *) page + BLCKSZ; + int my_bytes_written = *already_written; + + /* The undo record must contain a valid information. */ + Assert(uur->uur_info != 0); + + /* If caller is not updating only header then must provide valid length. */ + Assert(header_only || undo_len > 0); + + /* + * If this is the first call, copy the UnpackedUndoRecord into the + * temporary variables of the types that will actually be stored in the + * undo pages. We just initialize everything here, on the assumption that + * it's not worth adding branches to save a handful of assignments. + */ + if (*already_written == 0) + { + work_hdr.urec_rmid = uur->uur_rmid; + work_hdr.urec_type = uur->uur_type; + work_hdr.urec_info = uur->uur_info; + work_hdr.urec_reloid = uur->uur_reloid; + work_hdr.urec_prevxid = uur->uur_prevxid; + work_hdr.urec_xid = uur->uur_xid; + work_hdr.urec_cid = uur->uur_cid; + work_rd.urec_fork = uur->uur_fork; + work_blk.urec_blkprev = uur->uur_blkprev; + work_blk.urec_block = uur->uur_block; + work_blk.urec_offset = uur->uur_offset; + work_txn.urec_progress = uur->uur_progress; + work_txn.urec_xidepoch = uur->uur_xidepoch; + work_txn.urec_dbid = uur->uur_dbid; + work_txn.urec_prevurp = uur->uur_prevurp; + work_txn.urec_next = uur->uur_next; + work_payload.urec_payload_len = uur->uur_payload.len; + work_payload.urec_tuple_len = uur->uur_tuple.len; + } + else + { + /* + * We should have been passed the same record descriptor as before, or + * caller has messed up. + */ + Assert(work_hdr.urec_rmid == uur->uur_rmid); + Assert(work_hdr.urec_type == uur->uur_type); + Assert(work_hdr.urec_info == uur->uur_info); + Assert(work_hdr.urec_reloid == uur->uur_reloid); + Assert(work_hdr.urec_prevxid == uur->uur_prevxid); + Assert(work_hdr.urec_xid == uur->uur_xid); + Assert(work_hdr.urec_cid == uur->uur_cid); + Assert(work_rd.urec_fork == uur->uur_fork); + Assert(work_blk.urec_blkprev == uur->uur_blkprev); + Assert(work_blk.urec_block == uur->uur_block); + Assert(work_blk.urec_offset == uur->uur_offset); + Assert(work_txn.urec_progress == uur->uur_progress); + Assert(work_txn.urec_xidepoch == uur->uur_xidepoch); + Assert(work_txn.urec_dbid == uur->uur_dbid); + Assert(work_txn.urec_prevurp == uur->uur_prevurp); + Assert(work_txn.urec_next == uur->uur_next); + Assert(work_payload.urec_payload_len == uur->uur_payload.len); + Assert(work_payload.urec_tuple_len == uur->uur_tuple.len); + } + + /* + * Update already_written variable and return, see detailed comment in + * function header. + */ + if (page == NULL) + { + *already_written += (BLCKSZ - starting_byte); + if (remaining_bytes <= (BLCKSZ - starting_byte)) + return true; + else + return false; + } + + /* Write header (if not already done). */ + if (!InsertUndoBytes((char *) &work_hdr, SizeOfUndoRecordHeader, + &writeptr, endptr, + &my_bytes_written, already_written)) + return false; + + /* Write relation details (if needed and not already done). */ + if ((uur->uur_info & UREC_INFO_RELATION_DETAILS) != 0 && + !InsertUndoBytes((char *) &work_rd, SizeOfUndoRecordRelationDetails, + &writeptr, endptr, + &my_bytes_written, already_written)) + return false; + + /* Write block information (if needed and not already done). */ + if ((uur->uur_info & UREC_INFO_BLOCK) != 0 && + !InsertUndoBytes((char *) &work_blk, SizeOfUndoRecordBlock, + &writeptr, endptr, + &my_bytes_written, already_written)) + return false; + + /* Write transaction information (if needed and not already done). */ + if ((uur->uur_info & UREC_INFO_TRANSACTION) != 0 && + !InsertUndoBytes((char *) &work_txn, SizeOfUndoRecordTransaction, + &writeptr, endptr, + &my_bytes_written, already_written)) + return false; + + if (header_only) + return true; + + /* Write payload information (if needed and not already done). */ + if ((uur->uur_info & UREC_INFO_PAYLOAD) != 0) + { + /* Payload header. */ + if (!InsertUndoBytes((char *) &work_payload, SizeOfUndoRecordPayload, + &writeptr, endptr, + &my_bytes_written, already_written)) + return false; + + /* Payload bytes. */ + if (uur->uur_payload.len > 0 && + !InsertUndoBytes(uur->uur_payload.data, uur->uur_payload.len, + &writeptr, endptr, + &my_bytes_written, already_written)) + return false; + + /* Tuple bytes. */ + if (uur->uur_tuple.len > 0 && + !InsertUndoBytes(uur->uur_tuple.data, uur->uur_tuple.len, + &writeptr, endptr, + &my_bytes_written, already_written)) + return false; + } + + /* Insert undo record length at the end of the record. */ + if (!InsertUndoBytes((char *) &undo_len, sizeof(uint16), + &writeptr, endptr, + &my_bytes_written, already_written)) + return false; + + /* Hooray! */ + return true; +} + +/* + * Write undo bytes from a particular source, but only to the extent that + * they weren't written previously and will fit. + * + * 'sourceptr' points to the source data, and 'sourcelen' is the length of + * that data in bytes. + * + * 'writeptr' points to the insertion point for these bytes, and is updated + * for whatever we write. The insertion point must not pass 'endptr', which + * represents the end of the buffer into which we are writing. + * + * 'my_bytes_written' is a pointer to the count of previous-written bytes + * from this and following structures in this undo record; that is, any + * bytes that are part of previous structures in the record have already + * been subtracted out. + * + * 'total_bytes_written' points to the count of all previously-written bytes, + * and must it must be updated for the bytes we write. + * + * The return value is false if we ran out of space before writing all + * the bytes, and otherwise true. + */ +static bool +InsertUndoBytes(char *sourceptr, int sourcelen, + char **writeptr, char *endptr, + int *my_bytes_written, int *total_bytes_written) +{ + int can_write; + int remaining; + + /* + * If we've previously written all of these bytes, there's nothing to do + * except update *my_bytes_written, which we must do to ensure that the + * next call to this function gets the right starting value. + */ + if (*my_bytes_written >= sourcelen) + { + *my_bytes_written -= sourcelen; + return true; + } + + /* Compute number of bytes we can write. */ + remaining = sourcelen - *my_bytes_written; + can_write = Min(remaining, endptr - *writeptr); + + /* Bail out if no bytes can be written. */ + if (can_write == 0) + return false; + + /* Copy the bytes we can write. */ + memcpy(*writeptr, sourceptr + *my_bytes_written, can_write); + + /* Update bookkeeeping infrormation. */ + *writeptr += can_write; + *total_bytes_written += can_write; + *my_bytes_written = 0; + + /* Return true only if we wrote the whole thing. */ + return (can_write == remaining); +} + +/* + * Call UnpackUndoRecord() one or more times to unpack an undo record. For + * the first call, starting_byte should be set to the beginning of the undo + * record within the specified page, and *already_decoded should be set to 0; + * the function will update it based on the number of bytes decoded. The + * return value is true if the entire record was unpacked and false if the + * record continues on the next page. In the latter case, the function + * should be called again with the next page, passing starting_byte as the + * sizeof(PageHeaderData). + */ +bool +UnpackUndoRecord(UnpackedUndoRecord *uur, Page page, int starting_byte, + int *already_decoded, bool header_only, bool copy_data) +{ + char *readptr = (char *) page + starting_byte; + char *endptr = (char *) page + BLCKSZ; + int my_bytes_decoded = *already_decoded; + bool is_undo_splited = (my_bytes_decoded > 0) ? true : false; + + /* Decode header (if not already done). */ + if (!ReadUndoBytes((char *) &work_hdr, SizeOfUndoRecordHeader, + &readptr, endptr, + &my_bytes_decoded, already_decoded, false)) + return false; + + uur->uur_rmid = work_hdr.urec_rmid; + uur->uur_type = work_hdr.urec_type; + uur->uur_info = work_hdr.urec_info; + uur->uur_reloid = work_hdr.urec_reloid; + uur->uur_prevxid = work_hdr.urec_prevxid; + uur->uur_xid = work_hdr.urec_xid; + uur->uur_cid = work_hdr.urec_cid; + + if ((uur->uur_info & UREC_INFO_RELATION_DETAILS) != 0) + { + /* Decode header (if not already done). */ + if (!ReadUndoBytes((char *) &work_rd, SizeOfUndoRecordRelationDetails, + &readptr, endptr, + &my_bytes_decoded, already_decoded, false)) + return false; + + uur->uur_fork = work_rd.urec_fork; + } + + if ((uur->uur_info & UREC_INFO_BLOCK) != 0) + { + if (!ReadUndoBytes((char *) &work_blk, SizeOfUndoRecordBlock, + &readptr, endptr, + &my_bytes_decoded, already_decoded, false)) + return false; + + uur->uur_blkprev = work_blk.urec_blkprev; + uur->uur_block = work_blk.urec_block; + uur->uur_offset = work_blk.urec_offset; + } + + if ((uur->uur_info & UREC_INFO_TRANSACTION) != 0) + { + if (!ReadUndoBytes((char *) &work_txn, SizeOfUndoRecordTransaction, + &readptr, endptr, + &my_bytes_decoded, already_decoded, false)) + return false; + + uur->uur_progress = work_txn.urec_progress; + uur->uur_xidepoch = work_txn.urec_xidepoch; + uur->uur_dbid = work_txn.urec_dbid; + uur->uur_prevurp = work_txn.urec_prevurp; + uur->uur_next = work_txn.urec_next; + + } + + if (header_only) + return true; + + /* Read payload information (if needed and not already done). */ + if ((uur->uur_info & UREC_INFO_PAYLOAD) != 0) + { + if (!ReadUndoBytes((char *) &work_payload, SizeOfUndoRecordPayload, + &readptr, endptr, + &my_bytes_decoded, already_decoded, false)) + return false; + + uur->uur_payload.len = work_payload.urec_payload_len; + uur->uur_tuple.len = work_payload.urec_tuple_len; + + /* + * If we can read the complete record from a single page and copy_data + * is false then just point payload data and tuple data into the page + * otherwise allocate the memory. + * + * Note: There is possibility of optimization that instead of always + * allocating the memory whenever tuple is split we can check if any + * of the payload or tuple data falling into the same page then don't + * allocate the memory for that. + */ + if (!copy_data && !is_undo_splited && + uur->uur_payload.len + uur->uur_tuple.len <= (endptr - readptr)) + { + uur->uur_payload.data = readptr; + readptr += uur->uur_payload.len; + + uur->uur_tuple.data = readptr; + } + else + { + if (uur->uur_payload.len > 0 && uur->uur_payload.data == NULL) + uur->uur_payload.data = (char *) palloc0(uur->uur_payload.len); + + if (uur->uur_tuple.len > 0 && uur->uur_tuple.data == NULL) + uur->uur_tuple.data = (char *) palloc0(uur->uur_tuple.len); + + if (!ReadUndoBytes((char *) uur->uur_payload.data, + uur->uur_payload.len, &readptr, endptr, + &my_bytes_decoded, already_decoded, false)) + return false; + + if (!ReadUndoBytes((char *) uur->uur_tuple.data, + uur->uur_tuple.len, &readptr, endptr, + &my_bytes_decoded, already_decoded, false)) + return false; + } + } + + return true; +} + +/* + * Read undo bytes into a particular destination, + * + * 'destptr' points to the source data, and 'readlen' is the length of + * that data to be read in bytes. + * + * 'readptr' points to the read point for these bytes, and is updated + * for how much we read. The read point must not pass 'endptr', which + * represents the end of the buffer from which we are reading. + * + * 'my_bytes_read' is a pointer to the count of previous-read bytes + * from this and following structures in this undo record; that is, any + * bytes that are part of previous structures in the record have already + * been subtracted out. + * + * 'total_bytes_read' points to the count of all previously-read bytes, + * and must likewise be updated for the bytes we read. + * + * nocopy if this flag is set true then it will just skip the readlen + * size in undo but it will not copy into the buffer. + * + * The return value is false if we ran out of space before read all + * the bytes, and otherwise true. + */ +static bool +ReadUndoBytes(char *destptr, int readlen, char **readptr, char *endptr, + int *my_bytes_read, int *total_bytes_read, bool nocopy) +{ + int can_read; + int remaining; + + if (*my_bytes_read >= readlen) + { + *my_bytes_read -= readlen; + return true; + } + + /* Compute number of bytes we can read. */ + remaining = readlen - *my_bytes_read; + can_read = Min(remaining, endptr - *readptr); + + /* Bail out if no bytes can be read. */ + if (can_read == 0) + return false; + + /* Copy the bytes we can read. */ + if (!nocopy) + memcpy(destptr + *my_bytes_read, *readptr, can_read); + + /* Update bookkeeping information. */ + *readptr += can_read; + *total_bytes_read += can_read; + *my_bytes_read = 0; + + /* Return true only if we wrote the whole thing. */ + return (can_read == remaining); +} + +/* + * Set uur_info for an UnpackedUndoRecord appropriately based on which + * other fields are set. + */ +void +UndoRecordSetInfo(UnpackedUndoRecord *uur) +{ + if (uur->uur_fork != MAIN_FORKNUM) + uur->uur_info |= UREC_INFO_RELATION_DETAILS; + if (uur->uur_block != InvalidBlockNumber) + uur->uur_info |= UREC_INFO_BLOCK; + if (uur->uur_next != InvalidUndoRecPtr) + uur->uur_info |= UREC_INFO_TRANSACTION; + if (uur->uur_payload.len || uur->uur_tuple.len) + uur->uur_info |= UREC_INFO_PAYLOAD; +} diff --git a/src/backend/access/undo/perbackend/pbu_undorequest.c b/src/backend/access/undo/perbackend/pbu_undorequest.c new file mode 100644 index 0000000000000..9e29a53cf6ac0 --- /dev/null +++ b/src/backend/access/undo/perbackend/pbu_undorequest.c @@ -0,0 +1,1721 @@ +/*------------------------------------------------------------------------- + * + * undorequest.c + * This contains routines to register and fetch undo action requests. + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/backend/access/undo/undorequest.c + * + * To increase the efficiency of the rollbacks, we create three queues and + * a hash table for the rollback requests. A Xid based priority queue which + * will allow us to process the requests of older transactions and help us + * to move oldesdXidHavingUndo forward. A size-based queue which will help + * us to perform the rollbacks of larger aborts in a timely fashion, so that + * we don't get stuck while processing them during discard of the logs. + * An error queue to hold the requests for transactions that failed to apply + * its undo. The rollback hash table is used to avoid duplicate undo requests + * by backends and discard worker. The table must be able to accommodate all + * active undo requests. The undo requests must appear in both xid and size + * requests queues or neither. As of now we, process the requests from these + * queues in a round-robin fashion to give equal priority to all three type + * of requests. + * + * The rollback requests exceeding a certain threshold are pushed into both + * xid and size based queues. They are also registered in the hash table. + * + * To process the request, we get the request from one of the queues, search + * it in hash table and mark it as in-progress and then remove from the + * respective queue. Once we process all the actions, the request is removed + * from the hash table. If the other worker found the same request in other + * queue, it can just ignore the request (and remove it from that queue) if + * the request is not found in the hash table or is marked as in-progress. + * + * Also note that, if the work queues are full, then we put backpressure on + * backends to complete the requests by themselves. + *------------------------------------------------------------------------- + */ + +#include "postgres.h" + +#include "access/perbackend/pbu_compat.h" +#include "access/perbackend/pbu_helpers.h" + +/* + * pbu: binaryheap API drift + missing shmem helpers. + * + * zheap patched lib/binaryheap.c with shmem-aware constructors + * (binaryheap_allocate_shm/binaryheap_shmem_size) and used the older accessor + * names (binaryheap_cur_size/binaryheap_nth) and node-returning removers + * (binaryheap_remove_nth/_unordered). Master's binaryheap stores its nodes + * inline (bh_nodes[FLEXIBLE_ARRAY_MEMBER]), so it is already shmem-placeable; + * reconcile the drift here in one place rather than at every call site. + */ +#include "lib/binaryheap.h" +#include "storage/shmem.h" + +#define binaryheap_cur_size(h) binaryheap_size(h) +#define binaryheap_nth(h, n) binaryheap_get_node((h), (n)) + +static inline Size +binaryheap_shmem_size(int capacity) +{ + return offsetof(binaryheap, bh_nodes) + + sizeof(bh_node_type) * (Size) capacity; +} + +/* master's binaryheap_remove_node() returns void; return the node too */ +static inline bh_node_type +binaryheap_remove_nth(binaryheap *h, int n) +{ + bh_node_type node = binaryheap_get_node(h, n); + + binaryheap_remove_node(h, n); + return node; +} + +/* + * Remove node n without restoring the heap property (caller rebuilds with + * binaryheap_build afterwards). Swap with the last node and shrink. + */ +static inline void +binaryheap_remove_nth_unordered(binaryheap *h, int n) +{ + h->bh_nodes[n] = h->bh_nodes[h->bh_size - 1]; + h->bh_size--; + h->bh_has_heap_property = false; +} + +/* + * rollback_overflow_size was a zheap GUC (MB threshold for spilling rollback + * requests to the size-ordered undo worker queue). It is now the SIGHUP GUC + * undo_rollback_overflow_size, whose backing storage is defined below (the GUC + * table in guc_parameters.dat binds to this variable); this file uses it under + * the historical name. It only affects background undo worker scheduling, + * which is gated off until an AM produces per-backend undo. + */ +#define rollback_overflow_size undo_rollback_overflow_size + +#include "miscadmin.h" + +#include "access/genam.h" +#include "access/heapam.h" +#include "access/transam.h" +#include "access/perbackend/pbu_discardworker.h" +#include "access/perbackend/pbu_undodiscard.h" +#include "access/perbackend/pbu_undorequest.h" +#include "access/perbackend/pbu_undoworker.h" +#include "access/xact.h" +#include "catalog/indexing.h" +#include "catalog/pg_database.h" +#include "lib/binaryheap.h" +#include "storage/shmem.h" +#include "utils/fmgroids.h" +#include "utils/timestamp.h" +#include "access/xlog.h" + +/* GUC backing storage (declared extern in pbu_undorequest.h) */ +int undo_rollback_overflow_size = 16; + +/* Remembers the last seen RecentGlobalXmin (declared extern in pbu_undorequest.h) */ +TransactionId latestRecentGlobalXmin = InvalidTransactionId; + +#define ROLLBACK_REQUEST_QUEUE_SIZE 1024 + +/* A request that failed is not retried until this much time has passed. */ +#define UNDO_ERROR_RETRY_DELAY_MS 1000 +#define MAX_UNDO_WORK_QUEUES 3 +#define UNDO_PEEK_DEPTH 10 + +typedef struct +{ + binaryheap *bh; + union + { + UndoXidQueue *xid_elems; + UndoSizeQueue *size_elems; + UndoErrorQueue *error_elems; + } q_choice; +} UndoWorkerQueue; + +/* This is the hash table to store all the rollabck requests. */ +static HTAB *RollbackHT; +static UndoWorkerQueue UndoWorkerQueues[MAX_UNDO_WORK_QUEUES]; + +/* + * Shmem areas backing the rollback queues, reserved in + * PendingUndoShmemRequest() and initialized in PendingUndoShmemInit(). The + * framework populates these pointers before the init callback runs. + */ +static binaryheap *UndoXidBinaryHeap = NULL; +static binaryheap *UndoSizeBinaryHeap = NULL; +static binaryheap *UndoErrorBinaryHeap = NULL; +static UndoXidQueue *UndoXidQueueElems = NULL; +static UndoSizeQueue *UndoSizeQueueElems = NULL; +static UndoErrorQueue *UndoErrorQueueElems = NULL; + +static uint32 cur_undo_queue = 0; + +/* Different operations for XID queue */ +#define InitXidQueue(bh, elems) \ +( \ + UndoWorkerQueues[XID_QUEUE].bh = bh, \ + UndoWorkerQueues[XID_QUEUE].q_choice.xid_elems = elems \ +) + +#define XidQueueIsEmpty() \ + (binaryheap_empty(UndoWorkerQueues[XID_QUEUE].bh)) + +#define GetXidQueueSize() \ + (binaryheap_cur_size(UndoWorkerQueues[XID_QUEUE].bh)) + +#define GetXidQueueElem(elem) \ + (UndoWorkerQueues[XID_QUEUE].q_choice.xid_elems[elem]) + +#define GetXidQueueTopElem() \ +( \ + AssertMacro(!binaryheap_empty(UndoWorkerQueues[XID_QUEUE].bh)), \ + DatumGetPointer(binaryheap_first(UndoWorkerQueues[XID_QUEUE].bh)) \ +) + +#define GetXidQueueNthElem(n) \ +( \ + AssertMacro(!XidQueueIsEmpty()), \ + DatumGetPointer(binaryheap_nth(UndoWorkerQueues[XID_QUEUE].bh, n)) \ +) + +#define SetXidQueueElem(elem, e_dbid, e_full_xid, e_start_urec_ptr) \ +( \ + GetXidQueueElem(elem).dbid = e_dbid, \ + GetXidQueueElem(elem).full_xid = e_full_xid, \ + GetXidQueueElem(elem).start_urec_ptr = e_start_urec_ptr \ +) + +/* Different operations for SIZE queue */ +#define InitSizeQueue(bh, elems) \ +( \ + UndoWorkerQueues[SIZE_QUEUE].bh = bh, \ + UndoWorkerQueues[SIZE_QUEUE].q_choice.size_elems = elems \ +) + +#define SizeQueueIsEmpty() \ + (binaryheap_empty(UndoWorkerQueues[SIZE_QUEUE].bh)) + +#define GetSizeQueueSize() \ + (binaryheap_cur_size(UndoWorkerQueues[SIZE_QUEUE].bh)) + +#define GetSizeQueueElem(elem) \ + (UndoWorkerQueues[SIZE_QUEUE].q_choice.size_elems[elem]) + +#define GetSizeQueueTopElem() \ +( \ + AssertMacro(!SizeQueueIsEmpty()), \ + DatumGetPointer(binaryheap_first(UndoWorkerQueues[SIZE_QUEUE].bh)) \ +) + +#define GetSizeQueueNthElem(n) \ +( \ + AssertMacro(!SizeQueueIsEmpty()), \ + DatumGetPointer(binaryheap_nth(UndoWorkerQueues[SIZE_QUEUE].bh, n)) \ +) + +#define SetSizeQueueElem(elem, e_dbid, e_full_xid, e_size, e_start_urec_ptr) \ +( \ + GetSizeQueueElem(elem).dbid = e_dbid, \ + GetSizeQueueElem(elem).full_xid = e_full_xid, \ + GetSizeQueueElem(elem).request_size = e_size, \ + GetSizeQueueElem(elem).start_urec_ptr = e_start_urec_ptr \ +) + +/* Different operations for Error queue */ +#define InitErrorQueue(bh, elems) \ +( \ + UndoWorkerQueues[ERROR_QUEUE].bh = bh, \ + UndoWorkerQueues[ERROR_QUEUE].q_choice.error_elems = elems \ +) + +#define ErrorQueueIsEmpty() \ + (binaryheap_empty(UndoWorkerQueues[ERROR_QUEUE].bh)) + +#define GetErrorQueueSize() \ + (binaryheap_cur_size(UndoWorkerQueues[ERROR_QUEUE].bh)) + +#define GetErrorQueueElem(elem) \ + (UndoWorkerQueues[ERROR_QUEUE].q_choice.error_elems[elem]) + +#define GetErrorQueueTopElem() \ +( \ + AssertMacro(!binaryheap_empty(UndoWorkerQueues[ERROR_QUEUE].bh)), \ + DatumGetPointer(binaryheap_first(UndoWorkerQueues[ERROR_QUEUE].bh)) \ +) + +#define GetErrorQueueNthElem(n) \ +( \ + AssertMacro(!ErrorQueueIsEmpty()), \ + DatumGetPointer(binaryheap_nth(UndoWorkerQueues[ERROR_QUEUE].bh, n)) \ +) + +#define SetErrorQueueElem(elem, e_dbid, e_full_xid, e_occurred_at, e_start_urec_ptr) \ +( \ + GetErrorQueueElem(elem).dbid = e_dbid, \ + GetErrorQueueElem(elem).full_xid = e_full_xid, \ + GetErrorQueueElem(elem).err_occurred_at = e_occurred_at, \ + GetErrorQueueElem(elem).start_urec_ptr = e_start_urec_ptr \ +) + +/* + * Binary heap comparison function to compare the age of transactions. + */ +static int +undo_age_comparator(Datum a, Datum b, void *arg) +{ + UndoXidQueue *xidQueueElem1 = (UndoXidQueue *) DatumGetPointer(a); + UndoXidQueue *xidQueueElem2 = (UndoXidQueue *) DatumGetPointer(b); + + if (FullTransactionIdPrecedes(xidQueueElem1->full_xid, + xidQueueElem2->full_xid)) + return 1; + else if (FullTransactionIdEquals(xidQueueElem1->full_xid, + xidQueueElem2->full_xid)) + return 0; + return -1; +} + +/* + * Binary heap comparison function to compare the size of transactions. + */ +static int +undo_size_comparator(Datum a, Datum b, void *arg) +{ + UndoSizeQueue *sizeQueueElem1 = (UndoSizeQueue *) DatumGetPointer(a); + UndoSizeQueue *sizeQueueElem2 = (UndoSizeQueue *) DatumGetPointer(b); + + if (sizeQueueElem1->request_size > sizeQueueElem2->request_size) + return 1; + else if (sizeQueueElem1->request_size == sizeQueueElem2->request_size) + return 0; + return -1; +} + +/* + * Binary heap comparison function to compare the time at which an error + * occurred for transactions. + */ +static int +undo_err_time_comparator(Datum a, Datum b, void *arg) +{ + UndoErrorQueue *errQueueElem1 = (UndoErrorQueue *) DatumGetPointer(a); + UndoErrorQueue *errQueueElem2 = (UndoErrorQueue *) DatumGetPointer(b); + + if (errQueueElem1->err_occurred_at < errQueueElem2->err_occurred_at) + return 1; + else if (errQueueElem1->err_occurred_at == errQueueElem2->err_occurred_at) + return 0; + return -1; +} + +static int +UndoXidQueueElemsShmSize(void) +{ + return mul_size(ROLLBACK_REQUEST_QUEUE_SIZE, sizeof(UndoXidQueue)); +} + +static int +UndoSizeQueueElemsShmSize(void) +{ + return mul_size(ROLLBACK_REQUEST_QUEUE_SIZE, sizeof(UndoSizeQueue)); +} + +static int +UndoErrorQueueElemsShmSize(void) +{ + return mul_size(ROLLBACK_REQUEST_QUEUE_SIZE, sizeof(UndoErrorQueue)); +} + +static int +UndoRollbackHashTableSize(void) +{ + /* + * The rollback hash table is used to avoid duplicate undo requests by + * backends and discard worker. The table must be able to accomodate all + * active undo requests. The undo requests must appear in both xid and + * size requests queues or neither. In same transaction, there can be two + * requests one for logged relations and another for unlogged relations. + * So, the rollback hash table size should be equal to two request queues, + * an error queue (currently this is same as request queue) and max + * backends. This will ensure that it won't get filled. + */ + return ((2 * ROLLBACK_REQUEST_QUEUE_SIZE) + ROLLBACK_REQUEST_QUEUE_SIZE + + MaxBackends); +} + +/* Get the first free element of xid based request array. */ +static int +UndoXidQueueGetFreeElem(void) +{ + int i; + + for (i = 0; i < ROLLBACK_REQUEST_QUEUE_SIZE; i++) + { + if (FullTransactionIdEquals(GetXidQueueElem(i).full_xid, + InvalidFullTransactionId)) + return i; + } + + /* we should never call this function when the request queue is full. */ + Assert(false); + + /* silence compiler. */ + return -1; +} + +/* Push an element in the xid based request queue */ +static void +PushXidQueueElem(UndoRequestInfo *urinfo) +{ + int elem = UndoXidQueueGetFreeElem(); + + SetXidQueueElem(elem, urinfo->dbid, urinfo->full_xid, urinfo->start_urec_ptr); + + binaryheap_add(UndoWorkerQueues[XID_QUEUE].bh, + PointerGetDatum(&GetXidQueueElem(elem))); +} + +/* Pop nth element from the xid based request queue */ +static UndoXidQueue * +PopXidQueueNthElem(int n) +{ + Datum elem; + + Assert(!XidQueueIsEmpty()); + elem = binaryheap_remove_nth(UndoWorkerQueues[XID_QUEUE].bh, n); + + return (UndoXidQueue *) (DatumGetPointer(elem)); +} + +/* Get the first free element of size based request array. */ +static inline int +UndoSizeQueueGetFreeElem(void) +{ + int i; + + for (i = 0; i < ROLLBACK_REQUEST_QUEUE_SIZE; i++) + { + if (FullTransactionIdEquals(GetSizeQueueElem(i).full_xid, + InvalidFullTransactionId)) + return i; + } + + /* we should never call this function when the request queue is full. */ + Assert(false); + + /* silence compiler. */ + return -1; +} + +/* + * Traverse the queue and remove dangling entries, if any. The queue + * entry is considered dangling if the hash table doesn't contain the + * corresponding entry. + */ +static int +RemoveOldElemsFromXidQueue(void) +{ + int nCleaned = 0; + int i = 0; + + Assert(LWLockHeldByMeInMode(RollbackRequestLock, LW_EXCLUSIVE)); + + while (i < GetXidQueueSize()) + { + RollbackHashEntry *rh; + RollbackHashKey hkey; + UndoXidQueue *elem = (UndoXidQueue *) GetXidQueueNthElem(i); + + hkey.full_xid = elem->full_xid; + hkey.start_urec_ptr = elem->start_urec_ptr; + rh = (RollbackHashEntry *) hash_search(RollbackHT, + (void *) &hkey, + HASH_FIND, NULL); + + /* + * If some undo worker is already processing the rollback request or + * it is already processed, then we drop that request from the queue. + */ + if (!rh || rh->in_progress) + { + elem->dbid = InvalidOid; + elem->full_xid = InvalidFullTransactionId; + nCleaned++; + binaryheap_remove_nth_unordered(UndoWorkerQueues[XID_QUEUE].bh, i); + + continue; + } + + i++; + } + + binaryheap_build(UndoWorkerQueues[XID_QUEUE].bh); + + return nCleaned; +} + +/* Push an element in the size based request queue */ +static void +PushSizeQueueElem(UndoRequestInfo *urinfo) +{ + int elem = UndoSizeQueueGetFreeElem(); + + SetSizeQueueElem(elem, urinfo->dbid, urinfo->full_xid, urinfo->request_size, + urinfo->start_urec_ptr); + + binaryheap_add(UndoWorkerQueues[SIZE_QUEUE].bh, + PointerGetDatum(&GetSizeQueueElem(elem))); +} + +/* Pop nth element from the size based request queue */ +static UndoSizeQueue * +PopSizeQueueNthElem(int n) +{ + Datum elem; + + Assert(!binaryheap_empty(UndoWorkerQueues[SIZE_QUEUE].bh)); + elem = binaryheap_remove_nth(UndoWorkerQueues[SIZE_QUEUE].bh, n); + + return (UndoSizeQueue *) DatumGetPointer(elem); +} + +/* + * Traverse the queue and remove dangling entries, if any. The queue + * entry is considered dangling if the hash table doesn't contain the + * corresponding entry. + */ +static int +RemoveOldElemsFromSizeQueue(void) +{ + int nCleaned = 0; + int i = 0; + + Assert(LWLockHeldByMeInMode(RollbackRequestLock, LW_EXCLUSIVE)); + + while (i < GetSizeQueueSize()) + { + RollbackHashEntry *rh; + RollbackHashKey hkey; + UndoSizeQueue *elem = (UndoSizeQueue *) GetSizeQueueNthElem(i); + + hkey.full_xid = elem->full_xid; + hkey.start_urec_ptr = elem->start_urec_ptr; + rh = (RollbackHashEntry *) hash_search(RollbackHT, + (void *) &hkey, + HASH_FIND, NULL); + + /* + * If some undo worker is already processing the rollback request or + * it is already processed, then we drop that request from the queue. + */ + if (!rh || rh->in_progress) + { + elem->dbid = InvalidOid; + elem->full_xid = InvalidFullTransactionId; + elem->request_size = 0; + binaryheap_remove_nth_unordered(UndoWorkerQueues[SIZE_QUEUE].bh, i); + nCleaned++; + continue; + } + + i++; + } + + binaryheap_build(UndoWorkerQueues[SIZE_QUEUE].bh); + + return nCleaned; +} + +/* Get the first free element of error time based request array. */ +static int +UndoErrorQueueGetFreeElem(void) +{ + int i; + + for (i = 0; i < ROLLBACK_REQUEST_QUEUE_SIZE; i++) + { + if (FullTransactionIdEquals(GetErrorQueueElem(i).full_xid, + InvalidFullTransactionId)) + return i; + } + + /* we should never call this function when the request queue is full. */ + Assert(false); + + /* silence compiler. */ + return -1; +} + +/* Push an element in the error time based request queue */ +static void +PushErrorQueueElem(volatile UndoRequestInfo *urinfo) +{ + int elem = UndoErrorQueueGetFreeElem(); + TimestampTz now = GetCurrentTimestamp(); + + SetErrorQueueElem(elem, urinfo->dbid, urinfo->full_xid, now, + urinfo->start_urec_ptr); + + binaryheap_add(UndoWorkerQueues[ERROR_QUEUE].bh, + PointerGetDatum(&GetErrorQueueElem(elem))); +} + +/* Pop nth element from the error time based request queue */ +static UndoErrorQueue * +PopErrorQueueNthElem(int n) +{ + Datum elem; + + Assert(!ErrorQueueIsEmpty()); + elem = binaryheap_remove_nth(UndoWorkerQueues[ERROR_QUEUE].bh, n); + + return (UndoErrorQueue *) (DatumGetPointer(elem)); +} + +/* + * Traverse the queue and remove dangling entries, if any. The queue + * entry is considered dangling if the hash table doesn't contain the + * corresponding entry. + */ +static int +RemoveOldElemsFromErrorQueue(void) +{ + int nCleaned = 0; + int i = 0; + + Assert(LWLockHeldByMeInMode(RollbackRequestLock, LW_EXCLUSIVE)); + + while (i < GetErrorQueueSize()) + { + RollbackHashEntry *rh; + RollbackHashKey hkey; + UndoErrorQueue *elem = (UndoErrorQueue *) GetErrorQueueNthElem(i); + + hkey.full_xid = elem->full_xid; + hkey.start_urec_ptr = elem->start_urec_ptr; + rh = (RollbackHashEntry *) hash_search(RollbackHT, + (void *) &hkey, + HASH_FIND, NULL); + + /* + * If some undo worker is already processing the rollback request or + * it is already processed, then we drop that request from the queue. + */ + if (!rh || rh->in_progress) + { + elem->dbid = InvalidOid; + elem->full_xid = InvalidFullTransactionId; + elem->err_occurred_at = 0; + binaryheap_remove_nth_unordered(UndoWorkerQueues[ERROR_QUEUE].bh, i); + nCleaned++; + continue; + } + + i++; + } + + binaryheap_build(UndoWorkerQueues[ERROR_QUEUE].bh); + + return nCleaned; +} + +/* + * Remove nth work item from queue and clear the array element as well from + * the corresponding queue. + */ +static void +RemoveRequestFromQueue(UndoWorkerQueueType type, int n) +{ + if (type == XID_QUEUE) + { + UndoXidQueue *uXidQueueElem = (UndoXidQueue *) PopXidQueueNthElem(n); + + Assert(FullTransactionIdIsValid(uXidQueueElem->full_xid)); + uXidQueueElem->dbid = InvalidOid; + uXidQueueElem->full_xid = InvalidFullTransactionId; + } + else if (type == SIZE_QUEUE) + { + UndoSizeQueue *uSizeQueueElem = (UndoSizeQueue *) PopSizeQueueNthElem(n); + + Assert(FullTransactionIdIsValid(uSizeQueueElem->full_xid)); + uSizeQueueElem->dbid = InvalidOid; + uSizeQueueElem->full_xid = InvalidFullTransactionId; + uSizeQueueElem->request_size = 0; + } + else + { + UndoErrorQueue *uErrorQueueElem = (UndoErrorQueue *) PopErrorQueueNthElem(n); + + Assert(type == ERROR_QUEUE); + Assert(FullTransactionIdIsValid(uErrorQueueElem->full_xid)); + uErrorQueueElem->dbid = InvalidOid; + uErrorQueueElem->full_xid = InvalidFullTransactionId; + uErrorQueueElem->err_occurred_at = 0; + } +} + +/* + * Returns the hash key corresponding to the nth element of the specified + * queue. + */ +static bool +GetRollbackHashKeyFromQueue(UndoWorkerQueueType cur_queue, int n, + RollbackHashKey *hkey) +{ + if (cur_queue == XID_QUEUE) + { + UndoXidQueue *elem; + + /* check if there is a work in the next queue */ + if (GetXidQueueSize() <= n) + return false; + + elem = (UndoXidQueue *) GetXidQueueNthElem(n); + hkey->full_xid = elem->full_xid; + hkey->start_urec_ptr = elem->start_urec_ptr; + } + else if (cur_queue == SIZE_QUEUE) + { + UndoSizeQueue *elem; + + /* check if there is a work in the next queue */ + if (GetSizeQueueSize() <= n) + return false; + + elem = (UndoSizeQueue *) GetSizeQueueNthElem(n); + hkey->full_xid = elem->full_xid; + hkey->start_urec_ptr = elem->start_urec_ptr; + } + else + { + UndoErrorQueue *elem; + + /* It must be an error queue. */ + Assert(cur_queue == ERROR_QUEUE); + + /* check if there is a work in the next queue */ + if (GetErrorQueueSize() <= n) + { + cur_undo_queue++; + return false; + } + + elem = (UndoErrorQueue *) GetErrorQueueNthElem(n); + hkey->full_xid = elem->full_xid; + hkey->start_urec_ptr = elem->start_urec_ptr; + } + + return true; +} + +/* + * Fetch the end urec pointer for the transaction and the undo request size. + * + * end_urecptr_out - This is an INOUT parameter. If end undo pointer is + * specified, we use the same to calculate the size. Else, we calculate + * the end undo pointer and return the same. + * + * Note: We don't calculate the exact undo size. We always skip the size of + * the last undo record (if not already discarded) from the calculation. This + * optimization allows us to skip fetching an undo record for the most + * frequent cases where the end pointer and current start pointer belong to + * the same log. A simple subtraction between them gives us the size. In + * future this function can be modified if someone needs the exact undo size. + * As of now, we use this function to calculate the undo size for inserting + * in the pending undo actions in undo worker's size queue. + */ +static uint64 +FindUndoEndLocationAndSize(UndoRecPtr start_urecptr, + UndoRecPtr *end_urecptr_out, + FullTransactionId full_xid) +{ + UnpackedUndoRecord *uur = NULL; + UndoLogControl *log = NULL; + UndoRecPtr urecptr = start_urecptr; + UndoRecPtr end_urecptr = InvalidUndoRecPtr; + uint64 sz = 0; + +/* + * Accumulate the span from 'from' to 'to' into sz. + * + * An undo chain grows upward, so 'to' is never below 'from'. If it ever is, + * the subtraction would wrap around on uint64 and report a multi-exabyte + * request size, which CanPushReqToUndoWorker() reads as "far over the spill + * threshold" and hands to a background undo worker -- while the aborting + * backend, believing the work is queued, skips its own inline apply. The + * worker is then given an inverted (from, to) range and applies nothing, so the + * rollback is silently dropped: the aborted forward image stays on the page + * with an aborted xmin and every snapshot rejects it at the xmin gate. Count + * an inverted range as zero so the request stays with the backend, which + * applies the chain inline and correctly. + */ +#define ADD_UNDO_SPAN(from, to) \ + ((void) ((to) >= (from) ? (sz += (uint64) ((to) - (from))) : 0)) + + Assert(urecptr != InvalidUndoRecPtr); + + /* + * zheap only ever computed this size after the aborting transaction was + * no longer in-progress (its undo was applied by a background worker once + * the xid had left the proc array). The per-backend abort path here + * (PbuAtAbort_ApplyUndo) instead runs INLINE inside AbortTransaction(), + * where our own top xid is still in-progress in the proc array; that is + * expected and does not affect the chain walk below (it only follows undo + * prevlen/log pointers, not any xid state). So the old + * !TransactionIdIsInProgress() assertion does not hold for inline apply + * and has been dropped. FILEOPS is the first producer to exercise this + * path. + */ + + while (true) + { + UndoRecPtr next_urecptr = InvalidUndoRecPtr; + + if (*end_urecptr_out != InvalidUndoRecPtr) + { + /* + * Check whether end pointer and the current pointer belong to + * same log. In that case, we can get the size easily. + */ + if (UndoRecPtrGetLogNo(urecptr) == UndoRecPtrGetLogNo(*end_urecptr_out)) + { + ADD_UNDO_SPAN(urecptr, *end_urecptr_out); + break; + } + } + + /* + * Fetch the log and undo record corresponding the current undo + * pointer + */ + if ((log == NULL) || (UndoRecPtrGetLogNo(urecptr) != log->logno)) + log = UndoLogGet(UndoRecPtrGetLogNo(urecptr)); + Assert(log != NULL); + + /* The corresponding log must be ahead urecptr. */ + Assert(MakeUndoRecPtr(log->logno, log->meta.insert) >= urecptr); + + uur = UndoFetchRecord(urecptr, + InvalidBlockNumber, + InvalidOffsetNumber, + InvalidTransactionId, + NULL, NULL); + + /* + * If the corresponding undo record got rolled back and rewound, we + * return from here. + */ + if (uur == NULL) + break; + + /* The undo must belongs to a same transaction. */ + Assert(FullTransactionIdEquals(full_xid, + FullTransactionIdFromEpochAndXid(uur->uur_xidepoch, + uur->uur_xid))); + + /* + * Since this is the first undo record of this transaction in this + * log, this must include the transaction header. + */ + Assert(uur->uur_info & UREC_INFO_TRANSACTION); + next_urecptr = uur->uur_next; + + /* + * Case 1: If this is the last transaction in the log then calculate + * the latest urec pointer using next insert location of the undo log. + */ + if (!UndoRecPtrIsValid(next_urecptr)) + { + UndoLogOffset next_insert; + + /* + * While fetching the next insert location if the new transaction + * has already started in this log then lets re-fetch the undo + * record. + */ + next_insert = UndoLogGetNextInsertPtr(log->logno, uur->uur_xid); + if (!UndoRecPtrIsValid(next_insert)) + { + UndoRecordRelease(uur); + uur = NULL; + continue; + } + + /* + * If next_insert location points to the starting location of a + * new page, we should subtract the page header size from the + * insert location. + */ + if (UndoRecPtrGetPageOffset(next_insert) == UndoLogBlockHeaderSize) + next_insert -= UndoLogBlockHeaderSize; + + end_urecptr = UndoGetPrevUndoRecptr(next_insert, InvalidUndoRecPtr, + NULL); + ADD_UNDO_SPAN(urecptr, end_urecptr); + Assert(UndoRecPtrIsValid(end_urecptr)); + break; + } + + + /* + * Case 2: The transaction ended in the same undo log, but this is not + * the last transaction. + */ + if (UndoRecPtrGetLogNo(next_urecptr) == log->logno) + { + end_urecptr = + UndoGetPrevUndoRecptr(next_urecptr, InvalidUndoRecPtr, + NULL); + ADD_UNDO_SPAN(urecptr, end_urecptr); + Assert(UndoRecPtrIsValid(end_urecptr)); + break; + } + + /* + * Case 3: If transaction is overflowed to a different undolog and + * it's already discarded. It means that the undo actions for this + * transaction which are in the next log has already been executed. + */ + if (UndoLogIsDiscarded(next_urecptr)) + { + UndoLogOffset next_insert; + + next_insert = UndoLogGetNextInsertPtr(log->logno, uur->uur_xid); + Assert(UndoRecPtrIsValid(next_insert)); + + end_urecptr = UndoGetPrevUndoRecptr(next_insert, InvalidUndoRecPtr, + NULL); + ADD_UNDO_SPAN(urecptr, next_insert); + Assert(UndoRecPtrIsValid(end_urecptr)); + break; + } + + /* + * Case 4: The transaction is overflowed to a different log, so + * restart the processing from then next log. + */ + { + UndoLogOffset next_insert; + + next_insert = UndoLogGetNextInsertPtr(log->logno, uur->uur_xid); + Assert(UndoRecPtrIsValid(next_insert)); + + end_urecptr = UndoGetPrevUndoRecptr(next_insert, InvalidUndoRecPtr, + NULL); + ADD_UNDO_SPAN(urecptr, next_insert); + + UndoRecordRelease(uur); + uur = NULL; + } + + /* Follow the undo chain */ + urecptr = next_urecptr; + } + + if (uur != NULL) + UndoRecordRelease(uur); + + if (end_urecptr_out && (*end_urecptr_out == InvalidUndoRecPtr)) + *end_urecptr_out = end_urecptr; + + return sz; +#undef ADD_UNDO_SPAN +} + + +/* + * Returns true, if we can push the rollback request to undo wrokers, false, + * otherwise. + */ +static bool +CanPushReqToUndoWorker(UndoRecPtr start_urec_ptr, UndoRecPtr end_urec_ptr, + uint64 req_size) +{ + /* + * This must be called after acquring RollbackRequestLock as we will check + * the binary heaps which can change. + */ + Assert(LWLockHeldByMeInMode(RollbackRequestLock, LW_EXCLUSIVE)); + + /* + * We normally push the rollback request to undo workers if the size of + * same is above a certain threshold. However, discard worker is allowed + * to push any size request provided there is a space in rollback request + * queue. This is mainly because discard worker can be processing the + * rollback requests after crash recovery when no backend is alive. + * + * The same reasoning applies, more strongly, to a request registered + * while InRecovery is set: that is the crash-recovery undo driver + * (PbuPerformUndoRecovery) handing over a loser transaction it cannot + * apply itself, because the startup process has no relcache/syscache and + * must not call the index AMs. No backend can fall back to applying such + * a request inline, so it has to reach the worker queues no matter how + * small it is; otherwise it would sit in the hash table forever and the + * transaction would never be rolled back. + * + * We have a race condition where discard worker can process the request + * before the backend which has aborted the transaction in which case + * backend won't do anything. Normally, this won't happen because + * backends try to apply the undo actions immediately after marking the + * transaction as aborted in the clog. One way to avoid this race + * condition is that we register the request by backend in hash table but + * not in rollback queues before marking abort in clog and then later add + * them in rollback queues. However, we are not sure how important it is + * avoid such a race as this won't lead to any problem and OTOH, we might + * need some more trickery in the code to avoid such a race condition. + */ + if (req_size >= rollback_overflow_size * 1024 * 1024 || + IsDiscardProcess() || InRecovery) + { + if (GetXidQueueSize() >= ROLLBACK_REQUEST_QUEUE_SIZE || + GetSizeQueueSize() >= ROLLBACK_REQUEST_QUEUE_SIZE) + { + /* + * If one of the queues is full traverse both the queues and + * remove dangling entries, if any. The queue entry is considered + * dangling if the hash table doesn't contain the corresponding + * entry. It can happen due to two reasons (a) we have processed + * the entry from one of the queues, but not from the other. (b) + * the corresponding database has been dropped due to which we + * have removed the entries from hash table, but not from the + * queues. This is just a lazy cleanup, if we want we can remove + * the entries from the queues when we detect that the database is + * dropped and remove the corresponding entries from hash table. + */ + if (GetXidQueueSize() >= ROLLBACK_REQUEST_QUEUE_SIZE) + RemoveOldElemsFromXidQueue(); + if (GetSizeQueueSize() >= ROLLBACK_REQUEST_QUEUE_SIZE) + RemoveOldElemsFromSizeQueue(); + } + + if ((GetXidQueueSize() < ROLLBACK_REQUEST_QUEUE_SIZE)) + { + Assert(GetSizeQueueSize() < ROLLBACK_REQUEST_QUEUE_SIZE); + return true; + } + } + + return false; +} + +/* + * To return the size of the request queues and hash-table for rollbacks. + */ +int +PendingUndoShmemSize(void) +{ + Size size; + + size = hash_estimate_size(UndoRollbackHashTableSize(), sizeof(RollbackHashEntry)); + size = add_size(size, mul_size(MAX_UNDO_WORK_QUEUES, + binaryheap_shmem_size(ROLLBACK_REQUEST_QUEUE_SIZE))); + size = add_size(size, UndoXidQueueElemsShmSize()); + size = add_size(size, UndoSizeQueueElemsShmSize()); + size = add_size(size, UndoErrorQueueElemsShmSize()); + + return size; +} + +/* + * Reserve the hash table, binary heaps and queue element arrays that back the + * rollback request queues. Called in the request phase so the framework sizes + * the segment for them and populates the module pointers before + * PendingUndoShmemInit() runs (mirroring the sLog subsystem's pattern). These + * areas are large (the rollback hash table alone is ~190 kB), so they must be + * requested rather than drawn from the general shmem slop. + */ +void +PendingUndoShmemRequest(void) +{ + HASHCTL info; + + /* + * Requests are keyed by (full xid, start pointer), so the key must cover + * the whole RollbackHashKey; RollbackHashEntry starts with the same two + * fields so the hash code can treat its leading bytes as the key. + */ + StaticAssertStmt(offsetof(RollbackHashEntry, full_xid) == + offsetof(RollbackHashKey, full_xid) && + offsetof(RollbackHashEntry, start_urec_ptr) == + offsetof(RollbackHashKey, start_urec_ptr), + "RollbackHashEntry must start with a RollbackHashKey"); + + MemSet(&info, 0, sizeof(info)); + info.keysize = sizeof(RollbackHashKey); + info.entrysize = sizeof(RollbackHashEntry); + + ShmemRequestHash(.name = "Undo Actions Lookup Table", + .nelems = UndoRollbackHashTableSize(), + .hash_info = info, + .hash_flags = HASH_ELEM | HASH_BLOBS | HASH_FIXED_SIZE, + .ptr = &RollbackHT); + + ShmemRequestStruct(.name = "Undo Xid Binary Heap", + .size = binaryheap_shmem_size(ROLLBACK_REQUEST_QUEUE_SIZE), + .ptr = (void **) &UndoXidBinaryHeap); + ShmemRequestStruct(.name = "Undo Xid Queue Elements", + .size = UndoXidQueueElemsShmSize(), + .ptr = (void **) &UndoXidQueueElems); + + ShmemRequestStruct(.name = "Undo Size Binary Heap", + .size = binaryheap_shmem_size(ROLLBACK_REQUEST_QUEUE_SIZE), + .ptr = (void **) &UndoSizeBinaryHeap); + ShmemRequestStruct(.name = "Undo Size Queue Elements", + .size = UndoSizeQueueElemsShmSize(), + .ptr = (void **) &UndoSizeQueueElems); + + ShmemRequestStruct(.name = "Undo Error Binary Heap", + .size = binaryheap_shmem_size(ROLLBACK_REQUEST_QUEUE_SIZE), + .ptr = (void **) &UndoErrorBinaryHeap); + ShmemRequestStruct(.name = "Undo Error Queue Elements", + .size = UndoErrorQueueElemsShmSize(), + .ptr = (void **) &UndoErrorQueueElems); +} + +/* + * binaryheap_init_shm + * Initialize the header of a shmem-placed binary heap that the framework + * has already allocated (the request phase reserved the storage; here we + * set the header fields binaryheap_allocate_shm would have set). + */ +static void +binaryheap_init_shm(binaryheap *h, int capacity, + binaryheap_comparator compare, void *arg) +{ + h->bh_size = 0; + h->bh_space = capacity; + h->bh_has_heap_property = true; + h->bh_compare = compare; + h->bh_arg = arg; +} + +/* + * Initialize the hash-table and priority heap based queues for rollback + * requests in shared memory. The framework has already allocated every area + * (see PendingUndoShmemRequest); here we only initialize their contents. + */ +void +PendingUndoShmemInit(void) +{ + if (!IsUnderPostmaster) + { + binaryheap_init_shm(UndoXidBinaryHeap, ROLLBACK_REQUEST_QUEUE_SIZE, + undo_age_comparator, NULL); + memset(UndoXidQueueElems, 0, sizeof(UndoXidQueue)); + + binaryheap_init_shm(UndoSizeBinaryHeap, ROLLBACK_REQUEST_QUEUE_SIZE, + undo_size_comparator, NULL); + memset(UndoSizeQueueElems, 0, sizeof(UndoSizeQueue)); + + binaryheap_init_shm(UndoErrorBinaryHeap, ROLLBACK_REQUEST_QUEUE_SIZE, + undo_err_time_comparator, NULL); + memset(UndoErrorQueueElems, 0, sizeof(UndoErrorQueue)); + } + + /* + * The Init*Queue macros use their first parameter's token as the struct + * field name (UndoWorkerQueues[...].bh), so the argument must literally + * be "bh"; use a local of that name for each queue. + */ + { + binaryheap *bh; + + bh = UndoXidBinaryHeap; + InitXidQueue(bh, UndoXidQueueElems); + bh = UndoSizeBinaryHeap; + InitSizeQueue(bh, UndoSizeQueueElems); + bh = UndoErrorBinaryHeap; + InitErrorQueue(bh, UndoErrorQueueElems); + } +} + +/* + * Returns true, if there is no pending undo apply work, false, otherwise. + */ +bool +UndoWorkerQueuesEmpty(void) +{ + if (XidQueueIsEmpty() && SizeQueueIsEmpty()) + return true; + + return false; +} + +/* Insert the request in both xid and size based queues. */ +void +InsertRequestIntoUndoQueues(UndoRequestInfo *urinfo) +{ + /* + * This must be called after acquring RollbackRequestLock as we will + * insert into the binary heaps which can change. + */ + Assert(LWLockHeldByMeInMode(RollbackRequestLock, LW_EXCLUSIVE)); + PushXidQueueElem(urinfo); + PushSizeQueueElem(urinfo); + + elog(DEBUG1, "Undo action pushed Xid: " UINT64_FORMAT ", Size: " UINT64_FORMAT ", " + "Start: " UndoRecPtrFormat ", End: " UndoRecPtrFormat "", + U64FromFullTransactionId(urinfo->full_xid), urinfo->request_size, + (unsigned long long) urinfo->start_urec_ptr, + (unsigned long long) urinfo->end_urec_ptr); +} + +/* Insert the request into an error queue. */ +bool +InsertRequestIntoErrorUndoQueue(volatile UndoRequestInfo *urinfo) +{ + RollbackHashEntry *rh; + RollbackHashKey hkey; + + LWLockAcquire(RollbackRequestLock, LW_EXCLUSIVE); + + /* We can't insert into an error queue if it is already full. */ + if (GetErrorQueueSize() >= ROLLBACK_REQUEST_QUEUE_SIZE) + { + int num_removed = 0; + + /* Try to remove few elements */ + num_removed = RemoveOldElemsFromErrorQueue(); + + if (num_removed == 0) + { + LWLockRelease(RollbackRequestLock); + return false; + } + } + + /* + * Mark the undo request in hash table as not in_progress so that undo + * launcher or other undo worker don't remove the entry from queues. + */ + hkey.full_xid = urinfo->full_xid; + hkey.start_urec_ptr = urinfo->start_urec_ptr; + rh = (RollbackHashEntry *) hash_search(RollbackHT, &hkey, HASH_FIND, NULL); + if (rh == NULL) + { + /* Already completed or dropped by someone else: nothing to retry. */ + LWLockRelease(RollbackRequestLock); + return true; + } + rh->in_progress = false; + rh->in_error_queue = true; + + /* Insert the request into error queue for processing it later. */ + PushErrorQueueElem(urinfo); + LWLockRelease(RollbackRequestLock); + + elog(DEBUG1, "Undo action pushed(error) Xid: " UINT64_FORMAT ", Size: " UINT64_FORMAT ", " + "Start: " UndoRecPtrFormat ", End: " UndoRecPtrFormat "", + U64FromFullTransactionId(urinfo->full_xid), urinfo->request_size, + (unsigned long long) urinfo->start_urec_ptr, + (unsigned long long) urinfo->end_urec_ptr); + + return true; +} + +/* + * Set the undo worker queue from which the undo worker should start looking + * for work. + */ +void +SetUndoWorkerQueueStart(UndoWorkerQueueType undo_worker_queue) +{ + cur_undo_queue = undo_worker_queue; +} + +/* + * Get the next set of pending rollback request for undo worker. + * + * allow_peek - if true, peeks a few element from each queue to check whether + * any request matches current dbid. + * remove_from_queue - if true, picks an element from the queue whose dbid matches + * current dbid and remove it from the queue before returning the same to + * caller. + * urinfo - this is an OUT parameter that returns the details of undo request + * whose undo action is still pending. + * in_other_db_out - this is an OUT parameter. If we've not found any work for + * current database, but there are work for some other database, we set this + * parameter as true. + */ +bool +UndoGetWork(bool allow_peek, bool remove_from_queue, UndoRequestInfo *urinfo, + bool *in_other_db_out) +{ + int i; + bool found_work = false; + bool in_other_db = false; + + /* Reset the undo request info */ + ResetUndoRequestInfo(urinfo); + + /* Search the queues under lock as they can be modified concurrently. */ + LWLockAcquire(RollbackRequestLock, LW_EXCLUSIVE); + + /* Here, we check each of the work queues in a round-robin way. */ + for (i = 0; i < MAX_UNDO_WORK_QUEUES; i++) + { + RollbackHashKey hkey; + RollbackHashEntry *rh; + int cur_queue = (int) (cur_undo_queue % MAX_UNDO_WORK_QUEUES); + + if (!GetRollbackHashKeyFromQueue(cur_queue, 0, &hkey)) + { + cur_undo_queue++; + continue; + } + + /* + * Leave a failed request alone for a while: retrying it at once would + * most likely fail again, and spin. The error queue is ordered by + * failure time, so if its head is too recent, so is the rest. + */ + if (cur_queue == ERROR_QUEUE && + !TimestampDifferenceExceeds(((UndoErrorQueue *) GetErrorQueueNthElem(0))->err_occurred_at, + GetCurrentTimestamp(), + UNDO_ERROR_RETRY_DELAY_MS)) + { + cur_undo_queue++; + continue; + } + + rh = (RollbackHashEntry *) hash_search(RollbackHT, + (void *) &hkey, + HASH_FIND, NULL); + + /* + * If some undo worker is already processing the rollback request or + * it is already processed, then we drop that request from the queue + * and fetch the next entry from the queue. The same goes for the + * xid- or size-queue element of a request that failed: it is retried + * from the error queue only, after a delay. + */ + if (!rh || rh->in_progress || + (rh->in_error_queue && cur_queue != ERROR_QUEUE)) + { + RemoveRequestFromQueue(cur_queue, 0); + cur_undo_queue++; + continue; + } + + found_work = true; + + /* + * We've found a work for some database. If we don't want to remove + * the request, we return from here and spawn a worker process to + * apply the same. + */ + if (!remove_from_queue) + { + bool exists; + + StartTransactionCommand(); + exists = dbid_exists(rh->dbid); + CommitTransactionCommand(); + + /* + * If the database doesn't exist, just remove the request since we + * no longer need to apply the undo actions. + */ + if (!exists) + { + RemoveRequestFromQueue(cur_queue, 0); + RollbackHTRemoveEntry(rh->full_xid, rh->start_urec_ptr); + cur_undo_queue++; + continue; + } + + /* set the undo request info to process */ + SetUndoRequestInfoFromRHEntry(urinfo, rh, cur_queue); + + cur_undo_queue++; + LWLockRelease(RollbackRequestLock); + return true; + } + + /* + * The worker can perform this request if it is either not connected + * to any database or the request belongs to the same database to + * which it is connected. + */ + if ((MyDatabaseId == InvalidOid) || + (MyDatabaseId != InvalidOid && MyDatabaseId == rh->dbid)) + { + /* found a work for current database */ + if (in_other_db_out) + *in_other_db_out = false; + + /* + * Mark the undo request in hash table as in_progress so that + * other undo worker doesn't pick the same entry for rollback. + */ + rh->in_progress = true; + + /* set the undo request info to process */ + SetUndoRequestInfoFromRHEntry(urinfo, rh, cur_queue); + + /* + * Remove the request from queue so that other undo worker doesn't + * process the same entry. + */ + RemoveRequestFromQueue(cur_queue, 0); + + cur_undo_queue++; + LWLockRelease(RollbackRequestLock); + return true; + } + else + in_other_db = true; + + cur_undo_queue++; + } + + /* + * Iff a worker would need to switch databases less than + * undo_worker_quantum ms after starting, it peeks a few entries deep into + * each queue to see whether there's work for that database. This ensures + * that one worker doesn't have to restart quickly to switch databases. + */ + if (allow_peek) + { + int depth, + cur_queue; + RollbackHashKey hkey; + RollbackHashEntry *rh; + + /* + * We shouldn't have come here if we've found a work above for our + * database. + */ + Assert(!found_work || in_other_db); + + for (depth = 0; depth < UNDO_PEEK_DEPTH; depth++) + { + for (cur_queue = 0; cur_queue < MAX_UNDO_WORK_QUEUES; cur_queue++) + { + if (!GetRollbackHashKeyFromQueue(cur_queue, depth, &hkey)) + continue; + + rh = (RollbackHashEntry *) hash_search(RollbackHT, + (void *) &hkey, + HASH_FIND, NULL); + + /* + * If some undo worker is already processing the rollback + * request or it is already processed, then fetch the next + * entry from the queue. Failed requests are not peeked; see + * above. + */ + if (!rh || rh->in_progress || rh->in_error_queue) + continue; + + found_work = true; + + /* + * The worker can perform this request if it is either not + * connected to any database or the request belongs to the + * same database to which it is connected. + */ + if ((MyDatabaseId == InvalidOid) || + (MyDatabaseId != InvalidOid && MyDatabaseId == rh->dbid)) + { + /* found a work for current database */ + if (in_other_db_out) + *in_other_db_out = false; + + /* + * Mark the undo request in hash table as in_progress so + * that other undo worker doesn't pick the same entry for + * rollback. + */ + rh->in_progress = true; + + /* set the undo request info to process */ + SetUndoRequestInfoFromRHEntry(urinfo, rh, cur_queue); + + /* + * Remove the request from queue so that other undo worker + * doesn't process the same entry. + */ + RemoveRequestFromQueue(cur_queue, depth); + LWLockRelease(RollbackRequestLock); + return true; + } + else + in_other_db = true; + } + } + } + + LWLockRelease(RollbackRequestLock); + + if (in_other_db_out) + *in_other_db_out = in_other_db; + + return found_work; +} + +/* + * This function registers the rollback requests. + * + * Returns true, if the request is registered and will be processed by undo + * worker at some later point of time, false, otherwise in which case caller + * can process the undo request by itself. + * + * The caller may execute undo actions itself (a) if the entry is not already + * present in rollback hash table and can't be pushed to pending undo request + * queues, (b) if the entry is present, but the size is small enough that + * backend can execute by itself and undo worker hasn't started processing it + * yet. + */ +bool +RegisterRollbackReq(UndoRecPtr end_urec_ptr, UndoRecPtr start_urec_ptr, + Oid dbid, FullTransactionId full_xid) +{ + bool found = false; + bool can_push; + bool pushed = false; + bool request_registered = false; + RollbackHashEntry *rh; + uint64 req_size = 0; + + /* Do not push any rollback request if working in single user-mode */ + if (!IsUnderPostmaster) + return false; + + /* + * Temporary tables, and therefore temporary undo, are visible only to the + * backend that owns them, so no undo worker could apply it. The caller + * applies it itself. + */ + { + UndoLogControl *log = UndoLogGet(UndoRecPtrGetLogNo(start_urec_ptr)); + + if (log != NULL && log->meta.persistence == UNDO_TEMP) + return false; + } + + Assert(UndoRecPtrIsValid(start_urec_ptr)); + Assert(dbid != InvalidOid); + + /* + * There must be space to accommodate the new request. See + * UndoRollbackHashTableSize. + */ + Assert(!RollbackHTIsFull()); + + req_size = FindUndoEndLocationAndSize(start_urec_ptr, &end_urec_ptr, full_xid); + + /* The transaction got rolled back and rewound. */ + if (!UndoRecPtrIsValid(end_urec_ptr)) + return false; + + LWLockAcquire(RollbackRequestLock, LW_EXCLUSIVE); + + /* + * Check whether we can push the rollback request to the undo worker. This + * must be done under lock, see CanPushReqToUndoWorker. + */ + can_push = CanPushReqToUndoWorker(start_urec_ptr, end_urec_ptr, req_size); + + /* + * Backends always register the rollback request in the rollback hash + * table irrespective of whether we push it to undo worker. This ensures + * that discard worker won't try to process the request on which backend + * is working. OTOH, discard worker won't add an entry to the hash table + * unless it can push the request to undo worker. This is because + * otherwise backends might not process the request by themselves even + * though no undo worker is going to process such a request. + */ + if (can_push || + (!can_push && !IsDiscardProcess())) + { + RollbackHashKey hkey; + + hkey.full_xid = full_xid; + hkey.start_urec_ptr = start_urec_ptr; + + rh = (RollbackHashEntry *) hash_search(RollbackHT, &hkey, + HASH_ENTER_NULL, &found); + if (!rh) + { + LWLockRelease(RollbackRequestLock); + return false; + } + + /* An existing entry must be this very request, not a key collision. */ + Assert(!found || + (FullTransactionIdEquals(rh->full_xid, full_xid) && + rh->start_urec_ptr == start_urec_ptr)); + + /* We shouldn't try to add the same rollback request again. */ + if (!found) + { + rh->start_urec_ptr = start_urec_ptr; + rh->end_urec_ptr = end_urec_ptr; + rh->dbid = dbid; + rh->full_xid = full_xid; + rh->in_progress = false; + rh->in_error_queue = false; + rh->nfailures = 0; + + if (can_push) + { + UndoRequestInfo urinfo; + + ResetUndoRequestInfo(&urinfo); + + urinfo.full_xid = rh->full_xid; + urinfo.start_urec_ptr = rh->start_urec_ptr; + urinfo.end_urec_ptr = rh->end_urec_ptr; + urinfo.dbid = rh->dbid; + urinfo.request_size = req_size; + + InsertRequestIntoUndoQueues(&urinfo); + + /* + * Indicates that the request will be processed by undo + * worker. + */ + request_registered = true; + pushed = true; + } + else + { + /* Indicates that the request can be processed by backend. */ + request_registered = false; + } + } + else if (!rh->in_progress && !can_push) + { + /* + * Indicates that the request can be processed by backend. This is + * the case where discard worker would have pushed the request of + * smaller size which backend itself can process. Mark the request + * as in-progress, so that discard worker doesn't try to process + * it. + */ + rh->in_progress = true; + request_registered = false; + } + else + { + /* Indicates that the request will be processed by undo worker. */ + request_registered = true; + } + } + + LWLockRelease(RollbackRequestLock); + + /* + * If we are able to successfully push the request, wakeup the undo worker + * so that it can be processed in a timely fashion. + */ + if (pushed) + WakeupUndoWorker(dbid); + + return request_registered; +} + +/* + * Count one more attempt that did not complete the rollback request, and + * return the new count; 0 if the request is gone already. + */ +int +RollbackHTCountFailure(FullTransactionId full_xid, UndoRecPtr start_urec_ptr) +{ + RollbackHashKey hkey; + RollbackHashEntry *rh; + int n = 0; + + hkey.full_xid = full_xid; + hkey.start_urec_ptr = start_urec_ptr; + + LWLockAcquire(RollbackRequestLock, LW_EXCLUSIVE); + rh = (RollbackHashEntry *) hash_search(RollbackHT, &hkey, HASH_FIND, NULL); + if (rh != NULL) + n = ++rh->nfailures; + LWLockRelease(RollbackRequestLock); + + return n; +} + +/* + * Remove the rollback request entry from the rollback hash table. + */ +void +RollbackHTRemoveEntry(FullTransactionId full_xid, UndoRecPtr start_urec_ptr) +{ + RollbackHashKey hkey; + + hkey.full_xid = full_xid; + hkey.start_urec_ptr = start_urec_ptr; + + LWLockAcquire(RollbackRequestLock, LW_EXCLUSIVE); + + hash_search(RollbackHT, &hkey, HASH_REMOVE, NULL); + + LWLockRelease(RollbackRequestLock); +} + +/* + * To check if the rollback requests in the hash table are all + * completed or not. This is required because we don't not want to + * expose RollbackHT in xact.c, where it is required to ensure + * that we push the resuests only when there is some space in + * the hash-table. + */ +bool +RollbackHTIsFull(void) +{ + bool result = false; + + LWLockAcquire(RollbackRequestLock, LW_SHARED); + + if (hash_get_num_entries(RollbackHT) >= UndoRollbackHashTableSize()) + result = true; + + LWLockRelease(RollbackRequestLock); + + return result; +} + +/* + * Remove all the entries for the given dbid. This is required in cases when + * the database is dropped and there were rollback requests pushed to the + * hash-table. + */ +void +RollbackHTCleanup(Oid dbid) +{ + RollbackHashEntry *rh; + HASH_SEQ_STATUS status; + + /* Fetch the rollback requests */ + LWLockAcquire(RollbackRequestLock, LW_SHARED); + + Assert(hash_get_num_entries(RollbackHT) <= UndoRollbackHashTableSize()); + hash_seq_init(&status, RollbackHT); + while (RollbackHT != NULL && + (rh = (RollbackHashEntry *) hash_seq_search(&status)) != NULL) + { + if (rh->dbid == dbid) + { + RollbackHashKey hkey; + + hkey.full_xid = rh->full_xid; + hkey.start_urec_ptr = rh->start_urec_ptr; + + hash_search(RollbackHT, &hkey, HASH_REMOVE, NULL); + } + } + + LWLockRelease(RollbackRequestLock); +} diff --git a/src/backend/access/undo/perbackend/pbu_undoworker.c b/src/backend/access/undo/perbackend/pbu_undoworker.c new file mode 100644 index 0000000000000..85da6c8dafda4 --- /dev/null +++ b/src/backend/access/undo/perbackend/pbu_undoworker.c @@ -0,0 +1,908 @@ +/*------------------------------------------------------------------------- + * + * undoworker.c + * undo launcher and undo worker process. + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/postmaster/undoworker.c + * + * Undo launcher is responsible for launching the workers iff there is some + * work available in one of work queues and there are more workers available. + * To know more about work queues, see undorequest.c. The worker is launched + * to handle requests for a particular database. + * + * Each undo worker then start reading from one of the queue the requests for + * that particular database. A worker would peek into each queue for the + * requests from a particular database, if it needs to switch a database in + * less than undo_worker_quantum ms after starting. Also, if there is no + * work, it lingers for UNDO_WORKER_LINGER_MS. This avoids restarting + * the workers too frequently. + *------------------------------------------------------------------------- + */ + +#include "postgres.h" + +#include "access/perbackend/pbu_compat.h" +#include "access/perbackend/pbu_helpers.h" + +#include "funcapi.h" +#include "miscadmin.h" +#include "pgstat.h" + +#include "access/genam.h" +#include "access/table.h" +#include "access/xact.h" +#include "access/perbackend/pbu_undorequest.h" +#include "access/perbackend/pbu_undoworker.h" + +#include "libpq/pqsignal.h" + +#include "postmaster/bgworker.h" +#include "postmaster/fork_process.h" +#include "postmaster/postmaster.h" + +#include "replication/slot.h" +#include "replication/worker_internal.h" + +#include "storage/ipc.h" +#include "storage/lmgr.h" +#include "storage/proc.h" +#include "storage/procarray.h" +#include "storage/procsignal.h" + +#include "tcop/tcopprot.h" + +#include "utils/hsearch.h" +#include "utils/memutils.h" +#include "utils/resowner.h" + + +/* + * GUC parameters + */ +static int max_undo_workers = 5; + + +/* + * If, a worker would need to switch databases less than undo_worker_quantum + * (10s as default) after starting, it peeks a few entries deep into each + * queue to see whether there's work for that database. + */ +static int undo_worker_quantum_ms = 10000; + +/* max sleep time between cycles (100 milliseconds) */ +#define DEFAULT_NAPTIME_PER_CYCLE 100L + +/* + * Time for which undo worker can linger if there is no work, in + * milliseconds. + */ +#define UNDO_WORKER_LINGER_MS 10000 + +/* Flags set by signal handlers */ +static volatile sig_atomic_t got_SIGHUP = false; +static volatile sig_atomic_t got_SIGTERM = false; +static TimestampTz last_xact_processed_at; + +typedef struct UndoApplyWorker +{ + /* Indicates if this slot is used or free. */ + bool in_use; + + /* Increased every time the slot is taken by new worker. */ + uint16 generation; + + /* Pointer to proc array. NULL if not running. */ + PGPROC *proc; + + /* Database id this worker is connected to. */ + Oid dbid; + + /* this tells whether worker is lingering. */ + bool lingering; + + /* + * This tells the undo worker from which undo worker queue it should start + * processing. + */ + UndoWorkerQueueType undo_worker_queue; +} UndoApplyWorker; + +static UndoApplyWorker *MyUndoWorker = NULL; + +typedef struct UndoApplyCtxStruct +{ + /* Supervisor process. */ + pid_t launcher_pid; + + /* latch to wake up undo launcher. */ + Latch *undo_launcher_latch; + + /* Background workers. */ + UndoApplyWorker workers[FLEXIBLE_ARRAY_MEMBER]; +} UndoApplyCtxStruct; + +static UndoApplyCtxStruct *UndoApplyCtx; + +static void UndoWorkerOnExit(int code, Datum arg); +static void UndoWorkerCleanup(UndoApplyWorker *worker); +static void UndoWorkerIsLingering(bool sleep); +static void UndoWorkerGetSlotInfo(int slot, UndoRequestInfo *urinfo); +static void UndoworkerSigtermHandler(SIGNAL_ARGS); + +/* + * Cleanup function for undo worker launcher. + * + * Called on undo worker launcher exit. + */ +static void +UndoLauncherOnExit(int code, Datum arg) +{ + UndoApplyCtx->launcher_pid = 0; + UndoApplyCtx->undo_launcher_latch = NULL; +} + +/* SIGTERM: set flag to exit at next convenient time */ +static void +UndoworkerSigtermHandler(SIGNAL_ARGS) +{ + got_SIGTERM = true; + + /* Waken anything waiting on the process latch */ + SetLatch(MyLatch); +} + +/* SIGHUP: set flag to reload configuration at next convenient time */ +static void +UndoLauncherSighup(SIGNAL_ARGS) +{ + int save_errno = errno; + + got_SIGHUP = true; + + /* Waken anything waiting on the process latch */ + SetLatch(MyLatch); + + errno = save_errno; +} + +/* + * Wait for a background worker to start up and attach to the shmem context. + * + * This is only needed for cleaning up the shared memory in case the worker + * fails to attach. + */ +static void +WaitForUndoWorkerAttach(UndoApplyWorker *worker, + uint16 generation, + BackgroundWorkerHandle *handle) +{ + BgwHandleStatus status; + int rc; + + for (;;) + { + pid_t pid; + + CHECK_FOR_INTERRUPTS(); + + LWLockAcquire(UndoWorkerLock, LW_SHARED); + + /* Worker either died or has started; no need to do anything. */ + if (!worker->in_use || worker->proc) + { + LWLockRelease(UndoWorkerLock); + return; + } + + LWLockRelease(UndoWorkerLock); + + /* Check if worker has died before attaching, and clean up after it. */ + status = GetBackgroundWorkerPid(handle, &pid); + + if (status == BGWH_STOPPED) + { + LWLockAcquire(UndoWorkerLock, LW_EXCLUSIVE); + /* Ensure that this was indeed the worker we waited for. */ + if (generation == worker->generation) + UndoWorkerCleanup(worker); + LWLockRelease(UndoWorkerLock); + return; + } + + /* + * We need timeout because we generally don't get notified via latch + * about the worker attach. But we don't expect to have to wait long. + */ + rc = WaitLatch(MyLatch, + WL_LATCH_SET | WL_TIMEOUT | WL_POSTMASTER_DEATH, + 10L, WAIT_EVENT_BGWORKER_STARTUP); + + /* emergency bailout if postmaster has died */ + if (rc & WL_POSTMASTER_DEATH) + proc_exit(1); + + if (rc & WL_LATCH_SET) + { + ResetLatch(MyLatch); + CHECK_FOR_INTERRUPTS(); + } + } + + return; +} + +/* + * Attach to a slot. + */ +static void +UndoWorkerAttach(int slot) +{ + /* Block concurrent access. */ + LWLockAcquire(UndoWorkerLock, LW_EXCLUSIVE); + + MyUndoWorker = &UndoApplyCtx->workers[slot]; + + if (!MyUndoWorker->in_use) + { + LWLockRelease(UndoWorkerLock); + ereport(ERROR, + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), + errmsg("undo worker slot %d is empty, cannot attach", + slot))); + } + + if (MyUndoWorker->proc) + { + LWLockRelease(UndoWorkerLock); + ereport(ERROR, + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), + errmsg("undo worker slot %d is already used by " + "another worker, cannot attach", slot))); + } + + MyUndoWorker->proc = MyProc; + before_shmem_exit(UndoWorkerOnExit, (Datum) 0); + + LWLockRelease(UndoWorkerLock); +} + +/* + * Returns whether an undo worker is available. + */ +static int +IsUndoWorkerAvailable(void) +{ + int i; + int alive_workers = 0; + + LWLockAcquire(UndoWorkerLock, LW_EXCLUSIVE); + + /* Search for attached worker for a given db id. */ + for (i = 0; i < max_undo_workers; i++) + { + UndoApplyWorker *w = &UndoApplyCtx->workers[i]; + + if (w->in_use) + alive_workers++; + } + + LWLockRelease(UndoWorkerLock); + + return (alive_workers < max_undo_workers); +} + +static void +UndoWorkerIsLingering(bool sleep) +{ + /* Block concurrent access. */ + LWLockAcquire(UndoWorkerLock, LW_EXCLUSIVE); + + MyUndoWorker->lingering = sleep; + + LWLockRelease(UndoWorkerLock); +} + +/* Get the dbid and undo worker queue set by the undo launcher. */ +static void +UndoWorkerGetSlotInfo(int slot, UndoRequestInfo *urinfo) +{ + /* Block concurrent access. */ + LWLockAcquire(UndoWorkerLock, LW_EXCLUSIVE); + + MyUndoWorker = &UndoApplyCtx->workers[slot]; + + if (!MyUndoWorker->in_use) + { + LWLockRelease(UndoWorkerLock); + ereport(ERROR, + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), + errmsg("undo worker slot %d is empty", + slot))); + } + + urinfo->dbid = MyUndoWorker->dbid; + urinfo->undo_worker_queue = MyUndoWorker->undo_worker_queue; + + LWLockRelease(UndoWorkerLock); +} + +/* + * Start new undo apply background worker, if possible otherwise return false. + */ +static bool +UndoWorkerLaunch(UndoRequestInfo urinfo) +{ + BackgroundWorker bgw; + BackgroundWorkerHandle *bgw_handle; + uint16 generation; + int i; + int slot = 0; + UndoApplyWorker *worker = NULL; + + /* + * We need to do the modification of the shared memory under lock so that + * we have consistent view. + */ + LWLockAcquire(UndoWorkerLock, LW_EXCLUSIVE); + + /* Find unused worker slot. */ + for (i = 0; i < max_undo_workers; i++) + { + UndoApplyWorker *w = &UndoApplyCtx->workers[i]; + + if (!w->in_use) + { + worker = w; + slot = i; + break; + } + } + + /* We must not try to start a worker if there are no available workers. */ + Assert(worker != NULL); + + /* Prepare the worker slot. */ + worker->in_use = true; + worker->proc = NULL; + worker->dbid = urinfo.dbid; + worker->lingering = false; + worker->undo_worker_queue = urinfo.undo_worker_queue; + worker->generation++; + + generation = worker->generation; + LWLockRelease(UndoWorkerLock); + + /* Register the new dynamic worker. */ + memset(&bgw, 0, sizeof(bgw)); + bgw.bgw_flags = BGWORKER_SHMEM_ACCESS | + BGWORKER_BACKEND_DATABASE_CONNECTION; + bgw.bgw_start_time = BgWorkerStart_RecoveryFinished; + snprintf(bgw.bgw_library_name, BGW_MAXLEN, "postgres"); + snprintf(bgw.bgw_function_name, BGW_MAXLEN, "PbuUndoWorkerMain"); + snprintf(bgw.bgw_type, BGW_MAXLEN, "undo apply worker"); + snprintf(bgw.bgw_name, BGW_MAXLEN, "undo apply worker"); + + bgw.bgw_restart_time = BGW_NEVER_RESTART; + bgw.bgw_notify_pid = MyProcPid; + bgw.bgw_main_arg = Int32GetDatum(slot); + + if (!RegisterDynamicBackgroundWorker(&bgw, &bgw_handle)) + { + /* Failed to start worker, so clean up the worker slot. */ + LWLockAcquire(UndoWorkerLock, LW_EXCLUSIVE); + UndoWorkerCleanup(worker); + LWLockRelease(UndoWorkerLock); + + return false; + } + + /* Now wait until it attaches. */ + WaitForUndoWorkerAttach(worker, generation, bgw_handle); + + return true; +} + +/* + * Detach the worker (cleans up the worker info). + */ +static void +UndoWorkerDetach(void) +{ + /* Block concurrent access. */ + LWLockAcquire(UndoWorkerLock, LW_EXCLUSIVE); + + UndoWorkerCleanup(MyUndoWorker); + + LWLockRelease(UndoWorkerLock); +} + +/* + * Clean up worker info. + */ +static void +UndoWorkerCleanup(UndoApplyWorker *worker) +{ + Assert(LWLockHeldByMeInMode(UndoWorkerLock, LW_EXCLUSIVE)); + + worker->in_use = false; + worker->proc = NULL; + worker->dbid = InvalidOid; + worker->lingering = false; + worker->undo_worker_queue = InvalidUndoWorkerQueue; +} + +/* + * Cleanup function. + * + * Called on logical replication worker exit. + */ +static void +UndoWorkerOnExit(int code, Datum arg) +{ + UndoWorkerDetach(); +} + +/* + * Attempts an undo apply worker makes at a rollback request before it gives + * up on it. + */ +#define UNDO_WORKER_MAX_ATTEMPTS 5 + +/* + * A rollback request was not completed: queue it for a retry, or give up on + * it after UNDO_WORKER_MAX_ATTEMPTS attempts. + * + * Retrying for ever would keep the transaction's undo, and everything after + * it in the undo log, from ever being discarded. Giving up marks the + * rollback done, so the undo can be discarded; what could not be applied + * stays in place, which the WARNING reports. + */ +static void +UndoWorkerRetryOrGiveUp(UndoRequestInfo *urinfo) +{ + int attempts = RollbackHTCountFailure(urinfo->full_xid, + urinfo->start_urec_ptr); + + if (attempts < UNDO_WORKER_MAX_ATTEMPTS) + { + if (!InsertRequestIntoErrorUndoQueue(urinfo)) + RollbackHTRemoveEntry(urinfo->full_xid, urinfo->start_urec_ptr); + return; + } + + ereport(WARNING, + (errmsg("giving up on rolling back transaction %llu after %d attempts", + (unsigned long long) U64FromFullTransactionId(urinfo->full_xid), + attempts), + errdetail("Some of its per-backend undo could not be applied; those changes stay in place."))); + + StartTransactionCommand(); + PbuMarkRollbackDone(urinfo->full_xid, urinfo->start_urec_ptr); + CommitTransactionCommand(); +} + +/* + * Perform rollback request. We need to connect to the database for first + * request and that is required because we access system tables while + * performing undo actions. + */ +static void +UndoWorkerPerformRequest(UndoRequestInfo *urinfo) +{ + bool error = false; + bool applied = false; + + /* Should be connected to the database. */ + Assert(MyDatabaseId != InvalidOid); + + StartTransactionCommand(); + PG_TRY(); + { + applied = execute_undo_actions(urinfo->full_xid, urinfo->end_urec_ptr, + urinfo->start_urec_ptr, true); + } + PG_CATCH(); + { + error = true; + + /* Prevent interrupts while cleaning up. */ + HOLD_INTERRUPTS(); + + /* Background workers have no client; report to the server log. */ + EmitErrorReport(); + + /* + * Abort the transaction and continue processing pending undo + * requests. + */ + AbortOutOfAnyTransaction(); + FlushErrorState(); + + RESUME_INTERRUPTS(); + } + PG_END_TRY(); + + if (!error) + CommitTransactionCommand(); + + /* + * On an error, or if some record was skipped or failed without raising + * one, the rollback is not complete and execute_undo_actions() left the + * request in place. UndoGetWork() marked it in progress and took it off + * its queue: put it in the error queue to be retried after a delay. If + * the error queue is full, drop the hash entry so that the discard worker + * re-registers the transaction instead of waiting forever for a request + * nobody holds. + */ + if (error || !applied) + UndoWorkerRetryOrGiveUp(urinfo); +} + +/* + * UndoLauncherShmemSize + * Compute space needed for undo launcher shared memory + */ +Size +UndoLauncherShmemSize(void) +{ + Size size; + + /* + * Need the fixed struct and the array of LogicalRepWorker. + */ + size = sizeof(UndoApplyCtxStruct); + size = MAXALIGN(size); + size = add_size(size, mul_size(max_undo_workers, + sizeof(UndoApplyWorker))); + return size; +} + +/* + * UndoLauncherShmemInit + * Allocate and initialize undo worker launcher shared memory + */ +void +UndoLauncherShmemRequest(void) +{ + /* + * Reserve the launcher shmem in the request phase so the framework sizes + * the segment for it and sets UndoApplyCtx before UndoLauncherShmemInit() + * runs (mirroring the sLog subsystem's pattern). + */ + ShmemRequestStruct(.name = "Undo Worker Launcher Data", + .size = UndoLauncherShmemSize(), + .ptr = (void **) &UndoApplyCtx); +} + +void +UndoLauncherShmemInit(void) +{ + /* Framework has allocated the region and set UndoApplyCtx. */ + if (!IsUnderPostmaster) + memset(UndoApplyCtx, 0, UndoLauncherShmemSize()); +} + +/* + * UndoLauncherRegister + * Register a background worker running the undo worker launcher. + */ +void +UndoLauncherRegister(void) +{ + BackgroundWorker bgw; + + if (max_undo_workers == 0) + return; + + memset(&bgw, 0, sizeof(bgw)); + bgw.bgw_flags = BGWORKER_SHMEM_ACCESS | + BGWORKER_BACKEND_DATABASE_CONNECTION; + bgw.bgw_start_time = BgWorkerStart_RecoveryFinished; + snprintf(bgw.bgw_library_name, BGW_MAXLEN, "postgres"); + snprintf(bgw.bgw_function_name, BGW_MAXLEN, "UndoLauncherMain"); + snprintf(bgw.bgw_name, BGW_MAXLEN, + "undo worker launcher"); + snprintf(bgw.bgw_type, BGW_MAXLEN, + "undo worker launcher"); + bgw.bgw_restart_time = 5; + bgw.bgw_notify_pid = 0; + bgw.bgw_main_arg = (Datum) 0; + + RegisterBackgroundWorker(&bgw); +} + +/* + * PbuEnsureUndoLauncher + * Report whether the undo worker launcher can run to apply rollbacks that + * crash recovery has queued. + * + * Called by the crash-recovery undo driver (PbuPerformUndoRecovery) when it has + * queued rollback requests that it cannot apply itself. Those requests are + * drained by an undo apply worker, which is started by the launcher. The + * launcher is registered statically at postmaster start whenever + * max_undo_workers > 0 (see UndoLauncherRegister()), and it waits until + * recovery has finished before running (BgWorkerStart_RecoveryFinished), which + * is the earliest point at which undo apply may legally run. So there is + * nothing to register here; the only case to handle is a configuration in + * which no launcher can exist, where the queued requests would never be + * applied. + */ +void +PbuEnsureUndoLauncher(void) +{ + static bool warned = false; + + if (max_undo_workers > 0 || warned) + return; + + ereport(WARNING, + (errmsg("per-backend UNDO: max_undo_workers is 0, so recovered " + "rollback requests cannot be processed"), + errhint("Raise max_undo_workers and restart to complete the " + "rollback of transactions that crashed in flight."))); + warned = true; +} + +/* + * Main loop for the undo worker launcher process. + */ +void +UndoLauncherMain(Datum main_arg) +{ + UndoRequestInfo urinfo; + + ereport(DEBUG1, + (errmsg("undo launcher started"))); + + before_shmem_exit(UndoLauncherOnExit, (Datum) 0); + + Assert(UndoApplyCtx->launcher_pid == 0); + UndoApplyCtx->launcher_pid = MyProcPid; + + /* Establish signal handlers. */ + pqsignal(SIGHUP, UndoLauncherSighup); + pqsignal(SIGTERM, UndoworkerSigtermHandler); + BackgroundWorkerUnblockSignals(); + + /* Establish connection to nailed catalogs. */ + BackgroundWorkerInitializeConnection(NULL, NULL, 0); + + /* + * Advertise our latch that undo request enqueuer can use to wake us up + * while we're sleeping. + */ + UndoApplyCtx->undo_launcher_latch = &MyProc->procLatch; + + /* Enter main loop */ + while (!got_SIGTERM) + { + int rc; + + CHECK_FOR_INTERRUPTS(); + + ResetUndoRequestInfo(&urinfo); + + if (UndoGetWork(false, false, &urinfo, NULL) && + IsUndoWorkerAvailable()) + UndoWorkerLaunch(urinfo); + + /* Wait for more work. */ + rc = WaitLatch(MyLatch, + WL_LATCH_SET | WL_TIMEOUT | WL_POSTMASTER_DEATH, + DEFAULT_NAPTIME_PER_CYCLE, + WAIT_EVENT_UNDO_LAUNCHER_MAIN); + + /* emergency bailout if postmaster has died */ + if (rc & WL_POSTMASTER_DEATH) + proc_exit(1); + + if (rc & WL_LATCH_SET) + { + ResetLatch(MyLatch); + CHECK_FOR_INTERRUPTS(); + } + + if (got_SIGHUP) + { + got_SIGHUP = false; + ProcessConfigFile(PGC_SIGHUP); + } + } + + /* we're done */ + ereport(LOG, + (errmsg("undo launcher shutting down"))); + proc_exit(0); + +} + +/* + * PbuUndoWorkerMain -- Main loop for the undo apply worker. + */ +pg_noreturn void +PbuUndoWorkerMain(Datum main_arg) +{ + UndoRequestInfo urinfo; + int worker_slot = DatumGetInt32(main_arg); + bool in_other_db; + bool found_work; + TimestampTz started_at; + + /* Setup signal handling */ + pqsignal(SIGTERM, die); + BackgroundWorkerUnblockSignals(); + + ResetUndoRequestInfo(&urinfo); + started_at = GetCurrentTimestamp(); + + /* + * Get the dbid where the wroker should connect to and get the worker + * request queue from which the worker should start looking for an undo + * request. + */ + UndoWorkerGetSlotInfo(worker_slot, &urinfo); + + /* Connect to the requested database. */ + BackgroundWorkerInitializeConnectionByOid(urinfo.dbid, 0, 0); + + /* + * Set the undo worker request queue from which the undo worker start + * looking for a work. + */ + SetUndoWorkerQueueStart(urinfo.undo_worker_queue); + + /* + * Before attaching the worker, fetch and remove the undo request for + * which the undo launcher has launched this worker. This restricts the + * undo launcher from launching multiple workers for the same request. + * But, it's possible that the undo request has already been processed by + * other in-progress undo worker. In that case, we enter the undo worker + * main loop and fetch the next request. + */ + found_work = UndoGetWork(false, true, &urinfo, &in_other_db); + + /* Attach to slot */ + UndoWorkerAttach(worker_slot); + + if (found_work && !in_other_db) + { + /* We must have got the pending undo request. */ + Assert(FullTransactionIdIsValid(urinfo.full_xid)); + UndoWorkerPerformRequest(&urinfo); + last_xact_processed_at = GetCurrentTimestamp(); + } + + for (;;) + { + int rc; + bool allow_peek; + + allow_peek = !TimestampDifferenceExceeds(started_at, + GetCurrentTimestamp(), + undo_worker_quantum_ms); + + found_work = UndoGetWork(allow_peek, true, &urinfo, &in_other_db); + + if (found_work && in_other_db) + { + proc_exit(0); + } + else if (found_work) + { + /* We must have got the pending undo request. */ + Assert(FullTransactionIdIsValid(urinfo.full_xid)); + UndoWorkerPerformRequest(&urinfo); + last_xact_processed_at = GetCurrentTimestamp(); + } + else + { + TimestampTz timeout = 0; + + timeout = TimestampTzPlusMilliseconds(last_xact_processed_at, + UNDO_WORKER_LINGER_MS); + + /* + * We don't need to linger if we have already spent + * UNDO_WORKER_LINGER_MS since last transaction has processed. + */ + if (timeout <= GetCurrentTimestamp()) + { + proc_exit(0); + } + + /* + * Update the shared state to reflect that this worker is + * lingering so that if there is new work request, requester can + * wake us up. + */ + UndoWorkerIsLingering(true); + + /* Wait for more work. */ + rc = WaitLatch(MyLatch, + WL_LATCH_SET | WL_TIMEOUT | WL_POSTMASTER_DEATH, + DEFAULT_NAPTIME_PER_CYCLE, + WAIT_EVENT_UNDO_WORKER_MAIN); + + /* reset the shared state. */ + UndoWorkerIsLingering(false); + + /* emergency bailout if postmaster has died */ + if (rc & WL_POSTMASTER_DEATH) + proc_exit(1); + + if (rc & WL_LATCH_SET) + { + ResetLatch(MyLatch); + CHECK_FOR_INTERRUPTS(); + } + + if (got_SIGHUP) + { + got_SIGHUP = false; + ProcessConfigFile(PGC_SIGHUP); + } + } + } + + proc_exit(0); +} + +/* + * Wake up undo worker so that undo requests can be processed in a timely + * fashion. + * + * We first try to wake up the lingering worker in the given database. If we + * found even one such worker, we are done. + * + * Next, we try to stop some worker which is lingering, but doesn't belong to + * the given database. We know that any worker which is lingering doesn't have + * any pending work, so it is fine to stop it when we know that there is going + * to be some work in the other database. + * + * Finally, we wakeup launcher so that it can either restart the worker we have + * stopped or find some other worker who can take up this request. + */ +void +WakeupUndoWorker(Oid dbid) +{ + int i; + + LWLockAcquire(UndoWorkerLock, LW_EXCLUSIVE); + + /* wake up lingering worker in the given database. */ + for (i = 0; i < max_undo_workers; i++) + { + UndoApplyWorker *w = &UndoApplyCtx->workers[i]; + + if (w->in_use && w->lingering && w->dbid == dbid) + { + SetLatch(&w->proc->procLatch); + + LWLockRelease(UndoWorkerLock); + return; + } + } + + /* + * Stop one of the lingering worker which is not processing the requests + * in the given database. + */ + for (i = 0; i < max_undo_workers; i++) + { + UndoApplyWorker *w = &UndoApplyCtx->workers[i]; + + if (w->in_use && w->lingering && w->dbid != dbid) + kill(w->proc->pid, SIGTERM); + } + + if (UndoApplyCtx->undo_launcher_latch) + SetLatch(UndoApplyCtx->undo_launcher_latch); + + LWLockRelease(UndoWorkerLock); + + return; +} diff --git a/src/backend/access/undo/undo.c b/src/backend/access/undo/undo.c index b95efee0339f7..5b0dc4c6c8b01 100644 --- a/src/backend/access/undo/undo.c +++ b/src/backend/access/undo/undo.c @@ -51,6 +51,23 @@ */ MemoryContext UndoContext = NULL; +/* + * Per-backend UNDO engine entry points (declared locally to avoid including + * the colliding pbu_*.h type headers; see comment in the include block). + */ +extern Size PbuUndoLogShmemSize(void); +extern void PbuUndoLogShmemInit(void); +extern void UndoLogInit(void); +extern int PendingUndoShmemSize(void); +extern void PendingUndoShmemInit(void); +extern Size UndoLauncherShmemSize(void); +extern void UndoLauncherShmemInit(void); +extern void UndoLauncherRegister(void); +extern void DiscardWorkerRegister(void); +extern void PbuUndoLogShmemRequest(void); +extern void PendingUndoShmemRequest(void); +extern void UndoLauncherShmemRequest(void); + static void AtProcExit_Undo(int code, Datum arg); static void UndoShmemRequest_internal(void *arg); static void UndoShmemInit_internal(void *arg); @@ -88,6 +105,11 @@ UndoShmemSize(void) size = add_size(size, XactUndoShmemSize()); size = add_size(size, UndoWorkerShmemSize()); + /* Per-backend UNDO engine. */ + size = add_size(size, PbuUndoLogShmemSize()); + size = add_size(size, PendingUndoShmemSize()); + size = add_size(size, UndoLauncherShmemSize()); + return size; } @@ -126,14 +148,38 @@ UndoShmemRequest_internal(void *arg) { UndoWorkerRegister(); + /* + * The per-backend UNDO engine's discard worker reclaims undo log + * space, and its undo launcher applies rollbacks deferred by + * crash recovery. Neither is optional: without the discard + * worker the undo segment files of any FLUX table grow + * without bound. Both hibernate when there is no per-backend UNDO + * to process, so a cluster that never uses those AMs pays only + * for two idle processes on top of the cluster-wide UNDO worker + * and logical revert launcher. The default max_worker_processes + * is sized to leave parallel query its full budget alongside + * these. Registered here (request_fn phase) for the same reason + * as UndoWorkerRegister(): a static background worker cannot be + * registered after BackgroundWorkerShmemInit(). + */ + DiscardWorkerRegister(); + UndoLauncherRegister(); + undo_worker_registered = true; } } /* - * Initialize the UNDO resource manager dispatch table and register the - * built-in resource managers listed in access/undormgrlist.h. + * Register the per-backend UNDO engine's shmem areas so the framework + * sizes the segment for them and allocates them before the init_fn phase. + * The per-relation engine's structs are small enough to come from the + * general shmem slop; the per-backend engine's rollback hash table and + * queues are large, so they must be requested here (matching the sLog + * subsystem's pattern) rather than drawn from the slop. */ + PbuUndoLogShmemRequest(); + PendingUndoShmemRequest(); + UndoLauncherShmemRequest(); } /* @@ -168,6 +214,24 @@ UndoShmemInit(void) XactUndoShmemInit(); UndoWorkerShmemInit(); + /* + * Per-backend UNDO engine. + * + * Shmem + LWLock tranches + rollback queues + worker slots are set up + * here so the engine is live in shared memory. The engine's WAL rmgrs + * (RM_UNDOLOG_ID / RM_UNDOACTION_ID) redo correctly. + * + * Crash-recovery survival of per-backend undo logs -- CheckPointUndoLogs + * writing the pg_undo/ snapshot and StartupUndoLogs reading it back + * to rebuild UndoLogControl banks -- is wired into the checkpoint/startup + * path once a per-backend AM can produce non-empty undo-log state (create + * the pg_undo dir, call CheckPointUndoLogs from CheckPointGuts, call + * StartupUndoLogs from the recovery redo point). + */ + PbuUndoLogShmemInit(); + PendingUndoShmemInit(); + UndoLauncherShmemInit(); + /* * Initialize the UNDO resource manager dispatch table and register the * built-in resource managers listed in access/undormgrlist.h. @@ -207,6 +271,11 @@ UndoShmemAttach_internal(void *arg) UndoLogShmemInit(); UndoWorkerShmemInit(); + /* Per-backend UNDO engine shmem (see comment above about self-attach). */ + PbuUndoLogShmemInit(); + PendingUndoShmemInit(); + UndoLauncherShmemInit(); + RegisterUndoRmgrs(); } @@ -256,6 +325,12 @@ InitializeUndo(void) { InitializeXactUndo(); + /* + * Per-backend UNDO engine per-backend init: registers a before_shmem_exit + * callback that detaches this backend from its undo logs. + */ + UndoLogInit(); + on_shmem_exit(AtProcExit_Undo, 0); } diff --git a/src/backend/backup/basebackup.c b/src/backend/backup/basebackup.c index e3c04ecd810be..9bb55bc662e58 100644 --- a/src/backend/backup/basebackup.c +++ b/src/backend/backup/basebackup.c @@ -1313,7 +1313,9 @@ sendDir(bbsink *sink, const char *path, int basepathlen, bool sizeonly, /* * If there could be non-temporary relation files in this directory, - * try to parse the filename. + * try to parse the filename. Undo directories are not relation + * directories: their segments must be copied whole, not selected by + * the relation block maps in WAL summaries. */ if (isRelationDir) isRelationFile = @@ -1590,6 +1592,8 @@ sendFile(bbsink *sink, const char *readfilename, const char *tarfilename, off_t cnt; pgoff_t bytes_done = 0; bool verify_checksum = false; + BlockNumber undo_blockoffset; + bool is_undo = ParseUndoSegmentPath(readfilename, &undo_blockoffset); pg_checksum_context checksum_ctx; int ibindex = 0; @@ -1622,7 +1626,7 @@ sendFile(bbsink *sink, const char *readfilename, const char *tarfilename, * disabled while the backup runs, thus we check at each point where we * could be validating a checksum. */ - if (!noverify_checksums && RelFileNumberIsValid(relfilenumber) && + if (!noverify_checksums && (RelFileNumberIsValid(relfilenumber) || is_undo) && backup_checksums_verifiable(sink->bbs_state->startptr)) verify_checksum = true; @@ -1711,7 +1715,7 @@ sendFile(bbsink *sink, const char *readfilename, const char *tarfilename, */ cnt = read_file_data_into_buffer(sink, readfilename, fd, bytes_done, remaining, - blkno + segno * RELSEG_SIZE, + blkno + (is_undo ? undo_blockoffset : segno * RELSEG_SIZE), verify_checksum, &checksum_failures); } diff --git a/src/backend/postmaster/bgworker.c b/src/backend/postmaster/bgworker.c index d9d3e51396003..bbbc9c16078dd 100644 --- a/src/backend/postmaster/bgworker.c +++ b/src/backend/postmaster/bgworker.c @@ -14,6 +14,8 @@ #include "access/parallel.h" #include "access/undoworker.h" +#include "access/perbackend/pbu_undoworker.h" +#include "access/perbackend/pbu_discardworker.h" #include "commands/repack.h" #include "libpq/pqsignal.h" #include "miscadmin.h" @@ -171,6 +173,19 @@ static const struct { .fn_name = "UndoWorkerMain", .fn_addr = UndoWorkerMain + }, + /* per-backend UNDO engine workers */ + { + .fn_name = "PbuUndoWorkerMain", + .fn_addr = PbuUndoWorkerMain + }, + { + .fn_name = "UndoLauncherMain", + .fn_addr = UndoLauncherMain + }, + { + .fn_name = "DiscardWorkerMain", + .fn_addr = DiscardWorkerMain } }; diff --git a/src/backend/postmaster/datachecksum_state.c b/src/backend/postmaster/datachecksum_state.c index e2b673a96b5a6..5b94b0a65d1f4 100644 --- a/src/backend/postmaster/datachecksum_state.c +++ b/src/backend/postmaster/datachecksum_state.c @@ -204,6 +204,8 @@ */ #include "postgres.h" +#include "access/perbackend/pbu_undolog.h" + #include "access/genam.h" #include "access/heapam.h" #include "access/htup_details.h" @@ -1349,6 +1351,9 @@ DataChecksumsWorkerLauncherMain(Datum arg) * Data checksums have been set on all pages, set the state to on in * order to instruct backends to validate checksums on reading. */ + StartTransactionCommand(); + UndoLogEnableChecksums(); + CommitTransactionCommand(); SetDataChecksumsOn(); ereport(LOG, diff --git a/src/backend/storage/smgr/smgr.c b/src/backend/storage/smgr/smgr.c index 5391640d8613d..f4534e6997654 100644 --- a/src/backend/storage/smgr/smgr.c +++ b/src/backend/storage/smgr/smgr.c @@ -71,6 +71,8 @@ #include "storage/ipc.h" #include "storage/md.h" #include "storage/smgr.h" +#include "access/perbackend/pbu_undofile.h" +#include "access/perbackend/pbu_undolog.h" #include "utils/hsearch.h" #include "utils/inval.h" @@ -125,9 +127,13 @@ typedef struct f_smgr int (*smgr_fd) (SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, uint32 *off); } f_smgr; +/* Storage manager selectors (indexes into smgrsw[]). */ +#define SMGR_MD 0 +#define SMGR_UNDO 1 + static const f_smgr smgrsw[] = { /* magnetic disk */ - { + [SMGR_MD] = { .smgr_init = mdinit, .smgr_shutdown = NULL, .smgr_open = mdopen, @@ -148,6 +154,29 @@ static const f_smgr smgrsw[] = { .smgr_immedsync = mdimmedsync, .smgr_registersync = mdregistersync, .smgr_fd = mdfd, + }, + /* per-backend undo log segment files (base/undo) */ + [SMGR_UNDO] = { + .smgr_init = undofile_init, + .smgr_shutdown = undofile_shutdown, + .smgr_open = undofile_open, + .smgr_close = undofile_close, + .smgr_create = undofile_create, + .smgr_exists = undofile_exists, + .smgr_unlink = undofile_unlink, + .smgr_extend = undofile_extend, + .smgr_zeroextend = undofile_zeroextend, + .smgr_prefetch = undofile_prefetch, + .smgr_maxcombine = undofile_maxcombine, + .smgr_readv = undofile_readv, + .smgr_startreadv = undofile_startreadv, + .smgr_writev = undofile_writev, + .smgr_writeback = undofile_writeback, + .smgr_nblocks = undofile_nblocks, + .smgr_truncate = undofile_truncate, + .smgr_immedsync = undofile_immedsync, + .smgr_registersync = undofile_registersync, + .smgr_fd = undofile_fd, } }; @@ -273,7 +302,16 @@ smgropen(RelFileLocator rlocator, ProcNumber backend) reln->smgr_targblock = InvalidBlockNumber; for (int i = 0; i <= MAX_FORKNUM; ++i) reln->smgr_cached_nblocks[i] = InvalidBlockNumber; - reln->smgr_which = 0; /* we only have md.c at present */ + + /* + * Route per-backend undo log segment files (identified by the + * pseudo-database OID they use) to the undo smgr; everything else + * uses md.c. + */ + if (rlocator.dbOid == UndoLogDatabaseOid) + reln->smgr_which = SMGR_UNDO; + else + reln->smgr_which = SMGR_MD; /* it is not pinned yet */ reln->pincount = 0; diff --git a/src/backend/storage/sync/sync.c b/src/backend/storage/sync/sync.c index 2c964b6f3d941..da5f223ba4655 100644 --- a/src/backend/storage/sync/sync.c +++ b/src/backend/storage/sync/sync.c @@ -29,6 +29,7 @@ #include "storage/fd.h" #include "storage/latch.h" #include "storage/md.h" +#include "access/perbackend/pbu_undofile.h" #include "utils/hsearch.h" #include "utils/memutils.h" #include "utils/wait_event.h" @@ -115,6 +116,10 @@ static const SyncOps syncsw[] = { /* pg_multixact/members */ [SYNC_HANDLER_MULTIXACT_MEMBER] = { .sync_syncfiletag = multixactmemberssyncfiletag + }, + /* per-backend UNDO log segment files (base/undo) */ + [SYNC_HANDLER_UNDO] = { + .sync_syncfiletag = undofile_syncfiletag } }; diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c index b6bdfe213feec..b74b5a196025e 100644 --- a/src/backend/tcop/postgres.c +++ b/src/backend/tcop/postgres.c @@ -4786,6 +4786,9 @@ PostgresMain(const char *dbname, const char *username) if (notifyInterruptPending) ProcessNotifyInterrupt(false); + /* Finish rollbacks that had to wait for the transaction end. */ + PbuApplyPendingUndo(); + /* * Check if we need to report stats. If pgstat_report_stat() * decides it's too soon to flush out pending stats / lock diff --git a/src/backend/utils/activity/wait_event_names.txt b/src/backend/utils/activity/wait_event_names.txt index a81528df61f65..d840a5185f833 100644 --- a/src/backend/utils/activity/wait_event_names.txt +++ b/src/backend/utils/activity/wait_event_names.txt @@ -424,6 +424,7 @@ 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." UndoWorker "Waiting to access or modify UNDO worker shared memory queue." +UndoLogPBU "Waiting to access or modify per-backend UNDO log state." # No "ABI_compatibility" region here as WaitEventLWLock has its own C code. diff --git a/src/backend/utils/init/globals.c b/src/backend/utils/init/globals.c index bbd28d14d9948..aa58d9876312d 100644 --- a/src/backend/utils/init/globals.c +++ b/src/backend/utils/init/globals.c @@ -143,7 +143,7 @@ int max_parallel_maintenance_workers = 2; */ int NBuffers = 16384; int MaxConnections = 100; -int max_worker_processes = 8; +int max_worker_processes = 12; int max_parallel_workers = 8; int autovacuum_max_parallel_workers = 0; int MaxBackends = 0; diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index fef22d15030d7..1e76b1cf60b8a 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -2207,7 +2207,7 @@ { name => 'max_worker_processes', type => 'int', context => 'PGC_POSTMASTER', group => 'RESOURCES_WORKER_PROCESSES', short_desc => 'Maximum number of concurrent worker processes.', variable => 'max_worker_processes', - boot_val => '8', + boot_val => '12', min => '0', max => 'MAX_BACKENDS', }, @@ -3325,6 +3325,17 @@ max => 'INT_MAX / 2', }, +{ name => 'undo_rollback_overflow_size', type => 'int', context => 'PGC_SIGHUP', group => 'WAL_SETTINGS', + short_desc => 'Per-backend UNDO rollback-request size threshold for the worker queues.', + long_desc => 'Rollback requests estimated larger than this many megabytes are placed on the size-ordered undo worker queue (processed largest-first) instead of the xid-ordered queue. Only affects background undo worker scheduling.', + flags => 'GUC_UNIT_MB', + variable => 'undo_rollback_overflow_size', + boot_val => '16', + min => '0', + max => 'INT_MAX / 1024', +}, + + { 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.', diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index 4c4bf765805e0..6dddf08c04339 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -230,7 +230,8 @@ # - Worker Processes - -#max_worker_processes = 8 # (change requires restart) +#max_worker_processes = 12 # (change requires restart) + # includes the always-on UNDO workers #max_parallel_workers_per_gather = 2 # limited by max_parallel_workers #max_parallel_maintenance_workers = 2 # limited by max_parallel_workers #max_parallel_workers = 8 # number of max_worker_processes that @@ -920,6 +921,8 @@ #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 +#undo_rollback_overflow_size = 16MB # rollbacks larger than this go to the + # size-ordered undo worker queue #------------------------------------------------------------------------------ diff --git a/src/bin/pg_checksums/pg_checksums.c b/src/bin/pg_checksums/pg_checksums.c index 3b58c6ca6087e..ae27d1e0b2523 100644 --- a/src/bin/pg_checksums/pg_checksums.c +++ b/src/bin/pg_checksums/pg_checksums.c @@ -173,7 +173,7 @@ skipfile(const char *fn) } static void -scan_file(const char *fn, int segmentno) +scan_file(const char *fn, BlockNumber blockoffset) { PGIOAlignedBlock buf; PageHeader header = (PageHeader) buf.data; @@ -224,7 +224,7 @@ scan_file(const char *fn, int segmentno) if (PageIsNew(buf.data)) continue; - csum = pg_checksum_page(buf.data, blockno + segmentno * RELSEG_SIZE); + csum = pg_checksum_page(buf.data, blockno + blockoffset); if (mode == PG_MODE_CHECK) { if (csum != header->pd_checksum) @@ -343,6 +343,7 @@ scan_directory(const char *basedir, const char *subdir, bool sizeonly) char *forkpath, *segmentpath; int segmentno = 0; + BlockNumber blockoffset; if (skipfile(de->d_name)) continue; @@ -355,6 +356,13 @@ scan_directory(const char *basedir, const char *subdir, bool sizeonly) */ strlcpy(fnonly, de->d_name, sizeof(fnonly)); segmentpath = strchr(fnonly, '.'); + if (ParseUndoSegmentPath(fn, &blockoffset)) + { + dirsize += st.st_size; + if (!sizeonly && !only_filenode) + scan_file(fn, blockoffset); + continue; + } if (segmentpath != NULL) { *segmentpath++ = '\0'; @@ -379,7 +387,7 @@ scan_directory(const char *basedir, const char *subdir, bool sizeonly) * the items in the data folder. */ if (!sizeonly) - scan_file(fn, segmentno); + scan_file(fn, segmentno * RELSEG_SIZE); } else if (S_ISDIR(st.st_mode) || S_ISLNK(st.st_mode)) { diff --git a/src/bin/pg_rewind/filemap.c b/src/bin/pg_rewind/filemap.c index eeb26c5f8161c..77ebe0e4fc6dd 100644 --- a/src/bin/pg_rewind/filemap.c +++ b/src/bin/pg_rewind/filemap.c @@ -590,6 +590,8 @@ getFileContentType(const char *path) * relation files. Other forks are always copied in toto, because we * cannot reliably track changes to them, because WAL only contains block * references for the main fork. + * Undo segment files are also copied in full: their hexadecimal names + * below base/undo or a tablespace undo directory do not match here. * * Relation data files can be in one of the following directories: * diff --git a/src/bin/pg_waldump/rmgrdesc.c b/src/bin/pg_waldump/rmgrdesc.c index ed27a243febb7..fbe815570b332 100644 --- a/src/bin/pg_waldump/rmgrdesc.c +++ b/src/bin/pg_waldump/rmgrdesc.c @@ -31,6 +31,12 @@ #include "replication/message.h" #include "replication/origin.h" #include "rmgrdesc.h" + +/* per-backend UNDO rmgr desc routines (see access/perbackend/pbu_*_xlog.h) */ +extern void undolog_desc(StringInfo buf, XLogReaderState *record); +extern const char *undolog_identify(uint8 info); +extern void undoaction_desc(StringInfo buf, XLogReaderState *record); +extern const char *undoaction_identify(uint8 info); #include "storage/standbydefs.h" #include "utils/relmapper.h" diff --git a/src/bin/pg_waldump/t/001_basic.pl b/src/bin/pg_waldump/t/001_basic.pl index ded5e37feda65..c16afa1faadd7 100644 --- a/src/bin/pg_waldump/t/001_basic.pl +++ b/src/bin/pg_waldump/t/001_basic.pl @@ -98,7 +98,9 @@ LogicalMessage XLOG2 Undo -FileOps$/, +FileOps +UndoLog +UndoAction$/, 'rmgr list'); diff --git a/src/common/relpath.c b/src/common/relpath.c index 8fb3bed7873ab..c7fc5d310857a 100644 --- a/src/common/relpath.c +++ b/src/common/relpath.c @@ -220,3 +220,29 @@ GetRelationPath(Oid dbOid, Oid spcOid, RelFileNumber relNumber, return rp; } + +/* + * Undo filenames encode a log number and a hexadecimal byte offset. They + * are not relation segments; callers must copy them whole when making an + * incremental backup or rewinding, and use this block offset for checksums. + */ +bool +ParseUndoSegmentPath(const char *path, BlockNumber *block) +{ + const char *name = strrchr(path, '/'); + uint64 offset; + + if (name == NULL || name - path < 5 || + strncmp(name - 5, "/undo", 5) != 0) + return false; + name++; + if (strlen(name) != 17 || name[6] != '.' || + strspn(name, "0123456789ABCDEF") != 6 || + strspn(name + 7, "0123456789ABCDEF") != 10) + return false; + offset = strtoull(name + 7, NULL, 16); + if (offset % BLCKSZ != 0 || offset / BLCKSZ > MaxBlockNumber) + return false; + *block = offset / BLCKSZ; + return true; +} diff --git a/src/include/access/perbackend/pbu_compat.h b/src/include/access/perbackend/pbu_compat.h new file mode 100644 index 0000000000000..afcfd0d4e6fd5 --- /dev/null +++ b/src/include/access/perbackend/pbu_compat.h @@ -0,0 +1,185 @@ +/*------------------------------------------------------------------------- + * + * pbu_compat.h + * API-drift compatibility shims for the per-backend UNDO engine. + * + * The per-backend UNDO engine is vendored from EnterpriseDB zheap (2019, + * ~PG12-devel) and reconciled to current master (~PG18/19-devel). This + * header centralizes the mechanical drift fixes that would otherwise be + * duplicated across every vendored translation unit, so that all pbu_*.c + * files reconcile the same drift the same way. + * + * Where a shim maps to a "close enough" master API (for example wait-event + * labels), that is called out inline. + * + * Per-backend UNDO engine derives from EnterpriseDB zheap + * (Amit Kapila, Dilip Kumar, Kuntal Ghosh, Mahendra Singh Thalor, + * Rafia Sabih, Thomas Munro et al.). + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/perbackend/pbu_compat.h + * + *------------------------------------------------------------------------- + */ +#ifndef PBU_COMPAT_H +#define PBU_COMPAT_H + +#include "access/rmgr.h" +#include "access/transam.h" +#include "access/xlogutils.h" /* InRecovery, InHotStandby */ +#include "nodes/primnodes.h" /* OnCommitAction */ +#include "storage/bufmgr.h" /* ReadBufferMode */ +#include "storage/lwlock.h" +#include "storage/procarray.h" +#include "utils/wait_event.h" + +/* + * INT64_MODIFIER was removed from the tree; UndoRecPtrFormat/UndoLogOffset + * still reference it. The width modifier for a 64-bit integer is "ll" on all + * supported platforms (int64 is long long via pg_config). + */ +#ifndef INT64_MODIFIER +#define INT64_MODIFIER "ll" +#endif + +/* + * ShmemVariableCache was renamed to TransamVariables. + */ +#ifndef ShmemVariableCache +#define ShmemVariableCache TransamVariables +#endif + +/* + * Resource-manager IDs. RM_UNDOLOG_ID and RM_UNDOACTION_ID are dedicated + * resource manager IDs registered in rmgrlist.h (with the pbu undolog_redo/ + * desc/identify and undoaction_redo/desc/identify handlers). The existing + * RM_UNDO_ID (undo_redo) cluster-wide engine is untouched and keeps its own + * WAL stream. + */ + +/* + * Wait-event labels. Several zheap-specific wait events were never merged + * upstream. They are observability labels only. The per-backend undo file + * I/O paths (pbu_undofile.c) use the existing DATA_FILE_* events directly, as + * undo segments are ordinary data files; only the checkpoint-snapshot and + * background-worker labels below remain aliased to the closest existing event. + * These are purely cosmetic (they change the string shown in + * pg_stat_activity.wait_event), so dedicated wait_event_names.txt entries are + * a nice-to-have, not a correctness item. + */ +#ifndef WAIT_EVENT_UNDO_CHECKPOINT_WRITE +#define WAIT_EVENT_UNDO_CHECKPOINT_WRITE WAIT_EVENT_UNDO_FLUSH_SYNC +#endif +#ifndef WAIT_EVENT_UNDO_CHECKPOINT_SYNC +#define WAIT_EVENT_UNDO_CHECKPOINT_SYNC WAIT_EVENT_UNDO_FLUSH_SYNC +#endif +#ifndef WAIT_EVENT_UNDO_CHECKPOINT_READ +#define WAIT_EVENT_UNDO_CHECKPOINT_READ WAIT_EVENT_UNDO_FLUSH_SYNC +#endif +#ifndef WAIT_EVENT_UNDO_LAUNCHER_MAIN +#define WAIT_EVENT_UNDO_LAUNCHER_MAIN WAIT_EVENT_UNDO_WORKER_MAIN +#endif +#ifndef WAIT_EVENT_UNDO_DISCARD_WORKER_MAIN +#define WAIT_EVENT_UNDO_DISCARD_WORKER_MAIN WAIT_EVENT_UNDO_WORKER_MAIN +#endif +#ifndef WAIT_EVENT_BGWORKER_STARTUP +#define WAIT_EVENT_BGWORKER_STARTUP WAIT_EVENT_BGWORKER_SHUTDOWN +#endif + +/* + * PROCARRAY_FLAGS_* and the two-argument GetOldestXmin() were replaced by + * GetOldestNonRemovableTransactionId(Relation). The flags selected which + * process classes to ignore when computing the horizon. The discard worker + * calls this with a NULL relation, i.e. it wants the global oldest xid that + * any snapshot might still need; GetOldestNonRemovableTransactionId(NULL) is + * exactly that conservative global horizon. Being conservative only makes + * discard retain MORE undo than strictly necessary, never less, so it is safe: + * no snapshot can ever observe a prematurely discarded undo record. The alias + * therefore preserves correctness (the zheap flags were a discard-aggressiveness + * tuning knob, not a safety property). + */ +#ifndef PROCARRAY_FLAGS_DEFAULT +#define PROCARRAY_FLAGS_DEFAULT 0 +#define PROCARRAY_FLAGS_VACUUM 0 +#define PROCARRAY_FLAGS_AUTOVACUUM 0 +#define GetOldestXmin(rel, flags) GetOldestNonRemovableTransactionId(rel) +#endif + +/* + * Per-backend UNDO log freelist lock. zheap used a built-in named LWLock + * (UndoLogLock) from the main LWLock array. To avoid touching the shared + * lwlocklist.h, the engine allocates its own singleton lock in + * shared memory (see pbu_undolog.c PbuUndoLogShmemInit) and exposes it through + * this global. + */ +#ifndef FRONTEND +extern PGDLLIMPORT LWLock *UndoLogLock_pbu; +#define UndoLogLock UndoLogLock_pbu + +/* + * zheap used two more built-in named LWLocks: RollbackRequestLock (guards the + * rollback request queues / hash table) and UndoWorkerLock (guards the undo + * worker shmem slots). Like UndoLogLock, the pbu engine allocates these as + * its own singletons (see pbu_undolog.c PbuUndoLogShmemInit) to avoid touching + * the shared lwlocklist.h. + */ +extern PGDLLIMPORT LWLock *RollbackRequestLock_pbu; +extern PGDLLIMPORT LWLock *UndoWorkerLock_pbu; +#define RollbackRequestLock RollbackRequestLock_pbu +#define UndoWorkerLock UndoWorkerLock_pbu + +/* + * Oldest full XID that still has undo not yet discarded, published by the + * discard worker and read by other backends. zheap kept this as a + * pg_atomic_uint64 field (oldestXidWithEpochHavingUndo) on the shared PROC_HDR + * (ProcGlobal). To avoid widening that shared struct, the pbu + * engine owns its own atomic in its shmem region (see pbu_undolog.c). + */ +#include "port/atomics.h" +extern PGDLLIMPORT pg_atomic_uint64 *UndoOldestXidHavingUndo_pbu; +#endif + +/* + * pbu-private LWLock tranche IDs. Registered dynamically via + * LWLockRegisterTranche() in pbu_undolog.c so we do not touch the shared + * built-in tranche enum. These IDs live above LWTRANCHE_FIRST_USER_DEFINED. + */ +#define LWTRANCHE_UNDOLOG (LWTRANCHE_FIRST_USER_DEFINED + 1001) +#define LWTRANCHE_UNDODISCARD (LWTRANCHE_FIRST_USER_DEFINED + 1002) +#define LWTRANCHE_DISCARD_UPDATE (LWTRANCHE_FIRST_USER_DEFINED + 1003) + +/* + * ReadBufferMode: zheap's bare RBM_ZERO ("zero the page, do not read from + * disk, do not lock") was removed. Master's RBM_ZERO_AND_LOCK has the same + * meaning we need here -- do not read from disk, caller initializes the page -- + * and additionally takes the exclusive content lock. pbu_undoinsert.c guards + * every LockBuffer() with "if (rbm != RBM_ZERO_AND_LOCK)", so the alias is the + * correct master mapping: the page is zeroed-not-read exactly as before and is + * locked exactly once. This is resolved, not a deferral. + */ +#ifndef RBM_ZERO +#define RBM_ZERO RBM_ZERO_AND_LOCK +#endif + +/* + * target_prefetch_pages (a global derived from effective_io_concurrency) was + * removed; use effective_io_concurrency directly as the prefetch depth. + */ +#ifndef target_prefetch_pages +#define target_prefetch_pages effective_io_concurrency +#endif + +/* + * RelFileNode was renamed to RelFileLocator (PG16); its fields spcNode/ + * dbNode/relNode became spcOid/dbOid/relNumber. The zheap engine uses + * RelFileNode purely as the physical storage locator, which is exactly what + * RelFileLocator now is, so aliasing the type name lets the vendored code + * compile unchanged. Field accesses are fixed at their (few) sites. + */ +#ifndef FRONTEND +#define RelFileNode RelFileLocator +#endif + +#endif /* PBU_COMPAT_H */ diff --git a/src/include/access/perbackend/pbu_discardworker.h b/src/include/access/perbackend/pbu_discardworker.h new file mode 100644 index 0000000000000..1fa1070e584a9 --- /dev/null +++ b/src/include/access/perbackend/pbu_discardworker.h @@ -0,0 +1,24 @@ +/*------------------------------------------------------------------------- + * + * pbu_discardworker.h + * Exports from access/undo/perbackend/pbu_discardworker.c. + * + * Per-backend UNDO engine vendored from EnterpriseDB zheap + * (Amit Kapila, Dilip Kumar, Kuntal Ghosh, Mahendra Singh Thalor, + * Rafia Sabih, Thomas Munro et al.). + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/perbackend/pbu_discardworker.h + * + *------------------------------------------------------------------------- + */ +#ifndef PBU_DISCARDWORKER_H +#define PBU_DISCARDWORKER_H + +extern void DiscardWorkerRegister(void); +pg_noreturn extern void DiscardWorkerMain(Datum main_arg); +extern bool IsDiscardProcess(void); + +#endif /* PBU_DISCARDWORKER_H */ diff --git a/src/include/access/perbackend/pbu_helpers.h b/src/include/access/perbackend/pbu_helpers.h new file mode 100644 index 0000000000000..a7c92ce9ae56f --- /dev/null +++ b/src/include/access/perbackend/pbu_helpers.h @@ -0,0 +1,48 @@ +/*------------------------------------------------------------------------- + * + * pbu_helpers.h + * small helpers the per-backend UNDO engine relied on that were either + * zheap-private (GetEpochForXid, dbid_exists, + * SetCurrentUndoLocation) or provided as macros in a zheap-patched + * access/transam.h (GetXidFromEpochXid, GetEpochFromEpochXid). + * + * The helpers here are real implementations against the current master API. + * + * Per-backend undo engine derives from EnterpriseDB zheap + * (Amit Kapila, Dilip Kumar, Kuntal Ghosh, Mahendra Singh Thalor, + * Rafia Sabih, Thomas Munro et al.). + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/perbackend/pbu_helpers.h + * + *------------------------------------------------------------------------- + */ +#ifndef PBU_HELPERS_H +#define PBU_HELPERS_H + +#include "access/perbackend/pbu_undolog.h" +#include "access/transam.h" + +/* Extract xid / epoch from a 64-bit value comprised of epoch<<32 | xid. */ +#ifndef GetXidFromEpochXid +#define GetXidFromEpochXid(epochxid) ((uint32) (epochxid) & 0XFFFFFFFF) +#endif +#ifndef GetEpochFromEpochXid +#define GetEpochFromEpochXid(epochxid) ((uint32) ((epochxid) >> 32)) +#endif + +/* Compute the epoch of a (possibly past) plain TransactionId. */ +extern uint32 GetEpochForXid(TransactionId xid); + +/* True iff a database with the given OID currently exists. */ +extern bool dbid_exists(Oid dbid); + +/* + * Record the transaction's current undo insertion location on the + * TransactionState (via SetCurrentTransactionPbuUndoLocation in xact.c). + */ +extern void SetCurrentUndoLocation(UndoRecPtr urec_ptr); + +#endif /* PBU_HELPERS_H */ diff --git a/src/include/access/perbackend/pbu_undoaction_xlog.h b/src/include/access/perbackend/pbu_undoaction_xlog.h new file mode 100644 index 0000000000000..733f83fd77566 --- /dev/null +++ b/src/include/access/perbackend/pbu_undoaction_xlog.h @@ -0,0 +1,52 @@ +/*------------------------------------------------------------------------- + * + * undoaction_xlog.h + * undo action XLOG definitions + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undoaction_xlog.h + * + *------------------------------------------------------------------------- + */ +#ifndef PBU_UNDOACTION_XLOG_H +#define PBU_UNDOACTION_XLOG_H + +#include "access/perbackend/pbu_undolog.h" +#include "access/xlogreader.h" +#include "lib/stringinfo.h" +#include "storage/off.h" + +/* + * WAL record definitions for undoactions.c's WAL operations + */ +#define XLOG_UNDO_APPLY_PROGRESS 0x00 +#define XLOG_UNDO_APPLY_SUBXACT 0x10 +#define XLOG_UNDO_AMEND_RECORD 0x20 + +/* Each block carries a uint16 page offset followed by replacement bytes. */ + +/* This is what we need to know about undo apply progress */ +typedef struct xl_undoapply_progress +{ + UndoRecPtr urec_ptr; + uint32 progress; +} xl_undoapply_progress; + +#define SizeOfUndoActionProgress (offsetof(xl_undoapply_progress, progress) + sizeof(uint32)) + +/* + * An undo record whose change was reverted by ROLLBACK TO SAVEPOINT. Block 0 + * is the undo page holding the record; redo marks the record as applied. + */ +typedef struct xl_undoapply_subxact +{ + UndoRecPtr urec_ptr; +} xl_undoapply_subxact; + +extern void undoaction_redo(XLogReaderState *record); +extern void undoaction_desc(StringInfo buf, XLogReaderState *record); +extern const char *undoaction_identify(uint8 info); + +#endif /* PBU_UNDOACTION_XLOG_H */ diff --git a/src/include/access/perbackend/pbu_undodiscard.h b/src/include/access/perbackend/pbu_undodiscard.h new file mode 100644 index 0000000000000..96abe2246ccbd --- /dev/null +++ b/src/include/access/perbackend/pbu_undodiscard.h @@ -0,0 +1,24 @@ +/*------------------------------------------------------------------------- + * + * undoinsert.h + * undo discard definitions + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undodiscard.h + * + *------------------------------------------------------------------------- + */ +#ifndef PBU_UNDODISCARD_H +#define PBU_UNDODISCARD_H + +#include "access/perbackend/pbu_undolog.h" +#include "access/xlogdefs.h" +#include "catalog/pg_class.h" +#include "storage/lwlock.h" + +extern void UndoDiscard(TransactionId xmin, bool *hibernate); +extern void UndoLogDiscardAll(void); + +#endif /* PBU_UNDODISCARD_H */ diff --git a/src/include/access/perbackend/pbu_undofile.h b/src/include/access/perbackend/pbu_undofile.h new file mode 100644 index 0000000000000..8975d6672b2a8 --- /dev/null +++ b/src/include/access/perbackend/pbu_undofile.h @@ -0,0 +1,80 @@ +/*------------------------------------------------------------------------- + * + * pbu_undofile.h + * PostgreSQL undo file manager (per-backend UNDO engine). + * + * Manages the files that back per-backend undo logs on the filesystem and + * exposes the smgr vtable entry points that smgr.c dispatches to for the + * base/undo segment files. + * + * Vendored/adapted from EnterpriseDB zheap + * (Amit Kapila, Dilip Kumar, Kuntal Ghosh, Mahendra Singh Thalor, + * Rafia Sabih, Thomas Munro et al.). + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/perbackend/pbu_undofile.h + * + *------------------------------------------------------------------------- + */ +#ifndef PBU_UNDOFILE_H +#define PBU_UNDOFILE_H + +#include "access/perbackend/pbu_undolog.h" +#include "storage/block.h" +#include "storage/relfilelocator.h" +#include "storage/aio_types.h" +#include "storage/smgr.h" +#include "storage/sync.h" + +/* smgr vtable entry points for undo log segment files. */ +extern void undofile_init(void); +extern void undofile_open(SMgrRelation reln); +extern void undofile_shutdown(void); +extern void undofile_close(SMgrRelation reln, ForkNumber forknum); +extern void undofile_create(SMgrRelation reln, ForkNumber forknum, + bool isRedo); +extern bool undofile_exists(SMgrRelation reln, ForkNumber forknum); +extern void undofile_unlink(RelFileLocatorBackend rlocator, ForkNumber forknum, + bool isRedo); +extern void undofile_extend(SMgrRelation reln, ForkNumber forknum, + BlockNumber blocknum, const void *buffer, + bool skipFsync); +extern void undofile_zeroextend(SMgrRelation reln, ForkNumber forknum, + BlockNumber blocknum, int nblocks, + bool skipFsync); +extern bool undofile_prefetch(SMgrRelation reln, ForkNumber forknum, + BlockNumber blocknum, int nblocks); +extern uint32 undofile_maxcombine(SMgrRelation reln, ForkNumber forknum, + BlockNumber blocknum); +extern void undofile_readv(SMgrRelation reln, ForkNumber forknum, + BlockNumber blocknum, void **buffers, + BlockNumber nblocks); +extern void undofile_startreadv(PgAioHandle *ioh, + SMgrRelation reln, ForkNumber forknum, + BlockNumber blocknum, void **buffers, + BlockNumber nblocks); +extern void undofile_writev(SMgrRelation reln, ForkNumber forknum, + BlockNumber blocknum, const void **buffers, + BlockNumber nblocks, bool skipFsync); +extern void undofile_writeback(SMgrRelation reln, ForkNumber forknum, + BlockNumber blocknum, BlockNumber nblocks); +extern BlockNumber undofile_nblocks(SMgrRelation reln, ForkNumber forknum); +extern void undofile_truncate(SMgrRelation reln, ForkNumber forknum, + BlockNumber old_blocks, BlockNumber nblocks); +extern void undofile_immedsync(SMgrRelation reln, ForkNumber forknum); +extern void undofile_registersync(SMgrRelation reln, ForkNumber forknum); +extern int undofile_fd(SMgrRelation reln, ForkNumber forknum, + BlockNumber blocknum, uint32 *off); + +/* checkpointer sync handler (SYNC_HANDLER_UNDO). */ +extern int undofile_syncfiletag(const FileTag *tag, char *path); + +/* + * Forget any pending fsync request for a discarded/recycled undo segment. + */ +extern void undofile_forget_sync(UndoLogNumber logno, BlockNumber segno, + Oid tablespace); + +#endif /* PBU_UNDOFILE_H */ diff --git a/src/include/access/perbackend/pbu_undoinsert.h b/src/include/access/perbackend/pbu_undoinsert.h new file mode 100644 index 0000000000000..c4637aff572a0 --- /dev/null +++ b/src/include/access/perbackend/pbu_undoinsert.h @@ -0,0 +1,80 @@ +/*------------------------------------------------------------------------- + * + * undoinsert.h + * entry points for inserting undo records + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undoinsert.h + * + *------------------------------------------------------------------------- + */ +#ifndef PBU_UNDOINSERT_H +#define PBU_UNDOINSERT_H + +#include "access/perbackend/pbu_undolog.h" +#include "access/perbackend/pbu_undorecord.h" +#include "access/xlogdefs.h" +#include "catalog/pg_class.h" +#include "storage/relfilelocator.h" + +/* + * A page-sized tuple plus record headers can span three pages when the + * record starts near the end of a page. Records are not aligned or padded. + */ +#define MAX_BUFFER_PER_UNDO 3 + +/* Maximum record size that fits regardless of its starting byte. */ +#define UndoRecordMaxSize ((MAX_BUFFER_PER_UNDO - 1) * UndoLogUsableBytesPerPage + 1) + +/* + * Typedef for callback function for UndoFetchRecord. + * + * This checks whether an undorecord satisfies the given conditions. + */ +typedef bool (*SatisfyUndoRecordCallback) (UnpackedUndoRecord *urec, + BlockNumber blkno, + OffsetNumber offset, + TransactionId xid); + +extern UndoRecPtr PrepareUndoInsert(UnpackedUndoRecord *, FullTransactionId xid, + UndoPersistence, XLogReaderState *xlog_record, + xl_undolog_meta *); + +extern void PbuAmendPayload(UndoRecPtr urp, Size offset, + const char *data, Size len); +extern void InsertPreparedUndo(void); +extern void RegisterUndoLogBuffers(uint8 first_block_id); +extern int RegisterUndoLogBuffersWithData(uint8 first_block_id, + const char *data, uint32 datalen); +extern void RegisterUndoLogBuffersForImage(uint8 first_block_id); +extern void UndoLogBuffersSetLSN(XLogRecPtr recptr); +extern void UnlockReleaseUndoBuffers(void); + +extern UnpackedUndoRecord *UndoFetchRecord(UndoRecPtr urp, + BlockNumber blkno, OffsetNumber offset, + TransactionId xid, UndoRecPtr *urec_ptr_out, + SatisfyUndoRecordCallback callback); +extern void UndoRecordRelease(UnpackedUndoRecord *urec); +extern void UndoRecordSetPrevUndoLen(uint16 len); +extern void UndoSetPrepareSize(UnpackedUndoRecord *undorecords, int nrecords, + FullTransactionId fxid, UndoPersistence upersistence, + XLogReaderState *xlog_record, xl_undolog_meta *undometa); + +extern UndoRecPtr UndoGetPrevUndoRecptr(UndoRecPtr urp, UndoRecPtr prevurp, + Buffer *buffer); + +extern void UndoRecordOnUndoLogChange(UndoPersistence persistence); + +extern bool PrepareUpdateUndoActionProgress(XLogReaderState *xlog_record, + UndoRecPtr urecptr, int progress); +extern void UndoRecordUpdateTransInfo(int idx); + +extern void UndoGetOneRecord(UnpackedUndoRecord *urec, UndoRecPtr urp, + RelFileLocator rnode, UndoPersistence persistence, + bool keep_buffer); +extern void ResetUndoBuffers(void); +extern bool UndoRecordIsValid(UndoRecPtr urp); + +#endif /* PBU_UNDOINSERT_H */ diff --git a/src/include/access/perbackend/pbu_undolog.h b/src/include/access/perbackend/pbu_undolog.h new file mode 100644 index 0000000000000..682024fbf7237 --- /dev/null +++ b/src/include/access/perbackend/pbu_undolog.h @@ -0,0 +1,356 @@ +/*------------------------------------------------------------------------- + * + * undolog.h + * + * PostgreSQL undo log manager. This module is responsible for life-cycle + * management of undo logs and backing files, associating undo logs with + * backends, allocating and managing space within undo logs. + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undolog.h + * + *------------------------------------------------------------------------- + */ +#ifndef PBU_UNDOLOG_H +#define PBU_UNDOLOG_H + +#include "access/xlogreader.h" +#include "catalog/pg_class.h" +#include "common/relpath.h" +#include "storage/bufpage.h" + +#ifndef FRONTEND +#include "storage/lwlock.h" +#include "storage/procnumber.h" +#endif + +/* 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; + +/* Undo log statuses. */ +typedef enum +{ + UNDO_LOG_STATUS_UNUSED = 0, + UNDO_LOG_STATUS_ACTIVE, + UNDO_LOG_STATUS_EXHAUSTED, + UNDO_LOG_STATUS_DISCARDED +} UndoLogStatus; + +/* + * Undo log persistence levels. These have a one-to-one correspondence with + * relpersistence values, but are small integers so that we can use them as an + * index into the "logs" and "lognos" arrays. + */ +typedef enum +{ + UNDO_PERMANENT = 0, + UNDO_UNLOGGED = 1, + UNDO_TEMP = 2 +} UndoPersistence; + +#define UndoPersistenceLevels 3 + +/* + * Convert from relpersistence ('p', 'u', 't') to an UndoPersistence + * enumerator. + */ +#define UndoPersistenceForRelPersistence(rp) \ + ((rp) == RELPERSISTENCE_PERMANENT ? UNDO_PERMANENT : \ + (rp) == RELPERSISTENCE_UNLOGGED ? UNDO_UNLOGGED : UNDO_TEMP) + +/* + * Does an undo log of this persistence level need its buffers written at + * every checkpoint? This is ReadBufferWithoutRelcache()'s "permanent". + */ +#define UndoPersistenceIsPermanent(up) ((up) == UNDO_PERMANENT) + +/* + * Convert from UndoPersistence to a relpersistence value. + */ +#define RelPersistenceForUndoPersistence(up) \ + ((up) == UNDO_PERMANENT ? RELPERSISTENCE_PERMANENT : \ + (up) == UNDO_UNLOGGED ? RELPERSISTENCE_UNLOGGED : \ + RELPERSISTENCE_TEMP) + +/* + * Get the appropriate UndoPersistence value from a Relation. + */ +#define UndoPersistenceForRelation(rel) \ + (UndoPersistenceForRelPersistence((rel)->rd_rel->relpersistence)) + +/* Type for offsets within undo logs */ +typedef uint64 UndoLogOffset; + +/* printf-family format string for UndoRecPtr. */ +#define UndoRecPtrFormat "%016" INT64_MODIFIER "X" + +/* printf-family format string for UndoLogOffset. */ +#define UndoLogOffsetFormat UINT64_FORMAT + +/* Number of blocks of BLCKSZ in an undo log segment file. 128 = 1MB. */ +#define UNDOSEG_SIZE 128 + +/* Size of an undo log segment file in bytes. */ +#define UndoLogSegmentSize ((size_t) BLCKSZ * UNDOSEG_SIZE) + +/* The width of an undo log number in bits. 24 allows for 16.7m logs. */ +#define UndoLogNumberBits 24 + +/* The width of an undo log offset in bits. 40 allows for 1TB per log.*/ +#define UndoLogOffsetBits (64 - UndoLogNumberBits) + +/* Special value for undo record pointer which indicates that it is invalid. */ +#define InvalidUndoRecPtr ((UndoRecPtr) 0) + +/* End-of-list value when building linked lists of undo logs. */ +#define InvalidUndoLogNumber -1 + +/* + * The maximum amount of data that can be stored in an undo log. Can be set + * artificially low to test full log behavior. + */ +#define UndoLogMaxSize ((UndoLogOffset) 1 << UndoLogOffsetBits) + +/* Type for numbering undo logs. */ +typedef int UndoLogNumber; + +/* Extract the undo log number from an UndoRecPtr. */ +#define UndoRecPtrGetLogNo(urp) \ + ((urp) >> UndoLogOffsetBits) + +/* Extract the offset from an UndoRecPtr. */ +#define UndoRecPtrGetOffset(urp) \ + ((urp) & ((UINT64CONST(1) << UndoLogOffsetBits) - 1)) + +/* Make an UndoRecPtr from an log number and offset. */ +#define MakeUndoRecPtr(logno, offset) \ + (((uint64) (logno) << UndoLogOffsetBits) | (offset)) + +/* The number of unusable bytes in the header of each block. */ +#define UndoLogBlockHeaderSize SizeOfPageHeaderData + +/* The number of usable bytes we can store per block. */ +#define UndoLogUsableBytesPerPage (BLCKSZ - UndoLogBlockHeaderSize) + +/* The pseudo-database OID used for undo logs. */ +#define UndoLogDatabaseOid 9 + +/* Length of undo checkpoint filename */ +#define UNDO_CHECKPOINT_FILENAME_LENGTH 16 + +/* + * UndoRecPtrIsValid + * True iff undoRecPtr is valid. + */ +#define UndoRecPtrIsValid(undoRecPtr) \ + ((bool) ((UndoRecPtr) (undoRecPtr) != InvalidUndoRecPtr)) + +/* Extract the relnode for an undo log. */ +#define UndoRecPtrGetRelNode(urp) \ + UndoRecPtrGetLogNo(urp) + +/* The only valid fork number for undo log buffers. */ +#define UndoLogForkNum MAIN_FORKNUM + +/* Compute the block number that holds a given UndoRecPtr. */ +#define UndoRecPtrGetBlockNum(urp) \ + (UndoRecPtrGetOffset(urp) / BLCKSZ) + +/* Compute the offset of a given UndoRecPtr in the page that holds it. */ +#define UndoRecPtrGetPageOffset(urp) \ + (UndoRecPtrGetOffset(urp) % BLCKSZ) + +/* Compare two undo checkpoint files to find the oldest file. */ +#define UndoCheckPointFilenamePrecedes(file1, file2) \ + (strcmp(file1, file2) < 0) + +/* What is the offset of the i'th non-header byte? */ +#define UndoLogOffsetFromUsableByteNo(i) \ + (((i) / UndoLogUsableBytesPerPage) * BLCKSZ + \ + UndoLogBlockHeaderSize + \ + ((i) % UndoLogUsableBytesPerPage)) + +/* How many non-header bytes are there before a given offset? */ +#define UndoLogOffsetToUsableByteNo(offset) \ + (((offset) % BLCKSZ - UndoLogBlockHeaderSize) + \ + ((offset) / BLCKSZ) * UndoLogUsableBytesPerPage) + +/* Add 'n' usable bytes to offset stepping over headers to find new offset. */ +#define UndoLogOffsetPlusUsableBytes(offset, n) \ + UndoLogOffsetFromUsableByteNo(UndoLogOffsetToUsableByteNo(offset) + (n)) + +/* Find out which tablespace the given undo log location is backed by. */ +extern Oid UndoRecPtrGetTablespace(UndoRecPtr insertion_point); + +/* Populate a RelFileNode from an UndoRecPtr. */ +#define UndoRecPtrAssignRelFileNode(rfn, urp) \ + do \ + { \ + (rfn).spcOid = UndoRecPtrGetTablespace(urp); \ + (rfn).dbOid = UndoLogDatabaseOid; \ + (rfn).relNumber = UndoRecPtrGetRelNode(urp); \ + } while (false); + +/* + * Control metadata for an active undo log. Lives in shared memory inside an + * UndoLogControl object, but also written to disk during checkpoints. + */ +typedef struct UndoLogMetaData +{ + UndoLogStatus status; + Oid tablespace; + UndoPersistence persistence; /* permanent, unlogged, temp? */ + UndoLogOffset insert; /* next insertion point (head) */ + UndoLogOffset end; /* one past end of highest segment */ + UndoLogOffset discard; /* oldest data needed (tail) */ + UndoLogOffset last_xact_start; /* last transactions start undo offset */ + + bool is_first_rec; +} UndoLogMetaData; + +/* Record the undo log number used for a transaction. */ +typedef struct xl_undolog_meta +{ + UndoLogMetaData meta; + UndoLogNumber logno; + TransactionId xid; +} xl_undolog_meta; + +#ifndef FRONTEND + +/* + * The in-memory control object for an undo log. As well as the current + * meta-data for the undo log, we also lazily maintain a snapshot of the + * meta-data as it was at the redo point of a checkpoint that is in progress. + * + * The following two locks are used to manage the discard process + * discard_lock - should be acquired for undo read to protect it from discard and + * discard worker will acquire this lock to update oldest_data. + * + * discard_update_lock - This lock will be acquired in exclusive mode by discard + * worker during the discard process and in shared mode to update the + * next_urp in previous transaction's start header. + * + * Two different locks are used so that the readers are not blocked during the + * actual discard but only during the update of shared memory variable which + * influences the visibility decision but the updaters need to be blocked for + * the entire discard process to ensure proper ordering of WAL records. + * + * Conceptually the set of UndoLogControl objects is arranged into a very + * large array for access by log number, but because we typically need only a + * smallish number of adjacent undo logs to be active at a time we arrange + * them into smaller fragments called 'banks'. + */ +typedef struct UndoLogControl +{ + UndoLogNumber logno; + UndoLogMetaData meta; /* current meta-data */ + XLogRecPtr lsn; + bool need_attach_wal_record; /* need_attach_wal_record */ + + /* + * Identity of the executor this log is attached to, and the ownership + * key. + * + * procno is authoritative: it is the PGPROC slot of the attached + * executor, INVALID_PROC_NUMBER when unattached. A slot index rather + * than a PID because that identifies one executor whether executors are + * processes or threads, and because it distributes evenly when used to + * spread sessions across logs (PIDs cluster). + * + * pid is retained for diagnostics only -- pg_stat views and debugging + * want a value an operator can correlate with the OS -- and must not be + * used to decide ownership. + */ + ProcNumber procno; /* INVALID_PROC_NUMBER for unattached */ + pid_t pid; /* diagnostic only; InvalidPid when unattached */ + LWLock mutex; /* protects the above */ + TransactionId xid; + /* State used by undo workers. */ + TransactionId oldest_xid; /* cache of oldest transaction's xid */ + uint32 oldest_xidepoch; + UndoRecPtr oldest_data; + LWLock discard_update_lock; /* block updaters during discard */ + LWLock discard_lock; /* prevents discarding while reading */ + + UndoLogNumber next_free; /* protected by UndoLogLock */ + bool discard_corrupt_reported; /* WARNING about discard > insert + * issued */ +} UndoLogControl; + +#endif + +/* Space management. */ +extern UndoRecPtr UndoLogAllocate(size_t size, + UndoPersistence level); +extern UndoRecPtr UndoLogAllocateInRecovery(TransactionId xid, + size_t size, + UndoPersistence persistence); +extern void UndoLogAdvance(UndoRecPtr insertion_point, + size_t size, + UndoPersistence persistence); +extern void PbuUndoLogDiscard(UndoRecPtr discard_point, TransactionId xid); +extern bool UndoLogIsDiscarded(UndoRecPtr point); + +/* Initialization interfaces. */ +extern void StartupUndoLogs(XLogRecPtr checkPointRedo); +extern void PbuUndoLogShmemRequest(void); +extern void PbuUndoLogShmemInit(void); +extern Size PbuUndoLogShmemSize(void); +extern void UndoLogInit(void); +extern void UndoLogSegmentPath(UndoLogNumber logno, int segno, Oid tablespace, + char *path); +extern void UndoLogEnableChecksums(void); +extern void ResetUndoLogs(UndoPersistence persistence); +extern void ResetNonPermanentUndoLogs(void); + +/* Interface use by tablespace.c. */ +extern bool DropUndoLogsInTablespace(Oid tablespace); + +/* GUC interfaces. */ +extern void assign_undo_tablespaces(const char *newval, void *extra); + +/* Checkpoint interfaces. */ +extern void CheckPointUndoLogs(XLogRecPtr checkPointRedo, + XLogRecPtr priorCheckPointRedo); + +#ifndef FRONTEND + +extern UndoLogControl *UndoLogGet(UndoLogNumber logno); +extern UndoLogControl *UndoLogNext(UndoLogControl *log); +extern bool AmAttachedToUndoLog(UndoLogControl *log); + +#endif + +extern void UndoLogSetLastXactStartPoint(UndoRecPtr point); +extern UndoRecPtr UndoLogGetLastXactStartPoint(UndoLogNumber logno); +extern UndoRecPtr UndoLogGetCurrentLocation(UndoPersistence persistence); +extern UndoRecPtr UndoLogGetFirstValidRecord(UndoLogNumber logno); +extern UndoRecPtr UndoLogGetNextInsertPtr(UndoLogNumber logno, + TransactionId xid); +extern void UndoLogRewind(UndoRecPtr insert_urp); +extern bool IsTransactionFirstRec(TransactionId xid); +extern void UndoLogRecoverySetXidMap(TransactionId xid, UndoLogNumber logno); +extern void UndoLogSetPrevLen(UndoLogNumber logno, uint16 prevlen); +extern uint16 UndoLogGetPrevLen(UndoLogNumber logno); +extern bool NeedUndoMetaLog(XLogRecPtr redo_point); +extern void UndoLogSetLSN(XLogRecPtr lsn); +extern void LogUndoMetaData(xl_undolog_meta *xlrec); +extern bool CaptureCurrentUndoLogMeta(xl_undolog_meta *xlrec); +extern void LogUndoMetaDataNow(xl_undolog_meta *xlrec); +void UndoLogNewSegment(UndoLogNumber logno, Oid tablespace, int segno); + +/* Redo interface. */ +extern void undolog_redo(XLogReaderState *record); + +/* Discard the undo logs for temp tables */ +extern UndoRecPtr UndoLogStateGetAndClearPrevLogXactUrp(void); +extern UndoLogNumber UndoLogAmAttachedTo(UndoPersistence persistence); +extern Oid UndoLogStateGetDatabaseId(void); + +#endif /* PBU_UNDOLOG_H */ diff --git a/src/include/access/perbackend/pbu_undolog_xlog.h b/src/include/access/perbackend/pbu_undolog_xlog.h new file mode 100644 index 0000000000000..84c003cb671f7 --- /dev/null +++ b/src/include/access/perbackend/pbu_undolog_xlog.h @@ -0,0 +1,71 @@ +/*------------------------------------------------------------------------- + * + * undolog_xlog.h + * undo log access XLOG definitions. + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undolog_xlog.h + * + *------------------------------------------------------------------------- + */ +#ifndef PBU_UNDOLOG_XLOG_H +#define PBU_UNDOLOG_XLOG_H + +#include "access/perbackend/pbu_undolog.h" +#include "access/xlogreader.h" +#include "lib/stringinfo.h" + +/* XLOG records */ +#define XLOG_UNDOLOG_CREATE 0x00 +#define XLOG_UNDOLOG_EXTEND 0x10 +#define XLOG_UNDOLOG_ATTACH 0x20 +#define XLOG_UNDOLOG_DISCARD 0x30 +#define XLOG_UNDOLOG_REWIND 0x40 +#define XLOG_UNDOLOG_META 0x50 +#define XLOG_UNDOLOG_SWITCH 0x60 + +/* Create a new undo log. */ +typedef struct xl_undolog_create +{ + UndoLogNumber logno; + Oid tablespace; + UndoPersistence persistence; +} xl_undolog_create; + +/* Extend an undo log by adding a new segment. */ +typedef struct xl_undolog_extend +{ + UndoLogNumber logno; + UndoLogOffset end; +} xl_undolog_extend; + +/* Record the undo log number used for a transaction. */ +typedef struct xl_undolog_attach +{ + TransactionId xid; + UndoLogNumber logno; + Oid dbid; +} xl_undolog_attach; + +/* Discard space, and possibly destroy or recycle undo log segments. */ +typedef struct xl_undolog_discard +{ + UndoLogNumber logno; + UndoLogOffset discard; + UndoLogOffset end; + TransactionId latestxid; /* latest xid whose undolog are discarded. */ +} xl_undolog_discard; + +/* Rewind insert location of the undo log. */ +typedef struct xl_undolog_rewind +{ + UndoLogNumber logno; + UndoLogOffset insert; +} xl_undolog_rewind; + +extern void undolog_desc(StringInfo buf, XLogReaderState *record); +extern const char *undolog_identify(uint8 info); + +#endif diff --git a/src/include/access/perbackend/pbu_undorecord.h b/src/include/access/perbackend/pbu_undorecord.h new file mode 100644 index 0000000000000..72779219fcc50 --- /dev/null +++ b/src/include/access/perbackend/pbu_undorecord.h @@ -0,0 +1,215 @@ +/*------------------------------------------------------------------------- + * + * undorecord.h + * encode and decode undo records + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undorecord.h + * + *------------------------------------------------------------------------- + */ +#ifndef PBU_UNDORECORD_H +#define PBU_UNDORECORD_H + +#include "access/perbackend/pbu_undolog.h" +#include "lib/stringinfo.h" +#include "storage/block.h" +#include "storage/bufpage.h" +#include "storage/buf.h" +#include "storage/off.h" + +typedef enum undorectype +{ + UNDO_INSERT, + UNDO_MULTI_INSERT, + UNDO_DELETE, + UNDO_INPLACE_UPDATE, + UNDO_UPDATE, + UNDO_XID_LOCK_ONLY, + UNDO_XID_LOCK_FOR_UPDATE, + UNDO_XID_MULTI_LOCK_ONLY, + UNDO_ITEMID_UNUSED +} undorectype; + +/* + * Every undo record begins with an UndoRecordHeader structure, which is + * followed by the additional structures indicated by the contents of + * urec_info. All structures are packed into the alignment without padding + * bytes, and the undo record itself need not be aligned either, so care + * must be taken when reading the header. + */ +typedef struct UndoRecordHeader +{ + RmgrId urec_rmid; /* RMGR (note: this leaves an alignment hole) */ + uint8 urec_type; /* record type code */ + uint8 urec_info; /* flag bits */ + uint16 urec_prevlen; /* length of previous record in bytes */ + Oid urec_reloid; /* relation OID */ + + /* + * Transaction id that has modified the tuple present in this undo record. + * If this is older than oldestXidWithEpochHavingUndo, then we can + * consider the tuple in this undo record as visible. + */ + TransactionId urec_prevxid; + + /* + * Transaction id that has modified the tuple for which this undo record + * is written. We use this to skip the undo records. See comments atop + * function UndoFetchRecord. + */ + TransactionId urec_xid; /* Transaction id */ + CommandId urec_cid; /* command id */ +} UndoRecordHeader; + +#define SizeOfUndoRecordHeader \ + (offsetof(UndoRecordHeader, urec_cid) + sizeof(CommandId)) + +/* + * If UREC_INFO_RELATION_DETAILS is set, an UndoRecordRelationDetails structure + * follows. + * + * If UREC_INFO_BLOCK is set, an UndoRecordBlock structure follows. + * + * If UREC_INFO_TRANSACTION is set, an UndoRecordTransaction structure + * follows. + * + * If UREC_INFO_PAYLOAD is set, an UndoRecordPayload structure follows. + * + * When (as will often be the case) multiple structures are present, they + * appear in the same order in which the constants are defined here. That is, + * UndoRecordRelationDetails appears first. + */ +#define UREC_INFO_RELATION_DETAILS 0x01 +#define UREC_INFO_BLOCK 0x02 +#define UREC_INFO_PAYLOAD 0x04 +#define UREC_INFO_TRANSACTION 0x08 +#define UREC_INFO_PAYLOAD_CONTAINS_SLOT 0x10 +#define UREC_INFO_PAYLOAD_CONTAINS_SUBXACT 0x20 +/* + * Additional information about a relation to which this record pertains, + * namely the fork number. If the fork number is MAIN_FORKNUM, this structure + * can (and should) be omitted. + */ +typedef struct UndoRecordRelationDetails +{ + ForkNumber urec_fork; /* fork number */ +} UndoRecordRelationDetails; + +#define SizeOfUndoRecordRelationDetails \ + (offsetof(UndoRecordRelationDetails, urec_fork) + sizeof(uint8)) + +/* + * Identifying information for a block to which this record pertains, and + * a pointer to the previous record for the same block. + */ +typedef struct UndoRecordBlock +{ + UndoRecPtr urec_blkprev; /* byte offset of previous undo for block */ + BlockNumber urec_block; /* block number */ + OffsetNumber urec_offset; /* offset number */ +} UndoRecordBlock; + +#define SizeOfUndoRecordBlock \ + (offsetof(UndoRecordBlock, urec_offset) + sizeof(OffsetNumber)) + +/* + * Identifying information for a transaction to which this undo belongs. This + * also stores the dbid and the progress of the undo apply during rollback. + */ +typedef struct UndoRecordTransaction +{ + /* + * This indicates undo action apply progress, 0 means not started, 1 means + * completed. In future, it can also be used to show the progress of how + * much undo has been applied so far with some formula. + */ + uint32 urec_progress; + uint32 urec_xidepoch; /* epoch of the current transaction */ + Oid urec_dbid; /* database id */ + + /* + * Transaction's previous undo record pointer when a transaction spans + * across undo logs. The first undo record in the new log stores the + * previous undo record pointer in the previous log as we can't calculate + * that directly using prevlen during rollback. + */ + UndoRecPtr urec_prevurp; + UndoRecPtr urec_next; /* urec pointer of the next transaction */ +} UndoRecordTransaction; + +#define SizeOfUrecNext (sizeof(UndoRecPtr)) +#define SizeOfUndoRecordTransaction \ + (offsetof(UndoRecordTransaction, urec_next) + SizeOfUrecNext) + +/* + * Information about the amount of payload data and tuple data present + * in this record. The payload bytes immediately follow the structures + * specified by flag bits in urec_info, and the tuple bytes follow the + * payload bytes. + */ +typedef struct UndoRecordPayload +{ + uint16 urec_payload_len; /* # of payload bytes */ + uint16 urec_tuple_len; /* # of tuple bytes */ +} UndoRecordPayload; + +#define SizeOfUndoRecordPayload \ + (offsetof(UndoRecordPayload, urec_tuple_len) + sizeof(uint16)) + +/* + * Information that can be used to create an undo record or that can be + * extracted from one previously created. The raw undo record format is + * difficult to manage, so this structure provides a convenient intermediate + * form that is easier for callers to manage. + * + * When creating an undo record from an UnpackedUndoRecord, caller should + * set uur_info to 0. It will be initialized by the first call to + * UndoRecordSetInfo or InsertUndoRecord. We do set it in + * UndoRecordAllocate for transaction specific header information. + * + * When an undo record is decoded into an UnpackedUndoRecord, all fields + * will be initialized, but those for which no information is available + * will be set to invalid or default values, as appropriate. + */ +typedef struct UnpackedUndoRecord +{ + RmgrId uur_rmid; /* rmgr ID */ + uint8 uur_type; /* record type code */ + uint8 uur_info; /* flag bits */ + uint16 uur_prevlen; /* length of previous record */ + Oid uur_reloid; /* relation OID */ + TransactionId uur_prevxid; /* transaction id */ + TransactionId uur_xid; /* transaction id */ + CommandId uur_cid; /* command id */ + ForkNumber uur_fork; /* fork number */ + UndoRecPtr uur_blkprev; /* byte offset of previous undo for block */ + BlockNumber uur_block; /* block number */ + OffsetNumber uur_offset; /* offset number */ + Buffer uur_buffer; /* buffer in which undo record data points */ + uint32 uur_xidepoch; /* epoch of the inserting transaction. */ + UndoRecPtr uur_prevurp; /* urec pointer to the previous record in the + * different log */ + UndoRecPtr uur_next; /* urec pointer of the next transaction */ + Oid uur_dbid; /* database id */ + + /* undo applying progress, see detail comment in UndoRecordTransaction */ + uint32 uur_progress; + StringInfoData uur_payload; /* payload bytes */ + StringInfoData uur_tuple; /* tuple bytes */ +} UnpackedUndoRecord; + + +extern void UndoRecordSetInfo(UnpackedUndoRecord *uur); +extern Size UndoRecordExpectedSize(UnpackedUndoRecord *uur); +extern Size UnpackedUndoRecordSize(UnpackedUndoRecord *uur); +extern bool InsertUndoRecord(UnpackedUndoRecord *uur, Page page, + int starting_byte, int *already_written, + int remaining_bytes, uint16 undo_len, bool header_only); +extern bool UnpackUndoRecord(UnpackedUndoRecord *uur, Page page, + int starting_byte, int *already_decoded, bool header_only, + bool copy_data); + +#endif /* PBU_UNDORECORD_H */ diff --git a/src/include/access/perbackend/pbu_undorequest.h b/src/include/access/perbackend/pbu_undorequest.h new file mode 100644 index 0000000000000..70f32c9a6ad37 --- /dev/null +++ b/src/include/access/perbackend/pbu_undorequest.h @@ -0,0 +1,166 @@ +/*------------------------------------------------------------------------- + * + * undorequest.h + * Exports from undo/undorequest.c. + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * + * src/include/access/undorequest.h + * + *------------------------------------------------------------------------- + */ +#ifndef PBU_UNDOREQUEST_H +#define PBU_UNDOREQUEST_H + +#include "access/perbackend/pbu_undoinsert.h" +#include "datatype/timestamp.h" +#include "utils/relcache.h" + + +/* different types of undo worker */ +typedef enum +{ + XID_QUEUE = 0, + SIZE_QUEUE = 1, + ERROR_QUEUE +} UndoWorkerQueueType; + +#define InvalidUndoWorkerQueue -1 + +/* Remembers the last seen RecentGlobalXmin (defined in pbu_undorequest.c) */ +extern TransactionId latestRecentGlobalXmin; + +/* GUC: rollback-request size threshold (defined in pbu_undorequest.c) */ +extern int undo_rollback_overflow_size; + +/* This is the data structure for each hash table entry for rollbacks. */ +typedef struct RollbackHashEntry +{ + FullTransactionId full_xid; /* must be first entry */ + UndoRecPtr start_urec_ptr; + UndoRecPtr end_urec_ptr; + Oid dbid; + bool in_progress; /* indicates that undo actions are being + * processed */ + bool in_error_queue; /* failed; only its error queue element may + * hand it out again */ + int nfailures; /* attempts that did not complete it */ +} RollbackHashEntry; + +/* This is the data structure for each hash table key for rollbacks. */ +typedef struct RollbackHashKey +{ + FullTransactionId full_xid; + UndoRecPtr start_urec_ptr; +} RollbackHashKey; + +/* This is an entry for undo request queue that is sorted by xid. */ +typedef struct UndoXidQueue +{ + FullTransactionId full_xid; + UndoRecPtr start_urec_ptr; + Oid dbid; +} UndoXidQueue; + +/* This is an entry for undo request queue that is sorted by size. */ +typedef struct UndoSizeQueue +{ + FullTransactionId full_xid; + UndoRecPtr start_urec_ptr; + Oid dbid; + uint64 request_size; +} UndoSizeQueue; + +/* + * This is an entry for undo request queue that is sorted by time at which an + * error has occurred. + */ +typedef struct UndoErrorQueue +{ + FullTransactionId full_xid; + UndoRecPtr start_urec_ptr; + Oid dbid; + TimestampTz err_occurred_at; +} UndoErrorQueue; + +/* undo record information */ +typedef struct UndoRecInfo +{ + int index; /* Index of the element to make qsort stable. */ + UndoRecPtr urp; /* undo recptr (undo record location). */ + UnpackedUndoRecord *uur; /* actual undo record. */ +} UndoRecInfo; + +/* undo request information */ +typedef struct UndoRequestInfo +{ + FullTransactionId full_xid; + UndoRecPtr start_urec_ptr; + UndoRecPtr end_urec_ptr; + Oid dbid; + uint64 request_size; + UndoWorkerQueueType undo_worker_queue; +} UndoRequestInfo; + +/* Reset the undo request info */ +#define ResetUndoRequestInfo(urinfo) \ +( \ + (urinfo)->full_xid = InvalidFullTransactionId, \ + (urinfo)->start_urec_ptr = InvalidUndoRecPtr, \ + (urinfo)->end_urec_ptr = InvalidUndoRecPtr, \ + (urinfo)->dbid = InvalidOid, \ + (urinfo)->request_size = 0, \ + (urinfo)->undo_worker_queue = InvalidUndoWorkerQueue \ +) + +/* set the undo request info from the rollback request */ +#define SetUndoRequestInfoFromRHEntry(urinfo, rh, cur_queue) \ +( \ + urinfo->full_xid = rh->full_xid, \ + urinfo->start_urec_ptr = rh->start_urec_ptr, \ + urinfo->end_urec_ptr = rh->end_urec_ptr, \ + urinfo->dbid = rh->dbid, \ + urinfo->undo_worker_queue = cur_queue \ +) + +/* Exposed functions for rollback request queues. */ +extern int PendingUndoShmemSize(void); +extern void PendingUndoShmemRequest(void); +extern void PendingUndoShmemInit(void); +extern bool UndoWorkerQueuesEmpty(void); +extern void InsertRequestIntoUndoQueues(UndoRequestInfo *urinfo); +extern bool InsertRequestIntoErrorUndoQueue(volatile UndoRequestInfo *urinfo); +extern int RollbackHTCountFailure(FullTransactionId full_xid, + UndoRecPtr start_urec_ptr); +extern void PbuMarkRollbackDone(FullTransactionId full_xid, + UndoRecPtr start_urec_ptr); +extern void SetUndoWorkerQueueStart(UndoWorkerQueueType undo_worker_queue); +extern bool UndoGetWork(bool allow_peek, bool is_undo_launcher, + UndoRequestInfo *urinfo, bool *in_other_db); + +/* Exposed functions for rollback hash table. */ +extern bool RegisterRollbackReq(UndoRecPtr end_urec_ptr, UndoRecPtr start_urec_ptr, + Oid dbid, FullTransactionId full_xid); +extern void RollbackHTRemoveEntry(FullTransactionId full_xid, UndoRecPtr start_urec_ptr); +extern bool RollbackHTIsFull(void); +extern void RollbackHTCleanup(Oid dbid); + +/* functions exposed from undoaction.c */ +extern UndoRecInfo *UndoRecordBulkFetch(UndoRecPtr *from_urecptr, + UndoRecPtr to_urecptr, int undo_apply_size, + int *nrecords, bool one_page); +extern bool execute_undo_actions(FullTransactionId full_xid, UndoRecPtr from_urecptr, + UndoRecPtr to_urecptr, bool nopartial); +extern bool execute_undo_actions_page(UndoRecInfo *urp_array, int first_idx, + int last_idx, Oid reloid, FullTransactionId full_xid, + BlockNumber blkno, bool blk_chain_complete); + +/* Crash-recovery + 2PC apply driver (pbu_recovery.c). */ +extern int PbuPerformUndoRecovery(void); +extern void PbuAtPrepare_Undo(void); +extern void pbu_twophase_postabort(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); +extern void pbu_twophase_postcommit(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); + +#endif /* PBU_UNDOREQUEST_H */ diff --git a/src/include/access/perbackend/pbu_undoworker.h b/src/include/access/perbackend/pbu_undoworker.h new file mode 100644 index 0000000000000..da63493ab3801 --- /dev/null +++ b/src/include/access/perbackend/pbu_undoworker.h @@ -0,0 +1,29 @@ +/*------------------------------------------------------------------------- + * + * undoworker.h + * Exports from undoworker.c. + * + * Portions Copyright (c) 1996-2019, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undoworker.h + * + *------------------------------------------------------------------------- + */ +#ifndef PBU_UNDOWORKER_H +#define PBU_UNDOWORKER_H + +/* GUC options */ +/* undo worker sleep time between rounds */ +extern int UndoWorkerDelay; + +extern Size UndoLauncherShmemSize(void); +extern void UndoLauncherShmemRequest(void); +extern void UndoLauncherShmemInit(void); +extern void UndoLauncherRegister(void); +extern void PbuEnsureUndoLauncher(void); +extern void UndoLauncherMain(Datum main_arg); +pg_noreturn extern void PbuUndoWorkerMain(Datum main_arg); +extern void WakeupUndoWorker(Oid dbid); + +#endif /* PBU_UNDOWORKER_H */ diff --git a/src/include/access/rmgrlist.h b/src/include/access/rmgrlist.h index a34394a1e233e..7e49fa2339d3c 100644 --- a/src/include/access/rmgrlist.h +++ b/src/include/access/rmgrlist.h @@ -52,3 +52,5 @@ PG_RMGR(RM_UNDO_ID, "Undo", undo_redo, undo_desc, undo_identify, NULL, NULL, NUL #ifdef USE_FILEOPS PG_RMGR(RM_FILEOPS_ID, "FileOps", fileops_redo, fileops_desc, fileops_identify, NULL, NULL, NULL, NULL) #endif +PG_RMGR(RM_UNDOLOG_ID, "UndoLog", undolog_redo, undolog_desc, undolog_identify, NULL, NULL, NULL, NULL) +PG_RMGR(RM_UNDOACTION_ID, "UndoAction", undoaction_redo, undoaction_desc, undoaction_identify, NULL, NULL, NULL, NULL) diff --git a/src/include/access/twophase_rmgr.h b/src/include/access/twophase_rmgr.h index 37be3c7b2900c..7cde672320487 100644 --- a/src/include/access/twophase_rmgr.h +++ b/src/include/access/twophase_rmgr.h @@ -29,7 +29,8 @@ typedef uint8 TwoPhaseRmgrId; #define TWOPHASE_RM_MULTIXACT_ID 3 #define TWOPHASE_RM_PREDICATELOCK_ID 4 #define TWOPHASE_RM_FILEOPS_ID 5 -#define TWOPHASE_RM_MAX_ID TWOPHASE_RM_FILEOPS_ID +#define TWOPHASE_RM_PERBACKEND_ID 6 +#define TWOPHASE_RM_MAX_ID TWOPHASE_RM_PERBACKEND_ID extern PGDLLIMPORT const TwoPhaseCallback twophase_recover_callbacks[]; extern PGDLLIMPORT const TwoPhaseCallback twophase_postcommit_callbacks[]; diff --git a/src/include/access/xact.h b/src/include/access/xact.h index 8341d2015d393..30bc6e3356157 100644 --- a/src/include/access/xact.h +++ b/src/include/access/xact.h @@ -578,5 +578,27 @@ extern bool IsInParallelMode(void); /* UNDO chain management */ extern void SetCurrentTransactionUndoRecPtr(uint64 undo_ptr); extern uint64 GetCurrentTransactionUndoRecPtr(void); +/* + * Number of per-backend UNDO persistence levels (permanent, unlogged, temp); + * must match UndoPersistenceLevels in access/perbackend/pbu_undolog.h, which + * cannot be included here. + */ +#define PBU_UNDO_PERSISTENCE_LEVELS 3 + +extern void SetCurrentTransactionPbuUndoLocation(int level, uint64 urec_ptr); +extern uint64 GetCurrentTransactionPbuUndoStartAt(int level); +extern uint64 GetCurrentTransactionPbuUndoLatestAt(int level); +extern uint64 GetCurrentTransactionPbuUndoStart(void); +extern uint64 GetCurrentTransactionPbuUndoLatest(void); +extern void PbuAtAbort_ApplyUndo(void); +extern void PbuApplyPendingUndo(void); +extern bool PbuTempUndoPending(void); +extern void PbuApplySubxactUndo(uint8 rmid); +extern void PbuApplySubxactUndoAll(void); +extern bool PbuInSubxactApply(void); +extern bool PbuInSubxactRetry(void); +extern bool PbuSubxactUndoRetryPending(void); +extern void PbuRetrySubxactUndo(void); +extern void PbuAtPreCommit_Undo(void); #endif /* XACT_H */ diff --git a/src/include/common/relpath.h b/src/include/common/relpath.h index 9772125be7398..b639258ba2820 100644 --- a/src/include/common/relpath.h +++ b/src/include/common/relpath.h @@ -13,6 +13,8 @@ #ifndef RELPATH_H #define RELPATH_H +#include "storage/block.h" + /* * Required here; note that CppAsString2() does not throw an error if the * symbol is not defined. @@ -151,4 +153,6 @@ extern RelPathStr GetRelationPath(Oid dbOid, Oid spcOid, RelFileNumber relNumber #define relpath(rlocator, forknum) \ relpathbackend((rlocator).locator, (rlocator).backend, forknum) +extern bool ParseUndoSegmentPath(const char *path, BlockNumber *block); + #endif /* RELPATH_H */ diff --git a/src/include/storage/lwlocklist.h b/src/include/storage/lwlocklist.h index dfdd410d615f3..3b8301ebdd47b 100644 --- a/src/include/storage/lwlocklist.h +++ b/src/include/storage/lwlocklist.h @@ -143,3 +143,4 @@ PG_LWLOCKTRANCHE(AIO_URING_COMPLETION, AioUringCompletion) PG_LWLOCKTRANCHE(SHMEM_INDEX, ShmemIndex) PG_LWLOCKTRANCHE(UNDO_LOG, UndoLog) PG_LWLOCKTRANCHE(UNDO_WORKER, UndoWorker) +PG_LWLOCKTRANCHE(UNDO_LOG_PBU, UndoLogPBU) diff --git a/src/include/storage/sync.h b/src/include/storage/sync.h index 88290500bc912..5a971d5ce8ada 100644 --- a/src/include/storage/sync.h +++ b/src/include/storage/sync.h @@ -39,6 +39,7 @@ typedef enum SyncRequestHandler SYNC_HANDLER_COMMIT_TS, SYNC_HANDLER_MULTIXACT_OFFSET, SYNC_HANDLER_MULTIXACT_MEMBER, + SYNC_HANDLER_UNDO, SYNC_HANDLER_NONE, } SyncRequestHandler; diff --git a/src/test/modules/test_checksums/meson.build b/src/test/modules/test_checksums/meson.build index 7eccd5156b9f6..50862dc7b669a 100644 --- a/src/test/modules/test_checksums/meson.build +++ b/src/test/modules/test_checksums/meson.build @@ -48,6 +48,7 @@ tests += { 't/022_rewind_state.pl', 't/023_rewind_standby_target.pl', 't/024_combinebackup_mixed.pl', + 't/025_undo.pl', ], }, } diff --git a/src/test/modules/test_checksums/t/025_undo.pl b/src/test/modules/test_checksums/t/025_undo.pl new file mode 100644 index 0000000000000..8de6ffc5868fb --- /dev/null +++ b/src/test/modules/test_checksums/t/025_undo.pl @@ -0,0 +1,42 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Undo pages must participate in both initial and online checksums. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; +use FindBin; +use lib $FindBin::RealBin; +use DataChecksums::Utils; + +for my $am (qw(heap flux)) +{ + for my $online (0, 1) + { + my $node = PostgreSQL::Test::Cluster->new("${am}_${online}"); + $node->init( + allows_streaming => 1, + extra => [ $online ? '--no-data-checksums' : '--data-checksums' ] + ); + $node->start; + $node->safe_psql( + 'postgres', qq[ + CREATE TABLE t (a int, b text) USING $am; + INSERT INTO t SELECT g, repeat('x', 200) FROM generate_series(1,10000) g; + ]); + my $writer = $node->background_psql('postgres'); + $writer->query_safe('BEGIN; UPDATE t SET a = a + 1;'); + # Force pre-enable undo pages to disk while the transaction is open. + $node->safe_psql('postgres', 'CHECKPOINT'); + $writer->query_safe('COMMIT'); + $writer->quit; + enable_data_checksums($node, wait => 'on') if $online; + $node->backup('checksummed'); + $node->stop; + command_ok( + [ 'pg_checksums', '--check', '-D', $node->data_dir ], + "$am offline verification (online=$online)"); + } +} +done_testing(); diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index 500aeeb768f05..573ccae2d1e19 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -72,7 +72,19 @@ tests += { 't/061_fileops_2pc_deferred.pl', 't/062_fileops_recovery.pl', 't/063_fileops_2pc.pl', + 't/071_perbackend_undo.pl', 't/072_perbackend_crash_undo.pl', + 't/073_perbackend_2pc_rollback.pl', + 't/074_perbackend_subxact.pl', + 't/075_perbackend_nonpermanent.pl', + 't/083_perbackend_multi_update.pl', + 't/084_perbackend_helping_once.pl', + 't/085_perbackend_worker_done.pl', + 't/089_perbackend_rewrite_rollback.pl', + 't/096_perbackend_redo_header_image.pl', + 't/114_undo_recovery_error.pl', + 't/117_undo_rewind_snapshot.pl', + 't/152_undo_attach_order_after_checkpoint.pl', ], }, } diff --git a/src/test/recovery/t/071_perbackend_undo.pl b/src/test/recovery/t/071_perbackend_undo.pl new file mode 100644 index 0000000000000..21e8298bfe2ca --- /dev/null +++ b/src/test/recovery/t/071_perbackend_undo.pl @@ -0,0 +1,58 @@ +# Per-backend UNDO engine lifecycle and crash-recovery smoke test. +# The engine's background workers always run; this commit adds no table AM +# that produces per-backend undo, so here they only initialize and idle. This +# test confirms the engine initializes, both workers launch, and a +# crash/recover cycle is clean. Reclamation of undo written by a table AM is +# tested with the AMs that produce it. +use strict; +use warnings; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_primary'); +$node->init; +$node->start; + +# 1. server up +my $up = $node->safe_psql('postgres', 'SELECT 1'); +is($up, '1', 'server up'); + +# 2. txn + rollback + commit + checkpoint are correct (heap; engine dormant) +$node->safe_psql('postgres', 'CREATE TABLE t(a int)'); +$node->safe_psql('postgres', 'BEGIN; INSERT INTO t VALUES (1),(2); ROLLBACK'); +$node->safe_psql('postgres', 'INSERT INTO t VALUES (3)'); +$node->safe_psql('postgres', 'CHECKPOINT'); +my $cnt = $node->safe_psql('postgres', 'SELECT count(*) FROM t'); +is($cnt, '1', 'rollback discarded uncommitted rows, commit survived'); + +# 3. bgworkers actually present +my $log = slurp_file($node->logfile); +like($log, qr/UNDO worker started/, 'undo worker launched'); +like($log, qr/discard worker started/, 'discard worker launched'); + +# 4. crash + recover cleanly +$node->stop('immediate'); +$node->start; +my $after = $node->safe_psql('postgres', 'SELECT count(*) FROM t'); +is($after, '1', 'data intact after crash recovery'); +my $rlog = slurp_file($node->logfile); +unlike( + $rlog, + qr/PANIC|inconsistent|could not|corrupt/i, + 'clean crash recovery, no PANIC/corruption'); + +# 5. The engine's workers are always running, so each must absorb +# ProcSignalBarriers: one that never does stalls every +# WaitForProcSignalBarrier() caller. DROP DATABASE emits a barrier and waits +# for every process to absorb it, so it is a direct probe. +$node->safe_psql('postgres', 'CREATE DATABASE barrier_probe'); +my ($ret, $stdout, $stderr) = $node->psql( + 'postgres', + 'DROP DATABASE barrier_probe', + timeout => $PostgreSQL::Test::Utils::timeout_default); +is($ret, 0, + 'DROP DATABASE completes: undo workers absorb ProcSignalBarriers'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/073_perbackend_2pc_rollback.pl b/src/test/recovery/t/073_perbackend_2pc_rollback.pl new file mode 100644 index 0000000000000..c28d64f3a6013 --- /dev/null +++ b/src/test/recovery/t/073_perbackend_2pc_rollback.pl @@ -0,0 +1,102 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# ROLLBACK PREPARED must reverse-apply the prepared transaction's whole +# per-backend UNDO chain, not just its first record, both live and after a +# crash/restart between PREPARE and ROLLBACK PREPARED. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_2pc'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +max_prepared_transactions = 10 +)); +$node->start; + +# Each AM gets a table whose prepared transaction updates and deletes rows on +# several pages; every row must come back after ROLLBACK PREPARED. +sub setup_table +{ + my ($name, $am) = @_; + $node->safe_psql( + 'postgres', qq( +CREATE TABLE $name (id int, v text) USING $am; +INSERT INTO $name SELECT g, 'orig' || g FROM generate_series(1, 2000) g; +)); +} + +sub prepare_changes +{ + my ($name, $gid) = @_; + $node->safe_psql( + 'postgres', qq( +BEGIN; +UPDATE $name SET v = 'upd' WHERE id IN (1, 500, 1000, 1500, 2000); +DELETE FROM $name WHERE id IN (10, 1200); +PREPARE TRANSACTION '$gid'; +)); +} + +sub check_restored +{ + my ($name, $what) = @_; + is( $node->safe_psql( + 'postgres', + "SELECT count(*), count(*) FILTER (WHERE v = 'orig' || id) FROM $name" + ), + '2000|2000', + "$what: every row restored"); +} + +foreach my $am ('flux') +{ + setup_table("live_$am", $am); + prepare_changes("live_$am", "live_$am"); + $node->safe_psql('postgres', "ROLLBACK PREPARED 'live_$am'"); + check_restored("live_$am", "$am ROLLBACK PREPARED"); + + setup_table("crash_$am", $am); + prepare_changes("crash_$am", "crash_$am"); +} + +# A prepared transaction with undo in both a permanent and an unlogged undo +# log must have both chains applied by ROLLBACK PREPARED. +foreach my $am ('flux') +{ + $node->safe_psql( + 'postgres', qq( +CREATE TABLE mp_$am (id int, v text) USING $am; +CREATE UNLOGGED TABLE mu_$am (id int, v text) USING $am; +INSERT INTO mp_$am SELECT g, 'orig' || g FROM generate_series(1, 10) g; +INSERT INTO mu_$am SELECT g, 'orig' || g FROM generate_series(1, 10) g; +BEGIN; +UPDATE mp_$am SET v = 'upd'; +UPDATE mu_$am SET v = 'upd'; +PREPARE TRANSACTION 'mixed_$am'; +ROLLBACK PREPARED 'mixed_$am'; +)); + is( $node->safe_psql( + 'postgres', qq( +SELECT (SELECT count(*) FROM mp_$am WHERE v = 'orig' || id) + + (SELECT count(*) FROM mu_$am WHERE v = 'orig' || id))), + '20', + "$am ROLLBACK PREPARED of permanent and unlogged undo"); +} + +# Crash with both transactions still prepared, then resolve after restart. +$node->stop('immediate'); +$node->start; + +foreach my $am ('flux') +{ + $node->safe_psql('postgres', "ROLLBACK PREPARED 'crash_$am'"); + check_restored("crash_$am", "$am ROLLBACK PREPARED after crash"); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/074_perbackend_subxact.pl b/src/test/recovery/t/074_perbackend_subxact.pl new file mode 100644 index 0000000000000..a33472ed3778a --- /dev/null +++ b/src/test/recovery/t/074_perbackend_subxact.pl @@ -0,0 +1,294 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Per-backend UNDO written inside subtransactions must be rolled back by the +# enclosing (sub)transaction: undo of a RELEASEd savepoint belongs to its +# parent, both for a top-level ROLLBACK and for ROLLBACK TO an outer savepoint. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_subxact'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +foreach my $am ('flux') +{ + my $t = "sx_$am"; + + $node->safe_psql( + 'postgres', qq( +CREATE TABLE $t (id int, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' || g FROM generate_series(1, 20) g; +)); + + my %cases = ( + 'only a released savepoint writes' => qq( +BEGIN; +SAVEPOINT s; +UPDATE $t SET v = 'x' WHERE id = 1; +RELEASE s; +ROLLBACK;), + 'top, released savepoint, top' => qq( +BEGIN; +UPDATE $t SET v = 'x' WHERE id = 2; +SAVEPOINT s; +UPDATE $t SET v = 'x' WHERE id = 3; +RELEASE s; +UPDATE $t SET v = 'x' WHERE id = 4; +ROLLBACK;), + 'released savepoint writes first, then top' => qq( +BEGIN; +SAVEPOINT s; +UPDATE $t SET v = 'x' WHERE id = 5; +RELEASE s; +UPDATE $t SET v = 'x' WHERE id = 6; +ROLLBACK;), + 'nested released savepoints' => qq( +BEGIN; +SAVEPOINT a; +SAVEPOINT b; +UPDATE $t SET v = 'x' WHERE id = 7; +RELEASE b; +UPDATE $t SET v = 'x' WHERE id = 8; +RELEASE a; +ROLLBACK;), + 'ROLLBACK TO outer after RELEASE inner' => qq( +BEGIN; +SAVEPOINT p; +SAVEPOINT c; +UPDATE $t SET v = 'x' WHERE id = 9; +RELEASE c; +ROLLBACK TO p; +COMMIT;), + 'PL/pgSQL exception block that completes' => qq( +BEGIN; +DO \$\$ BEGIN + BEGIN + UPDATE $t SET v = 'x' WHERE id = 10; + EXCEPTION WHEN others THEN NULL; + END; +END \$\$; +ROLLBACK;),); + + foreach my $name (sort keys %cases) + { + my ($ret, $out, $err) = $node->psql('postgres', $cases{$name}); + is($ret, 0, "$am: $name: no error") or diag($err); + is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM $t WHERE v = 'orig' || id"), + '20', + "$am: $name: every row restored"); + } +} + +# Undo that ROLLBACK TO SAVEPOINT already applied must never be applied +# again. Once the subtransaction has rolled back and released its row locks, +# another transaction may update and commit the same row; re-applying the +# subtransaction's before-image would silently overwrite that committed value. +# Three variants cover the three ways the rest of the chain gets applied: the +# aborting backend itself, an undo apply worker, and crash recovery. +sub n1_setup +{ + my ($t, $am) = @_; + $node->safe_psql( + 'postgres', qq( +DROP TABLE IF EXISTS $t; +CREATE TABLE $t (id int, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' || g FROM generate_series(1, 3) g; +)); +} + +# T1 writes, rolls back a savepoint that wrote row 2, and keeps running while +# T2 updates row 2 and commits. Returns T1's session. +sub n1_interleave +{ + my ($t) = @_; + my $t1 = $node->background_psql('postgres'); + $t1->query_safe( + qq(BEGIN; +UPDATE $t SET v = 't1' WHERE id = 1; +SAVEPOINT s; +UPDATE $t SET v = 't1-sub' WHERE id = 2; +ROLLBACK TO s; +RELEASE s;)); + $node->safe_psql('postgres', "UPDATE $t SET v = 't2' WHERE id = 2"); + return $t1; +} + +sub n1_check +{ + my ($t, $what) = @_; + is( $node->safe_psql( + 'postgres', "SELECT string_agg(v, ',' ORDER BY id) FROM $t"), + 'orig1,t2,orig3', + $what); +} + +foreach my $am ('flux') +{ + my $t = "n1_$am"; + + # Inline: T1 writes again after ROLLBACK TO, then rolls back. + n1_setup($t, $am); + my $t1 = n1_interleave($t); + $t1->query_safe("UPDATE $t SET v = 't1' WHERE id = 3; ROLLBACK;"); + $t1->quit; + n1_check($t, + "$am: ROLLBACK keeps a committed update made after ROLLBACK TO"); + + # Worker: every rollback request goes to an undo apply worker, which + # derives the end of the chain from the undo log's insert pointer. + $node->safe_psql('postgres', + 'ALTER SYSTEM SET undo_rollback_overflow_size = 0'); + $node->reload; + n1_setup($t, $am); + $t1 = n1_interleave($t); + $t1->query_safe('ROLLBACK'); + $t1->quit; + $node->poll_query_until('postgres', + "SELECT v = 'orig1' FROM $t WHERE id = 1") + or diag("$am: worker never rolled back T1"); + n1_check($t, + "$am: worker rollback keeps a committed update made after ROLLBACK TO" + ); + $node->safe_psql('postgres', + 'ALTER SYSTEM RESET undo_rollback_overflow_size'); + $node->reload; + + # Crash recovery: T1 is still running when the server crashes. + n1_setup($t, $am); + $t1 = n1_interleave($t); + $node->stop('immediate'); + $t1->{run}->kill_kill; + $node->start; + $node->poll_query_until('postgres', + "SELECT v = 'orig1' FROM $t WHERE id = 1") + or diag("$am: crashed T1 was never rolled back"); + n1_check($t, + "$am: crash recovery keeps a committed update made after ROLLBACK TO" + ); +} + +# A subtransaction that writes tables of several persistence levels has an +# undo chain in each level's undo log. ROLLBACK TO SAVEPOINT must apply all +# of them, whichever level it wrote first. Nothing of them may be left for +# later: a top-level ROLLBACK walks only the parent's own records, and after +# a COMMIT the undo is discarded, so a change left in place survives or the +# row is lost. +my @mixed; +foreach my $am ('flux') +{ + foreach my $order ('p u t', 'u t p', 't p u') + { + foreach my $end ('COMMIT', 'ROLLBACK') + { + my $sfx = "${am}_" . join('', split(/ /, $order)) . "_$end"; + my $upd = join("\n", + map { "UPDATE m${_}_$sfx SET v = 'sub';" } + split(/ /, $order)); + my $cnt = join( + " || ',' || ", + map { + "(SELECT count(*) FROM m${_}_$sfx WHERE v = 'orig' || id)" + } ('p', 'u', 't')); + my $out = $node->safe_psql( + 'postgres', qq( +CREATE TABLE mp_$sfx (id int, v text) USING $am; +CREATE UNLOGGED TABLE mu_$sfx (id int, v text) USING $am; +CREATE TEMP TABLE mt_$sfx (id int, v text) USING $am; +INSERT INTO mp_$sfx SELECT g, 'orig' || g FROM generate_series(1, 10) g; +INSERT INTO mu_$sfx SELECT g, 'orig' || g FROM generate_series(1, 10) g; +INSERT INTO mt_$sfx SELECT g, 'orig' || g FROM generate_series(1, 10) g; +BEGIN; +SAVEPOINT s; +$upd +ROLLBACK TO s; +SELECT $cnt; +$end; +SELECT $cnt; +)); + is($out, "10,10,10\n10,10,10", + "$am: ROLLBACK TO restores every level (order $order), then $end" + ); + push @mixed, $sfx; + } + } +} + +# A savepoint that updates rows and then truncates or drops the table. When +# the subtransaction's undo is first applied, the relation still looks +# dropped or empty, so the records cannot be applied then. They must be +# applied once the subtransaction's catalog changes are rolled back, before +# the transaction goes on: it commits with every committed row intact. The +# variants reach that through ROLLBACK TO, ROLLBACK TO after an error, +# ROLLBACK TO an outer savepoint, and a PL/pgSQL exception block. +my %sd = ( + rt => sub { + my ($t, $op) = @_; + "SAVEPOINT s;\nUPDATE $t SET v = 'sub';\n$op $t;\nROLLBACK TO s;"; + }, + rterr => sub { + my ($t, $op) = @_; + "SAVEPOINT s;\nUPDATE $t SET v = 'sub';\n$op $t;\nSELECT 1/0;\nROLLBACK TO s;"; + }, + outer => sub { + my ($t, $op) = @_; + "SAVEPOINT a;\nSAVEPOINT b;\nUPDATE $t SET v = 'sub';\n$op $t;\nROLLBACK TO a;"; + }, + plpgsql => sub { + my ($t, $op) = @_; + "DO \$\$ BEGIN\n BEGIN\n UPDATE $t SET v = 'sub';\n $op $t;\n" + . " RAISE EXCEPTION 'x';\n EXCEPTION WHEN raise_exception THEN NULL;\n" + . " END;\nEND \$\$;"; + },); +foreach my $am ('flux') +{ + foreach my $op ('TRUNCATE', 'DROP TABLE') + { + foreach my $how (sort keys %sd) + { + my $t = "sd_${am}_" . lc((split(/ /, $op))[0]) . "_$how"; + $node->safe_psql( + 'postgres', qq( +CREATE TABLE $t (id int, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' || g FROM generate_series(1, 10) g; +)); + my ($ret, $out, $err) = $node->psql( + 'postgres', qq( +BEGIN; +@{[ $sd{$how}->($t, $op) ]} +SELECT count(*) FROM $t WHERE v = 'orig' || id; +COMMIT; +SELECT count(*) FROM $t WHERE v = 'orig' || id; +), + on_error_stop => 0); + is($out, "10\n10", + "$am: $op in a savepoint ($how): rows restored") + or diag($err); + unlike($err, qr/incomplete/, + "$am: $op in a savepoint ($how): commits"); + push @mixed, $t; + } + } +} + +# Committed or rolled back, the undo is gone after a restart; the rows must +# not depend on it. +$node->restart; +foreach my $sfx (@mixed) +{ + my $q = + $sfx =~ /^sd_/ + ? "SELECT count(*) FROM $sfx WHERE v = 'orig' || id" + : qq(SELECT (SELECT count(*) FROM mp_$sfx WHERE v = 'orig' || id) || ',' || + (SELECT count(*) FROM mu_$sfx WHERE v = 'orig' || id)); + is( $node->safe_psql('postgres', $q), + $sfx =~ /^sd_/ ? '10' : '10,10', + "$sfx: rows intact after restart"); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/075_perbackend_nonpermanent.pl b/src/test/recovery/t/075_perbackend_nonpermanent.pl new file mode 100644 index 0000000000000..6f6e41e30bc4a --- /dev/null +++ b/src/test/recovery/t/075_perbackend_nonpermanent.pl @@ -0,0 +1,273 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Per-backend UNDO for UNLOGGED and TEMP tables. Their undo lives in undo +# logs whose contents are never WAL-logged, so redo must not try to patch +# those logs; temp undo must be applied by its own backend and discarded once +# its transaction has ended. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_nonperm'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +# A rollback on an unlogged table marks its undo as applied. That must not +# leave anything in the WAL that redo cannot replay: the undo pages it would +# touch were never WAL-logged. +foreach my $am ('flux') +{ + $node->safe_psql( + 'postgres', qq( +CREATE UNLOGGED TABLE u_$am (id int, v text) USING $am; +INSERT INTO u_$am SELECT g, 'orig' || g FROM generate_series(1, 100) g; +BEGIN; +UPDATE u_$am SET v = 'upd' WHERE id <= 10; +ROLLBACK; +)); + is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM u_$am WHERE v = 'orig' || id"), + '100', + "$am unlogged: rollback restored every row"); +} + +# The numbers of the unlogged and temporary undo logs, as a SQL list. Their +# pages are the undo pseudo-relations with these relfilenumbers. +sub nonpermanent_lognos +{ + return $node->safe_psql( + 'postgres', q( +SELECT coalesce(string_agg(logno::text, ','), '0') FROM pg_stat_get_undo_logs() + WHERE persistence <> 'permanent')); +} + +# WAL records that discard or extend an unlogged or temporary undo log since +# $from. Those logs are not WAL-logged: their contents never reach the WAL, +# crash recovery empties them, and a standby replaying their discards and +# extensions would create, recycle and remove segments of logs it never has. +sub nonpermanent_undolog_wal +{ + my ($from) = @_; + my $lognos = nonpermanent_lognos(); + return $node->safe_psql( + 'postgres', qq( +SELECT count(*) FROM pg_get_wal_records_info('$from', pg_current_wal_lsn()) + WHERE resource_manager = 'UndoLog' AND record_type IN ('DISCARD', 'EXTEND') + AND split_part(description, ' ', 2)::int IN ($lognos))); +} + +# Has the discard worker discarded all of the unlogged undo? +my $unlogged_discarded = q( +SELECT bool_and(discard = insert) FROM pg_stat_get_undo_logs() + WHERE persistence = 'unlogged'); + +# Undo pages of unlogged and temp tables are not written at a checkpoint, as +# for any unlogged page; only permanent undo must be. +$node->safe_psql('postgres', 'CREATE EXTENSION pg_buffercache'); +$node->safe_psql('postgres', 'CREATE EXTENSION pg_walinspect'); +$node->safe_psql( + 'postgres', q( +CREATE TEMP TABLE bt (id int, v text) USING flux; +INSERT INTO bt SELECT g, 'orig' || g FROM generate_series(1, 100) g; +UPDATE bt SET v = 'upd'; +UPDATE u_flux SET v = 'upd2'; +UPDATE u_flux SET v = 'orig' || id; +CHECKPOINT; +)); +my $lognos = nonpermanent_lognos(); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) > 0 FROM pg_buffercache + WHERE reldatabase = 9 AND relfilenode IN ($lognos) AND isdirty"), + 't', + 'checkpoint leaves unlogged and temp undo pages dirty'); + +# Write committed unlogged undo after that checkpoint too, so the crash +# loses undo the discard worker already knew about, and leave an +# unlogged-table transaction in flight, so the crash finds undo for it whose +# pages never reached disk. +my $lsn = $node->safe_psql('postgres', 'SELECT pg_current_wal_lsn()'); +$node->safe_psql('postgres', "UPDATE u_flux SET v = 'orig' || id"); +ok($node->poll_query_until('postgres', $unlogged_discarded), + 'discard worker discards the unlogged undo'); +is(nonpermanent_undolog_wal($lsn), '0', + 'unlogged undo discards write no WAL'); +my $inflight = $node->background_psql('postgres'); +$inflight->query_safe("BEGIN; UPDATE u_flux SET v = 'inflight';"); + +$node->stop('immediate'); +$inflight->{run}->kill_kill; +ok($node->start(fail_ok => 1), + 'server restarts after a crash following unlogged rollbacks'); + +# The unlogged undo written before the crash, including the in-flight +# transaction's, is garbage now, like the unlogged tables themselves: crash +# recovery must not walk it, and the discard worker must not trip over it. +my $rlog = slurp_file($node->logfile); +unlike( + $rlog, + qr/per-backend UNDO recovery: rolling back/, + 'crash recovery does not walk lost unlogged undo'); +foreach my $am ('flux') +{ + is($node->safe_psql('postgres', "SELECT count(*) FROM u_$am"), + '0', "$am unlogged: table reset by crash recovery"); + $node->safe_psql( + 'postgres', qq( +INSERT INTO u_$am SELECT g, 'orig' || g FROM generate_series(1, 100) g; +BEGIN; +UPDATE u_$am SET v = 'upd' WHERE id <= 10; +ROLLBACK; +)); + is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM u_$am WHERE v = 'orig' || id"), + '100', + "$am unlogged: rollback after crash recovery restored every row"); +} + +# The discard worker skips an undo log with nothing to discard, so it meets +# a reset unlogged log only once undo has been written to it again, as just +# now. Wait until it has discarded that. +ok( $node->poll_query_until('postgres', $unlogged_discarded), + 'discard worker discards unlogged undo written after the reset'); +unlike( + slurp_file($node->logfile), + qr/cannot move discard pointer backwards|TRAP|PANIC/, + 'discard worker copes with reset unlogged undo logs'); + +# A rollback handed to an undo apply worker that cannot apply every record +# must be retried until it is, not left marked in progress forever. +SKIP: +{ + skip 'Injection points not supported by this build', 3 + if $ENV{enable_injection_points} ne 'yes'; + + $node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); + # The worker requeueing the request logs "Undo action pushed(error)". + $node->safe_psql('postgres', + 'ALTER SYSTEM SET undo_rollback_overflow_size = 0'); + $node->safe_psql('postgres', + 'ALTER SYSTEM SET log_min_messages = debug1'); + $node->reload; + my $off = -s $node->logfile; + $node->safe_psql( + 'postgres', q( +CREATE TABLE w_flux (id int, v text) USING flux; +INSERT INTO w_flux SELECT g, 'orig' || g FROM generate_series(1, 10) g; +SELECT injection_points_attach('pbu-undo-apply-skip', 'notice'); +BEGIN; +UPDATE w_flux SET v = 'upd'; +ROLLBACK; +)); + $node->wait_for_log(qr/Undo action pushed\(error\)/, $off); + $node->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-apply-skip')"); + ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 10 FROM w_flux WHERE v = 'orig' || id"), + 'incomplete worker rollback is retried and applied'); + + # Temp undo cannot be applied by a worker, which cannot see the table. + foreach my $am ('flux') + { + is( $node->safe_psql( + 'postgres', qq( +CREATE TEMP TABLE tw_$am (id int, v text) USING $am; +INSERT INTO tw_$am SELECT g, 'orig' || g FROM generate_series(1, 10) g; +BEGIN; +UPDATE tw_$am SET v = 'upd'; +ROLLBACK; +SELECT count(*) FROM tw_$am WHERE v = 'orig' || id; +)), + '10', + "$am temp: rollback is applied by the backend, not a worker"); + } + $node->safe_psql('postgres', + 'ALTER SYSTEM RESET undo_rollback_overflow_size'); + $node->safe_psql('postgres', 'ALTER SYSTEM RESET log_min_messages'); + $node->reload; +} + +# One transaction that writes tables of several persistence levels has an +# undo chain in each level's undo log; a rollback must apply all of them, +# whether the backend applies them itself or hands them to an undo apply +# worker (undo_rollback_overflow_size = 0; temp undo always stays in the +# backend). Two chains of one transaction are also two rollback requests with +# the same xid, which the request table must keep apart. +foreach my $overflow (16, 0) +{ + $node->safe_psql('postgres', + "ALTER SYSTEM SET undo_rollback_overflow_size = $overflow"); + $node->reload; + foreach my $am ('flux') + { + my $out = $node->safe_psql( + 'postgres', qq( +CREATE TABLE mp_$am (id int, v text) USING $am; +CREATE UNLOGGED TABLE mu_$am (id int, v text) USING $am; +CREATE TEMP TABLE mt_$am (id int, v text) USING $am; +INSERT INTO mp_$am SELECT g, 'orig' || g FROM generate_series(1, 10) g; +INSERT INTO mu_$am SELECT g, 'orig' || g FROM generate_series(1, 10) g; +INSERT INTO mt_$am SELECT g, 'orig' || g FROM generate_series(1, 10) g; +BEGIN; +UPDATE mp_$am SET v = 'upd'; +UPDATE mu_$am SET v = 'upd'; +UPDATE mt_$am SET v = 'upd'; +ROLLBACK; +SELECT count(*) FROM mt_$am WHERE v = 'orig' || id; +)); + is($out, '10', "$am mixed, overflow $overflow: temp rows restored"); + ok( $node->poll_query_until( + 'postgres', qq( +SELECT (SELECT count(*) FROM mp_$am WHERE v = 'orig' || id) + + (SELECT count(*) FROM mu_$am WHERE v = 'orig' || id) = 20)), + "$am mixed, overflow $overflow: permanent and unlogged rows restored" + ); + $node->safe_psql('postgres', "DROP TABLE mp_$am, mu_$am"); + } +} +$node->safe_psql('postgres', + 'ALTER SYSTEM RESET undo_rollback_overflow_size'); +$node->reload; + +# Temp undo is private to its backend and needed only until the transaction +# that wrote it ends, so a session churning a temp table must not accumulate +# undo segments: each transaction's undo is about 0.3 MB, 30 of them would +# need 10 one-megabyte segments if nothing were ever discarded. +sub undo_segments +{ + opendir(my $dh, $node->data_dir . '/base/undo') or return 0; + my $n = grep { !/^\./ } readdir($dh); + closedir($dh); + return $n; +} + +# Its discards, at the first temp write of every transaction, and the +# segments added and removed for it, write no WAL. +my $temp_lsn = $node->safe_psql('postgres', 'SELECT pg_current_wal_lsn()'); +foreach my $am ('flux') +{ + my $before = undo_segments(); + my $out = $node->safe_psql( + 'postgres', qq( +CREATE TEMP TABLE t_$am (id int, v text) USING $am; +INSERT INTO t_$am SELECT g, repeat('x', 200) FROM generate_series(1, 1000) g; +SELECT format('UPDATE t_$am SET v = repeat(%L, 200)', chr(97 + i % 20)) + FROM generate_series(1, 30) i \\gexec +BEGIN; +UPDATE t_$am SET v = 'rolled back' WHERE id <= 10; +ROLLBACK; +SELECT count(*) FROM t_$am WHERE v = repeat(chr(97 + 30 % 20), 200); +)); + is($out, '1000', "$am temp: rollback after churn restored every row"); + cmp_ok(undo_segments() - $before, + '<=', 3, "$am temp: undo is discarded as transactions end"); +} +is(nonpermanent_undolog_wal($temp_lsn), + '0', 'temp undo discards and segment changes write no WAL'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/083_perbackend_multi_update.pl b/src/test/recovery/t/083_perbackend_multi_update.pl new file mode 100644 index 0000000000000..e6b4ac3c7ae79 --- /dev/null +++ b/src/test/recovery/t/083_perbackend_multi_update.pl @@ -0,0 +1,134 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A row updated several times in one transaction has one undo record per +# update, each restoring the image the one before it wrote. A rollback must +# apply all of them, newest first, and end at the committed image: inline, by +# an undo apply worker, by crash recovery, and by ROLLBACK TO SAVEPOINT; for +# permanent, unlogged and temporary FLUX tables. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_multi_update'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +my %kw = (p => '', u => 'UNLOGGED', t => 'TEMP'); + +# Create the table, update every row $n times in one transaction (the +# updates grow the row, then shrink it), then run $end. Returns the number +# of rows that hold the committed value. +sub run_case +{ + my ($am, $pers, $n, $body_end) = @_; + my $t = "mu_${am}_$pers"; + my @upd = + map { "UPDATE $t SET v = repeat('u$_', " . ($_ == 2 ? 50 : $_) . ');' } + (1 .. $n); + return $node->safe_psql( + 'postgres', qq( +CREATE $kw{$pers} TABLE $t (id int PRIMARY KEY, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' || g FROM generate_series(1, 10) g; +BEGIN; +@{[ $body_end->($t, join("\n", @upd)) ]} +SELECT count(*) FROM $t WHERE v = 'orig' || id; +DROP TABLE $t; +)); +} + +my %ends = ( + 'ROLLBACK' => sub { my ($t, $u) = @_; "$u\nROLLBACK;" }, + 'ROLLBACK TO + COMMIT' => + sub { my ($t, $u) = @_; "SAVEPOINT s;\n$u\nROLLBACK TO s;\nCOMMIT;" }, + 'ROLLBACK TO + ROLLBACK' => + sub { my ($t, $u) = @_; "SAVEPOINT s;\n$u\nROLLBACK TO s;\nROLLBACK;" }, + 'one update before the savepoint, the rest after; ROLLBACK TO + COMMIT' + => sub { + my ($t, $u) = @_; + my ($first, @rest) = split(/\n/, $u); + "$first\nSAVEPOINT s;\n@{[ join(qq(\n), @rest) ]}\nROLLBACK TO s;\n" + . "UPDATE $t SET v = 'orig' || id;\nCOMMIT;"; + },); + +foreach my $overflow (16, 0) +{ + $node->safe_psql('postgres', + "ALTER SYSTEM SET undo_rollback_overflow_size = $overflow"); + $node->reload; + foreach my $am ('flux') + { + foreach my $pers ('p', 'u', 't') + { + foreach my $n (2, 3) + { + foreach my $end (sort keys %ends) + { + my $name = + "$am $pers, $n updates, $end, overflow $overflow"; + + # A worker applies the rollback asynchronously: wait for + # it rather than reading the table at once. + if ($overflow == 0 && $pers ne 't' && $end eq 'ROLLBACK') + { + my $t = "mw_${am}_$pers$n"; + my @upd = map { + "UPDATE $t SET v = repeat('u$_', " + . ($_ == 2 ? 50 : $_) . ');' + } (1 .. $n); + $node->safe_psql( + 'postgres', qq( +CREATE $kw{$pers} TABLE $t (id int PRIMARY KEY, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' || g FROM generate_series(1, 10) g; +BEGIN; +@upd +ROLLBACK; +)); + ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 10 FROM $t WHERE v = 'orig' || id" + ), + $name); + next; + } + is(run_case($am, $pers, $n, $ends{$end}), '10', $name); + } + } + } + } +} +$node->safe_psql('postgres', + 'ALTER SYSTEM RESET undo_rollback_overflow_size'); +$node->reload; + +# Crash recovery: the transaction is still running when the server crashes. +# Permanent tables only; unlogged tables are reset by the crash. +foreach my $am ('flux') +{ + foreach my $n (2, 3) + { + my $t = "mc_${am}_$n"; + $node->safe_psql( + 'postgres', qq( +CREATE TABLE $t (id int PRIMARY KEY, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' || g FROM generate_series(1, 10) g; +)); + my $s = $node->background_psql('postgres'); + $s->query_safe('BEGIN;'); + $s->query_safe( + "UPDATE $t SET v = repeat('u$_', " . ($_ == 2 ? 50 : $_) . ')') + for (1 .. $n); + $node->stop('immediate'); + $s->{run}->kill_kill; + $node->start; + ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 10 FROM $t WHERE v = 'orig' || id"), + "$am: $n updates, crash: committed rows restored"); + } +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/084_perbackend_helping_once.pl b/src/test/recovery/t/084_perbackend_helping_once.pl new file mode 100644 index 0000000000000..2cc920ece0d5d --- /dev/null +++ b/src/test/recovery/t/084_perbackend_helping_once.pl @@ -0,0 +1,227 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Every per-backend undo record is applied exactly once, whoever applies it: +# ROLLBACK TO SAVEPOINT (which marks what it applied), a writer that runs +# into an aborted version (helping), the undo apply worker, and crash +# recovery. Here all of them work on one aborted transaction, T1, whose +# rows other transactions update and commit before the worker gets to it. +# Applying any record twice would put an old image back over a committed +# value. +# +# undo_rollback_overflow_size = 0 sends T1's rollback to the undo apply +# workers, which are held at the injection point pbu-undo-worker-before-apply +# until the end. T1 has an undo chain in the permanent and in the unlogged +# undo log, each a rollback request of its own, so there can be more than one +# worker to release. +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('pbu_helping'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +)); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('pageinspect')) +{ + plan skip_all => 'injection_points or pageinspect not installed'; +} +$node->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait'); +-- Wake one held undo apply worker; true once none is held any more. +CREATE FUNCTION release_one() RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE wait_event = 'pbu-undo-worker-before-apply') THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup('pbu-undo-worker-before-apply'); + EXCEPTION WHEN OTHERS THEN + NULL; -- it left the injection point meanwhile + END; + RETURN false; +END $$; +)); + +my @s = + map { $node->background_psql('postgres', on_error_stop => 0) } (1 .. 3); + +my @tabs; +foreach my $am ('flux') +{ + foreach my $pers ('', 'UNLOGGED') + { + my $t = "h_${am}_" . ($pers ? 'u' : 'p'); + push @tabs, $t; + $s[0]->query_safe( + qq( +CREATE $pers TABLE $t (id int PRIMARY KEY, v int) USING $am; +INSERT INTO $t SELECT g, g FROM generate_series(1, 6) g; +)); + } +} + +# T1: rows 1-3 at the top level; rows 2 and 4 in a savepoint that is rolled +# back at once (and released, so that what follows is top-level again); row +# 5 after it; then the whole transaction rolls back. Row +# 5's aborted value, 16908801 = 0x01020201, reads the same in either byte +# order, so the page can be searched for it. +my $each = sub { + my $sql = shift; + return join("\n", map { (my $x = $sql) =~ s/\bT\b/$_/g; $x } @tabs); +}; +$s[0]->query_safe("BEGIN;\n" + . $each->('UPDATE T SET v = v + 100 WHERE id IN (1, 2, 3);') + . "\nSAVEPOINT s;\n" + . $each->('UPDATE T SET v = v + 1000 WHERE id IN (2, 4);') + . "\nROLLBACK TO s;\nRELEASE s;\n" + . $each->('SELECT string_agg(v::text, \',\' ORDER BY id) FROM T;') + . "\n" + . $each->('UPDATE T SET v = 16908801 WHERE id = 5;') + . "\nROLLBACK;"); + +my $pending = sub { + my $t = shift; + return + "SELECT position('\\x01020201'::bytea IN get_raw_page('$t', 0)) > 0"; +}; +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +foreach my $t (@tabs) +{ + is($s[1]->query_safe($pending->($t)), + 't', "$t: T1's rollback is still pending"); +} + +# T2 updates rows 1, 2 and 4 and commits. Rows 1 and 2 carry T1's aborted +# top-level images, which T2 reverts first (helping); row 4 was already +# restored, and marked applied, by ROLLBACK TO. Row 2 has both: a record +# T1's ROLLBACK TO applied and marked, under one helping reverts. T3 helps row 3 inside a savepoint that it rolls +# back, then commits an update of row 6. +$s[1]->query_safe($each->('UPDATE T SET v = v + 20 WHERE id IN (1, 2, 4);')); +$s[2]->query_safe("BEGIN;\nSAVEPOINT a;\n" + . $each->('UPDATE T SET v = v + 7000 WHERE id = 3;') + . "\nROLLBACK TO a;\n" + . $each->('UPDATE T SET v = v + 30 WHERE id = 6;') + . "\nCOMMIT;"); + +my $want = '21,22,3,24,5,36'; +my $rows = sub { + my $t = shift; + return "SELECT string_agg(v::text, ',' ORDER BY id) FROM $t"; +}; +foreach my $t (@tabs) +{ + is($s[1]->query_safe($rows->($t)), + $want, "$t: helpers' committed values over T1's pending rollback"); +} + +# Let the held undo apply workers go. injection_points_wakeup() releases +# one waiter per call. +my $release = sub { + $node->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-worker-before-apply')"); + $node->poll_query_until('postgres', 'SELECT release_one()') + or die 'held undo apply workers were not released'; +}; + +# Now the workers apply the rest of T1: row 5, and nothing else. +$release->(); +foreach my $t (@tabs) +{ + ok( $node->poll_query_until( + 'postgres', "SELECT NOT (" . $pending->($t) . ")"), + "$t: worker applied T1's remaining record"); + is($node->safe_psql('postgres', $rows->($t)), + $want, "$t: worker left the committed values alone"); +} + +# ROLLBACK TO a savepoint that wrote nothing reverts nothing: not the +# parent's records, whose rollback is left to the worker, nor, in a +# transaction that commits, the parent's changes. ROLLBACK TO restarts the +# savepoint, so what T4 writes after it is the savepoint's, and the +# top-level ROLLBACK applies that itself when it aborts the savepoint. Row +# 6's aborted value 16974593 = 0x01030301 is searchable like row 5's. +my $pending6 = sub { + my $t = shift; + return + "SELECT position('\\x01030301'::bytea IN get_raw_page('$t', 0)) > 0"; +}; +$node->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait')"); +my $t4 = $node->safe_psql('postgres', + "BEGIN;\n" + . $each->('UPDATE T SET v = 16908801 WHERE id = 5;') + . "\nSAVEPOINT s;\nROLLBACK TO s;\n" + . $each->("SELECT 'T', v FROM T WHERE id = 5;") . "\n" + . $each->('UPDATE T SET v = 16974593 WHERE id = 6;') + . "\nROLLBACK;"); +is( $t4, + join("\n", map { "$_|16908801" } @tabs), + 'ROLLBACK TO an empty savepoint keeps the parent\'s change'); +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +foreach my $t (@tabs) +{ + is($node->safe_psql('postgres', $pending->($t)), + 't', "$t: the parent's record is left to the worker"); + is($node->safe_psql('postgres', $pending6->($t)), + 'f', "$t: the savepoint's record is applied by the ROLLBACK"); +} +$release->(); +foreach my $t (@tabs) +{ + ok( $node->poll_query_until( + 'postgres', "SELECT NOT (" . $pending->($t) . ")"), + "$t: worker applied the parent's record"); + is($node->safe_psql('postgres', $rows->($t)), + $want, "$t: T4 left no trace"); +} +$node->safe_psql('postgres', + "BEGIN;\n" + . $each->('UPDATE T SET v = v + 1 WHERE id = 1;') + . "\nSAVEPOINT s;\nROLLBACK TO s;\nCOMMIT;"); +foreach my $t (@tabs) +{ + is( $node->safe_psql('postgres', $rows->($t)), + '22,22,3,24,5,36', + "$t: a commit after ROLLBACK TO an empty savepoint keeps the change"); +} +$want = '22,22,3,24,5,36'; + +$_->quit for @s; + +$node->restart; +foreach my $t (@tabs) +{ + is($node->safe_psql('postgres', $rows->($t)), $want, "$t: after restart"); +} + +# Crash recovery finds nothing left to apply either. +$node->stop('immediate'); +$node->start; +foreach my $t (grep { /_p$/ } @tabs) +{ + is($node->safe_psql('postgres', $rows->($t)), $want, "$t: after crash"); +} +unlike( + slurp_file($node->logfile), + qr/cannot restore|TRAP|PANIC/, + 'no failed restore'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/085_perbackend_worker_done.pl b/src/test/recovery/t/085_perbackend_worker_done.pl new file mode 100644 index 0000000000000..dce2d0f49d955 --- /dev/null +++ b/src/test/recovery/t/085_perbackend_worker_done.pl @@ -0,0 +1,113 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A rollback handed to an undo apply worker must finish, and its undo must +# become discardable, even when there is nothing left to undo or when some of +# it can never be applied. +# +# - The table was dropped (or truncated) before the worker got to it: the +# records have nothing left to undo, so the request completes. +# - A record keeps failing (injection point pbu-undo-apply-skip): after a +# bounded number of attempts the worker gives up with a WARNING instead of +# retrying forever and pinning the undo log. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_worker_done'); +$node->init; +$node->append_conf('postgresql.conf', + "autovacuum = off\nundo_rollback_overflow_size = 0\nlog_min_messages = debug1\n" +); +$node->start; + +# The worker logs "Undo action pushed(error)" (DEBUG1) whenever it puts a +# request back for a retry. +sub retries +{ + my ($from) = @_; + my $log = substr(slurp_file($node->logfile), $from); + return scalar(() = $log =~ /Undo action pushed\(error\)/g); +} + +# Undo of a transaction is discardable once its rollback is done: the +# discard worker then moves past it. +sub wait_discarded +{ + my ($lsn, $what) = @_; + ok( $node->poll_query_until( + 'postgres', qq( +SELECT count(*) > 0 FROM pg_get_wal_records_info('$lsn', pg_current_wal_lsn()) + WHERE resource_manager = 'UndoLog' AND record_type = 'DISCARD')), + "$what: undo discarded"); +} + +$node->safe_psql('postgres', 'CREATE EXTENSION pg_walinspect'); + +foreach my $am ('flux') +{ + foreach my $op ('DROP TABLE', 'TRUNCATE') + { + my $t = "w_${am}_" . lc((split(/ /, $op))[0]); + my $what = "$am, $op before the worker ran"; + $node->safe_psql( + 'postgres', qq( +CREATE TABLE $t (id int, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' || g FROM generate_series(1, 10) g; +)); + my $lsn = $node->safe_psql('postgres', 'SELECT pg_current_wal_lsn()'); + my $off = -s $node->logfile; + + # The DROP / TRUNCATE takes the table's AccessExclusiveLock right after + # the rollback, before the worker can open the relation; the worker's + # open then waits and finds the relation gone or empty. + $node->safe_psql( + 'postgres', qq( +BEGIN; +UPDATE $t SET v = 'x'; +ROLLBACK; +$op $t; +)); + ok( $node->poll_query_until( + 'postgres', q{SELECT count(*) = 0 FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + "$what: worker finished"); + wait_discarded($lsn, $what); + + is(retries($off), 0, "$what: request was not retried"); + } +} + +SKIP: +{ + skip 'Injection points not supported by this build', 3 + if $ENV{enable_injection_points} ne 'yes'; + + $node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); + $node->safe_psql( + 'postgres', q( +CREATE TABLE stuck (id int, v text) USING flux; +INSERT INTO stuck SELECT g, 'orig' || g FROM generate_series(1, 10) g; +)); + my $lsn = $node->safe_psql('postgres', 'SELECT pg_current_wal_lsn()'); + my $off = -s $node->logfile; + $node->safe_psql( + 'postgres', q( +SELECT injection_points_attach('pbu-undo-apply-skip', 'notice'); +BEGIN; +UPDATE stuck SET v = 'x'; +ROLLBACK; +)); + ok( $node->wait_for_log( + qr/WARNING: .*giving up on rolling back transaction/, $off), + 'worker gives up on a request that keeps failing'); + $node->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-apply-skip')"); + my $n = retries($off); + ok($n >= 1 && $n <= 10, "bounded number of retries ($n)"); + wait_discarded($lsn, 'given-up request'); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/089_perbackend_rewrite_rollback.pl b/src/test/recovery/t/089_perbackend_rewrite_rollback.pl new file mode 100644 index 0000000000000..b5c2a0b89bd83 --- /dev/null +++ b/src/test/recovery/t/089_perbackend_rewrite_rollback.pl @@ -0,0 +1,124 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Per-backend UNDO of a table that the same transaction then rewrote into a +# new relfilenode (TRUNCATE, ALTER TABLE ... TYPE) must be applied to the +# file the undo was written against. The rollback restores the old +# relfilenode, so the records belong to it; applying them to the new file +# (or counting them as done there) leaves the aborted images in the old file +# and the committed rows are lost. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_rewrite_rollback'); +$node->init; +# Applying undo to the wrong file can crash the undo apply worker; let the +# remaining checks run. +$node->append_conf('postgresql.conf', + "autovacuum = off\nrestart_after_crash = on\n"); +$node->start; +# +# The comparison is exact except for the two CLOG hint bits in the high byte +# of t_flags (byte 37 of the FLUX tuple header): FLUX_TUPLE_XMIN_COMMITTED +# (0x02) and FLUX_TUPLE_XMAX_COMMITTED (0x04). The UPDATE's visibility +# check may set them on the page after the committed image was read here and +# before the UPDATE copies its before-image, so the image the worker puts +# back can carry a hint the committed image did not. A hint bit only caches +# what CLOG says about t_xmin or t_xmax, which both images share; it is not +# WAL-logged and wal_consistency_checking masks it. Every other byte, +# including the reserved word at bytes 16-19, must match. +my $raw_mk = q{SELECT encode(set_byte(t, 37, get_byte(t, 37) & ~6), 'hex') + FROM (SELECT substr(get_raw_page('mk', 0), lp_off + 1, lp_len) AS t + FROM heap_page_items(get_raw_page('mk', 0)) WHERE lp = 1) s}; + +my %cases = ( + 'a_update_truncate_insert' => sub { + my ($t) = @_; + return qq( +BEGIN; +UPDATE $t SET v = 'x'; +TRUNCATE $t; +INSERT INTO $t VALUES (99, 'new'); +ROLLBACK;); + }, + 'b_update_truncate' => sub { + my ($t) = @_; + return qq( +BEGIN; +UPDATE $t SET v = 'x'; +TRUNCATE $t; +ROLLBACK;); + }, + 'c_savepoint_update_truncate_insert' => sub { + my ($t) = @_; + return qq( +BEGIN; +SAVEPOINT s; +UPDATE $t SET v = 'x'; +TRUNCATE $t; +INSERT INTO $t VALUES (99, 'new'); +ROLLBACK TO s; +COMMIT;); + }, + 'd_update_alter_type' => sub { + my ($t) = @_; + return qq( +BEGIN; +UPDATE $t SET v = 'x'; +ALTER TABLE $t ALTER COLUMN v TYPE varchar(100); +ROLLBACK;); + },); + +# The worker path applies undo in the background; give it a moment. +sub check +{ + my ($t, $what) = @_; + ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) || ',' || count(*) FILTER (WHERE v = 'orig') FROM $t", + '10,10'), + $what); +} + +foreach my $path ('inline', 'worker') +{ + if ($path eq 'worker') + { + $node->safe_psql('postgres', + 'ALTER SYSTEM SET undo_rollback_overflow_size = 0'); + $node->restart; + } + + my @tables; + foreach my $am ('flux') + { + foreach my $case (sort keys %cases) + { + my $t = "${path}_${am}_" . substr($case, 0, 1); + my $what = "$path, $am, $case"; + $node->poll_query_until('postgres', 'SELECT true'); + $node->safe_psql( + 'postgres', qq( +CREATE TABLE $t (id int primary key, v text) USING $am; +INSERT INTO $t SELECT g, 'orig' FROM generate_series(1, 10) g; +)); + my ($ret, $out, $err) = + $node->psql('postgres', $cases{$case}->($t)); + is($ret, 0, "$what: no error") or diag($err); + check($t, "$what: rows restored"); + push @tables, [ $t, $what ]; + } + } + + $node->restart; + check($_->[0], "$_->[1]: rows restored after restart") foreach @tables; + + $node->stop('immediate'); + $node->start; + check($_->[0], "$_->[1]: rows restored after crash restart") + foreach @tables; +} + +done_testing(); diff --git a/src/test/recovery/t/096_perbackend_redo_header_image.pl b/src/test/recovery/t/096_perbackend_redo_header_image.pl new file mode 100644 index 0000000000000..cae84457860e5 --- /dev/null +++ b/src/test/recovery/t/096_perbackend_redo_header_image.pl @@ -0,0 +1,117 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A transaction's first undo record links the previous transaction's header +# to it (uur_next). When that is the first change to the header's undo page +# since the last checkpoint, the WAL record carries a full-page image of the +# page. If a later discard then removes the whole segment file holding the +# page and the server crashes, redo of the link finds the page neither in +# shared buffers nor on disk: it has to come from the image. Redo used to +# ignore undo page images, read a zeroed page, and fail an assertion. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('pbu_redo_header_image'); +$node->init; +$node->append_conf( + 'postgresql.conf', q( +autovacuum = off +checkpoint_timeout = 1h +max_wal_size = 4GB +)); +$node->start; +$node->safe_psql( + 'postgres', q( +CREATE EXTENSION amcheck; +CREATE TABLE t (id int PRIMARY KEY, v text) USING flux; +INSERT INTO t SELECT g, repeat('x', 100) FROM generate_series(1, 20000) g; +)); + +# Hold the discard horizon back, so that the first transaction's undo is +# still there when the second one links its header. +my $hold = $node->background_psql('postgres'); +$hold->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1;'); + +my $s = $node->background_psql('postgres'); +$s->query_safe("UPDATE t SET v = 'a' WHERE id = 1"); +my $logno = $s->query_safe( + "SELECT logno FROM pg_stat_get_undo_logs() + WHERE pid = pg_backend_pid() AND persistence = 'permanent'"); +$s->query_safe('CHECKPOINT'); + +# The link to the first transaction's header: the first change to its page +# since the checkpoint, hence a full-page image. Check the replay of this +# record against the primary's pages too. +$s->query_safe("SET wal_consistency_checking = 'FLUX'"); +$s->query_safe('DELETE FROM t WHERE id = 2'); +$s->query_safe('RESET wal_consistency_checking'); + +# More than a segment of undo, so that discarding it all removes the segment +# file holding the header page. +for (my $lo = 2; $lo < 20000; $lo += 1000) +{ + $s->query_safe( + "UPDATE t SET v = repeat('y', 100) + WHERE id > $lo AND id <= $lo + 1000"); +} +$hold->query_safe('COMMIT'); + +ok( $node->poll_query_until( + 'postgres', qq( +SELECT ('x' || discard)::bit(64)::bigint & ((1::bigint << 40) - 1) >= 1048576 + FROM pg_stat_get_undo_logs() WHERE logno = $logno)), + 'the discard worker discards past the first segment'); +my $seg0 = $node->data_dir . sprintf('/base/undo/%06X.0000000000', $logno); +ok(!-e $seg0, 'the segment file holding the header page is gone'); + +$node->stop('immediate'); +$s->{run}->kill_kill; +$hold->{run}->kill_kill; + +my $integrity = q( +SELECT count(*), count(*) FILTER (WHERE id = 1 AND v = 'a'), + count(*) FILTER (WHERE id = 2), + count(*) FILTER (WHERE id > 2 AND v = repeat('y', 100)) + FROM t); + +ok($node->start(fail_ok => 1), + 'crash recovery restores the header page from its image'); +my $log = slurp_file($node->logfile); +unlike($log, qr/TRAP:|PANIC:/, 'no assertion failure or PANIC in recovery'); + +foreach my $round ( + 'after crash recovery', + 'after a second crash', + 'after a clean restart') +{ + if ($round eq 'after a second crash') + { + $node->stop('immediate'); + $node->start; + } + elsif ($round eq 'after a clean restart') + { + $node->restart; + } + is($node->safe_psql('postgres', $integrity), + '19999|1|0|19998', "committed changes intact $round"); + is( $node->safe_psql('postgres', "SELECT bt_index_check('t_pkey', true)"), + '', + "index consistent with the table $round"); +} + +# The recovered undo log still takes undo and rolls it back. +$node->safe_psql( + 'postgres', q( +BEGIN; +UPDATE t SET v = 'z' WHERE id <= 100; +DELETE FROM t WHERE id BETWEEN 101 AND 200; +ROLLBACK; +)); +ok($node->poll_query_until('postgres', $integrity, '19999|1|0|19998'), + 'a rollback after recovery restores every row'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/114_undo_recovery_error.pl b/src/test/recovery/t/114_undo_recovery_error.pl new file mode 100644 index 0000000000000..13a655b75648b --- /dev/null +++ b/src/test/recovery/t/114_undo_recovery_error.pl @@ -0,0 +1,48 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# An ERROR in startup undo must fail startup, leaving the chain for retry. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; +plan skip_all => 'Injection points required' + unless $ENV{enable_injection_points} eq 'yes'; +my $node = PostgreSQL::Test::Cluster->new('undo_recovery_error'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +autovacuum = off +shared_preload_libraries = 'test_idxundo' +}); +$node->start; +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE TABLE f (id int, v int) USING flux; +INSERT INTO f VALUES (1, 10); +CHECKPOINT; +}); +my $s = $node->background_psql('postgres'); +$s->query_safe('BEGIN; UPDATE f SET v = 16908801;'); +$node->safe_psql('postgres', 'CHECKPOINT'); +$node->stop('immediate'); +$s->{run}->kill_kill; +$node->append_conf('postgresql.conf', 'test_idxundo.recovery_error = on'); +ok(!$node->start(fail_ok => 1), 'undo ERROR fails startup'); +like( + slurp_file($node->logfile), + qr/FATAL:.*test_idxundo: recovery error/, + 'startup reports FATAL'); +# Stop the baseline if it incorrectly accepted connections. +$node->stop('immediate', fail_ok => 1); +$node->append_conf('postgresql.conf', 'test_idxundo.recovery_error = off'); +$node->start; +ok( $node->poll_query_until( + 'postgres', q{ +SELECT position('\x01020201'::bytea IN get_raw_page('f', 0)) = 0 +}), + 'retry physically removes the aborted image'); +is($node->safe_psql('postgres', 'SELECT v FROM f'), + '10', 'committed value survives restart'); +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/115_flux_subxact_abort_dropped_storage.pl b/src/test/recovery/t/115_flux_subxact_abort_dropped_storage.pl new file mode 100644 index 0000000000000..f1c7c6cc1de13 --- /dev/null +++ b/src/test/recovery/t/115_flux_subxact_abort_dropped_storage.pl @@ -0,0 +1,163 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# ROLLBACK TO SAVEPOINT of a subtransaction that gave a relation new storage +# (CREATE TABLE, TRUNCATE) and then updated rows in it. The subtransaction's +# abort record drops that storage on redo, so every UNDO apply record for it +# must come before the abort record. FLUX used to write them after it: a +# streaming standby then replayed an UNDO apply record for a block of a file +# it had just unlinked and stopped with "WAL contains references to invalid +# pages", and so did crash recovery of the primary. Heap is the control, +# except for REINDEX in the subtransaction, which hit heap's index UNDO too. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +wal_consistency_checking = all +)); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION amcheck'); +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->start; + +foreach my $am ('heap', 'flux') +{ + $node->safe_psql( + 'postgres', qq( +CREATE TABLE ${am}_keep (id int PRIMARY KEY, v int) USING $am; +INSERT INTO ${am}_keep SELECT g, 0 FROM generate_series(1, 10) g; +)); + + # Tables created in the subtransaction, written, updated, rolled back; + # the parent commits. This is the join_hash regression test's shape. + $node->safe_psql( + 'postgres', qq( +BEGIN; +INSERT INTO ${am}_keep VALUES (0, 0); +SAVEPOINT s; +CREATE TABLE ${am}_a (id int) USING $am; +CREATE TABLE ${am}_b (id int) USING $am; +INSERT INTO ${am}_a VALUES (1); +INSERT INTO ${am}_b VALUES (2); +UPDATE ${am}_b SET id = 2; +ROLLBACK TO s; +COMMIT; +)); + + # The same, with an index and a whole-transaction ROLLBACK after it. + $node->safe_psql( + 'postgres', qq( +BEGIN; +SAVEPOINT s; +CREATE TABLE ${am}_c (id int PRIMARY KEY, v int) USING $am; +INSERT INTO ${am}_c SELECT g, 0 FROM generate_series(1, 50) g; +UPDATE ${am}_c SET v = v + 1; +ROLLBACK TO s; +ROLLBACK; +)); + + # An existing table TRUNCATEd in the subtransaction: the new file is + # dropped by the subtransaction's abort, the old one comes back. + $node->safe_psql( + 'postgres', qq( +BEGIN; +INSERT INTO ${am}_keep VALUES (-1, 0); +SAVEPOINT s; +TRUNCATE ${am}_keep; +INSERT INTO ${am}_keep SELECT g, 1 FROM generate_series(1, 6) g; +UPDATE ${am}_keep SET v = 2; +ROLLBACK TO s; +COMMIT; +)); + + # Rows written in the subtransaction to the old index files, then a + # REINDEX: the index UNDO names the index by OID, which now maps to the + # REINDEXed file the abort drops. heap's index UNDO (the cluster-wide + # engine) and FLUX's in-place index reversal both have to leave it alone. + $node->safe_psql( + 'postgres', qq( +CREATE INDEX ${am}_keep_v ON ${am}_keep (v); +BEGIN; +INSERT INTO ${am}_keep VALUES (-2, 0); +SAVEPOINT s; +INSERT INTO ${am}_keep SELECT g, g FROM generate_series(100, 110) g; +UPDATE ${am}_keep SET v = v + 1000 WHERE id BETWEEN 1 AND 5; +REINDEX TABLE ${am}_keep; +ROLLBACK TO s; +COMMIT; +)); + + foreach my $scope ('top', 'sub') + { + my $savepoint = $scope eq 'sub' ? 'SAVEPOINT s;' : ''; + my $rollback = + $scope eq 'sub' ? 'ROLLBACK TO s; COMMIT;' : 'ROLLBACK;'; + $node->safe_psql( + 'postgres', qq( +BEGIN; +$savepoint +UPDATE ${am}_keep SET v = v + 1000 WHERE id BETWEEN 1 AND 5; +REINDEX TABLE ${am}_keep; +$rollback +)); + is( $node->safe_psql( + 'postgres', "SELECT bt_index_check('${am}_keep_v', true)"), + '', + "$am: $scope rollback restores the old index file"); + } + + is( $node->safe_psql( + 'postgres', "SELECT count(*), sum(v) FROM ${am}_keep"), + '13|0', + "$am: the parent's rows survive the savepoint rollbacks"); + is( $node->safe_psql( + 'postgres', qq( +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT count(*) FROM ${am}_keep WHERE v >= 0; +)), + '13', + "$am: the index has exactly the parent's rows"); +} + +$node->wait_for_replay_catchup($standby); +is($standby->safe_psql('postgres', 'SELECT pg_is_in_recovery()'), + 't', 'standby replayed the savepoint rollbacks'); +foreach my $am ('heap', 'flux') +{ + is( $standby->safe_psql( + 'postgres', "SELECT count(*), sum(v) FROM ${am}_keep"), + '13|0', + "$am: standby has the parent's rows"); +} +unlike( + slurp_file($standby->logfile), + qr/invalid pages|does not exist.*CONTEXT: WAL redo at .* Undo|PANIC|inconsistent page found/s, + 'standby log has no invalid-page reference or PANIC'); + +# Crash recovery of the primary replays the same WAL from the backup point. +$node->stop('immediate'); +$node->start; +foreach my $am ('heap', 'flux') +{ + is( $node->safe_psql( + 'postgres', "SELECT count(*), sum(v) FROM ${am}_keep"), + '13|0', + "$am: rows after crash recovery"); +} +unlike( + slurp_file($node->logfile), + qr/invalid pages|PANIC/, + 'primary crash recovery found no invalid page'); + +$standby->stop; +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/117_undo_rewind_snapshot.pl b/src/test/recovery/t/117_undo_rewind_snapshot.pl new file mode 100644 index 0000000000000..09b50bd9c4b15 --- /dev/null +++ b/src/test/recovery/t/117_undo_rewind_snapshot.pl @@ -0,0 +1,116 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# pg_rewind copies pg_undo/ whole from the source, whose checkpoints have +# already pruned the snapshot for the target's restart checkpoint (the last +# common one). The rewound server must still replay the per-backend undo +# log records written after that checkpoint and come up with the source's +# data. The heap twin is the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +for my $am (qw(heap flux)) +{ + my $primary = PostgreSQL::Test::Cluster->new("primary_$am"); + $primary->init(allows_streaming => 1); + $primary->append_conf( + 'postgresql.conf', qq[ +wal_log_hints = on +wal_keep_size = 320MB +undo_worker_naptime = 10 +]); + $primary->start; + $primary->safe_psql( + 'postgres', qq[ + CREATE EXTENSION amcheck; + CREATE TABLE t (id int PRIMARY KEY, v int) USING $am; + INSERT INTO t SELECT g, 0 FROM generate_series(1, 2000) g; + UPDATE t SET v = v + 1; + CHECKPOINT; + ]); + $primary->backup('b'); + my $standby = PostgreSQL::Test::Cluster->new("standby_$am"); + $standby->init_from_backup($primary, 'b', has_streaming => 1); + $standby->start; + + # Undo activity and the last common checkpoint. + $primary->safe_psql('postgres', 'UPDATE t SET v = v + 1; CHECKPOINT;'); + $primary->wait_for_catchup($standby); + $standby->promote; + + # Undo writes, discards and checkpoints on the new primary only: the + # checkpoints prune the common checkpoint's pg_undo snapshot. + for my $i (1 .. 3) + { + $standby->safe_psql('postgres', 'UPDATE t SET v = v + 1'); + $standby->poll_query_until('postgres', + "SELECT coalesce(bool_and(discard = insert), true) FROM pg_stat_get_undo_logs() WHERE persistence = 'permanent'" + ) or die "timed out waiting for undo discard"; + $standby->safe_psql('postgres', 'CHECKPOINT'); + } + my $expected = + $standby->safe_psql('postgres', 'SELECT count(*), sum(v) FROM t'); + + # Diverge the old primary. + $primary->safe_psql('postgres', 'UPDATE t SET v = v + 100'); + $primary->stop; + $standby->stop; + + command_ok( + [ + 'pg_rewind', + '--target-pgdata' => $primary->data_dir, + '--source-pgdata' => $standby->data_dir, + ], + "$am: pg_rewind"); + + $primary->append_conf('postgresql.conf', + 'port = ' . $primary->port . "\n"); + $primary->enable_streaming($standby); + $standby->start; + my $logstart = -s $primary->logfile; + my $started = $primary->start(fail_ok => 1); + ok($started, "$am: rewound server starts"); + if (!$started) + { + diag(slurp_file($primary->logfile, $logstart)); + $standby->stop; + next; + } + + # More undo after the rewind must replay too. + $standby->safe_psql('postgres', 'UPDATE t SET v = v + 1'); + $standby->wait_for_catchup($primary); + $expected = + $standby->safe_psql('postgres', 'SELECT count(*), sum(v) FROM t'); + is($primary->safe_psql('postgres', 'SELECT count(*), sum(v) FROM t'), + $expected, "$am: rewound data matches the source"); + is( $primary->safe_psql( + 'postgres', "SELECT bt_index_check('t_pkey', true)"), + '', + "$am: index consistent after rewind"); + unlike( + slurp_file($primary->logfile, $logstart), + qr/unknown undo log/, + "$am: no unknown undo log during replay"); + ok( $am eq 'heap' || $primary->log_contains( + 'undo log checkpoint snapshot for redo point .* is missing, using', + $logstart), + "$am: replay started from a newer undo log snapshot"); + $primary->stop; + $standby->stop; + + # With every snapshot gone, a cluster that has undo logs must refuse to + # start instead of starting with no undo logs. + next if $am eq 'heap'; + my $dir = $primary->data_dir . '/pg_undo'; + unlink glob("$dir/*"); + command_fails_like( + [ 'postgres', '-D', $primary->data_dir ], + qr/undo log checkpoint snapshot .* is missing/, + "$am: start without any undo log snapshot fails"); +} +done_testing(); diff --git a/src/test/recovery/t/152_undo_attach_order_after_checkpoint.pl b/src/test/recovery/t/152_undo_attach_order_after_checkpoint.pl new file mode 100644 index 0000000000000..c85fa6b8c74f2 --- /dev/null +++ b/src/test/recovery/t/152_undo_attach_order_after_checkpoint.pl @@ -0,0 +1,79 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Guard for a crash-recovery bug in the per-backend UNDO log: the startup +# process died with "xid to undo log number map not initialized" when the first +# undo record of a transaction replayed after a checkpoint had no preceding +# XLOG_UNDOLOG_ATTACH/META for its xid. +# +# Root cause (fixed): LogUndoMetaData() decided whether to emit the xid->logno +# mapping record by comparing the undo log's last-logged LSN against the +# backend-cached redo point from GetFullPageWriteInfo(). That cached copy is +# refreshed only inside XLogInsertRecord, so between a backend's WAL inserts it +# can lag a concurrent checkpoint; the mapping record was then skipped and the +# undo record that followed became the first after the new redo point with +# nothing mapping its xid. The fix compares against the current redo point +# (GetRedoRecPtr()); FLUX redo also re-seeds the map from the undo record +# pointer the forward record already carries, so recovery no longer depends on +# the mapping record's ordering at all. +# +# This is a GUARD, not a reproduction: it passes on the base too, because the +# bug needs a checkpoint to advance the redo point in the narrow window between +# a backend's own WAL inserts, which this cannot force deterministically. The +# reliable reproduction is recovery/137_flux_vacuum_parallel under +# wal_consistency_checking (fails ~1/3 of runs on the base, 0/15 after the fix). +# Here we exercise the shape -- a transaction whose undo spans a checkpoint, +# then an immediate crash -- for both access methods, with the heap run as a +# control (heap writes no undo and so cannot hit this). +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('undo_attach_order'); +$node->init; +$node->append_conf('postgresql.conf', "autovacuum = off\n"); +$node->start; + +sub run_am +{ + my ($am) = @_; + + $node->safe_psql( + 'postgres', qq{ +DROP TABLE IF EXISTS u_$am; +CREATE TABLE u_$am (id int, v int) USING $am WITH (autovacuum_enabled = off); +INSERT INTO u_$am SELECT g, g FROM generate_series(1, 20000) g; +CHECKPOINT; +}); + + my $h = $node->background_psql('postgres', on_error_stop => 0); + $h->query_safe("BEGIN"); + # First undo op of the transaction: emits ATTACH + META, grows the log. + $h->query_safe("DELETE FROM u_$am WHERE id <= 10000"); + + # Advance the redo point from another backend while the held session is + # idle, then do a second undo op in the SAME transaction (no new ATTACH): + # only a META could carry the mapping for its first record after the + # checkpoint. + $node->safe_psql('postgres', "CHECKPOINT"); + $h->query_safe("DELETE FROM u_$am WHERE id = 20000"); + + # Crash before commit; recovery must replay the second op and roll back. + $node->stop('immediate'); + eval { $h->quit }; + + $node->start; + + is( $node->safe_psql('postgres', "SELECT count(*) FROM u_$am"), + 20000, + "$am: recovery survives an undo op after a checkpoint-advanced redo point" + ); +} + +run_am('heap'); +run_am('flux'); + +$node->stop; +done_testing(); diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index e97b9252d8c89..7202708a90144 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -1803,6 +1803,7 @@ MultiXactStatus MultirangeIOData MultirangeParseState MultirangeType +MyUndoLogState NDBOX NDistinctParseState NDistinctSemanticState @@ -1919,6 +1920,7 @@ OverridingKind PACE_HEADER PACL PATH +PbuTwoPhaseUndoRecord PCtxtHandle PendingFileOp PendingFileOpType @@ -2105,6 +2107,7 @@ PREDICATELOCK PREDICATELOCKTAG PREDICATELOCKTARGET PREDICATELOCKTARGETTAG +PreparedUndoSpace PROCESS_INFORMATION PROCLOCK PROCLOCKTAG @@ -2240,6 +2243,7 @@ PatternInfo PatternInfoArray Pattern_Prefix_Status Pattern_Type +PbuPendingUndo PendingFsyncEntry PendingListenAction PendingListenEntry @@ -2517,6 +2521,8 @@ RI_ConstraintInfo RI_FastPathState RI_QueryHashEntry RI_QueryKey +RollbackHashEntry +RollbackHashKey RTEKind RTEPermissionInfo RWConflict @@ -2731,6 +2737,7 @@ RunMode RunningTransactions RunningTransactionsData SASLStatus +SatisfyUndoRecordCallback SC_HANDLE SECURITY_ATTRIBUTES SECURITY_STATUS @@ -3319,27 +3326,47 @@ UINT ULARGE_INTEGER ULONG ULONG_PTR +UndoApplyCtxStruct UndoApplyResult +UndoApplyWorker UndoBatchData +UndoBuffers UndoBufferStat +UndoErrorQueue +UndoFileMru UndoLogControl +UndoLogMetaData UndoLogNumber UndoLogOffset UndoLogSharedData UndoLogStat UndoLogState +UndoLogStatus +UndoPersistence UndoPersistenceLevel +UndoRecInfo +UndoRecordBlock UndoRecordHeader +UndoRecordPayload +UndoRecordRelationDetails UndoRecordSet UndoRecordSetChunkHeader UndoRecordSetType UndoRecordSize +UndoRecordTransaction UndoRecoveryEntry UndoRecPtr +undorectype +UndoRequestInfo UndoRmgrApplyFunc UndoRmgrData UndoRmgrDescFunc +UndoSizeQueue +UndoWorkerQueue +UndoWorkerQueueType UndoWorkerShmemData +UndoXidQueue +UnpackedUndoRecord UV UVersionInfo UnicodeNormalizationForm @@ -3522,6 +3549,7 @@ X509_STORE_CTX X86FeatureId XactUndoContext XactUndoData +XactUndoRecordInfo XactUndoSubTransactionState xl_fileops_chmod xl_fileops_chown @@ -3544,6 +3572,13 @@ xl_undo_discard xl_undo_extend xl_undo_page_write xl_undo_rotate +xl_undoapply_progress +xl_undolog_attach +xl_undolog_create +xl_undolog_discard +xl_undolog_extend +xl_undolog_meta +xl_undolog_rewind XLTW_Oper XLogCtlData XLogCtlInsert @@ -4566,6 +4601,7 @@ xl_multixact_truncate xl_overwrite_contrecord xl_parameter_change xl_relmap_update +xl_relundo_truncate xl_replorigin_drop xl_replorigin_set xl_restore_point From 97b6513b11f1cc6070114ccd1e031ac006f151ea Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Sun, 27 Sep 2026 15:09:46 -0400 Subject: [PATCH 07/22] Add the ATM, sLog, and logical revert worker Add asynchronous UNDO application on top of the cluster-wide and per-backend UNDO engines. Instead of applying an aborted or in-doubt transaction's UNDO chain synchronously at abort, ROLLBACK PREPARED, or end of recovery, the transaction is recorded in an in-memory Aborted Transaction Map (ATM) and its chain is reverse-applied later by a background logical revert worker. * The ATM (atm.c, atm.h, atm_xlog.h) is a crash-safe map of aborted transactions and their UNDO chain-head LSNs, backed by the sLog (slog.c, slog_flathash.c, slog_tuple.c, slog.h and friends): a shared-memory, DSA-backed store with an optional per-tuple tracking hash a consumer AM may extend. A new ATM resource manager (rmgrdesc/atmdesc.c) makes ATM inserts/forgets crash-safe, and CheckPointATM / ATMReloadFromCheckpoint carry the map across a checkpoint that advances the redo pointer past an un-forgotten entry. * The logical revert worker (logical_revert_worker.c) is a per-database background worker, spawned by a launcher registered in postmaster.c, that drains the ATM by walking each recorded UNDO chain via ApplyUndoChainFromWAL(). * Abort, ROLLBACK PREPARED, and the end-of-recovery deferral queue now route to ATMAddAborted() instead of applying inline; abort keeps a synchronous fast path for small transactions governed by undo_instant_abort_threshold. * Adds the SLOG/ATM LWLock tranches and wait events, the sLog shmem subsystem, and the ATM/sLog/logical-revert GUCs. Author: Greg Burd Changes since v2: - Replace the dead UNDO log stats with a retention report [ebdd152b37] - Fix comments that contradict the code [112a0c74ac] - Always roll back non-index UNDO inline, whatever its size [7cb511bb5d] - Keep interrupts held when an inline UNDO apply throws [37360eaa7a] - Revert only objects that still show the operation being undone [514f51c381] - Register pg_stat_get_undo_logs() [cda0d6ea92] - Make an ordinary filesystem error an ERROR, not a PANIC [41b74acee7] - Say plainly that syscall-first leaves an unlogged crash window [9aaad487fc] - Remove the whole new database directory on abort or crash [030472f3c9] - Roll back in-flight transactions whose UNDO precedes the checkpoint [8bb6b33b89] - Do not chain new batches onto a rolled-back subtransaction's [da620b2a3d] - Do not lose a ROLLBACK PREPARED that crashes before the ATM entry [84429a4c0e] - Pgindent: typedefs for the in-flight UNDO record [4eb0c3db7e] - Make FILEOPS abort effects survive WAL replay [e6e8b9732f] - Restore prepared row locks without clearing other owners [8d76c9354e] - Remove the unused sLog before-image API [0d0e167bea] - Complete early error cleanup during inline undo abort [d77c63c85d] - Remove dead cluster-wide UNDO compatibility code [e329c8fc0e] - Remove no-op sLog writer brackets [08757e6f74] - Drop the O(1) claim from the ATM instant-abort description [381cb66f4a] - ATM tests: aborting into a full ATM must not fail [cff6ca71b1] - Recovery/126: create the test table USING heap [60c0b7adbf] - Apply pgperltidy to its tests (2 files) [f1a4d5a0f5] - FILEOPS routing: apply pgperltidy to its tests (1 file) [818a5dc556] Tests: - Wait for the log, not a sleep loop, in test_idxundo/004 [456281b4e2] - Show that a failed inverse fsync during abort re-enters abort [426f98c779] - A cluster that never uses UNDO must cost what master does [90fb63c170] --- doc/src/sgml/undo.sgml | 36 +- src/backend/access/rmgrdesc/Makefile | 1 + src/backend/access/rmgrdesc/atmdesc.c | 66 + src/backend/access/rmgrdesc/fileopsdesc.c | 5 +- src/backend/access/rmgrdesc/meson.build | 1 + src/backend/access/rmgrdesc/undodesc.c | 108 +- src/backend/access/rmgrdesc/xactdesc.c | 8 + src/backend/access/transam/rmgr.c | 1 + src/backend/access/transam/twophase.c | 117 +- src/backend/access/transam/xact.c | 76 +- src/backend/access/transam/xlog.c | 46 +- src/backend/access/transam/xlogrecovery.c | 3 + src/backend/access/undo/Makefile | 5 + src/backend/access/undo/README | 84 +- src/backend/access/undo/atm.c | 608 ++++ .../access/undo/logical_revert_worker.c | 837 +++++ src/backend/access/undo/meson.build | 5 + .../access/undo/perbackend/pbu_undolog.c | 56 +- src/backend/access/undo/slog.c | 818 +++++ src/backend/access/undo/slog_flathash.c | 796 +++++ src/backend/access/undo/slog_tuple.c | 2711 +++++++++++++++++ src/backend/access/undo/undo.c | 10 +- src/backend/access/undo/undo_xlog.c | 682 ++--- src/backend/access/undo/undoapply.c | 192 +- src/backend/access/undo/undoinsert.c | 2 + src/backend/access/undo/undolog.c | 124 +- src/backend/access/undo/undostats.c | 373 +-- src/backend/access/undo/undoworker.c | 135 +- src/backend/access/undo/xactundo.c | 517 +++- src/backend/commands/dbcommands.c | 18 +- src/backend/postmaster/bgworker.c | 9 + src/backend/postmaster/postmaster.c | 9 + src/backend/storage/file/README | 25 +- src/backend/storage/file/copydir.c | 30 +- src/backend/storage/file/fileops.c | 1329 ++++---- src/backend/storage/file/fileops_undo.c | 561 +++- .../utils/activity/wait_event_names.txt | 2 + src/backend/utils/misc/guc_parameters.dat | 47 + src/backend/utils/misc/guc_tables.c | 2 + src/backend/utils/misc/postgresql.conf.sample | 5 + src/bin/pg_waldump/rmgrdesc.c | 1 + src/bin/pg_waldump/t/001_basic.pl | 1 + src/include/access/atm.h | 73 + src/include/access/atm_xlog.h | 54 + src/include/access/logical_revert_worker.h | 43 + src/include/access/rmgrlist.h | 1 + src/include/access/slog.h | 353 +++ src/include/access/slog_flathash.h | 282 ++ src/include/access/slog_internal.h | 68 + src/include/access/undo_bufmgr.h | 8 - src/include/access/undo_xlog.h | 144 +- src/include/access/undolog.h | 36 +- src/include/access/undorecord.h | 42 + src/include/access/undormgr.h | 2 +- src/include/access/undostats.h | 47 +- src/include/access/xact.h | 13 +- src/include/access/xactundo.h | 15 + src/include/catalog/pg_proc.dat | 28 +- src/include/storage/buf_internals.h | 14 - src/include/storage/copydir.h | 10 +- src/include/storage/fileops.h | 121 +- src/include/storage/lwlocklist.h | 2 + src/include/storage/subsystemlist.h | 9 + src/test/modules/Makefile | 1 + src/test/modules/meson.build | 1 + src/test/modules/test_fileops/Makefile | 2 + src/test/modules/test_fileops/meson.build | 12 + .../t/001_fileops_apply_failure.pl | 7 +- .../t/002_fileops_large_rollback.pl | 47 + .../test_fileops/t/003_fileops_atm_reapply.pl | 82 + .../test_fileops/t/004_fileops_fs_errors.pl | 107 + .../t/005_fileops_checkpoint_loser.pl | 103 + .../t/006_fileops_rollback_prepared_crash.pl | 95 + .../test_fileops/t/007_fileops_abort_redo.pl | 110 + .../t/007_fileops_commit_crash.pl | 37 +- .../t/008_fileops_standby_abort.pl | 59 + .../test_fileops/t/009_fileops_reuse.pl | 70 + .../t/016_fileops_inverse_fsync.pl | 79 + .../test_fileops/test_fileops--1.0.sql | 6 + src/test/modules/test_fileops/test_fileops.c | 23 + .../t/002_idxundo_abort_threshold.pl | 86 + .../t/003_idxundo_apply_failure.pl | 144 + .../test_idxundo/t/004_idxundo_apply_order.pl | 86 + .../t/005_idxundo_atm_retention.pl | 103 + .../t/006_idxundo_revert_retry.pl | 128 + .../t/007_idxundo_2pc_database.pl | 66 + .../t/008_idxundo_revert_liveness.pl | 77 + .../t/009_idxundo_recovery_first_batch.pl | 57 + src/test/recovery/meson.build | 3 + .../t/060_fileops_standby_divergence.pl | 9 +- .../recovery/t/061_fileops_2pc_deferred.pl | 56 +- src/test/recovery/t/062_fileops_recovery.pl | 154 +- src/test/recovery/t/063_fileops_2pc.pl | 35 +- .../recovery/t/072_perbackend_crash_undo.pl | 43 +- .../recovery/t/075_perbackend_nonpermanent.pl | 9 + .../recovery/t/090_fileops_createdb_crash.pl | 113 + src/test/recovery/t/126_atm_full_abort.pl | 125 + .../recovery/t/134_undo_shmem_on_demand.pl | 97 + src/tools/pgindent/typedefs.list | 33 +- 99 files changed, 11507 insertions(+), 2585 deletions(-) create mode 100644 src/backend/access/rmgrdesc/atmdesc.c create mode 100644 src/backend/access/undo/atm.c create mode 100644 src/backend/access/undo/logical_revert_worker.c create mode 100644 src/backend/access/undo/slog.c create mode 100644 src/backend/access/undo/slog_flathash.c create mode 100644 src/backend/access/undo/slog_tuple.c create mode 100644 src/include/access/atm.h create mode 100644 src/include/access/atm_xlog.h create mode 100644 src/include/access/logical_revert_worker.h create mode 100644 src/include/access/slog.h create mode 100644 src/include/access/slog_flathash.h create mode 100644 src/include/access/slog_internal.h create mode 100644 src/test/modules/test_fileops/t/002_fileops_large_rollback.pl create mode 100644 src/test/modules/test_fileops/t/003_fileops_atm_reapply.pl create mode 100644 src/test/modules/test_fileops/t/004_fileops_fs_errors.pl create mode 100644 src/test/modules/test_fileops/t/005_fileops_checkpoint_loser.pl create mode 100644 src/test/modules/test_fileops/t/006_fileops_rollback_prepared_crash.pl create mode 100644 src/test/modules/test_fileops/t/007_fileops_abort_redo.pl create mode 100644 src/test/modules/test_fileops/t/008_fileops_standby_abort.pl create mode 100644 src/test/modules/test_fileops/t/009_fileops_reuse.pl create mode 100644 src/test/modules/test_fileops/t/016_fileops_inverse_fsync.pl create mode 100644 src/test/modules/test_idxundo/t/002_idxundo_abort_threshold.pl create mode 100644 src/test/modules/test_idxundo/t/003_idxundo_apply_failure.pl create mode 100644 src/test/modules/test_idxundo/t/004_idxundo_apply_order.pl create mode 100644 src/test/modules/test_idxundo/t/005_idxundo_atm_retention.pl create mode 100644 src/test/modules/test_idxundo/t/006_idxundo_revert_retry.pl create mode 100644 src/test/modules/test_idxundo/t/007_idxundo_2pc_database.pl create mode 100644 src/test/modules/test_idxundo/t/008_idxundo_revert_liveness.pl create mode 100644 src/test/modules/test_idxundo/t/009_idxundo_recovery_first_batch.pl create mode 100644 src/test/recovery/t/090_fileops_createdb_crash.pl create mode 100644 src/test/recovery/t/126_atm_full_abort.pl create mode 100644 src/test/recovery/t/134_undo_shmem_on_demand.pl diff --git a/doc/src/sgml/undo.sgml b/doc/src/sgml/undo.sgml index c1dbcd478fa24..be93794a7dedb 100644 --- a/doc/src/sgml/undo.sgml +++ b/doc/src/sgml/undo.sgml @@ -348,24 +348,32 @@ FROM pg_stat_undo; - pg_stat_get_undo_logs(): returns one row per - UNDO log with its log number, insert and discard offsets, size in - bytes, oldest active transaction, and state. - - - - - pg_stat_get_undo_buffers(): returns a single row - of UNDO buffer-cache statistics (buffer count, hits, misses, - evictions, writes, and hit ratio). + pg_stat_get_undo_retention(): returns one row per + holder of UNDO WAL: an in-flight transaction that has written UNDO + (backend, with its pid), + an aborted transaction waiting for the logical revert worker + (aborted), or the prepared transactions + (prepared). Each row gives the oldest WAL position + the holder needs (oldest_lsn) and how much + WAL that retains (retained_bytes); WAL older + than the minimum oldest_lsn can be recycled. - pg_undo_force_discard(force_rotate boolean): - forces an immediate discard of reclaimable UNDO records and returns - the number of log segments freed. Membership in the - pg_maintain role is required. + pg_stat_get_undo_logs(): returns one row per + per-backend undo log, as used by table access methods that keep their + undo there. Each row gives the log number + (logno), its + persistence (permanent, + unlogged or temporary) and + tablespace, the + discard, insert + and end pointers (undo record pointers in + hexadecimal; the undo between discard and + insert is still needed), and the + xid and pid of + the transaction and process attached to the log, if any. diff --git a/src/backend/access/rmgrdesc/Makefile b/src/backend/access/rmgrdesc/Makefile index efc715b789102..3394ae8f765e0 100644 --- a/src/backend/access/rmgrdesc/Makefile +++ b/src/backend/access/rmgrdesc/Makefile @@ -9,6 +9,7 @@ top_builddir = ../../../.. include $(top_builddir)/src/Makefile.global OBJS = \ + atmdesc.o \ brindesc.o \ clogdesc.o \ committsdesc.o \ diff --git a/src/backend/access/rmgrdesc/atmdesc.c b/src/backend/access/rmgrdesc/atmdesc.c new file mode 100644 index 0000000000000..7bd26ac2679a4 --- /dev/null +++ b/src/backend/access/rmgrdesc/atmdesc.c @@ -0,0 +1,66 @@ +/*------------------------------------------------------------------------- + * + * atmdesc.c + * rmgr descriptor routines for access/undo/atm.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/atmdesc.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/atm_xlog.h" + +void +atm_desc(StringInfo buf, XLogReaderState *record) +{ + char *data = XLogRecGetData(record); + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + switch (info) + { + case XLOG_ATM_ABORT: + { + xl_atm_abort *xlrec = (xl_atm_abort *) data; + + appendStringInfo(buf, + "xid %u, last_batch_lsn %X/%X, stop_lsn %X/%X, first_batch_lsn %X/%X, dboid %u", + xlrec->xid, + LSN_FORMAT_ARGS(xlrec->last_batch_lsn), + LSN_FORMAT_ARGS(xlrec->stop_lsn), + LSN_FORMAT_ARGS(xlrec->first_batch_lsn), + xlrec->dboid); + } + break; + + case XLOG_ATM_FORGET: + { + xl_atm_forget *xlrec = (xl_atm_forget *) data; + + appendStringInfo(buf, "xid %u", xlrec->xid); + } + break; + } +} + +const char * +atm_identify(uint8 info) +{ + const char *id = NULL; + + switch (info & ~XLR_INFO_MASK) + { + case XLOG_ATM_ABORT: + id = "ABORT"; + break; + case XLOG_ATM_FORGET: + id = "FORGET"; + break; + } + + return id; +} diff --git a/src/backend/access/rmgrdesc/fileopsdesc.c b/src/backend/access/rmgrdesc/fileopsdesc.c index d13891729ee1a..ba4cb111c372e 100644 --- a/src/backend/access/rmgrdesc/fileopsdesc.c +++ b/src/backend/access/rmgrdesc/fileopsdesc.c @@ -92,8 +92,9 @@ fileops_desc(StringInfo buf, XLogReaderState *record) 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); + appendStringInfo(buf, "mkdir \"%s\" mode 0%o%s", + path, (unsigned int) xlrec->mode, + (xlrec->flags & FILEOPS_MKDIR_TREE) ? " tree" : ""); } break; diff --git a/src/backend/access/rmgrdesc/meson.build b/src/backend/access/rmgrdesc/meson.build index 84dcf1b666739..4535cabfde24c 100644 --- a/src/backend/access/rmgrdesc/meson.build +++ b/src/backend/access/rmgrdesc/meson.build @@ -2,6 +2,7 @@ # used by frontend programs like pg_waldump rmgr_desc_sources = files( + 'atmdesc.c', 'brindesc.c', 'clogdesc.c', 'committsdesc.c', diff --git a/src/backend/access/rmgrdesc/undodesc.c b/src/backend/access/rmgrdesc/undodesc.c index d4817684ad471..feff7e13462c3 100644 --- a/src/backend/access/rmgrdesc/undodesc.c +++ b/src/backend/access/rmgrdesc/undodesc.c @@ -30,38 +30,8 @@ undo_desc(StringInfo buf, XLogReaderState *record) 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: { @@ -107,13 +77,23 @@ undo_desc(StringInfo buf, XLogReaderState *record) } break; - case XLOG_UNDO_PAGE_WRITE: + + case XLOG_UNDO_INFLIGHT: { - xl_undo_page_write *xlrec = (xl_undo_page_write *) rec; + xl_undo_inflight *xlrec = (xl_undo_inflight *) rec; - appendStringInfo(buf, "page_offset %u, data_len %u", - xlrec->page_offset, - xlrec->data_len); + appendStringInfo(buf, "nxacts %u", xlrec->nxacts); + for (uint32 i = 0; i < xlrec->nxacts; i++) + { + xl_undo_inflight_xact *x = &xlrec->xacts[i]; + + appendStringInfo(buf, "; xid %u db %u", x->xid, x->dboid); + for (int j = 0; j < NUndoPersistenceLevels; j++) + if (XLogRecPtrIsValid(x->last_batch_lsn[j])) + appendStringInfo(buf, " p%d %X/%08X..%X/%08X", j, + LSN_FORMAT_ARGS(x->first_batch_lsn[j]), + LSN_FORMAT_ARGS(x->last_batch_lsn[j])); + } } break; @@ -122,10 +102,11 @@ undo_desc(StringInfo buf, XLogReaderState *record) xl_undo_batch *xlrec = (xl_undo_batch *) rec; appendStringInfo(buf, - "undo batch: xid %u, nrecords %u, " + "undo batch: xid %u, db %u, nrecords %u, " "total_len %u, chain_prev %X/%X, " "primary_reloid %u, persistence %d", xlrec->xid, + xlrec->dboid, xlrec->nrecords, xlrec->total_len, LSN_FORMAT_ARGS(xlrec->chain_prev), @@ -134,39 +115,7 @@ undo_desc(StringInfo buf, XLogReaderState *record) } 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; } } @@ -182,27 +131,20 @@ undo_identify(uint8 info) 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; + case XLOG_UNDO_INFLIGHT: + id = "INFLIGHT"; + break; } return id; diff --git a/src/backend/access/rmgrdesc/xactdesc.c b/src/backend/access/rmgrdesc/xactdesc.c index 96aa6e17bd50c..568ed626eb974 100644 --- a/src/backend/access/rmgrdesc/xactdesc.c +++ b/src/backend/access/rmgrdesc/xactdesc.c @@ -230,6 +230,12 @@ ParseAbortRecord(uint8 info, xl_xact_abort *xlrec, xl_xact_parsed_abort *parsed) data += sizeof(xl_xact_origin); } + if (parsed->xinfo & XACT_XINFO_HAS_FILEOPS) + { + memcpy(&parsed->fileops_len, data, sizeof(uint32)); + parsed->fileops = data + sizeof(uint32); + } + } /* @@ -391,6 +397,8 @@ xact_desc_abort(StringInfo buf, uint8 info, xl_xact_abort *xlrec, ReplOriginId o } xact_desc_stats(buf, "", parsed.nstats, parsed.stats); + if (parsed.fileops_len) + appendStringInfo(buf, "; fileops undo: %u bytes", parsed.fileops_len); } static void diff --git a/src/backend/access/transam/rmgr.c b/src/backend/access/transam/rmgr.c index f0c7809c6c2b8..38bc228c7339a 100644 --- a/src/backend/access/transam/rmgr.c +++ b/src/backend/access/transam/rmgr.c @@ -42,6 +42,7 @@ #include "storage/standby.h" #include "utils/relmapper.h" #include "access/undo_xlog.h" +#include "access/atm.h" #include "access/perbackend/pbu_undolog_xlog.h" #include "access/perbackend/pbu_undoaction_xlog.h" /* IWYU pragma: end_keep */ diff --git a/src/backend/access/transam/twophase.c b/src/backend/access/transam/twophase.c index 127343cc3fc81..2ece61233bec6 100644 --- a/src/backend/access/transam/twophase.c +++ b/src/backend/access/transam/twophase.c @@ -77,7 +77,7 @@ #include #include "access/commit_ts.h" -#include "access/undorecord.h" +#include "access/atm.h" #include "access/xactundo.h" #include "access/htup_details.h" #include "access/subtrans.h" @@ -192,8 +192,13 @@ typedef struct GlobalTransactionData * recycled -- neither the per-backend retention slot (cleared when the * preparing backend exits) nor the ATM (prepared xacts aren't in it) * covers this case. + * + * undo_batch_lsn is the chain head (where ROLLBACK PREPARED starts the + * walk); undo_first_batch_lsn its oldest batch, which is what retention + * must keep. */ XLogRecPtr undo_batch_lsn; + XLogRecPtr undo_first_batch_lsn; FullTransactionId fxid; /* The GXACT full xid */ Oid owner; /* ID of user that executed the xact */ @@ -259,7 +264,7 @@ static void RecordTransactionAbortPrepared(TransactionId xid, RelFileLocator *rels, int nstats, xl_xact_stats_item *stats, - const char *gid); + const char *gid, const char *fileops, uint32 fileops_len); static void ProcessRecords(char *bufptr, FullTransactionId fxid, const TwoPhaseCallback callbacks[]); static void RemoveGXact(GlobalTransaction gxact); @@ -525,6 +530,8 @@ MarkAsPreparingGuts(GlobalTransaction gxact, FullTransactionId fxid, gxact->valid = false; gxact->inredo = false; gxact->undo_batch_lsn = GetCurrentXactLastBatchLSN(UNDOPERSISTENCE_PERMANENT); + gxact->undo_first_batch_lsn = + GetCurrentXactFirstBatchLSN(UNDOPERSISTENCE_PERMANENT); strlcpy(gxact->gid, gid, GIDSIZE); /* @@ -1023,8 +1030,10 @@ TwoPhaseFilePath(char *path, FullTransactionId fxid) * 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. + * + * Bumped to 0x57F94537: the header gained undo_first_batch_lsn. */ -#define TWOPHASE_MAGIC 0x57F94536 /* format identifier */ +#define TWOPHASE_MAGIC 0x57F94537 /* format identifier */ typedef xl_xact_prepare TwoPhaseFileHeader; @@ -1149,6 +1158,8 @@ StartPrepare(GlobalTransaction gxact) /* 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); + hdr.undo_first_batch_lsn = + GetCurrentXactFirstBatchLSN(UNDOPERSISTENCE_PERMANENT); save_state_data(&hdr, sizeof(TwoPhaseFileHeader)); save_state_data(gxact->gid, hdr.gidlen); @@ -1686,6 +1697,19 @@ FinishPreparedTransaction(const char *gid, bool isCommit) * callbacks will release the locks the transaction held. */ if (isCommit) + { + XLogRecPtr atm_lsn; + + /* + * A ROLLBACK PREPARED that crashed before its abort record can have + * left an ATM entry (see below). Nothing was applied from it: the + * revert worker skips a transaction still in progress. Forget it + * before the commit record, so a committed transaction never has one. + */ + if (XLogRecPtrIsValid(hdr->last_batch_lsn[UNDOPERSISTENCE_PERMANENT]) && + ATMGetLastBatchLSN(xid, &atm_lsn)) + ATMForget(xid); + RecordTransactionCommitPrepared(xid, hdr->nsubxacts, children, hdr->ncommitrels, commitrels, @@ -1693,21 +1717,31 @@ FinishPreparedTransaction(const char *gid, bool isCommit) commitstats, hdr->ninvalmsgs, invalmsgs, hdr->initfileinval, gid); + } else { - RecordTransactionAbortPrepared(xid, - hdr->nsubxacts, children, - hdr->nabortrels, abortrels, - hdr->nabortstats, - abortstats, - gid); + char *fileops = NULL; + uint32 fileops_len = 0; + char *scan = bufptr; /* - * ROLLBACK PREPARED: apply any cluster-wide UNDO chain synchronously, - * here, before the prepared transaction's resources are torn down. + * ROLLBACK PREPARED: hand any cluster-wide UNDO chain to the same + * async revert machinery an ordinary large-transaction abort uses. * 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. + * header at PREPARE; ATMAddAborted() records (xid, dboid, + * last_batch_lsn) in the sLog Aborted Transaction Map, and the + * logical revert worker walks the UNDO chain backwards from that LSN, + * restoring before-images. No apply code lives here. + * + * This must precede the abort record. Once that is durable, a crash + * leaves no prepared transaction to roll back again, so an ATM entry + * not yet written would be lost with its rollback. The abort + * record's flush makes the ATM record durable too. If we crash + * before that, the transaction is still prepared, perhaps with an ATM + * entry, from which the revert worker applies nothing while the + * transaction is in progress. A later ROLLBACK PREPARED finds the + * entry already present (SLogTxnInsert keeps it); a COMMIT PREPARED + * forgets it (above). * * Only the permanent level is applied: TEMP undo is gone (the * originating backend exited) and UNLOGGED forks are reset on crash; @@ -1717,9 +1751,43 @@ FinishPreparedTransaction(const char *gid, bool isCommit) XLogRecPtr perm_lsn = hdr->last_batch_lsn[UNDOPERSISTENCE_PERMANENT]; - if (XLogRecPtrIsValid(perm_lsn)) - (void) ApplyUndoChainFromWAL(perm_lsn); + if (XLogRecPtrIsValid(perm_lsn) && + !ATMAddAborted(xid, hdr->database, perm_lsn, + hdr->undo_first_batch_lsn)) + elog(WARNING, + "ATM full: could not record rolled-back prepared " + "transaction %u for UNDO", xid); } + + + /* The abort decision and inverses must occupy the same WAL record. */ + for (;;) + { + TwoPhaseRecordOnDisk *record = (TwoPhaseRecordOnDisk *) scan; + + if (record->rmid == TWOPHASE_RM_END_ID) + break; + scan += MAXALIGN(sizeof(TwoPhaseRecordOnDisk)); + if (record->rmid == TWOPHASE_RM_FILEOPS_ID && + record->info == FILEOPS_TWOPHASE_INVERSES) + { + fileops = scan; + fileops_len = record->len; + break; + } + scan += MAXALIGN(record->len); + } + + INJECTION_POINT("rollback-prepared-before-abort-record", NULL); + + RecordTransactionAbortPrepared(xid, + hdr->nsubxacts, children, + hdr->nabortrels, abortrels, + hdr->nabortstats, + abortstats, + gid, fileops, fileops_len); + + INJECTION_POINT("rollback-prepared-after-abort-record", NULL); } ProcArrayRemove(proc, latestXid); @@ -2282,6 +2350,7 @@ RecoverPreparedTransactions(void) * PREPARED. */ gxact->undo_batch_lsn = hdr->last_batch_lsn[UNDOPERSISTENCE_PERMANENT]; + gxact->undo_first_batch_lsn = hdr->undo_first_batch_lsn; /* recovered, so reset the flag for entries generated by redo */ gxact->inredo = false; @@ -2582,7 +2651,7 @@ RecordTransactionAbortPrepared(TransactionId xid, RelFileLocator *rels, int nstats, xl_xact_stats_item *stats, - const char *gid) + const char *gid, const char *fileops, uint32 fileops_len) { XLogRecPtr recptr; bool replorigin; @@ -2602,6 +2671,8 @@ RecordTransactionAbortPrepared(TransactionId xid, elog(PANIC, "cannot abort transaction %u, it was already committed", xid); + if (fileops_len) + FileOpsBeginAbort(); START_CRIT_SECTION(); /* @@ -2614,7 +2685,7 @@ RecordTransactionAbortPrepared(TransactionId xid, nrels, rels, nstats, stats, MyXactFlags | XACT_FLAGS_ACQUIREDACCESSEXCLUSIVELOCK, - xid, gid); + xid, gid, fileops, fileops_len); if (replorigin) /* Move LSNs forward for this replication origin */ @@ -2631,6 +2702,12 @@ RecordTransactionAbortPrepared(TransactionId xid, TransactionIdAbortTree(xid, nchildren, children); END_CRIT_SECTION(); + if (fileops_len) + { + INJECTION_POINT("fileops-abort-after-flush", NULL); + FileOpsApplyAbort(fileops, fileops_len); + FileOpsEndAbort(); + } /* * Wait for synchronous replication, if required. @@ -2736,6 +2813,7 @@ PrepareRedoAdd(FullTransactionId fxid, char *buf, gxact->ondisk = !XLogRecPtrIsValid(start_lsn); gxact->inredo = true; /* yes, added in redo */ gxact->undo_batch_lsn = hdr->last_batch_lsn[UNDOPERSISTENCE_PERMANENT]; + gxact->undo_first_batch_lsn = hdr->undo_first_batch_lsn; strlcpy(gxact->gid, gid, GIDSIZE); /* And insert it into the active array */ @@ -3020,7 +3098,8 @@ TwoPhaseGetOldestXidInCommit(void) /* * TwoPhaseGetOldestUndoBatchLSN - * Return the oldest permanent-level UNDO chain-head LSN across all + * Return the oldest permanent-level UNDO batch LSN (the first batch of + * each chain, since ROLLBACK PREPARED walks all of it) across all * prepared transactions, or InvalidXLogRecPtr if none generated UNDO. * * UndoGetOldestBatchLSN() folds this into the WAL-retention horizon so the @@ -3039,7 +3118,7 @@ TwoPhaseGetOldestUndoBatchLSN(void) for (int i = 0; i < TwoPhaseState->numPrepXacts; i++) { - XLogRecPtr lsn = TwoPhaseState->prepXacts[i]->undo_batch_lsn; + XLogRecPtr lsn = TwoPhaseState->prepXacts[i]->undo_first_batch_lsn; if (XLogRecPtrIsValid(lsn) && (!XLogRecPtrIsValid(oldest) || lsn < oldest)) diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c index 979baf46f950b..7bee90e354871 100644 --- a/src/backend/access/transam/xact.c +++ b/src/backend/access/transam/xact.c @@ -59,6 +59,7 @@ #include "storage/aio_subsys.h" #include "storage/condition_variable.h" #include "storage/fd.h" +#include "storage/fileops.h" #include "storage/lmgr.h" #include "storage/md.h" #include "storage/predicate.h" @@ -2055,6 +2056,8 @@ RecordTransactionAbort(bool isSubXact) TransactionId *children; TimestampTz xact_time; bool replorigin; + char *fileops; + uint32 fileops_len; /* * If we haven't been assigned an XID, nobody will care whether we aborted @@ -2097,6 +2100,10 @@ RecordTransactionAbort(bool isSubXact) nchildren = xactGetCommittedChildren(&children); ndroppedstats = pgstat_get_transactional_drops(false, &droppedstats); + FileOpsGetAbortData(&fileops, &fileops_len); + if (fileops_len) + FileOpsBeginAbort(); + /* XXX do we really need a critical section here? */ START_CRIT_SECTION(); @@ -2113,7 +2120,7 @@ RecordTransactionAbort(bool isSubXact) nrels, rels, ndroppedstats, droppedstats, MyXactFlags, InvalidTransactionId, - NULL); + NULL, fileops, fileops_len); if (replorigin) /* Move LSNs forward for this replication origin */ @@ -2142,8 +2149,19 @@ RecordTransactionAbort(bool isSubXact) */ TransactionIdAbortTree(xid, nchildren, children); + if (fileops_len) + XLogFlush(XactLastRecEnd); END_CRIT_SECTION(); + if (fileops_len) + { + INJECTION_POINT("fileops-abort-after-flush", NULL); + FileOpsApplyAbort(fileops, fileops_len); + FileOpsEndAbort(); + } + if (fileops) + pfree(fileops); + /* Compute latestXid while we have the child XIDs handy */ latestXid = TransactionIdLatest(xid, nchildren, children); @@ -2934,13 +2952,13 @@ PrepareTransaction(void) /* * 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. + * that has no working ROLLBACK PREPARED apply path. Cluster-wide UNDO + * has one: its chain is saved in the 2PC state (xl_xact_prepare) and + * FinishPreparedTransaction() hands it to ATMAddAborted() on ROLLBACK + * PREPARED, so XactUndoHasUnrecoverableUndo() currently always returns + * false. It stays as the single place to reject a future kind of UNDO + * that is not 2PC-safe; that must happen here, before StartPrepare() + * writes any 2PC state. */ if (XactUndoHasUnrecoverableUndo()) ereport(ERROR, @@ -3238,21 +3256,9 @@ AbortTransaction(void) Assert(s->parent == NULL); /* - * Discard the UNDO record pointer for this transaction. - * - * Physical UNDO application is NOT needed during standard transaction - * abort because PostgreSQL's MVCC-based heap already handles rollback - * through CLOG: the aborting transaction's xid is marked as aborted in - * CLOG, and subsequent visibility checks will ignore changes made by this - * transaction. INSERT tuples become invisible (eventually pruned), - * DELETE/UPDATE changes are ignored (old tuple versions remain visible). - * - * Physical UNDO application is intended for cases where the page has been - * modified in-place and the old state cannot be recovered through CLOG - * alone (e.g., in ZHeap-style in-place updates, or after pruning has - * removed old tuple versions). The UNDO records written during this - * transaction are preserved in the UNDO log for use by the undo worker, - * crash recovery, or future in-place update mechanisms. + * Discard the UNDO record pointer for this transaction. The UNDO itself + * is applied below, by AtAbort_XactUndo() and PbuAtAbort_ApplyUndo(), + * before the abort record is written. */ s->undoRecPtr = 0; @@ -3291,8 +3297,8 @@ AbortTransaction(void) s->parallelModeLevel = 0; s->parallelChildXact = false; /* should be false already */ - /* Clean up transaction undo state (free per-persistence record sets) */ - AtAbort_XactUndo(); + /* Flush pending UNDO before publishing the abort decision. */ + XactUndoFlushPending(); /* * Roll back the per-backend UNDO engine's records for this transaction, @@ -3347,6 +3353,8 @@ AbortTransaction(void) XLogSetAsyncXactLSN(XactLastRecEnd); } + AtAbort_XactUndo(); + TRACE_POSTGRESQL_TRANSACTION_ABORT(MyProc->vxid.lxid); /* @@ -6425,7 +6433,8 @@ XactLogAbortRecord(TimestampTz abort_time, int nrels, RelFileLocator *rels, int ndroppedstats, xl_xact_stats_item *droppedstats, int xactflags, TransactionId twophase_xid, - const char *twophase_gid) + const char *twophase_gid, + const char *fileops, uint32 fileops_len) { xl_xact_abort xlrec; xl_xact_xinfo xl_xinfo; @@ -6440,7 +6449,7 @@ XactLogAbortRecord(TimestampTz abort_time, Assert(CritSectionCount > 0); - xl_xinfo.xinfo = 0; + xl_xinfo.xinfo = fileops_len ? XACT_XINFO_HAS_FILEOPS : 0; /* decide between a plain and 2pc abort */ if (!TransactionIdIsValid(twophase_xid)) @@ -6553,6 +6562,12 @@ XactLogAbortRecord(TimestampTz abort_time, if (xl_xinfo.xinfo & XACT_XINFO_HAS_ORIGIN) XLogRegisterData(&xl_origin, sizeof(xl_xact_origin)); + if (fileops_len) + { + XLogRegisterData(&fileops_len, sizeof(fileops_len)); + XLogRegisterData(fileops, fileops_len); + } + /* Include the replication origin */ XLogSetRecordFlags(XLOG_INCLUDE_ORIGIN); @@ -6775,6 +6790,12 @@ xact_redo_abort(xl_xact_parsed_abort *parsed, TransactionId xid, false /* backward */ , false /* WAL */ ); } + if (parsed->fileops_len) + { + XLogFlush(lsn); + FileOpsApplyAbort(parsed->fileops, parsed->fileops_len); + } + /* Make sure files supposed to be dropped are dropped */ if (parsed->nrels > 0) { @@ -6899,6 +6920,7 @@ xact_redo(XLogReaderState *record) { if (!XLogRecPtrIsInvalid(xlrec->last_batch_lsn[j])) UndoRecoveryTrackBatch(xlrec->xid, + xlrec->database, xlrec->last_batch_lsn[j], InvalidXLogRecPtr, (UndoPersistenceLevel) j); diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c index da92c323a9724..072271b975c2a 100644 --- a/src/backend/access/transam/xlog.c +++ b/src/backend/access/transam/xlog.c @@ -46,6 +46,7 @@ #include #include +#include "access/atm.h" #include "access/clog.h" #include "access/commit_ts.h" #include "access/heaptoast.h" @@ -58,6 +59,7 @@ #include "access/undolog.h" #include "access/undo_xlog.h" #include "access/xact.h" +#include "access/xactundo.h" #include "access/xlog_internal.h" #include "access/xlogarchive.h" #include "access/xloginsert.h" @@ -6400,6 +6402,18 @@ StartupXLOG(void) */ restoreTwoPhaseData(); + /* + * Reload the Aborted Transaction Map from its checkpoint state file + * before the redo pass. The ATM is otherwise reconstructed only by + * replaying XLOG_ATM_ABORT / XLOG_ATM_FORGET, which redo cannot do for + * aborts whose records precede the checkpoint redo point. Reloading here + * -- before redo, exactly like restoreTwoPhaseData() above -- lets + * atm_redo's XLOG_ATM_FORGET replays correctly remove entries forgotten + * after the checkpoint, and XLOG_ATM_ABORT replays re-add (idempotently) + * entries aborted after it. + */ + ATMReloadFromCheckpoint(); + /* * When starting with crash recovery, reset pgstat data - it might not be * valid. Otherwise restore pgstat data. It's safe to do this here, @@ -6869,10 +6883,19 @@ StartupXLOG(void) 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. + * Finalize ATM state after recovery. WAL replay has reconstructed the + * Aborted Transaction Map via XLOG_ATM_ABORT and XLOG_ATM_FORGET redo + * handlers. Log a summary of entries that still need Logical Revert. + */ + if (performedWalRecovery) + ATMRecoveryFinalize(); + + /* + * Flush any deferred UNDO transactions to the ATM. During the UNDO + * phase, if syscache wasn't available, we deferred transaction + * processing. Now that recovery is complete and WAL writes are allowed + * (checkpoint/ end-of-recovery record was written above), we can add them + * to the ATM for asynchronous processing by the logical revert worker. */ if (performedWalRecovery) FlushDeferredUndoXacts(); @@ -8112,6 +8135,14 @@ CreateCheckPoint(int flags) if (!shutdown && XLogStandbyInfoActive()) LogStandbySnapshot(); + /* + * Tell crash recovery, which starts at our redo pointer, about the UNDO + * chains of transactions still in flight; it would not replay the batches + * they wrote before the redo pointer. + */ + if (!shutdown) + LogXactUndoInflight(); + START_CRIT_SECTION(); /* @@ -8537,6 +8568,13 @@ CheckPointGuts(XLogRecPtr checkPointRedo, int flags) CheckPointSnapBuild(); CheckPointLogicalRewriteHeap(); CheckPointTwoPhase(checkPointRedo); + + /* + * Persist the Aborted Transaction Map so it survives a crash even when + * this checkpoint advances the redo pointer past an un-forgotten + * XLOG_ATM_ABORT record (see CheckPointATM / ATMReloadFromCheckpoint). + */ + CheckPointATM(); } /* diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index 77c231a681b01..7938c796f3764 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -1901,6 +1901,9 @@ PerformWalRecovery(void) (errmsg("undo phase complete"))); } + /* The deferred chains are in the ATM, which retains their WAL. */ + UndoSetRecoveryBatchLSN(InvalidXLogRecPtr); + /* * Reverse-apply per-backend UNDO for loser transactions. The * per-backend engine's UndoLogControl banks were rebuilt by diff --git a/src/backend/access/undo/Makefile b/src/backend/access/undo/Makefile index a34b24942cbcb..ab403117f6eae 100644 --- a/src/backend/access/undo/Makefile +++ b/src/backend/access/undo/Makefile @@ -13,6 +13,11 @@ top_builddir = ../../../.. include $(top_builddir)/src/Makefile.global OBJS = \ + atm.o \ + logical_revert_worker.o \ + slog.o \ + slog_flathash.o \ + slog_tuple.o \ undo.o \ undo_bufmgr.o \ undo_xlog.o \ diff --git a/src/backend/access/undo/README b/src/backend/access/undo/README index aca848bc0837d..e342b2fed1c0b 100644 --- a/src/backend/access/undo/README +++ b/src/backend/access/undo/README @@ -167,6 +167,55 @@ UNDOPERSISTENCE_UNLOGGED records are skipped (unlogged table data forks are rese to their empty init fork on crash recovery). This mirrors the behavior of the standard heap AM for these persistence levels. +## Deferred Abort: ATM, sLog, and the Logical Revert Worker + +The cluster-wide engine applies a transaction's UNDO chain inline at +abort. On top of that, three cooperating components let ROLLBACK of a +large transaction return without applying its UNDO ("instant abort"), +deferring the physical undo to the background: + + - **ATM (Aborted Transaction Map, atm.c).** AtAbort_XactUndo() + classifies the transaction by UNDO volume + (undo_instant_abort_threshold, default 64 KB). Small transactions + apply their UNDO chain inline before ROLLBACK returns, and so does a + transaction with any UNDO other than index UNDO (for example + FILEOPS), whose effects must be gone when ROLLBACK returns. Other + large transactions take the instant-abort path: ATMAddAborted() + records the xid in the shared-memory ATM (WAL-logged via RM_ATM_ID: + XLOG_ATM_ABORT / XLOG_ATM_FORGET, so the map is reconstructed during + redo) and ROLLBACK returns without applying the UNDO, so its latency + does not depend on how much UNDO the transaction wrote. + + - **sLog (slog.c, slog_flathash.c, slog_tuple.c).** A shared-memory + map consulted by an in-place-update AM's visibility checks so that an + aborted transaction's writes are invisible to every reader + immediately, before any physical undo has run. The sLog serves the + abort/self-visibility window; the commit oracle remains CLOG. The + optional per-tuple flat-hash extension (the tuple sLog) tracks + uncommitted writers for bounded recovery and is engine-agnostic + (keyed on physical tuple identity). + + - **Logical Revert worker (logical_revert_worker.c).** A background + worker that scans the ATM for entries not yet reverted and calls + ApplyUndoChainFromWAL() to physically walk and apply each one's UNDO + chain, generating a CLR (XLOG_UNDO_APPLY_RECORD) per page it + restores. This work is fully decoupled from ROLLBACK's return to the + client; a loser transaction found unresolved at end of crash recovery + is queued to the ATM and reverted here too. + +During crash recovery, PerformUndoRecovery() applies the chains of +transactions that were in progress at the crash before the database +accepts connections. A record it cannot apply in the startup process (an +index AM's UNDO callback needs the syscache) is skipped, and the +transaction is entered in the ATM, so that the logical revert worker walks +the whole chain again after the database opens. Transactions already in +the ATM before the crash are back in it after redo and are reverted by the +logical revert worker as during normal operation. + +These components serve the cluster-wide engine only. The per-backend +engine's rollback requests go to its own undo apply workers (see "The UNDO +Store: Per-Backend UNDO Logs" above). + ## 2. UndoRecPtr Format UndoRecPtr is a 64-bit pointer encoding both log identity and position: @@ -494,15 +543,6 @@ A resource manager is registered for UNDO-related WAL: ### 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 @@ -513,9 +553,6 @@ A resource manager is registered for UNDO-related WAL: 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. @@ -595,7 +632,7 @@ 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) + 3. Advances the WAL retention horizon Because the cluster-wide engine stores UNDO records in the normal WAL stream rather than in separate segment files, advancing discard_ptr is all @@ -695,14 +732,10 @@ setting controls memory available for all buffer types including UNDO. ### 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 +The following SQL function is implemented (undostats.c): -They are NOT SQL-callable: there are no pg_proc.dat entries for them, so no -CREATE FUNCTION binding exists and they cannot be queried from SQL as written. + pg_stat_get_undo_retention() What retains UNDO WAL: in-flight backends, + ATM entries, prepared transactions Wiring them into the catalog is outstanding work. ### Key Log Messages @@ -719,12 +752,6 @@ Wiring them into the catalog is outstanding work. 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. @@ -751,7 +778,7 @@ Wiring them into the catalog is outstanding work. 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.) + undo_xlog.h WAL record structures (xl_undo_batch, xl_undo_apply, etc.) xactundo.h Per-transaction undo API (PrepareXactUndoData, etc.) undoworker.h Worker shared memory and GUC declarations undo_bufmgr.h shared_buffers wrapper API for UNDO log blocks @@ -946,8 +973,7 @@ 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. +pg_stat_get_undo_retention() shows what holds UNDO WAL back. ### Rollback Plan diff --git a/src/backend/access/undo/atm.c b/src/backend/access/undo/atm.c new file mode 100644 index 0000000000000..4c3d92a503a26 --- /dev/null +++ b/src/backend/access/undo/atm.c @@ -0,0 +1,608 @@ +/*------------------------------------------------------------------------- + * + * atm.c + * Aborted Transaction Map for CTR (Constant-Time Recovery) + * + * The ATM is a shared-memory data structure mapping TransactionId to UNDO + * chain metadata for aborted transactions. It enables: + * + * 1. Background Logical Revert: the Logical Revert worker scans the + * sLog for entries where revert_complete == false and applies their + * UNDO chains asynchronously. + * + * 2. Instant abort: at transaction abort time, the backend writes an + * ATM entry (sLog + WAL) instead of performing synchronous + * rollback, making ROLLBACK O(1). + * + * Implementation: All ATM functions are thin wrappers around the sLog + * (Secondary Log) hash tables defined in access/slog.h. The sLog + * provides O(1) lookups, replacing the old fixed-size linear array. + * + * WAL: ATMAddAborted() emits XLOG_ATM_ABORT; ATMForget() emits + * XLOG_ATM_FORGET. During recovery, atm_redo() replays these without + * re-emitting WAL. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/atm.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include + +#include "access/atm.h" +#include "access/slog.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "access/xlogreader.h" +#include "access/xlogutils.h" +#include "common/file_utils.h" +#include "pgstat.h" +#include "port/pg_crc32c.h" +#include "storage/fd.h" +#include "storage/lwlock.h" +#include "storage/shmem.h" +#include "utils/wait_event.h" + +/* + * On-disk ATM state file. + * + * The Aborted Transaction Map is a shared-memory-only structure otherwise + * reconstructed only by replaying XLOG_ATM_ABORT / XLOG_ATM_FORGET during the + * redo pass. Because a checkpoint may advance the redo pointer PAST an + * un-forgotten XLOG_ATM_ABORT, redo alone can miss aborts and silently lose a + * guaranteed rollback. To close that window we persist the map at each + * checkpoint (CheckPointATM) and reload it at startup before redo + * (ATMReloadFromCheckpoint), exactly mirroring how CheckPointTwoPhase / + * restoreTwoPhaseData keep prepared-xact state that predates the redo point. + * + * The file is a single flat file in the data directory: a header, then a + * packed array of records, then a CRC over header+records. It is written to + * a temporary name and durably renamed into place, so a torn write never + * corrupts a previously good file. + */ +#define ATM_STATE_FILE "pg_undo_atm" +#define ATM_STATE_TMP_FILE "pg_undo_atm.tmp" +#define ATM_STATE_MAGIC 0x41544D33 /* "ATM3" */ + +typedef struct AtmStateHeader +{ + uint32 magic; + uint32 count; /* number of AtmStateRecord that follow */ +} AtmStateHeader; + +typedef struct AtmStateRecord +{ + TransactionId xid; + Oid reloid; + Oid dboid; + XLogRecPtr last_batch_lsn; + XLogRecPtr stop_lsn; + XLogRecPtr first_batch_lsn; + bool revert_complete; +} AtmStateRecord; + +/* Internal helpers that skip WAL emission (used during redo) */ +static bool ATMAddAbortedInternal(TransactionId xid, Oid dboid, Oid reloid, + XLogRecPtr last_batch_lsn, + XLogRecPtr stop_lsn, + XLogRecPtr first_batch_lsn); +static void ATMForgetInternal(TransactionId xid); + +/* + * ATMShmemSize + * Calculate shared memory space needed for the ATM. + * + * The ATM is now backed by sLog, which manages its own shared memory. + * ATM itself needs no additional shared memory. + */ +Size +ATMShmemSize(void) +{ + return 0; +} + +/* + * ATMShmemInit + * Initialize ATM shared memory (no-op, sLog handles it). + */ +void +ATMShmemInit(void) +{ + /* sLog initialization is done separately via SLogShmemInit() */ +} + +/* + * ATMGetLastBatchLSN + * Retrieve the WAL LSN of the last UNDO batch for an aborted transaction. + * + * Returns true if found, storing the LSN in *lsn_out. + */ +bool +ATMGetLastBatchLSN(TransactionId xid, XLogRecPtr *lsn_out) +{ + return SLogTxnLookupByXid(xid, lsn_out); +} + +/* + * ATMAddAbortedInternal + * Add an entry to the ATM without emitting WAL. + * + * Used during both normal operation (after WAL has been written by the + * caller) and during redo replay. + * + * Returns false if the sLog is full. + */ +static bool +ATMAddAbortedInternal(TransactionId xid, Oid dboid, Oid reloid, + XLogRecPtr last_batch_lsn, XLogRecPtr stop_lsn, + XLogRecPtr first_batch_lsn) +{ + return SLogTxnInsert(xid, reloid, dboid, last_batch_lsn, stop_lsn, + first_batch_lsn); +} + +/* + * ATMAddAborted + * Record an aborted transaction in the ATM with WAL logging. + * + * last_batch_lsn is the head of the UNDO chain, where the revert worker + * starts; first_batch_lsn is its oldest batch, from which on WAL is retained + * for as long as the entry exists (ATMGetOldestUnrevertedLSN). The caller's + * own retention (the per-backend slot, or the 2PC state) may be released once + * this returns true. + * + * Called from the abort path. Returns false if the sLog is full, + * signaling the caller to fall back to synchronous rollback. + */ +bool +ATMAddAborted(TransactionId xid, Oid dboid, XLogRecPtr last_batch_lsn, + XLogRecPtr first_batch_lsn) +{ + return ATMAddAbortedBounded(xid, dboid, last_batch_lsn, InvalidXLogRecPtr, + first_batch_lsn); +} + +/* + * ATMAddAbortedBounded + * Like ATMAddAborted(), but the revert worker applies only the batches + * strictly newer than stop_lsn (cf. ApplyUndoChainFromWALBounded). + * + * For a rolled-back subtransaction whose inline apply failed: the batches at + * or below stop_lsn belong to its parent, which may yet commit. The caller + * passes the subtransaction's own xid, which no other entry can use. + */ +bool +ATMAddAbortedBounded(TransactionId xid, Oid dboid, XLogRecPtr last_batch_lsn, + XLogRecPtr stop_lsn, XLogRecPtr first_batch_lsn) +{ + xl_atm_abort xlrec; + + /* Write WAL first */ + xlrec.xid = xid; + xlrec.last_batch_lsn = last_batch_lsn; + xlrec.stop_lsn = stop_lsn; + xlrec.first_batch_lsn = first_batch_lsn; + xlrec.dboid = dboid; + xlrec.reloid = InvalidOid; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfXlAtmAbort); + XLogInsert(RM_ATM_ID, XLOG_ATM_ABORT); + + /* Now update shared memory */ + return ATMAddAbortedInternal(xid, dboid, InvalidOid, last_batch_lsn, + stop_lsn, first_batch_lsn); +} + +/* + * ATMAddAbortedNoWAL + * Record an aborted transaction in the ATM before WAL can be written. + * + * For the crash-recovery UNDO phase, which finds loser transactions while + * still in recovery. The entry pins its UNDO WAL from this point on: the + * end-of-recovery checkpoint's KeepLogSeg() sees it through + * ATMGetOldestUnrevertedLSN(), and CheckPointATM() persists it. The caller + * must WAL-log it with ATMAddAborted() once WAL is writable, so standbys get + * it too; that call finds the entry already present and leaves it alone. + */ +void +ATMAddAbortedNoWAL(TransactionId xid, Oid dboid, XLogRecPtr last_batch_lsn, + XLogRecPtr first_batch_lsn) +{ + (void) ATMAddAbortedInternal(xid, dboid, InvalidOid, last_batch_lsn, + InvalidXLogRecPtr, first_batch_lsn); +} + +/* + * ATMForgetInternal + * Remove ATM entries for a transaction without emitting WAL. + */ +static void +ATMForgetInternal(TransactionId xid) +{ + SLogTxnRemoveByXid(xid); +} + +/* + * ATMForget + * Remove ATM entries after Logical Revert has completed. + * + * Emits a WAL record so that the removal survives recovery. + */ +void +ATMForget(TransactionId xid) +{ + xl_atm_forget xlrec; + + /* Write WAL first */ + xlrec.xid = xid; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfXlAtmForget); + XLogInsert(RM_ATM_ID, XLOG_ATM_FORGET); + + /* Now update shared memory */ + ATMForgetInternal(xid); +} + +/* + * ATMMarkReverted + * Mark an ATM entry's revert as complete. + * + * The entry is kept in the ATM (for visibility checks) until ATMForget() + * is called after the Logical Revert worker confirms all effects are gone. + */ +void +ATMMarkReverted(TransactionId xid) +{ + SLogTxnMarkReverted(xid); +} + +/* + * ATMGetNextUnreverted + * Find the next ATM entry of database dboid that hasn't been reverted + * yet, after after_xid (InvalidTransactionId: from the start). + * + * Used by the Logical Revert background worker to find work. + * + * Returns true if an unreverted entry was found, filling in the output + * parameters. + */ +bool +ATMGetNextUnreverted(Oid dboid, TransactionId after_xid, + TransactionId *xid_out, + XLogRecPtr *lsn_out, XLogRecPtr *stop_lsn_out) +{ + return SLogTxnGetNextUnreverted(dboid, after_xid, xid_out, lsn_out, + stop_lsn_out); +} + +/* + * ATMCollectUnrevertedDatabases + * Collect the distinct database OIDs that have unreverted ATM entries. + * + * Returns the count; fills dboids[] up to max_dboids. Used by the logical + * revert launcher to spawn workers only for databases that have + * aborted-transaction UNDO to apply. + */ +int +ATMCollectUnrevertedDatabases(Oid *dboids, int max_dboids) +{ + return SLogTxnCollectUnrevertedDatabases(dboids, max_dboids); +} + +/* + * ATMGetOldestUnrevertedLSN + * Return the oldest UNDO WAL position any unreverted ATM entry needs, + * i.e. the oldest first_batch_lsn (the whole chain must stay readable). + * + * Used by the WAL retention logic to prevent recycling WAL segments that + * still contain UNDO batches needed by the logical revert worker. + * Returns InvalidXLogRecPtr if no unreverted entries exist. + */ +XLogRecPtr +ATMGetOldestUnrevertedLSN(void) +{ + return SLogTxnGetOldestUnrevertedLSN(); +} + +/* + * ATMRecoveryFinalize + * Called at the end of recovery to log the ATM state. + * + * After WAL redo has reconstructed the ATM via sLog, this logs the number + * of unreverted entries so the DBA can see how much Logical Revert work + * remains, and the oldest unreverted last_batch_lsn -- the LSN that pins + * UNDO WAL against recycling (ATMGetOldestUnrevertedLSN -> + * UndoGetOldestBatchLSN -> KeepLogSeg) until the logical revert worker + * forgets the entry. Emitting the LSN here, before any worker runs, gives + * an observable proof that the retention floor survived the crash. + */ +void +ATMRecoveryFinalize(void) +{ + int total = 0; + int unreverted = 0; + + SLogRecoveryFinalize(&total, &unreverted); + + if (total > 0) + elog(LOG, "ATM recovery complete: %d entries, %d unreverted, " + "oldest unreverted LSN %X/%X", + total, unreverted, + LSN_FORMAT_ARGS(ATMGetOldestUnrevertedLSN())); +} + +/* + * atm_redo + * WAL redo handler for ATM resource manager. + */ +void +atm_redo(XLogReaderState *record) +{ + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + switch (info) + { + case XLOG_ATM_ABORT: + { + xl_atm_abort *xlrec = + (xl_atm_abort *) XLogRecGetData(record); + + ATMAddAbortedInternal(xlrec->xid, xlrec->dboid, + xlrec->reloid, xlrec->last_batch_lsn, + xlrec->stop_lsn, + xlrec->first_batch_lsn); + } + break; + + case XLOG_ATM_FORGET: + { + xl_atm_forget *xlrec = + (xl_atm_forget *) XLogRecGetData(record); + + ATMForgetInternal(xlrec->xid); + } + break; + + default: + elog(PANIC, "atm_redo: unknown op code %u", info); + break; + } +} + +/* + * CheckPointATM + * Durably persist the Aborted Transaction Map at a checkpoint. + * + * Writes every current ATM entry (xid, reloid, dboid, last_batch_lsn, + * revert_complete) to a single flat file, via a temporary file that is + * durably renamed into place, so the map survives a crash even when the + * checkpoint's redo pointer advances past the entries' XLOG_ATM_ABORT + * records. Called from CheckPointGuts(), outside any critical section + * (palloc and file I/O are therefore safe), alongside CheckPointTwoPhase. + * + * We snapshot ALL entries, not only those with an XLOG_ATM_ABORT preceding + * the redo point: an entry whose abort record follows the redo point will be + * re-added by redo, and ATMAddAbortedInternal is idempotent on (xid, reloid) + * (SLogTxnInsert leaves an existing entry untouched), so double-add across + * the checkpoint boundary converges to a single entry. Persisting the whole + * map is simpler than partitioning it by redo point and is cheap because the + * ATM only holds un-forgotten aborts. + */ +void +CheckPointATM(void) +{ + SLogTxnEntry *entries; + int count; + AtmStateHeader hdr; + pg_crc32c crc; + int fd; + int i; + + count = SLogTxnSnapshotForCheckpoint(&entries); + + /* + * Always (re)write the file, even when empty, so a stale file from a + * checkpoint that had entries is replaced by an authoritative empty one. + * An empty file (count == 0) records "no un-forgotten aborts as of this + * redo point". + */ + hdr.magic = ATM_STATE_MAGIC; + hdr.count = (uint32) count; + + INIT_CRC32C(crc); + COMP_CRC32C(crc, &hdr, sizeof(hdr)); + + fd = OpenTransientFile(ATM_STATE_TMP_FILE, + O_CREAT | O_TRUNC | O_WRONLY | PG_BINARY); + if (fd < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create file \"%s\": %m", + ATM_STATE_TMP_FILE))); + + pgstat_report_wait_start(WAIT_EVENT_TWOPHASE_FILE_WRITE); + errno = 0; + if (write(fd, &hdr, sizeof(hdr)) != sizeof(hdr)) + { + if (errno == 0) + errno = ENOSPC; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write file \"%s\": %m", + ATM_STATE_TMP_FILE))); + } + + for (i = 0; i < count; i++) + { + AtmStateRecord rec; + + rec.xid = entries[i].xid; + rec.reloid = entries[i].reloid; + rec.dboid = entries[i].dboid; + rec.last_batch_lsn = entries[i].last_batch_lsn; + rec.stop_lsn = entries[i].stop_lsn; + rec.first_batch_lsn = entries[i].first_batch_lsn; + rec.revert_complete = entries[i].revert_complete; + + COMP_CRC32C(crc, &rec, sizeof(rec)); + + errno = 0; + if (write(fd, &rec, sizeof(rec)) != sizeof(rec)) + { + if (errno == 0) + errno = ENOSPC; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write file \"%s\": %m", + ATM_STATE_TMP_FILE))); + } + } + + FIN_CRC32C(crc); + errno = 0; + if (write(fd, &crc, sizeof(crc)) != sizeof(crc)) + { + if (errno == 0) + errno = ENOSPC; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write file \"%s\": %m", + ATM_STATE_TMP_FILE))); + } + pgstat_report_wait_end(); + + pgstat_report_wait_start(WAIT_EVENT_TWOPHASE_FILE_SYNC); + if (pg_fsync(fd) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\": %m", + ATM_STATE_TMP_FILE))); + pgstat_report_wait_end(); + + if (CloseTransientFile(fd) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not close file \"%s\": %m", + ATM_STATE_TMP_FILE))); + + /* Atomically replace the live file, fsyncing both file and directory. */ + durable_rename(ATM_STATE_TMP_FILE, ATM_STATE_FILE, ERROR); + + if (entries != NULL) + pfree(entries); +} + +/* + * ATMReloadFromCheckpoint + * Reconstruct the ATM from the checkpoint state file, before redo. + * + * Called from StartupXLOG at the same point as restoreTwoPhaseData(), i.e. + * BEFORE the redo pass. Each persisted entry is re-inserted into the map + * without emitting WAL (ATMAddAbortedInternal), preserving its + * revert_complete flag. Reloading before redo is essential: an + * XLOG_ATM_FORGET replayed after the checkpoint must be able to remove an + * entry that WAS persisted, and an XLOG_ATM_ABORT replayed after the + * checkpoint re-adds idempotently on top of what we loaded. + * + * A missing file (fresh initdb, or a cluster that never checkpointed the + * ATM) is not an error: it means "no persisted entries". A file that fails + * its CRC or magic check is treated as absent with a warning; every entry it + * could have held is still WAL-durable via XLOG_ATM_ABORT, and any whose + * abort record precedes the redo point would then be lost -- but a corrupt + * ATM state file implies a torn write that durable_rename is designed to + * prevent, so this is a belt-and-suspenders path, not an expected one. + */ +void +ATMReloadFromCheckpoint(void) +{ + int fd; + AtmStateHeader hdr; + pg_crc32c crc, + file_crc; + AtmStateRecord *records = NULL; + uint32 i; + ssize_t nread; + + fd = OpenTransientFile(ATM_STATE_FILE, O_RDONLY | PG_BINARY); + if (fd < 0) + { + if (errno == ENOENT) + return; /* no persisted state -- nothing to reload */ + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not open file \"%s\": %m", ATM_STATE_FILE))); + } + + pgstat_report_wait_start(WAIT_EVENT_TWOPHASE_FILE_READ); + nread = read(fd, &hdr, sizeof(hdr)); + if (nread != sizeof(hdr) || hdr.magic != ATM_STATE_MAGIC) + { + pgstat_report_wait_end(); + CloseTransientFile(fd); + ereport(WARNING, + (errmsg("ignoring ATM state file \"%s\" with invalid header", + ATM_STATE_FILE))); + return; + } + + INIT_CRC32C(crc); + COMP_CRC32C(crc, &hdr, sizeof(hdr)); + + if (hdr.count > 0) + { + Size bytes = (Size) hdr.count * sizeof(AtmStateRecord); + + records = (AtmStateRecord *) palloc(bytes); + nread = read(fd, records, bytes); + if (nread != (ssize_t) bytes) + { + pgstat_report_wait_end(); + CloseTransientFile(fd); + pfree(records); + ereport(WARNING, + (errmsg("ignoring truncated ATM state file \"%s\"", + ATM_STATE_FILE))); + return; + } + COMP_CRC32C(crc, records, bytes); + } + + nread = read(fd, &file_crc, sizeof(file_crc)); + pgstat_report_wait_end(); + CloseTransientFile(fd); + + FIN_CRC32C(crc); + if (nread != sizeof(file_crc) || !EQ_CRC32C(crc, file_crc)) + { + if (records != NULL) + pfree(records); + ereport(WARNING, + (errmsg("ignoring ATM state file \"%s\" with bad checksum", + ATM_STATE_FILE))); + return; + } + + for (i = 0; i < hdr.count; i++) + { + ATMAddAbortedInternal(records[i].xid, records[i].dboid, + records[i].reloid, records[i].last_batch_lsn, + records[i].stop_lsn, + records[i].first_batch_lsn); + if (records[i].revert_complete) + ATMMarkReverted(records[i].xid); + } + + if (records != NULL) + pfree(records); + + if (hdr.count > 0) + elog(LOG, "ATM reloaded %u entries from checkpoint state file", + hdr.count); +} diff --git a/src/backend/access/undo/logical_revert_worker.c b/src/backend/access/undo/logical_revert_worker.c new file mode 100644 index 0000000000000..1c70e44b676ff --- /dev/null +++ b/src/backend/access/undo/logical_revert_worker.c @@ -0,0 +1,837 @@ +/*------------------------------------------------------------------------- + * + * logical_revert_worker.c + * Background worker for timer-driven Logical Revert via ATM scan + * + * This worker periodically scans the ATM (Aborted Transaction Map) for + * entries whose WAL-based UNDO chains have not yet been confirmed as applied. + * For each unreverted entry whose database matches the worker's connected + * database, the worker: + * + * 1. Applies the WAL-based UNDO chain via ApplyUndoChainFromWAL() + * (idempotent: CLR records prevent double-application) + * 2. Marks the ATM entry as reverted via ATMMarkReverted() + * 3. Emits XLOG_ATM_FORGET and removes the entry via ATMForget() + * + * Unlike event-driven UNDO worker variants (which process a shared memory work + * queue), this worker is timer-driven: it sleeps for logical_revert_naptime + * milliseconds between scan cycles. + * + * An entry the worker cannot finish (a batch it cannot read yet, records that + * fail to apply, a malformed chain) is retried with a backoff, and the other + * entries are processed meanwhile; see process_revert_entry(). + * + * Shared memory: a single LogicalRevertState struct holds the LWLock + * protecting the running flag and a counter for assigning worker IDs. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/logical_revert_worker.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include + +#include "access/atm.h" +#include "access/heapam.h" +#include "access/logical_revert_worker.h" +#include "access/table.h" +#include "access/undo_xlog.h" +#include "access/undorecord.h" +#include "access/xact.h" +#include "access/xlog.h" +#include "access/xlog_internal.h" +#include "access/xlogdefs.h" +#include "catalog/pg_database.h" +#include "miscadmin.h" +#include "nodes/pg_list.h" +#include "pgstat.h" +#include "postmaster/bgworker.h" +#include "storage/ipc.h" +#include "storage/latch.h" +#include "storage/procarray.h" +#include "storage/lwlock.h" +#include "storage/shmem.h" +#include "tcop/tcopprot.h" +#include "utils/guc.h" +#include "utils/injection_point.h" +#include "utils/memutils.h" +#include "utils/timestamp.h" + +/* GUC parameter: sleep time between ATM scans in milliseconds */ +int logical_revert_naptime = 1000; + +/* GUC parameter: max number of logical revert workers (0 = disabled) */ +int max_logical_revert_workers = 2; + +/* + * Upper bound on distinct databases the launcher tracks per scan when + * collecting those with unreverted ATM entries. Far more than the number of + * databases that realistically have in-flight aborted-transaction UNDO at + * once; extras (if ever) are picked up on the next scan. + */ +#define MAX_LOGICAL_REVERT_DATABASES 128 + +/* + * Shared memory state for the Logical Revert worker. + * + * Minimal: just a lock and a worker-id counter. The ATM itself is the + * "work queue" -- the worker reads it directly via ATMGetNextUnreverted(). + */ +typedef struct LogicalRevertState +{ + LWLock lock; + int next_worker_id; +} LogicalRevertState; + +static LogicalRevertState *RevertState = NULL; + +/* Flags set by signal handlers */ +static volatile sig_atomic_t got_SIGHUP = false; +static volatile sig_atomic_t got_SIGTERM = false; + +/* Signal handlers */ +static void logical_revert_sighup(SIGNAL_ARGS); +static void logical_revert_sigterm(SIGNAL_ARGS); +static bool process_revert_entry(TransactionId xid, XLogRecPtr last_batch_lsn, + XLogRecPtr stop_lsn); +static List *get_revertable_database_list(MemoryContext resultcxt); + +/* + * LogicalRevertShmemSize + * Calculate shared memory space needed. + */ +Size +LogicalRevertShmemSize(void) +{ + return sizeof(LogicalRevertState); +} + +/* + * LogicalRevertShmemInit + * Allocate and initialize shared memory. + */ +void +LogicalRevertShmemInit(void) +{ + bool found; + + RevertState = (LogicalRevertState *) + ShmemInitStruct("Logical Revert Worker State", + sizeof(LogicalRevertState), + &found); + + if (!found) + { + LWLockInitialize(&RevertState->lock, LWTRANCHE_UNDO_WORKER); + RevertState->next_worker_id = 1; + } +} + +/* + * logical_revert_sighup + * SIGHUP signal handler -- reload configuration. + */ +static void +logical_revert_sighup(SIGNAL_ARGS) +{ + int save_errno = errno; + + got_SIGHUP = true; + SetLatch(MyLatch); + + errno = save_errno; +} + +/* + * logical_revert_sigterm + * SIGTERM signal handler -- request shutdown. + */ +static void +logical_revert_sigterm(SIGNAL_ARGS) +{ + int save_errno = errno; + + got_SIGTERM = true; + SetLatch(MyLatch); + + errno = save_errno; +} + +/* + * How many times the worker walks a chain whose records fail to apply, or + * that is malformed, before it gives up on it. A record that fails is retried + * in case the failure was transient (a lock or file that is in the way for a + * moment); one that keeps failing, like a malformed chain, would otherwise pin + * its WAL forever. + */ +#define LOGICAL_REVERT_MAX_RECORD_RETRIES 3 + +/* + * Per ATM entry that is being retried: how many walks have failed, and when + * the next may start. The worker moves on to other entries in the meantime, + * so one entry it cannot finish does not hold up the rest of its database. + * + * Backend-local: after a worker restart an entry simply gets its retries + * again. When the table is full, a failure gives up on the entry at once. + */ +typedef struct RevertRetry +{ + TransactionId xid; + int attempts; /* failed walks */ + TimestampTz next_try; /* do not walk again before this */ +} RevertRetry; + +#define LOGICAL_REVERT_RETRY_SLOTS 64 +static RevertRetry revert_retries[LOGICAL_REVERT_RETRY_SLOTS]; + +static RevertRetry * +revert_retry_find(TransactionId xid) +{ + for (int i = 0; i < LOGICAL_REVERT_RETRY_SLOTS; i++) + { + if (revert_retries[i].xid == xid) + return &revert_retries[i]; + } + return NULL; +} + +/* + * Record a failed walk of xid's chain and schedule the next. Returns the + * number of failed walks so far, or 0 if there is no slot to track them. + */ +static int +revert_retry_bump(TransactionId xid) +{ + RevertRetry *r = revert_retry_find(xid); + int backoff; + + if (r == NULL) + { + r = revert_retry_find(InvalidTransactionId); + if (r == NULL) + return 0; + r->xid = xid; + r->attempts = 0; + } + r->attempts++; + /* logical_revert_naptime, doubled per failed walk, up to 64 times that */ + backoff = 1 << Min(r->attempts - 1, 6); + r->next_try = TimestampTzPlusMilliseconds(GetCurrentTimestamp(), + (int64) logical_revert_naptime * + backoff); + return r->attempts; +} + +static void +revert_retry_forget(TransactionId xid) +{ + RevertRetry *r = revert_retry_find(xid); + + if (r != NULL) + r->xid = InvalidTransactionId; +} + +/* Is xid waiting for its next retry? */ +static bool +revert_retry_waiting(TransactionId xid, TimestampTz now) +{ + RevertRetry *r = revert_retry_find(xid); + + return r != NULL && now < r->next_try; +} + +/* + * process_revert_entry + * Apply the WAL-based UNDO chain for a single ATM entry. + * + * Walks the UNDO chain from last_batch_lsn backward, applying each record. + * Returns true if the entry is finished with and may be forgotten, false if + * it must stay in the ATM to be retried on a later pass: + * + * - The whole chain was applied: done. + * - Some batch could not be read: retry later, keeping the entry (and with + * it the WAL retention it provides). If that batch's WAL segment has + * already been removed, retention -- which pins WAL from the chain's first + * batch for as long as the entry exists -- has failed, and the rollback + * can never be completed: PANIC, the same as the error path below. + * - Every batch was read but some records failed to apply, or a batch was + * malformed: retry up to LOGICAL_REVERT_MAX_RECORD_RETRIES times, then give + * up on the entry with a WARNING and forget it. Failed records already + * failed inline before being handed here, a malformed batch will not heal, + * and retrying either forever would pin WAL forever. + * + * Retries wait with a backoff (revert_retry_bump); the caller skips an entry + * until its next retry is due. + */ +static bool +process_revert_entry(TransactionId xid, XLogRecPtr last_batch_lsn, + XLogRecPtr stop_lsn) +{ + UndoChainWalkResult res; + int attempts; + + if (!XLogRecPtrIsValid(last_batch_lsn)) + return true; /* nothing to apply */ + + ereport(DEBUG1, + (errmsg("logical revert: applying UNDO chain for xid %u " + "from LSN %X/%X", + xid, LSN_FORMAT_ARGS(last_batch_lsn)))); + + INJECTION_POINT("logical-revert-process-error", NULL); + + switch (ApplyUndoChain(last_batch_lsn, stop_lsn, &res)) + { + case UNDO_CHAIN_DONE: + revert_retry_forget(xid); + return true; + + case UNDO_CHAIN_INCOMPLETE: + { + XLogSegNo segno; + + XLByteToSeg(res.unread_batch, segno, wal_segment_size); + if (XLogRecPtrIsValid(res.unread_batch) && + segno <= XLogGetLastRemovedSegno()) + elog(PANIC, "logical revert worker: UNDO WAL for " + "un-reverted xid %u at %X/%X was removed; retention " + "invariant violated (ATMGetOldestUnrevertedLSN -> " + "UndoGetOldestBatchLSN -> KeepLogSeg must pin this " + "WAL until ATMForget) -- guaranteed rollback can no " + "longer be honored", + xid, LSN_FORMAT_ARGS(res.unread_batch)); + (void) revert_retry_bump(xid); + ereport(LOG, + (errmsg("logical revert: could not read the whole UNDO " + "chain of transaction %u, will retry", xid))); + return false; + } + + case UNDO_CHAIN_RECORD_ERROR: + attempts = revert_retry_bump(xid); + if (attempts > 0 && attempts < LOGICAL_REVERT_MAX_RECORD_RETRIES) + { + ereport(LOG, + (errmsg("logical revert: %d UNDO record(s) of transaction " + "%u failed to apply, will retry", + res.failed, xid))); + return false; + } + revert_retry_forget(xid); + ereport(WARNING, + (errmsg("logical revert: giving up on %d UNDO record(s) of " + "transaction %u that failed to apply %d times", + res.failed, xid, Max(attempts, 1)))); + return true; + + case UNDO_CHAIN_MALFORMED: + attempts = revert_retry_bump(xid); + if (attempts > 0 && attempts < LOGICAL_REVERT_MAX_RECORD_RETRIES) + { + ereport(LOG, + (errmsg("logical revert: UNDO chain of transaction %u " + "is malformed at %X/%X, will retry", + xid, LSN_FORMAT_ARGS(res.unread_batch)))); + return false; + } + revert_retry_forget(xid); + ereport(WARNING, + (errmsg("logical revert: giving up on transaction %u, whose " + "UNDO chain is malformed at %X/%X", + xid, LSN_FORMAT_ARGS(res.unread_batch)), + errdetail("%d batch(es) were applied; the older ones are " + "left for VACUUM.", res.batches))); + return true; + } + + return false; /* keep compiler quiet */ +} + +/* + * LogicalRevertWorkerMain + * Main entry point for the Logical Revert background worker. + * + * The worker connects to a specific database, then loops: scan the ATM + * for unreverted entries of this database, apply them, mark done, forget. + * Each pass visits every entry once, in xid order, skipping the ones waiting + * for a retry. Sleep between passes. + */ +void +LogicalRevertWorkerMain(Datum main_arg) +{ + Oid dboid = DatumGetObjectId(main_arg); + int worker_id; + TransactionId cursor = InvalidTransactionId; + TimestampTz pass_start = 0; + + /* Establish signal handlers */ + pqsignal(SIGHUP, logical_revert_sighup); + pqsignal(SIGTERM, logical_revert_sigterm); + + BackgroundWorkerUnblockSignals(); + + /* Connect to the target database */ + BackgroundWorkerInitializeConnectionByOid(dboid, InvalidOid, 0); + + /* Assign a worker ID */ + LWLockAcquire(&RevertState->lock, LW_EXCLUSIVE); + worker_id = RevertState->next_worker_id++; + LWLockRelease(&RevertState->lock); + + elog(LOG, "logical revert worker %d started for database %u", + worker_id, dboid); + + while (!got_SIGTERM) + { + TransactionId xid; + XLogRecPtr last_batch_lsn; + XLogRecPtr stop_lsn; + volatile bool done = false; + bool error = false; + int rc; + + /* + * Service interrupts and ProcSignalBarriers at the top of every + * iteration. Without this, a worker that is busy reverting back- + * to-back ATM entries via the `continue` path below never reaches the + * WaitLatch sleep, and ALTER DATABASE ... SET TABLESPACE / CREATE + * DATABASE ... STRATEGY=FILE_COPY will hang waiting for this backend + * to accept PROCSIGNAL_BARRIER_SMGRRELEASE. + */ + CHECK_FOR_INTERRUPTS(); + + /* Reload configuration on SIGHUP */ + if (got_SIGHUP) + { + got_SIGHUP = false; + ProcessConfigFile(PGC_SIGHUP); + } + + if (!TransactionIdIsValid(cursor)) + pass_start = GetCurrentTimestamp(); + + /* Scan ATM for the next unreverted entry of this database */ + if (!ATMGetNextUnreverted(MyDatabaseId, cursor, &xid, + &last_batch_lsn, &stop_lsn)) + { + /* End of the pass */ + cursor = InvalidTransactionId; + goto sleep; + } + cursor = xid; + + /* Not due for a retry yet: on to the next entry. */ + if (revert_retry_waiting(xid, pass_start)) + continue; + + /* + * Not aborted yet. An aborting backend adds its entry before its + * abort record, and ROLLBACK PREPARED does too; after a crash in + * between, the transaction may still be prepared. Its UNDO must not + * be applied until the abort is final, or a COMMIT PREPARED could + * still follow (FinishPreparedTransaction refuses one while the entry + * exists). + */ + if (TransactionIdIsInProgress(xid)) + continue; + + StartTransactionCommand(); + + /* + * Test hook: let a recovery test freeze the worker here, after the + * ATM entry has been (re)discovered but before it is reverted and + * forgotten, so the test can observe the reconstructed ATM state. + */ + INJECTION_POINT("logical-revert-before-process", NULL); + + PG_TRY(); + { + done = process_revert_entry(xid, last_batch_lsn, stop_lsn); + + /* + * Mark the ATM entry as reverted, then emit XLOG_ATM_FORGET and + * remove it from the ATM entirely. + */ + if (done) + { + ATMMarkReverted(xid); + ATMForget(xid); + } + } + PG_CATCH(); + { + error = true; + HOLD_INTERRUPTS(); + EmitErrorReport(); + AbortOutOfAnyTransaction(); + FlushErrorState(); + + /* + * Reset the cached WAL reader -- it may hold stale segment state + * (invalid FD, partial read buffer) after the error. Without + * this, the next UndoReadBatchFromWAL call would reuse the + * corrupted reader and SIGSEGV. + */ + UndoResetBatchReader(); + RESUME_INTERRUPTS(); + + /* + * If the WAL segment holding this abort's UNDO batch is behind + * the redo pointer, it looks recycled -- which must never happen + * for an un-reverted abort. The retention chain pins that WAL + * against recycling for exactly as long as the ATM entry + * survives: + * + * ATMGetOldestUnrevertedLSN (atm.c) -> UndoGetOldestBatchLSN + * (undolog.c) -> KeepLogSeg (xlog.c) + * + * so KeepLogSeg cannot advance past last_batch_lsn until + * ATMForget removes the entry. Reaching this branch means that + * invariant was violated and UNDO WAL for an un-reverted abort is + * genuinely gone: the cluster's guaranteed-rollback claim is + * already broken. Silently marking the entry reverted would fake + * a rollback that never happened and leave stale tuple state + * behind, so fail loudly instead -- the integrity guarantee is + * cluster-wide, hence PANIC rather than continuing. + * + * The other-error branch is a transient failure (the WAL is still + * retained); log it and retry after a backoff. + */ + { + XLogRecPtr redo = GetRedoRecPtr(); + + if (XLogRecPtrIsValid(last_batch_lsn) && + last_batch_lsn < redo) + { + elog(PANIC, "logical revert worker: UNDO WAL for " + "un-reverted xid %u at %X/%X was recycled " + "(redo at %X/%X); retention invariant violated " + "(ATMGetOldestUnrevertedLSN -> " + "UndoGetOldestBatchLSN -> KeepLogSeg must pin " + "this WAL until ATMForget) -- guaranteed " + "rollback can no longer be honored", + xid, LSN_FORMAT_ARGS(last_batch_lsn), + LSN_FORMAT_ARGS(redo)); + } + else + { + (void) revert_retry_bump(xid); + elog(LOG, "logical revert worker: failed to revert " + "xid %u, will retry", xid); + } + } + } + PG_END_TRY(); + + if (!error) + CommitTransactionCommand(); + + /* On to the next entry; one to be retried is skipped until due. */ + continue; + +sleep: + /* End of a pass, wait */ + rc = WaitLatch(MyLatch, + WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH, + logical_revert_naptime, + PG_WAIT_EXTENSION); + + ResetLatch(MyLatch); + + if (rc & WL_POSTMASTER_DEATH) + proc_exit(1); + } + + elog(LOG, "logical revert worker %d shutting down", worker_id); + proc_exit(0); +} + +/* + * StartLogicalRevertWorker + * Launch a logical revert worker for the specified database. + * + * The worker uses a modest `bgw_restart_time` so that if it exits due + * to a transient error the postmaster auto-restarts it. That removes + * the need for the launcher to re-spawn workers on every scan. + */ +void +StartLogicalRevertWorker(Oid dboid) +{ + BackgroundWorker worker; + BackgroundWorkerHandle *handle; + + memset(&worker, 0, sizeof(BackgroundWorker)); + worker.bgw_flags = BGWORKER_SHMEM_ACCESS | + BGWORKER_BACKEND_DATABASE_CONNECTION | + BGWORKER_INTERRUPTIBLE; + worker.bgw_start_time = BgWorkerStart_RecoveryFinished; + worker.bgw_restart_time = 60; + sprintf(worker.bgw_library_name, "postgres"); + sprintf(worker.bgw_function_name, "LogicalRevertWorkerMain"); + snprintf(worker.bgw_name, BGW_MAXLEN, + "logical revert worker for database %u", dboid); + snprintf(worker.bgw_type, BGW_MAXLEN, "logical revert worker"); + worker.bgw_main_arg = ObjectIdGetDatum(dboid); + worker.bgw_notify_pid = MyProcPid; + + if (!RegisterDynamicBackgroundWorker(&worker, &handle)) + { + ereport(WARNING, + (errmsg("could not register logical revert worker for database %u", + dboid))); + } + else + { + elog(DEBUG1, "started logical revert worker for database %u", dboid); + } +} + +/* + * get_revertable_database_list + * Return a palloc'd List of OIDs of databases that should get a worker. + * + * Returns only databases that have at least one unreverted ATM entry: those + * are the only databases with aborted-transaction UNDO for a worker to apply. + * A connectable, non-template database with no unreverted entries gets no + * worker, so an idle cluster (the common case) launches nothing and no + * revert worker connects to -- or reports pgstat activity for -- a database + * that has no work. The returned list is allocated in resultcxt (a + * long-lived context) so it survives the transaction the scan runs in. + */ +static List * +get_revertable_database_list(MemoryContext resultcxt) +{ + List *dblist = NIL; + Relation pg_database; + TableScanDesc scan; + HeapTuple tup; + Oid work_dboids[MAX_LOGICAL_REVERT_DATABASES]; + int nwork; + int i; + + /* + * Fast path: if no database has an unreverted ATM entry, there is nothing + * to revert anywhere, so spawn no workers and open no database. + */ + nwork = ATMCollectUnrevertedDatabases(work_dboids, + MAX_LOGICAL_REVERT_DATABASES); + if (nwork == 0) + return NIL; + + StartTransactionCommand(); + + pg_database = table_open(DatabaseRelationId, AccessShareLock); + scan = table_beginscan_catalog(pg_database, 0, NULL); + while ((tup = heap_getnext(scan, ForwardScanDirection)) != NULL) + { + Form_pg_database db = (Form_pg_database) GETSTRUCT(tup); + MemoryContext oldcxt; + bool has_work = false; + + if (!db->datallowconn) + continue; + + /* + * Skip template databases. template1 / template0 are not expected to + * accumulate ATM entries, and holding a revert-worker connection to + * template1 would block subsequent CREATE DATABASE commands that use + * it as the source template. + */ + if (strncmp(NameStr(db->datname), "template", 8) == 0) + continue; + + /* Only databases with unreverted ATM entries need a worker. */ + for (i = 0; i < nwork; i++) + { + if (work_dboids[i] == db->oid) + { + has_work = true; + break; + } + } + if (!has_work) + continue; + + /* Append the OID in the caller's long-lived context. */ + oldcxt = MemoryContextSwitchTo(resultcxt); + dblist = lappend_oid(dblist, db->oid); + MemoryContextSwitchTo(oldcxt); + } + table_endscan(scan); + table_close(pg_database, AccessShareLock); + + CommitTransactionCommand(); + + return dblist; +} + +/* + * LogicalRevertLauncherMain + * Launcher background worker. + * + * The launcher re-scans pg_database on every wake-up and spawns a + * LogicalRevertWorker for any connectable, non-template database that does + * not already have one. This way databases created after server start get + * a worker without requiring a restart. Databases that disappear are + * dropped from the tracking set so their OIDs can be re-tracked if reused. + * + * We track the set of databases we have already spawned a worker for so we + * do not re-register on every wake-up: the per-db workers use + * `bgw_restart_time = 60` and the postmaster auto-restarts them if they + * exit, and the bgworker slot pool is small enough that eager re-spawning + * would exhaust it. + */ +void +LogicalRevertLauncherMain(Datum main_arg) +{ + MemoryContext launcher_cxt; + List *known_dbs = NIL; + + pqsignal(SIGHUP, logical_revert_sighup); + pqsignal(SIGTERM, logical_revert_sigterm); + BackgroundWorkerUnblockSignals(); + + /* + * The launcher does not connect to any database itself. It spawns per-db + * workers which do their own connection (via + * BackgroundWorkerInitializeConnectionByOid). Connect with no database + * at all (dbname == NULL): the launcher only reads the shared pg_database + * catalog, which is accessible without a database-specific connection. + * Connecting to a real database (postgres or template1) would pin it as + * an active session and block CREATE DATABASE ... TEMPLATE , which is exactly how the autovacuum and logical-replication + * launchers avoid the same hazard. + */ + BackgroundWorkerInitializeConnection(NULL, NULL, 0); + + /* + * Long-lived context for the set of databases we have already started a + * worker for. It must outlive the per-scan transactions, so it cannot + * live in a transaction-scoped context. + */ + launcher_cxt = AllocSetContextCreate(TopMemoryContext, + "Logical Revert Launcher", + ALLOCSET_SMALL_SIZES); + + elog(LOG, "logical revert launcher started"); + + while (!got_SIGTERM) + { + int rc; + List *current_dbs; + List *next_known = NIL; + MemoryContext oldcxt; + ListCell *lc; + + /* Service interrupts and ProcSignalBarriers at every iteration. */ + CHECK_FOR_INTERRUPTS(); + + if (got_SIGHUP) + { + got_SIGHUP = false; + ProcessConfigFile(PGC_SIGHUP); + } + + /* Re-scan pg_database to discover databases created or dropped. */ + current_dbs = get_revertable_database_list(launcher_cxt); + + oldcxt = MemoryContextSwitchTo(launcher_cxt); + foreach(lc, current_dbs) + { + Oid dboid = lfirst_oid(lc); + + /* + * Carry forward databases that already have a live worker. + */ + if (list_member_oid(known_dbs, dboid)) + { + next_known = lappend_oid(next_known, dboid); + continue; + } + + /* + * Start a worker for a newly seen database only while we stay at + * or below max_logical_revert_workers concurrently tracked + * per-database workers. The GUC is a hard ceiling on the number + * of live revert workers so the launcher never exhausts the + * shared background-worker slot pool. On a cluster with more + * connectable databases than workers, the databases past the + * ceiling are serviced once a tracked database is dropped (its + * slot frees up on the next scan); raise the GUC to cover all + * databases concurrently. + */ + if (list_length(next_known) >= max_logical_revert_workers) + continue; + + StartLogicalRevertWorker(dboid); + next_known = lappend_oid(next_known, dboid); + } + MemoryContextSwitchTo(oldcxt); + + /* + * Replace the tracked set with the databases that still exist. Any + * dropped database falls out here; its worker (if any) exits on its + * own when its database disappears, and forgetting the OID lets a + * reused OID be re-tracked on a later scan. + */ + list_free(known_dbs); + known_dbs = next_known; + list_free(current_dbs); + + /* + * Sleep until the next scan. A shorter interval than the original + * one-shot design so newly created databases pick up a revert worker + * promptly; the no-op fast path (every db already tracked) is cheap. + */ + rc = WaitLatch(MyLatch, + WL_LATCH_SET | WL_TIMEOUT | WL_POSTMASTER_DEATH, + 60000L, /* 1 minute */ + PG_WAIT_EXTENSION); + + if (rc & WL_POSTMASTER_DEATH) + proc_exit(1); + + ResetLatch(MyLatch); + } + + elog(LOG, "logical revert launcher shutting down"); + proc_exit(0); +} + +/* + * LogicalRevertLauncherRegister + * Register the logical revert launcher as a static background worker. + * + * Called from postmaster.c at startup, alongside ApplyLauncherRegister(). + * UNDO is always-on infrastructure; table AMs opt in via am_supports_undo. + */ +void +LogicalRevertLauncherRegister(void) +{ + BackgroundWorker bgw; + + /* Disabled during binary upgrade or when explicitly turned off. */ + if (IsBinaryUpgrade) + return; + if (max_logical_revert_workers <= 0) + return; + + memset(&bgw, 0, sizeof(bgw)); + bgw.bgw_flags = BGWORKER_SHMEM_ACCESS | + BGWORKER_BACKEND_DATABASE_CONNECTION; + bgw.bgw_start_time = BgWorkerStart_RecoveryFinished; + bgw.bgw_restart_time = 5; + snprintf(bgw.bgw_library_name, MAXPGPATH, "postgres"); + snprintf(bgw.bgw_function_name, BGW_MAXLEN, "LogicalRevertLauncherMain"); + snprintf(bgw.bgw_name, BGW_MAXLEN, "logical revert launcher"); + snprintf(bgw.bgw_type, BGW_MAXLEN, "logical revert launcher"); + bgw.bgw_notify_pid = 0; + bgw.bgw_main_arg = (Datum) 0; + + RegisterBackgroundWorker(&bgw); +} diff --git a/src/backend/access/undo/meson.build b/src/backend/access/undo/meson.build index b21e198f88aa7..1c74d6e856583 100644 --- a/src/backend/access/undo/meson.build +++ b/src/backend/access/undo/meson.build @@ -1,6 +1,11 @@ # Copyright (c) 2022-2026, PostgreSQL Global Development Group backend_sources += files( + 'atm.c', + 'logical_revert_worker.c', + 'slog.c', + 'slog_flathash.c', + 'slog_tuple.c', 'undo.c', 'undo_bufmgr.c', 'undo_xlog.c', diff --git a/src/backend/access/undo/perbackend/pbu_undolog.c b/src/backend/access/undo/perbackend/pbu_undolog.c index 94c402219f64b..0e3f2374a0173 100644 --- a/src/backend/access/undo/perbackend/pbu_undolog.c +++ b/src/backend/access/undo/perbackend/pbu_undolog.c @@ -202,8 +202,6 @@ LWLock *RollbackRequestLock_pbu = NULL; LWLock *UndoWorkerLock_pbu = NULL; pg_atomic_uint64 *UndoOldestXidHavingUndo_pbu = NULL; -PG_FUNCTION_INFO_V1(pbu_pg_stat_get_undo_logs); - /* * Return the amount of traditional smhem required for undo log management. * Extra shared memory will be managed using DSM segments. @@ -2648,46 +2646,24 @@ ResetNonPermanentUndoLogs(void) ResetUndoLogs(UNDO_TEMP); } +/* + * pg_stat_get_undo_logs + * One row per per-backend undo log: its number, persistence level and + * tablespace, its discard, insert and end pointers, and the transaction + * and process attached to it, if any. + */ Datum -pbu_pg_stat_get_undo_logs(PG_FUNCTION_ARGS) +pg_stat_get_undo_logs(PG_FUNCTION_ARGS) { -#define PG_STAT_GET_UNDO_LOGS_COLS 9 +#define PG_STAT_GET_UNDO_LOGS_COLS 8 ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; - TupleDesc tupdesc; - Tuplestorestate *tupstore; - MemoryContext per_query_ctx; - MemoryContext oldcontext; UndoLogNumber low_logno; UndoLogNumber high_logno; - UndoLogNumber logno; UndoLogSharedData *shared = MyUndoLogState.shared; char *tablespace_name = NULL; Oid last_tablespace = InvalidOid; - /* check to see if caller supports us returning a tuplestore */ - if (rsinfo == NULL || !IsA(rsinfo, ReturnSetInfo)) - ereport(ERROR, - (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), - errmsg("set-valued function called in context that cannot accept a set"))); - if (!(rsinfo->allowedModes & SFRM_Materialize)) - ereport(ERROR, - (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), - errmsg("materialize mode required, but it is not " \ - "allowed in this context"))); - - /* Build a tuple descriptor for our result type */ - if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE) - elog(ERROR, "return type must be a row type"); - - per_query_ctx = rsinfo->econtext->ecxt_per_query_memory; - oldcontext = MemoryContextSwitchTo(per_query_ctx); - - tupstore = tuplestore_begin_heap(true, false, work_mem); - rsinfo->returnMode = SFRM_Materialize; - rsinfo->setResult = tupstore; - rsinfo->setDesc = tupdesc; - - MemoryContextSwitchTo(oldcontext); + InitMaterializedSRF(fcinfo, 0); /* Find the range of active log numbers. */ LWLockAcquire(UndoLogLock, LW_SHARED); @@ -2696,7 +2672,7 @@ pbu_pg_stat_get_undo_logs(PG_FUNCTION_ARGS) LWLockRelease(UndoLogLock); /* Scan all undo logs to build the results. */ - for (logno = low_logno; logno < high_logno; ++logno) + for (UndoLogNumber logno = low_logno; logno < high_logno; ++logno) { UndoLogControl *log = get_undo_log_by_number(logno); char buffer[17]; @@ -2721,10 +2697,9 @@ pbu_pg_stat_get_undo_logs(PG_FUNCTION_ARGS) } values[0] = ObjectIdGetDatum((Oid) logno); - values[1] = CStringGetTextDatum( - log->meta.persistence == UNDO_PERMANENT ? "permanent" : + values[1] = CStringGetTextDatum(log->meta.persistence == UNDO_PERMANENT ? "permanent" : log->meta.persistence == UNDO_UNLOGGED ? "unlogged" : - log->meta.persistence == UNDO_TEMP ? "temporary" : ""); + "temporary"); tablespace = log->meta.tablespace; snprintf(buffer, sizeof(buffer), UndoRecPtrFormat, @@ -2743,7 +2718,7 @@ pbu_pg_stat_get_undo_logs(PG_FUNCTION_ARGS) if (log->procno == INVALID_PROC_NUMBER) nulls[7] = true; else - values[7] = Int32GetDatum((int64) log->pid); + values[7] = Int32GetDatum(log->pid); LWLockRelease(&log->mutex); /* @@ -2759,14 +2734,11 @@ pbu_pg_stat_get_undo_logs(PG_FUNCTION_ARGS) last_tablespace = tablespace; } if (tablespace_name) - { values[2] = CStringGetTextDatum(tablespace_name); - nulls[2] = false; - } else nulls[2] = true; - tuplestore_putvalues(tupstore, tupdesc, values, nulls); + tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls); } if (tablespace_name) pfree(tablespace_name); diff --git a/src/backend/access/undo/slog.c b/src/backend/access/undo/slog.c new file mode 100644 index 0000000000000..a3f03746d6819 --- /dev/null +++ b/src/backend/access/undo/slog.c @@ -0,0 +1,818 @@ +/*------------------------------------------------------------------------- + * + * slog.c + * Secondary Log (sLog) -- transaction Aborted Transaction Map (ATM) + * + * The sLog tracks aborted transactions in shared memory for the UNDO + * subsystem's Constant-Time Recovery. + * + * Transaction sLog (adaptive radix tree): + * - A shared-memory adaptive radix tree (radixtree.h, RT_SHMEM) keyed by + * (xid, reloid) packed into a uint64 stores full abort metadata. With + * xid in the high bits, ordered iteration groups all entries for a + * given xid contiguously, enabling efficient per-xid range operations. + * - The tree lives in the sLog's DSA area and grows on demand. + * - The radix tree is protected by a single LWLock (sLog modifications + * only occur on transaction abort, an uncommon path). + * + * An optional per-tuple flat-hash extension (see the tuple sLog, added by + * the consumer that needs bounded-recovery uncommitted-writer tracking) + * shares this file's shared-memory segment and initialization but is not + * required by the UNDO core. + * + * Locking: Transaction sLog uses LWTRANCHE_SLOG. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/slog.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include +#ifdef WIN32 +#include +#endif + +#include "access/slog.h" +#include "access/slog_internal.h" +#include "access/transam.h" +#include "access/xact.h" +#include "common/hashfn.h" +#include "nodes/lockoptions.h" +#include "miscadmin.h" +#include "storage/lock.h" +#include "storage/lwlock.h" +#include "storage/off.h" +#include "storage/proc.h" +#include "storage/procarray.h" +#include "storage/shmem.h" +#include "storage/spin.h" +#include "storage/subsystems.h" +#include "utils/dsa.h" +#include "utils/memutils.h" +#include "utils/snapmgr.h" +#include "utils/timestamp.h" + +/* ---------------------------------------------------------------- + * Adaptive radix tree instantiation for the transaction sLog (ATM) + * + * The Aborted Transaction Map is keyed by a single uint64 formed from + * (xid, reloid): key = (xid << 32) | reloid. Both are uint32 and the + * two halves pack exactly. Because xid occupies the high 32 bits, the + * radix tree's ordered iteration groups all entries for one xid + * contiguously (xid-major, reloid-minor ascending) -- the same ordering + * property the old skip-list provided, which the per-xid range + * operations below rely on. + * + * The tree lives in the sLog's DSA area so it grows on demand rather + * than reserving a fixed shared-memory pool. All access is serialized externally by the + * existing txn_lock (LWLock): SET/DELETE under LW_EXCLUSIVE, FIND and + * iteration under LW_SHARED. We do not use the radix tree's own + * internal lock (RT_LOCK_*), since txn_lock already provides the needed + * serialization. + * ---------------------------------------------------------------- + */ + +/* + * slog_atm_value_t - value stored in the ATM radix tree. + * + * The key (xid, reloid) is implicit in the tree path, so only the data + * fields are stored here. + */ +typedef struct slog_atm_value_t +{ + XLogRecPtr last_batch_lsn; /* LSN of last UNDO batch for this xid */ + XLogRecPtr stop_lsn; /* chain walk boundary (see SLogTxnEntry) */ + XLogRecPtr first_batch_lsn; /* oldest batch of the chain */ + Oid dboid; + TimestampTz abort_time; + bool revert_complete; +} slog_atm_value_t; + +#define RT_PREFIX slog_atm +#define RT_SCOPE static inline +#define RT_DECLARE +#define RT_DEFINE +#define RT_VALUE_TYPE slog_atm_value_t +#define RT_SHMEM +#define RT_USE_DELETE +#include "lib/radixtree.h" + +/* ATM key encoding: xid in the high 32 bits, reloid in the low 32 bits. */ +static inline uint64 +slog_atm_key(TransactionId xid, Oid reloid) +{ + return ((uint64) xid << 32) | (uint64) reloid; +} + +static inline TransactionId +slog_atm_key_xid(uint64 key) +{ + return (TransactionId) (key >> 32); +} + +static inline Oid +slog_atm_key_reloid(uint64 key) +{ + return (Oid) (key & 0xFFFFFFFFu); +} + +/* + * Initial size for the sLog DSA area (backs the aborted-txn radix tree). + * Grows dynamically as needed up to slog_dsa_max_size_mb. SLOG_DSA_INIT_SIZE + * and the shared-state struct are defined in access/slog_internal.h, shared + * with the optional tuple sLog (slog_tuple.c). + */ +#define SLOG_DSA_MAX_SIZE_MB 256 /* default max: 256 MB */ + +/* GUC: maximum sLog DSA area size (in MB) */ +int slog_dsa_max_size_mb = SLOG_DSA_MAX_SIZE_MB; + +/* ---------------------------------------------------------------- + * Static variables + * ---------------------------------------------------------------- + */ +SLogSharedState *SLogState = NULL; + +/* Per-backend DSA attachment (lazy, via SLogEnsureDsaAttached) */ +static dsa_area *slog_dsa_handle = NULL; + +/* Per-backend attached ATM radix tree (lazy, via slog_atm_tree) */ +static slog_atm_radix_tree * slog_atm_tree_local = NULL; + + +/* + * slog_atm_tree + * Return this backend's attached ATM radix tree, attaching lazily. + * + * Ensures the sLog DSA area is attached, then attaches to the shared + * radix tree via its handle. The attached wrapper is cached for the + * life of the backend (the control block lives in the pinned DSA area). + * The caller must hold txn_lock across any use of the returned tree. + */ +static slog_atm_radix_tree * +slog_atm_tree(void) +{ + MemoryContext oldcxt; + + if (slog_atm_tree_local != NULL) + return slog_atm_tree_local; + + SLogEnsureDsaAttached(); + if (slog_dsa_handle == NULL) + elog(PANIC, "sLog: DSA not attached for ATM radix tree"); + + /* + * Attach in TopMemoryContext so the wrapper persists across transactions, + * mirroring the DSA attach above. + */ + oldcxt = MemoryContextSwitchTo(TopMemoryContext); + slog_atm_tree_local = slog_atm_attach(slog_dsa_handle, + SLogState->atm_handle); + MemoryContextSwitchTo(oldcxt); + + return slog_atm_tree_local; +} + +/* ---------------------------------------------------------------- + * Shared memory sizing and initialization + * ---------------------------------------------------------------- + */ + +/* + * SLogShmemSize + * Calculate shared memory needed for the sLog. + * + * Note: The DSA initial region is embedded in SLogSharedState (dsa_space[]), + * so sizeof(SLogSharedState) already includes SLOG_DSA_INIT_SIZE. The ATM + * radix tree is allocated from that DSA area on demand, so it needs no fixed + * reservation here. + */ +Size +SLogShmemSize(void) +{ + return add_size(MAXALIGN(sizeof(SLogSharedState)), SLogTupleShmemSize()); +} + +/* + * SLogShmemRequest + * Register shared memory needs for the sLog. + * + * Registers the shared state struct (which embeds the DSA initial region + * backing the aborted-transaction radix tree). + */ +void +SLogShmemRequest(void) +{ + /* Register the shared state structure */ + ShmemRequestStruct(.name = "Secondary Log State", + .size = sizeof(SLogSharedState), + .ptr = (void **) &SLogState, + ); + + /* Optional tuple sLog: register its flat-hash partition block. */ + SLogTupleShmemRequest(); +} + +/* + * SLogShmemInit + * Initialize sLog shared memory contents. + * + * Called during the init_fn phase. The framework has already allocated + * SLogState. We initialize the transaction ATM radix tree and its DSA area. + */ +void +SLogShmemInit(void) +{ + /* ATM radix tree is created below, after the DSA area exists. */ + SLogState->atm_handle = InvalidDsaPointer; + + /* ---- Initialize locks ---- */ + LWLockInitialize(&SLogState->txn_lock.lock, LWTRANCHE_SLOG); + + /* ---- Optional tuple sLog: allocate + init the flat-hash partitions ---- */ + SLogTupleShmemInit(); + + /* ---- Initialize DSA area (backs the aborted-txn radix tree) ---- */ + SLogState->dsa_area = dsa_create_in_place(SLogState->dsa_space, + SLOG_DSA_INIT_SIZE, + LWTRANCHE_SLOG, + 0); + dsa_pin(SLogState->dsa_area); + dsa_set_size_limit(SLogState->dsa_area, + (Size) slog_dsa_max_size_mb * 1024 * 1024); + + /* + * Create the ATM radix tree in the DSA area and record its handle. The + * transient tree wrapper returned here is discarded when init's memory + * context is reset; the control block lives in the pinned DSA area, and + * each backend re-attaches lazily via slog_atm_tree(). We reuse + * LWTRANCHE_SLOG for the tree's internal lock (unused -- txn_lock + * serializes all access -- but a valid tranche is required). + */ + { + slog_atm_radix_tree *tree; + + tree = slog_atm_create(SLogState->dsa_area, LWTRANCHE_SLOG); + SLogState->atm_handle = slog_atm_get_handle(tree); + } + + dsa_detach(SLogState->dsa_area); + SLogState->dsa_area = NULL; /* backends re-attach lazily */ +} + +/* + * SLogShmemRequest_cb / SLogShmemInit_cb + * ShmemCallbacks adapters for SLogShmemRequest()/SLogShmemInit(). + * + * The sLog is registered as its own PG_SHMEM_SUBSYSTEM entry (see + * storage/subsystemlist.h) rather than being sized/initialized from within + * the generic UNDO subsystem's callback, so the UNDO core has no + * compile-time or link-time dependency on any consumer. + * + * No .attach_fn is provided: SLogShmemInit() has no found-guard, so the + * framework re-attaches EXEC_BACKEND children to the already-initialized + * shared struct via the ShmemRequestStruct(.ptr=&SLogState) registration + * in SLogShmemRequest(). + */ +static void +SLogShmemRequest_cb(void *arg) +{ + SLogShmemRequest(); +} + +static void +SLogShmemInit_cb(void *arg) +{ + SLogShmemInit(); +} + +const ShmemCallbacks SLogShmemCallbacks = { + .request_fn = SLogShmemRequest_cb, + .init_fn = SLogShmemInit_cb, +}; + +/* ---------------------------------------------------------------- + * DSA lifecycle for the shared sLog DSA area + * ---------------------------------------------------------------- + */ + +/* + * SLogEnsureDsaAttached + * Lazy per-backend DSA attachment. + * + * Must be called before any DSA alloc/free/get_address operations. + * Safe to call multiple times (no-op after first attach). + */ +void +SLogEnsureDsaAttached(void) +{ + MemoryContext oldcxt; + + if (slog_dsa_handle != NULL) + return; /* already attached */ + + if (SLogState == NULL) + return; /* sLog not yet initialized */ + + /* + * Allocate in TopMemoryContext so the dsa_area struct persists across + * transactions. dsa_attach_in_place internally palloc's, so if we're in + * a transaction context, the handle would become a dangling pointer after + * transaction end. + */ + oldcxt = MemoryContextSwitchTo(TopMemoryContext); + slog_dsa_handle = dsa_attach_in_place(SLogState->dsa_space, NULL); + dsa_pin_mapping(slog_dsa_handle); + MemoryContextSwitchTo(oldcxt); +} + +/* ================================================================ + * Transaction sLog functions + * ================================================================ + */ + +/* + * SLogTxnInsert + * Insert an aborted transaction entry into the sLog. + * + * Creates an entry in the ATM radix tree. The tree grows on demand from + * the DSA area, so unlike the old fixed pool this never fails for lack of + * space; the return type is retained for API compatibility and is always + * true. + */ +bool +SLogTxnInsert(TransactionId xid, Oid reloid, Oid dboid, + XLogRecPtr last_batch_lsn, XLogRecPtr stop_lsn, + XLogRecPtr first_batch_lsn) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + uint64 key = slog_atm_key(xid, reloid); + slog_atm_value_t value; + + value.last_batch_lsn = last_batch_lsn; + value.stop_lsn = stop_lsn; + value.first_batch_lsn = first_batch_lsn; + value.dboid = dboid; + value.abort_time = GetCurrentTimestamp(); + value.revert_complete = false; + + LWLockAcquire(&SLogState->txn_lock.lock, LW_EXCLUSIVE); + + /* + * RT_SET returns whether the key already existed. A duplicate insert is + * a no-op on the (xid, reloid) identity; the original entry's data fields + * would be overwritten, so preserve the old behavior of leaving an + * existing entry untouched by finding first. + */ + if (slog_atm_find(tree, key) == NULL) + (void) slog_atm_set(tree, key, &value); + + LWLockRelease(&SLogState->txn_lock.lock); + return true; +} + +/* + * SLogTxnLookup + * Look up a specific (xid, reloid) entry. + * + * Returns true if found, copying the entry into *entry_out. + */ +bool +SLogTxnLookup(TransactionId xid, Oid reloid, SLogTxnEntry *entry_out) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + uint64 key = slog_atm_key(xid, reloid); + slog_atm_value_t *found; + + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); + + found = slog_atm_find(tree, key); + + if (found != NULL && entry_out != NULL) + { + entry_out->xid = xid; + entry_out->reloid = reloid; + entry_out->last_batch_lsn = found->last_batch_lsn; + entry_out->stop_lsn = found->stop_lsn; + entry_out->first_batch_lsn = found->first_batch_lsn; + entry_out->dboid = found->dboid; + entry_out->abort_time = found->abort_time; + entry_out->revert_complete = found->revert_complete; + } + + LWLockRelease(&SLogState->txn_lock.lock); + + return (found != NULL); +} + +/* + * SLogTxnLookupByXid + * Find the UNDO chain for a given xid (any reloid). + * + * The radix tree has no seek-to-key API, so we iterate its entries in + * ascending key order (xid-major) and return the first one whose xid + * matches. Because keys are xid-major we can stop as soon as we pass + * the target xid. The ATM only holds un-reverted aborted transactions, + * so it is small and a full scan is acceptable on this cold path. + */ +bool +SLogTxnLookupByXid(TransactionId xid, XLogRecPtr *lsn_out) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_iter *iter; + slog_atm_value_t *val; + uint64 k; + bool result = false; + + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); + + iter = slog_atm_begin_iterate(tree); + while ((val = slog_atm_iterate_next(iter, &k)) != NULL) + { + TransactionId k_xid = slog_atm_key_xid(k); + + if (k_xid < xid) + continue; + if (k_xid > xid) + break; /* passed the target range */ + + if (lsn_out) + *lsn_out = val->last_batch_lsn; + result = true; + break; + } + slog_atm_end_iterate(iter); + + LWLockRelease(&SLogState->txn_lock.lock); + return result; +} + +/* + * SLogTxnRemove + * Remove a specific (xid, reloid) entry. + */ +void +SLogTxnRemove(TransactionId xid, Oid reloid) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + uint64 key = slog_atm_key(xid, reloid); + + LWLockAcquire(&SLogState->txn_lock.lock, LW_EXCLUSIVE); + + (void) slog_atm_delete(tree, key); + + LWLockRelease(&SLogState->txn_lock.lock); +} + +/* + * SLogTxnRemoveByXid + * Remove all sLog entries for a given transaction ID. + * + * Collects all keys for this xid into a local array during an ascending + * iteration (keys are contiguous thanks to the xid-major ordering), then + * deletes them after ending the iteration -- we do not delete while + * iterating. The ATM is small, so the full scan is acceptable. + */ +void +SLogTxnRemoveByXid(TransactionId xid) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_iter *iter; + slog_atm_value_t *val; + uint64 k; + uint64 *to_remove; + int nremove = 0; + int max_remove = 64; + int i; + + to_remove = (uint64 *) palloc(max_remove * sizeof(uint64)); + + LWLockAcquire(&SLogState->txn_lock.lock, LW_EXCLUSIVE); + + /* Collect all keys with matching xid */ + iter = slog_atm_begin_iterate(tree); + while ((val = slog_atm_iterate_next(iter, &k)) != NULL) + { + TransactionId k_xid = slog_atm_key_xid(k); + + (void) val; + if (k_xid < xid) + continue; + if (k_xid > xid) + break; + + if (nremove >= max_remove) + { + max_remove *= 2; + to_remove = (uint64 *) repalloc(to_remove, + max_remove * sizeof(uint64)); + } + to_remove[nremove++] = k; + } + slog_atm_end_iterate(iter); + + /* Remove collected keys */ + for (i = 0; i < nremove; i++) + (void) slog_atm_delete(tree, to_remove[i]); + + LWLockRelease(&SLogState->txn_lock.lock); + + pfree(to_remove); +} + +/* + * SLogTxnMarkReverted + * Mark all entries for a given xid as revert_complete. + * + * Iterates the tree in ascending key order and rewrites the value of each + * entry for this xid with revert_complete = true. Keys for one xid are + * contiguous, so we stop once we pass the target xid. We collect the + * matching (key, value) pairs first and RT_SET them after ending the + * iteration, so the tree is never mutated while an iterator is live. + */ +void +SLogTxnMarkReverted(TransactionId xid) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_iter *iter; + slog_atm_value_t *val; + uint64 k; + uint64 *keys; + slog_atm_value_t *vals; + int n = 0; + int max_n = 64; + int i; + + keys = (uint64 *) palloc(max_n * sizeof(uint64)); + vals = (slog_atm_value_t *) palloc(max_n * sizeof(slog_atm_value_t)); + + LWLockAcquire(&SLogState->txn_lock.lock, LW_EXCLUSIVE); + + /* + * Collect matching entries first, then RT_SET them after ending the + * iteration, to avoid mutating the tree while an iterator is live. + */ + iter = slog_atm_begin_iterate(tree); + while ((val = slog_atm_iterate_next(iter, &k)) != NULL) + { + TransactionId k_xid = slog_atm_key_xid(k); + + if (k_xid < xid) + continue; + if (k_xid > xid) + break; + + if (n >= max_n) + { + max_n *= 2; + keys = (uint64 *) repalloc(keys, max_n * sizeof(uint64)); + vals = (slog_atm_value_t *) repalloc(vals, + max_n * sizeof(slog_atm_value_t)); + } + keys[n] = k; + vals[n] = *val; + vals[n].revert_complete = true; + n++; + } + slog_atm_end_iterate(iter); + + for (i = 0; i < n; i++) + (void) slog_atm_set(tree, keys[i], &vals[i]); + + LWLockRelease(&SLogState->txn_lock.lock); + + pfree(keys); + pfree(vals); +} + +/* + * SLogTxnGetNextUnreverted + * Find an entry of database dboid that hasn't been reverted yet. + * + * Iterates the tree in ascending key order (xid-major), returning the + * first entry of dboid after after_xid with revert_complete == false. + * Pass InvalidTransactionId to start from the beginning. + */ +bool +SLogTxnGetNextUnreverted(Oid dboid, TransactionId after_xid, + TransactionId *xid_out, + XLogRecPtr *lsn_out, XLogRecPtr *stop_lsn_out) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_iter *iter; + slog_atm_value_t *val; + uint64 k; + bool result = false; + + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); + + iter = slog_atm_begin_iterate(tree); + while ((val = slog_atm_iterate_next(iter, &k)) != NULL) + { + if (slog_atm_key_xid(k) <= after_xid || val->dboid != dboid) + continue; + if (!val->revert_complete) + { + *xid_out = slog_atm_key_xid(k); + *lsn_out = val->last_batch_lsn; + *stop_lsn_out = val->stop_lsn; + result = true; + break; + } + } + slog_atm_end_iterate(iter); + + LWLockRelease(&SLogState->txn_lock.lock); + return result; +} + +/* + * SLogTxnCollectUnrevertedDatabases + * Collect the distinct database OIDs that have at least one unreverted + * ATM entry. + * + * Fills dboids[] (caller-provided, capacity max_dboids) with the distinct + * OIDs and returns the count. Used by the logical revert launcher so it + * spawns a per-database worker only for databases that actually have + * aborted-transaction UNDO to apply, rather than for every database in the + * cluster. The set of databases with unreverted entries is tiny, so a + * linear dedup against the collected prefix is adequate. + */ +int +SLogTxnCollectUnrevertedDatabases(Oid *dboids, int max_dboids) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_iter *iter; + slog_atm_value_t *val; + uint64 k; + int n = 0; + + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); + + iter = slog_atm_begin_iterate(tree); + while ((val = slog_atm_iterate_next(iter, &k)) != NULL) + { + int i; + bool seen = false; + + if (val->revert_complete) + continue; + + for (i = 0; i < n; i++) + { + if (dboids[i] == val->dboid) + { + seen = true; + break; + } + } + if (!seen && n < max_dboids) + dboids[n++] = val->dboid; + } + slog_atm_end_iterate(iter); + + LWLockRelease(&SLogState->txn_lock.lock); + return n; +} + +/* + * SLogRecoveryFinalize + * Count entries after recovery for logging. + */ +void +SLogRecoveryFinalize(int *total_out, int *unreverted_out) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_iter *iter; + slog_atm_value_t *val; + uint64 k; + int total = 0; + int unreverted = 0; + + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); + + iter = slog_atm_begin_iterate(tree); + while ((val = slog_atm_iterate_next(iter, &k)) != NULL) + { + total++; + if (!val->revert_complete) + unreverted++; + } + slog_atm_end_iterate(iter); + + LWLockRelease(&SLogState->txn_lock.lock); + + if (total_out) + *total_out = total; + if (unreverted_out) + *unreverted_out = unreverted; +} + +/* + * SLogTxnGetOldestUnrevertedLSN + * Return the oldest UNDO WAL position any unreverted entry still needs: + * the minimum over entries of their chain's first batch LSN. + * + * Used by the WAL retention logic to prevent recycling WAL segments that + * still contain UNDO batches needed by the logical revert worker. + * Returns InvalidXLogRecPtr if no unreverted entries exist. + */ +XLogRecPtr +SLogTxnGetOldestUnrevertedLSN(void) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_iter *iter; + slog_atm_value_t *val; + uint64 k; + XLogRecPtr oldest = InvalidXLogRecPtr; + + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); + + iter = slog_atm_begin_iterate(tree); + while ((val = slog_atm_iterate_next(iter, &k)) != NULL) + { + /* + * The revert worker reads the whole chain, back to its oldest batch, + * so that is what must stay; the head alone would let checkpoints + * recycle the older batches first. An entry without a known first + * batch falls back to its head. + */ + XLogRecPtr lsn = XLogRecPtrIsValid(val->first_batch_lsn) ? + val->first_batch_lsn : val->last_batch_lsn; + + if (!val->revert_complete && XLogRecPtrIsValid(lsn)) + { + if (!XLogRecPtrIsValid(oldest) || lsn < oldest) + oldest = lsn; + } + } + slog_atm_end_iterate(iter); + + LWLockRelease(&SLogState->txn_lock.lock); + return oldest; +} + +/* + * SLogTxnSnapshotForCheckpoint + * Copy every ATM entry into a palloc'd array for durable checkpointing. + * + * Returns the number of entries and stores a palloc'd array of SLogTxnEntry + * in *entries_out (NULL if there are none). The caller owns the array and + * must pfree it. Runs outside any critical section (CheckPointATM), so + * palloc is safe. + */ +int +SLogTxnSnapshotForCheckpoint(SLogTxnEntry **entries_out) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_iter *iter; + slog_atm_value_t *val; + uint64 k; + SLogTxnEntry *arr = NULL; + int count = 0; + int capacity = 64; + + *entries_out = NULL; + + arr = (SLogTxnEntry *) palloc(sizeof(SLogTxnEntry) * capacity); + + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); + + iter = slog_atm_begin_iterate(tree); + while ((val = slog_atm_iterate_next(iter, &k)) != NULL) + { + if (count >= capacity) + { + capacity *= 2; + arr = (SLogTxnEntry *) repalloc(arr, + sizeof(SLogTxnEntry) * capacity); + } + + arr[count].xid = slog_atm_key_xid(k); + arr[count].reloid = slog_atm_key_reloid(k); + arr[count].last_batch_lsn = val->last_batch_lsn; + arr[count].stop_lsn = val->stop_lsn; + arr[count].first_batch_lsn = val->first_batch_lsn; + arr[count].dboid = val->dboid; + arr[count].abort_time = val->abort_time; + arr[count].revert_complete = val->revert_complete; + count++; + } + slog_atm_end_iterate(iter); + + LWLockRelease(&SLogState->txn_lock.lock); + + if (count == 0) + { + pfree(arr); + return 0; + } + + *entries_out = arr; + return count; +} diff --git a/src/backend/access/undo/slog_flathash.c b/src/backend/access/undo/slog_flathash.c new file mode 100644 index 0000000000000..2e0aaf8a0658f --- /dev/null +++ b/src/backend/access/undo/slog_flathash.c @@ -0,0 +1,796 @@ +/*------------------------------------------------------------------------- + * + * slog_flathash.c + * Seqlock-protected flat open-addressing hash for sLog tuple tracking. + * + * Implements the flat hash table operations (probe, insert, remove) and + * the apply handler that mutates the single hash copy. The hash uses + * linear probing with a power-of-2 capacity and tombstone markers. A + * per-partition seqlock (in SLogFlatPartition) provides retry-based + * consistent reads; the writer lock serializes mutations. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/slog_flathash.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/slog_flathash.h" +#include "access/transam.h" +#include "common/hashfn.h" +#include "utils/dsa.h" + +/* + * Maximum probe distance before giving up. With load factor < 0.7 and + * power-of-2 sizing, typical probe chains are very short (< 5). + * We cap at 128 to bound worst-case scan time. + */ +#define SLOG_FLAT_MAX_PROBE 128 + +/* ---------------------------------------------------------------- + * Size computation + * ---------------------------------------------------------------- + */ + +/* + * SLogFlatHashDataSize + * Size of one copy of the flat hash data structure. + */ +Size +SLogFlatHashDataSize(int capacity) +{ + return offsetof(SLogFlatHash, buckets) + + (Size) capacity * sizeof(SLogFlatBucket); +} + +/* + * SLogFlatHashShmemSize + * Shared memory needed for one flat-hash copy (the seqlock guards a + * single copy in place; the sequence counter and writer lock live in + * the SLogFlatPartition struct, not here). + */ +Size +SLogFlatHashShmemSize(int capacity, int max_backends) +{ + (void) max_backends; /* retained for call-site compatibility */ + return MAXALIGN(SLogFlatHashDataSize(capacity)); +} + +/* + * SLogFlatHashPartitionedShmemSize + * Total shared memory needed for all partitions' flat-hash copies. + * + * Each partition gets capacity/N buckets in one copy. The per-partition + * seqlock counter and writer lock are embedded in SLogFlatPartition in the + * SLogSharedState, so only the hash copies are sized here. + */ +Size +SLogFlatHashPartitionedShmemSize(int total_capacity, int max_backends) +{ + int per_partition_capacity; + Size per_partition_size; + Size total; + + per_partition_capacity = total_capacity / SLogNumPartitions; + if (per_partition_capacity < 64) + per_partition_capacity = 64; + + per_partition_size = SLogFlatHashShmemSize(per_partition_capacity, + max_backends); + total = (Size) SLogNumPartitions * MAXALIGN(per_partition_size); + + return total; +} + +/* ---------------------------------------------------------------- + * Initialization + * ---------------------------------------------------------------- + */ + +/* + * SLogFlatHashInit + * Initialize a flat hash data block (one copy). + * + * Sets all buckets to EMPTY state. + */ +void +SLogFlatHashInit(void *data, int capacity) +{ + SLogFlatHash *ht = (SLogFlatHash *) data; + int i; + + ht->capacity = capacity; + ht->num_entries = 0; + ht->num_tombstones = 0; + ht->padding = 0; + + for (i = 0; i < capacity; i++) + { + ht->buckets[i].hash_val = SLOG_FLAT_EMPTY; + memset(&ht->buckets[i].key, 0, sizeof(SLogTupleKey)); + ht->buckets[i].padding = 0; + ht->buckets[i].entry.nops = 0; + memset(ht->buckets[i].entry.ops, 0, + sizeof(SLogTupleOp) * SLOG_MAX_TUPLE_OPS); + } +} + +/* ---------------------------------------------------------------- + * Hash function + * ---------------------------------------------------------------- + */ + +/* + * SLogFlatHashComputeHash + * Compute a 32-bit hash for an SLogTupleKey. + * + * The key must have been zeroed before population (for padding bytes). + * Returns a non-zero, non-TOMBSTONE value (adjusts if hash_bytes returns + * 0 or UINT32_MAX). + */ +uint32 +SLogFlatHashComputeHash(const SLogTupleKey *key) +{ + uint32 h; + + h = hash_bytes((const unsigned char *) key, sizeof(SLogTupleKey)); + + /* Ensure we never produce EMPTY or TOMBSTONE values */ + if (h == SLOG_FLAT_EMPTY) + h = 1; + else if (h == SLOG_FLAT_TOMBSTONE) + h = SLOG_FLAT_TOMBSTONE - 1; + + return h; +} + +/* ---------------------------------------------------------------- + * Probe operations + * ---------------------------------------------------------------- + */ + +/* + * SLogFlatHashProbe + * Look up a key in the flat hash. Returns bucket pointer if found, + * NULL if not present. + * + * Linear probing: start at hash_val % capacity, walk forward skipping + * tombstones, stop at EMPTY (not found) or matching key (found). + */ +SLogFlatBucket * +SLogFlatHashProbe(const SLogFlatHash *ht, const SLogTupleKey *key) +{ + uint32 h; + uint32 idx; + int probe; + + /* + * Fast path: an empty hash cannot contain the key, so skip the key hash + * computation and bucket walk entirely. This lookup sits on the calling + * AM's per-tuple visibility/conflict hot path and the sLog + * (uncommitted-writer tracking) is empty for the vast majority of lookups + * in a committed-data OLTP workload (measured ~4%% of AM CPU under a + * TPROC-C-style workload before this guard). Callers read the hash under + * the partition seqlock, so a concurrent writer's insert (which bumps + * num_entries) is caught by the seqlock retry. + */ + if (ht->num_entries == 0) + return NULL; + + h = SLogFlatHashComputeHash(key); + idx = h & (uint32) (ht->capacity - 1); + + for (probe = 0; probe < SLOG_FLAT_MAX_PROBE; probe++) + { + const SLogFlatBucket *bucket = &ht->buckets[idx]; + + if (bucket->hash_val == SLOG_FLAT_EMPTY) + return NULL; /* definitive miss */ + + if (bucket->hash_val != SLOG_FLAT_TOMBSTONE && + bucket->hash_val == h && + memcmp(&bucket->key, key, sizeof(SLogTupleKey)) == 0) + { + return (SLogFlatBucket *) bucket; /* found */ + } + + idx = (idx + 1) & (uint32) (ht->capacity - 1); + } + + return NULL; /* probe limit exceeded */ +} + +/* + * SLogFlatHashHasOpForXid + * Return true iff the entry for key holds an in-use op for xid. + * + * Used by SLogTupleInsert to confirm an op was actually stored, rather than + * silently dropped because the per-TID ops array was full. Probing the + * bucket alone is insufficient on a hot row, where the bucket pre-exists with + * other markers. + */ +bool +SLogFlatHashHasOpForXid(const SLogFlatHash *ht, const SLogTupleKey *key, + TransactionId xid) +{ + const SLogFlatBucket *bucket = SLogFlatHashProbe(ht, key); + const SLogTupleEntry *entry; + int i; + + if (bucket == NULL) + return false; + + entry = &bucket->entry; + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (entry->ops[i].in_use && + TransactionIdEquals(entry->ops[i].xid, xid)) + return true; + } + return false; +} + +/* + * SLogFlatHashProbeForInsert + * Find a slot for inserting a key. Returns the bucket to use. + * + * If the key already exists, returns that bucket (for update-in-place). + * Otherwise returns the first EMPTY or TOMBSTONE slot encountered. + * Returns NULL if probe limit exceeded without finding a slot. + */ +SLogFlatBucket * +SLogFlatHashProbeForInsert(SLogFlatHash *ht, const SLogTupleKey *key, + uint32 hash_val) +{ + uint32 idx; + int probe; + SLogFlatBucket *first_free = NULL; + + idx = hash_val & (uint32) (ht->capacity - 1); + + for (probe = 0; probe < SLOG_FLAT_MAX_PROBE; probe++) + { + SLogFlatBucket *bucket = &ht->buckets[idx]; + + if (bucket->hash_val == SLOG_FLAT_EMPTY) + { + /* Definitive miss; use first_free if we found one, else this */ + return first_free ? first_free : bucket; + } + + if (bucket->hash_val == SLOG_FLAT_TOMBSTONE) + { + /* Remember first tombstone for potential reuse */ + if (first_free == NULL) + first_free = bucket; + } + else if (bucket->hash_val == hash_val && + memcmp(&bucket->key, key, sizeof(SLogTupleKey)) == 0) + { + /* Key already exists */ + return bucket; + } + + idx = (idx + 1) & (uint32) (ht->capacity - 1); + } + + /* Probe limit exceeded; return first_free if available */ + return first_free; +} + +/* ---------------------------------------------------------------- + * Apply handler (single-copy, under the seqlock writer) + * ---------------------------------------------------------------- + */ + +/* + * flat_hash_apply_insert + * Apply an INSERT operation: find/create entry, add op to slot. + */ +static void +flat_hash_apply_insert(SLogFlatHash *ht, const SLogFlatOp *op) +{ + uint32 h; + SLogFlatBucket *bucket; + SLogTupleEntry *entry; + int i; + bool existing; + + h = SLogFlatHashComputeHash(&op->key); + bucket = SLogFlatHashProbeForInsert(ht, &op->key, h); + + if (bucket == NULL) + { + elog(WARNING, "SLOG_LOST_OP table_full relid=%u xid=%u op=%d", + op->key.relid, op->tuple_op.xid, (int) op->tuple_op.op_type); + return; /* table full, operation lost */ + } + + /* Determine if this is an existing entry */ + existing = (bucket->hash_val != SLOG_FLAT_EMPTY && + bucket->hash_val != SLOG_FLAT_TOMBSTONE); + + if (!existing) + { + /* New entry */ + if (bucket->hash_val == SLOG_FLAT_TOMBSTONE) + ht->num_tombstones--; + + bucket->hash_val = h; + memcpy(&bucket->key, &op->key, sizeof(SLogTupleKey)); + bucket->entry.nops = 0; + memset(&bucket->entry.key, 0, sizeof(SLogTupleKey)); + memcpy(&bucket->entry.key, &op->key, sizeof(SLogTupleKey)); + memset(bucket->entry.ops, 0, sizeof(bucket->entry.ops)); + ht->num_entries++; + } + + entry = &bucket->entry; + + /* Check if this xid already has an op (overwrite) */ + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (entry->ops[i].in_use && + entry->ops[i].xid == op->tuple_op.xid) + { + /* Overwrite existing op for same xid */ + memcpy(&entry->ops[i], &op->tuple_op, sizeof(SLogTupleOp)); + return; + } + } + + /* Find a free slot */ + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (!entry->ops[i].in_use) + { + memcpy(&entry->ops[i], &op->tuple_op, sizeof(SLogTupleOp)); + entry->nops++; + return; + } + } + + /* + * No free slot; reclaim the oldest horizon-eligible retained UPDATE + * marker. + * + * Hot rows (e.g. a TPC-C district) accumulate one retained UPDATE marker + * per committed transaction. Committed UPDATE markers are removed at + * commit (flat_hash_apply_commit_xid), so in steady state the array only + * fills with in-progress writers or stragglers; this path drains the + * latter. + */ + { + int oldest_idx = -1; + TransactionId reclaim_xid_horizon = op->reclaim_xid_horizon; + + /* + * Take the first horizon-eligible UPDATE marker. A marker is + * reclaimable once its xid precedes the oldest active snapshot's xmin + * (reclaim_xid_horizon): at that point the xid's outcome is settled + * and visible to (or irrelevant to) every live snapshot, so no + * residual consumer can still need it (the write-write conflict probe + * and MVCC read are served from the durable UNDO log chain). An + * in-progress xid can never precede this horizon, so this test alone + * never frees a marker a concurrent reader or writer still needs. + * Every marker that passes the horizon predicate is equally + * reclaimable, so we take the first one found. + * + * An INVALID reclaim_xid_horizon disables reclamation entirely: every + * marker is treated as non-reclaimable and we fall through to + * oldest_idx == -1. This is the fail-safe direction and is exactly + * what the caller relies on -- SLogTupleInsert passes + * InvalidTransactionId on the fast path (skipping the ProcArrayLock + * horizon scan) and only supplies a real, freshly-computed horizon on + * the full-array retry. A missing horizon must never make markers + * look reclaimable, or a live reader's / in-progress writer's op + * could be freed (lost update / MVCC corruption). + * + * The xid horizon is the authoritative, self-healing reclaim gate: a + * below-horizon marker is reclaimable whether or not it was ever + * committed in CLOG. + */ + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (!entry->ops[i].in_use) + continue; + if (entry->ops[i].op_type != SLOG_OP_UPDATE) + continue; + if (!TransactionIdIsValid(entry->ops[i].xid)) + continue; + if (!TransactionIdIsValid(reclaim_xid_horizon) || + !TransactionIdPrecedes(entry->ops[i].xid, reclaim_xid_horizon)) + continue; /* invalid horizon disables reclaim + * (fail-safe); else still visible-relevant to + * an active snapshot, or in-progress */ + oldest_idx = i; + break; + } + + if (oldest_idx >= 0) + { + /* Safe to reclaim; no active snapshot needs this entry */ + entry->ops[oldest_idx].in_use = false; + entry->nops--; + + /* Now insert the new op in the freed slot */ + memcpy(&entry->ops[oldest_idx], &op->tuple_op, sizeof(SLogTupleOp)); + entry->nops++; + return; + } + } + + /* + * No reclaimable slot: every one of the SLOG_MAX_TUPLE_OPS slots holds an + * UPDATE whose xid is still at/above the reclaim horizon (in-progress or + * very recently committed) or a non-UPDATE marker. With the xid-horizon + * reclaim above this requires SLOG_MAX_TUPLE_OPS concurrent unsettled + * writers on a single TID, which is not reachable under normal load. The + * caller (SLogTupleInsert) detects the drop via SLogFlatHashHasOpForXid + * and falls back to local-only tracking + UNDO replay, so this is safe + * but worth surfacing. + */ + elog(WARNING, "SLOG_LOST_OP ops_full relid=%u xid=%u op=%d nops=%d horizon=%u", + op->key.relid, op->tuple_op.xid, (int) op->tuple_op.op_type, + entry->nops, op->reclaim_xid_horizon); +} + +/* + * flat_hash_apply_remove_xid + * Remove all ops for a given xid from an entry. + * Remove the entry entirely if nops reaches 0. + */ +static void +flat_hash_apply_remove_xid(SLogFlatHash *ht, const SLogFlatOp *op) +{ + SLogFlatBucket *bucket; + SLogTupleEntry *entry; + int i; + + bucket = SLogFlatHashProbe(ht, &op->key); + if (bucket == NULL) + return; + + entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (entry->ops[i].in_use && + entry->ops[i].xid == op->xid) + { + entry->ops[i].in_use = false; + entry->nops--; + } + } + + if (entry->nops == 0) + { + bucket->hash_val = SLOG_FLAT_TOMBSTONE; + ht->num_entries--; + ht->num_tombstones++; + } +} + +/* + * flat_hash_apply_remove_entry + * Remove an entire entry (tombstone it). + */ +static void +flat_hash_apply_remove_entry(SLogFlatHash *ht, const SLogFlatOp *op) +{ + SLogFlatBucket *bucket; + + bucket = SLogFlatHashProbe(ht, &op->key); + if (bucket == NULL) + return; + + bucket->hash_val = SLOG_FLAT_TOMBSTONE; + ht->num_entries--; + ht->num_tombstones++; +} + +/* + * flat_hash_apply_mark_aborted + * Mark all ops for a given xid as SLOG_OP_ABORTED. + */ +static void +flat_hash_apply_mark_aborted(SLogFlatHash *ht, const SLogFlatOp *op) +{ + SLogFlatBucket *bucket; + SLogTupleEntry *entry; + int i; + + bucket = SLogFlatHashProbe(ht, &op->key); + if (bucket == NULL) + return; + + entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (entry->ops[i].in_use && + entry->ops[i].xid == op->xid) + { + entry->ops[i].op_type = SLOG_OP_ABORTED; + } + } +} + +/* + * flat_hash_apply_update_op + * Update a specific op slot in place (e.g. re-parent a subxid). + */ +static void +flat_hash_apply_update_op(SLogFlatHash *ht, const SLogFlatOp *op) +{ + SLogFlatBucket *bucket; + SLogTupleEntry *entry; + int i; + + bucket = SLogFlatHashProbe(ht, &op->key); + if (bucket == NULL) + return; + + entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (entry->ops[i].in_use && + entry->ops[i].xid == op->xid) + { + /* + * Apply selective updates from the op. We use the tuple_op + * fields as the source of truth for what to update. + */ + if (op->subxid != InvalidTransactionId) + { + /* Re-parent subxid */ + if (entry->ops[i].subxid == op->tuple_op.subxid) + entry->ops[i].subxid = op->subxid; + } + break; + } + } +} + +/* + * flat_hash_apply_commit_xid + * Remove every op belonging to xid. + * + * Committed UPDATE markers are no longer retained here. + * The write-write conflict probe reads the head verptr on the tuple + * and resolves it in the durable UNDO log (via the AM's version-reconstruction + * walk), and no shared before-image is kept. With no consumer + * for a retained marker, an UPDATE at commit is treated like INSERT/DELETE/LOCK: + * removed. This drains bucket table_full pressure. + */ +static void +flat_hash_apply_commit_xid(SLogFlatHash *ht, const SLogFlatOp *op) +{ + SLogFlatBucket *bucket; + SLogTupleEntry *entry; + int i; + + bucket = SLogFlatHashProbe(ht, &op->key); + if (bucket == NULL) + return; + + entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (!entry->ops[i].in_use) + continue; + if (entry->ops[i].xid != op->xid) + continue; + + entry->ops[i].in_use = false; + entry->nops--; + } + + if (entry->nops == 0) + { + bucket->hash_val = SLOG_FLAT_TOMBSTONE; + ht->num_entries--; + ht->num_tombstones++; + } +} + +/* + * flat_hash_apply_cleanup_retained + * Remove retained committed UPDATE markers whose committing xid + * precedes the reclaim xid horizon (op->reclaim_xid_horizon). + * + * Gates on the xid horizon -- NOT an HLC threshold -- so a marker is + * reclaimed only when its committing xid precedes every live snapshot. + * The write-write conflict probe and MVCC read use the durable per-backend + * UNDO log chain, not this hash, so retained UPDATE markers here are dead + * bookkeeping; this sweep still applies for any leftover + * entries not removed at commit. + */ +static void +flat_hash_apply_cleanup_retained(SLogFlatHash *ht, const SLogFlatOp *op) +{ + SLogFlatBucket *bucket; + SLogTupleEntry *entry; + int i; + + bucket = SLogFlatHashProbe(ht, &op->key); + if (bucket == NULL) + return; + + entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (!entry->ops[i].in_use) + continue; + if (entry->ops[i].op_type != SLOG_OP_UPDATE) + continue; + if (!TransactionIdIsValid(entry->ops[i].xid)) + continue; + if (TransactionIdIsValid(op->reclaim_xid_horizon) && + !TransactionIdPrecedes(entry->ops[i].xid, + op->reclaim_xid_horizon)) + continue; + + /* + * Expired retained entry. Gated on the xid horizon alone -- a marker + * committed in CLOG or an aborted/in-flight one is drained the moment + * its xid precedes the horizon, so no slot is pinned forever. Must + * match the read-side eligibility scan in SLogTupleCleanupRetained. + */ + entry->ops[i].in_use = false; + entry->nops--; + } + + if (entry->nops == 0) + { + bucket->hash_val = SLOG_FLAT_TOMBSTONE; + ht->num_entries--; + ht->num_tombstones++; + } +} + +/* + * flat_hash_apply_create_aborted + * Create a new entry with an ABORTED op (for local-only INSERT abort). + */ +static void +flat_hash_apply_create_aborted(SLogFlatHash *ht, const SLogFlatOp *op) +{ + uint32 h; + SLogFlatBucket *bucket; + SLogTupleEntry *entry; + int i; + bool existing; + + h = SLogFlatHashComputeHash(&op->key); + bucket = SLogFlatHashProbeForInsert(ht, &op->key, h); + + if (bucket == NULL) + return; /* table full */ + + existing = (bucket->hash_val != SLOG_FLAT_EMPTY && + bucket->hash_val != SLOG_FLAT_TOMBSTONE); + + if (!existing) + { + if (bucket->hash_val == SLOG_FLAT_TOMBSTONE) + ht->num_tombstones--; + + bucket->hash_val = h; + memcpy(&bucket->key, &op->key, sizeof(SLogTupleKey)); + bucket->entry.nops = 0; + memset(&bucket->entry.key, 0, sizeof(SLogTupleKey)); + memcpy(&bucket->entry.key, &op->key, sizeof(SLogTupleKey)); + memset(bucket->entry.ops, 0, sizeof(bucket->entry.ops)); + ht->num_entries++; + } + + entry = &bucket->entry; + + /* Find a free slot for the ABORTED entry */ + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (!entry->ops[i].in_use) + { + entry->ops[i].xid = op->xid; + entry->ops[i].subxid = op->subxid; + entry->ops[i].op_type = SLOG_OP_ABORTED; + entry->ops[i].in_use = true; + entry->ops[i].commit_ts = 0; + entry->ops[i].spec_token = 0; + entry->ops[i].cid = InvalidCommandId; + entry->nops++; + return; + } + } + /* No free slot; operation lost */ +} + +/* + * SLogFlatHashApply + * Apply one op to the flat hash. Dispatches to operation-specific handlers. + */ +void +SLogFlatHashApply(void *data, const void *operation, Size op_size) +{ + SLogFlatHash *ht = (SLogFlatHash *) data; + const SLogFlatOp *op = (const SLogFlatOp *) operation; + + Assert(op_size == sizeof(SLogFlatOp)); + + switch (op->kind) + { + case SLOG_FLAT_OP_INSERT: + flat_hash_apply_insert(ht, op); + break; + case SLOG_FLAT_OP_REMOVE_XID: + flat_hash_apply_remove_xid(ht, op); + break; + case SLOG_FLAT_OP_REMOVE_ENTRY: + flat_hash_apply_remove_entry(ht, op); + break; + case SLOG_FLAT_OP_MARK_ABORTED: + flat_hash_apply_mark_aborted(ht, op); + break; + case SLOG_FLAT_OP_UPDATE_OP: + flat_hash_apply_update_op(ht, op); + break; + case SLOG_FLAT_OP_COMMIT_XID: + flat_hash_apply_commit_xid(ht, op); + break; + case SLOG_FLAT_OP_CLEANUP_RETAINED: + flat_hash_apply_cleanup_retained(ht, op); + break; + case SLOG_FLAT_OP_CREATE_ABORTED: + flat_hash_apply_create_aborted(ht, op); + break; + } +} + +/* ---------------------------------------------------------------- + * Scan API + * ---------------------------------------------------------------- + */ + +/* + * SLogFlatHashScanInit + * Initialize a sequential scan over the flat hash. + */ +void +SLogFlatHashScanInit(SLogFlatHashScanState *state) +{ + state->current_index = 0; +} + +/* + * SLogFlatHashScanNext + * Return the next occupied bucket, or NULL when the scan is complete. + * + * Skips EMPTY and TOMBSTONE slots. The returned pointer is valid only + * within the current seqlock read section (or under the writer lock). + */ +const SLogFlatBucket * +SLogFlatHashScanNext(const SLogFlatHash *ht, SLogFlatHashScanState *state) +{ + while (state->current_index < ht->capacity) + { + const SLogFlatBucket *bucket = &ht->buckets[state->current_index]; + + state->current_index++; + + if (bucket->hash_val != SLOG_FLAT_EMPTY && + bucket->hash_val != SLOG_FLAT_TOMBSTONE) + { + return bucket; + } + } + + return NULL; +} diff --git a/src/backend/access/undo/slog_tuple.c b/src/backend/access/undo/slog_tuple.c new file mode 100644 index 0000000000000..8aa62cb874abc --- /dev/null +++ b/src/backend/access/undo/slog_tuple.c @@ -0,0 +1,2711 @@ +/*------------------------------------------------------------------------- + * + * slog_tuple.c + * Tuple sLog -- optional per-tuple UNDO tracking extension + * + * The tuple sLog is an OPTIONAL extension of the sLog subsystem (slog.c). + * It provides bounded-recovery-time uncommitted-writer tracking for in-place + * MVCC table access methods: a flat, partitioned, seqlock-guarded hash of + * in-flight per-tuple operations, keyed by (relid, tid). Because the + * uncommitted writers of every hot tuple are recorded in shared memory, + * recovery and cross-backend visibility need not scan an unbounded UNDO + * chain to decide whether a given physical tuple version is visible; the + * hash answers "is there an in-flight/aborted writer of this tuple?" in + * O(1), which is what bounds recovery and read cost. + * + * The flat hash and its shared-memory partitions are carved from the same + * shared segment as the transaction sLog (see slog.c's SLogShmemInit); the + * two facilities share only that segment and its initialization, never each + * other's data structures. + * + * WAL-free: entries are transient, removed at commit/abort. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/slog_tuple.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include +#ifdef WIN32 +#include +#endif + +#include "access/slog.h" +#include "access/slog_flathash.h" +#include "access/slog_internal.h" +#include "access/transam.h" +#include "access/xact.h" +#include "common/hashfn.h" +#include "nodes/lockoptions.h" +#include "miscadmin.h" +#include "storage/lock.h" +#include "storage/lwlock.h" +#include "storage/off.h" +#include "storage/proc.h" +#include "storage/procarray.h" +#include "storage/shmem.h" +#include "storage/spin.h" +#include "utils/dsa.h" +#include "utils/memutils.h" +#include "utils/snapmgr.h" +#include "utils/timestamp.h" + + +/* + * slog_num_cpus - portable replacement for sysconf(_SC_NPROCESSORS_ONLN). + * + * Returns the number of online CPUs (or a sane positive fallback if the + * platform refuses to answer). Used to auto-size the sLog flat-hash + * partition count when the GUC is left at zero. + */ +static int +slog_num_cpus(void) +{ +#ifdef WIN32 + SYSTEM_INFO si; + + GetSystemInfo(&si); + if (si.dwNumberOfProcessors > 0) + return (int) si.dwNumberOfProcessors; + return 4; +#else + long n = sysconf(_SC_NPROCESSORS_ONLN); + + if (n <= 0) + return 4; + return (int) n; +#endif +} + + +/* + * Maximum number of abort ops applied to a partition between seqlock + * begin/end cycles in SLogTupleMarkAborted / SLogTupleCleanupRetained. + * + * The seqlock keeps its counter odd for the whole write cycle, so any + * concurrent wait-free reader spins until the counter turns even. A very + * large rollback (hundreds of thousands of tuples applied in one cycle) + * would pin readers spinning for the entire batch. Ending and re-beginning + * the seqlock every SLOG_ABORT_PUBLISH_BATCH ops bounds that odd-hold + * window; the partition writer_lock is held across the whole function, so + * dropping to even at a batch boundary leaves the partition consistent for + * any reader that observes it. + */ +#define SLOG_ABORT_PUBLISH_BATCH 24 + + +/* + * Wall-clock sampling period for the self-clocking throttles in + * SLogTupleInsert() and SLogTupleMaybeCleanupRetained(). GetCurrentTimestamp() + * is a clock_gettime() syscall that dominates the per-tuple hot path, so we + * read it once every SLOG_INSERT_CLOCK_PERIOD calls and reuse the cached + * horizon in between. Must be a power of two (callers mask with PERIOD - 1). + */ +#define SLOG_INSERT_CLOCK_PERIOD 256 + + +/* + * SLogTupleNumEntries + * Calculate the number of hash entries for the tuple sLog. + * + * Auto-sizing formula: MaxBackends * 1024, clamped to [4096, 4194304]. + */ +int +SLogTupleNumEntries(void) +{ + int n = MaxBackends * SLOG_TUPLE_PER_BACKEND_SLOTS; + + n = Max(n, SLOG_TUPLE_MIN_ENTRIES); + n = Min(n, SLOG_TUPLE_MAX_ENTRIES); + return n; +} + + + +/* + * GUC: slog_num_partitions; number of flat hash partitions. + * 0 = auto (heuristic: 4 x NumCPUs, clamped [16..256], power of 2). + * Set at postmaster startup, immutable thereafter. + */ +int slog_num_partitions = 0; + +/* Runtime partition count (set once during SLogShmemInit, read everywhere) */ +int SLogNumPartitions = SLOG_FLAT_DEFAULT_PARTITIONS; + +/* + * Compute the effective partition count from the GUC value. + * Called once during SLogShmemInit. + */ +static int +SLogComputeNumPartitions(void) +{ + int n; + + if (slog_num_partitions > 0) + { + /* Explicit GUC value; clamp and round to power of 2 */ + n = slog_num_partitions; + } + else + { + /* Auto-size: 4 x number of CPUs */ + int ncpus = slog_num_cpus(); + + n = ncpus * 4; + } + + /* Clamp */ + n = Max(n, SLOG_FLAT_MIN_PARTITIONS); + n = Min(n, SLOG_FLAT_MAX_PARTITIONS); + + /* Round up to next power of 2 (for fast modulo via bitmask) */ + { + int p = 1; + + while (p < n) + p <<= 1; + n = p; + } + + return n; +} + +/* + * Flat hash capacity: round up SLogTupleNumEntries to next power of 2, + * then divide by 0.7 (max load factor) to ensure probe chains stay short. + */ +static inline int +SLogFlatHashCapacity(void) +{ + int n = SLogTupleNumEntries(); + int cap; + + /* Target: num_entries / capacity <= 0.7, so capacity >= n / 0.7 */ + cap = (int) ((double) n / 0.7) + 1; + + /* Round up to next power of 2 */ + cap--; + cap |= cap >> 1; + cap |= cap >> 2; + cap |= cap >> 4; + cap |= cap >> 8; + cap |= cap >> 16; + cap++; + + /* Clamp to reasonable bounds */ + if (cap < 2048) + cap = 2048; + if (cap > 2 * 1048576) + cap = 2 * 1048576; + + return cap; +} + +/* ---------------------------------------------------------------- + * Static variables (tuple sLog) + * ---------------------------------------------------------------- + */ + +/* Pointer to ShmemAlloc'd flat-hash region, set by ShmemRequestStruct framework */ +static char *SLogFlatHashBlock = NULL; /* single allocation for all partition + * flat-hash copies */ + +/* Rate-limiting for sLog overflow warnings (per-backend) */ +static int slog_overflow_warning_count = 0; +static TimestampTz slog_overflow_last_warning = 0; + + +/* ---------------------------------------------------------------- + * Backend-private tracking for tuple sLog cleanup + * ---------------------------------------------------------------- + */ +typedef struct SLogTrackedKey +{ + SLogTupleKey key; + TransactionId xid; + TransactionId subxid; + bool local_only; /* no shared hash entry (INSERT-only) */ + SLogOpType op_type; /* DML type (for commit retention decisions) */ + + struct SLogTrackedKey *next; +} SLogTrackedKey; + +static SLogTrackedKey *slog_tracked_keys = NULL; +static bool slog_has_shared_entries = false; /* any non-local_only entries? */ + +/* ---------------------------------------------------------------- + * TID encoding for backend-local INSERT tracking + * + * Each (blkno, offnum) pair maps to a dense 64-bit key used by the + * backend-local INSERT-tracking hash (see below): + * + * blkno * MaxOffsetNumber + (offnum - 1) + * + * Losslessness invariant: this key is in-memory and per-backend only -- + * never written to WAL or disk -- so the divisor need only be strictly + * greater than any real offnum a tracked page can hold. MaxOffsetNumber + * is the maximum number of line pointers any page can hold and is an + * AM-agnostic upper bound: offnum <= MaxOffsetNumber always holds, so the + * divisor never aliases two pages' TIDs onto the same key. A check at each + * encode site enforces this; an out-of-range offset is a corruption bug, + * not a recoverable condition, so it raises ERROR even in production builds. + * ---------------------------------------------------------------- + */ +#define SLOG_ENCODE_TID(blkno, offnum) \ + ((uint64) (blkno) * MaxOffsetNumber + (uint64) ((offnum) - 1)) +#define SLOG_DECODE_BLKNO(encoded) \ + ((BlockNumber) ((encoded) / MaxOffsetNumber)) +#define SLOG_DECODE_OFFNUM(encoded) \ + ((OffsetNumber) ((encoded) % MaxOffsetNumber + 1)) + +/* ---------------------------------------------------------------- + * Backend-local INSERT tracking (simplehash) + * + * Top-level local-only INSERTs are recorded in a single backend-local + * open-addressing hash keyed by (relid, encoded_tid). This replaces the + * former per-relid linked-list-of-sparsemaps, which forced an O(n) chunk + * scan on every per-tuple visibility probe. The hash lives in + * TopTransactionContext and is destroyed per transaction. No locks: it is + * strictly backend-private. + * ---------------------------------------------------------------- + */ +typedef struct SLogInsertTidKey +{ + Oid relid; + uint64 encoded_tid; /* SLOG_ENCODE_TID(blkno, offnum) */ +} SLogInsertTidKey; + +typedef struct SLogInsertTidEntry +{ + SLogInsertTidKey key; /* (relid, encoded_tid) */ + char status; /* required by simplehash */ +} SLogInsertTidEntry; + +static inline uint32 +slog_insert_tid_hash(Oid relid, uint64 encoded_tid) +{ + uint64 h = hash_combine64((uint64) hash_uint32((uint32) relid), + murmurhash64(encoded_tid)); + + return (uint32) (h ^ (h >> 32)); +} + +#define SH_PREFIX sloginsert +#define SH_ELEMENT_TYPE SLogInsertTidEntry +#define SH_KEY_TYPE SLogInsertTidKey +#define SH_KEY key +#define SH_HASH_KEY(tb, key) slog_insert_tid_hash((key).relid, (key).encoded_tid) +#define SH_EQUAL(tb, a, b) ((a).relid == (b).relid && (a).encoded_tid == (b).encoded_tid) +#define SH_SCOPE static inline +#define SH_DEFINE +#define SH_DECLARE +#include "lib/simplehash.h" + +static sloginsert_hash * slog_insert_tids = NULL; + +/* + * Lazily create the backend-local INSERT hash in TopTransactionContext. + * Returns the (now non-NULL) table. + */ +static inline sloginsert_hash * +slog_insert_tids_ensure(void) +{ + if (slog_insert_tids == NULL) + slog_insert_tids = sloginsert_create(TopTransactionContext, 256, NULL); + return slog_insert_tids; +} + +/* ---------------------------------------------------------------- + * Internal helpers + * ---------------------------------------------------------------- + */ + +/* + * Partition accessor helpers for the tuple sLog. + * + * These inline functions encapsulate the key->partition routing so that + * callers don't need to repeat the pattern. + */ +static inline SLogFlatPartition * +SLogGetPartition(const SLogTupleKey *key) +{ + int part = SLogFlatHashPartitionIndex(key); + + return &SLogState->tuple_partitions[part]; +} + +static inline SLogFlatPartition * +SLogGetPartitionByIndex(int part) +{ + Assert(part >= 0 && part < SLogNumPartitions); + return &SLogState->tuple_partitions[part]; +} + +/* + * Convenience macros for partition-routed locking. + * + * Most functions have a local `key` variable of type SLogTupleKey and need + * to route to the correct partition. These macros minimize the diff from + * the old single-lock code. The `fp__` variable is defined in-scope by + * SLOG_PART_READ_BEGIN / SLOG_PART_WRITE_BEGIN. + */ +#define SLOG_PART_WRITER_LOCK(key_ptr) \ + (&SLogGetPartition(key_ptr)->writer_lock.lock) + +/* + * SLogPartApplyOne + * Single-op write helper: acquire writer_lock, one seqlock cycle, one + * apply, release. Covers the many write sites that apply exactly one + * SLogFlatOp to fp->hash. + */ +static inline void +SLogPartApplyOne(SLogFlatPartition *fp, const SLogFlatOp *op) +{ + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + SLogFlatHashApply(fp->hash, op, sizeof(*op)); + LWLockRelease(&fp->writer_lock.lock); +} + +/* ---------------------------------------------------------------- + * Tuple sLog shared-memory sizing and initialization + * + * Called from slog.c's SLogShmemSize/Request/Init so the tuple flat-hash + * partitions share the sLog's shared segment. Keeping the tuple-specific + * shmem code here (rather than in slog.c) keeps slog.c free of any + * flat-hash/partition knowledge. + * ---------------------------------------------------------------- + */ + +/* + * SLogTupleShmemSize + * Additional shared memory needed for the tuple flat-hash partitions. + */ +Size +SLogTupleShmemSize(void) +{ + return SLogFlatHashPartitionedShmemSize(SLogFlatHashCapacity(), MaxBackends); +} + +/* + * SLogTupleShmemRequest + * Register the tuple flat-hash partition block. + */ +void +SLogTupleShmemRequest(void) +{ + /* Compute partition count early so shmem sizing is correct */ + SLogNumPartitions = SLogComputeNumPartitions(); + + ShmemRequestStruct(.name = "sLog Flat Hash Partitions", + .size = SLogFlatHashPartitionedShmemSize( + SLogFlatHashCapacity(), MaxBackends), + .ptr = (void **) &SLogFlatHashBlock, + ); +} + +/* + * SLogTupleShmemInit + * Allocate and initialize the tuple flat-hash partitions. + * + * Invoked from SLogShmemInit() after the shared state struct exists. Runs + * unconditionally (whether or not an AM has opted in) so the two sLog files + * share one segment with no init-ordering dependency; an idle flat hash + * costs only its shared-memory footprint. + */ +void +SLogTupleShmemInit(void) +{ + int total_capacity; + int per_part_cap; + Size per_part_shmem_size; + char *block_ptr; + int part; + + /* ---- Compute and set partition count (once, globally) ---- */ + SLogNumPartitions = SLogComputeNumPartitions(); + SLogState->num_partitions = SLogNumPartitions; + + /* Allocate partition array in shared memory (after SLogState) */ + SLogState->tuple_partitions = (SLogFlatPartition *) + ShmemAlloc(sizeof(SLogFlatPartition) * SLogNumPartitions); + memset(SLogState->tuple_partitions, 0, + sizeof(SLogFlatPartition) * SLogNumPartitions); + + ereport(DEBUG1, + (errmsg("sLog: %d flat hash partitions (slog_num_partitions=%d, CPUs=%d)", + SLogNumPartitions, slog_num_partitions, + slog_num_cpus()))); + + /* ---- Initialize partitioned seqlock flat hashes ---- */ + total_capacity = SLogFlatHashCapacity(); + per_part_cap = total_capacity / SLogNumPartitions; + if (per_part_cap < 64) + per_part_cap = 64; + + per_part_shmem_size = MAXALIGN(SLogFlatHashShmemSize(per_part_cap, + MaxBackends)); + block_ptr = SLogFlatHashBlock; + + for (part = 0; part < SLogNumPartitions; part++) + { + SLogFlatPartition *fp = &SLogState->tuple_partitions[part]; + + /* Initialize per-partition writer lock */ + LWLockInitialize(&fp->writer_lock.lock, LWTRANCHE_SLOG); + + /* Single flat-hash copy carved from the shared block */ + fp->hash = (SLogFlatHash *) block_ptr; + SLogFlatHashInit(fp->hash, per_part_cap); + + block_ptr += per_part_shmem_size; + } +} + +/* ================================================================ + * Tuple sLog functions + * ================================================================ + */ + +/* ---------------------------------------------------------------- + * Emergency eviction + * ---------------------------------------------------------------- + */ + +/* + * SLogTupleEvictCommitted -- evict entries for committed transactions. + * + * Called when the sLog flat hash is full. Scans each partition under its + * writer lock to collect evictable keys, then applies REMOVE_ENTRY ops. + * (This is a cold path, run only when a partition is full; holding the + * writer lock across the scan is simpler than a seqlock retry loop and the + * per-op TransactionIdIsInProgress/DidCommit probes must not run repeatedly.) + * + * Returns the number of entries evicted. + */ +static int +SLogTupleEvictCommitted(void) +{ + SLogTupleKey *keys_to_evict; + int nkeys = 0; + int max_evict = 1024; + int part; + + keys_to_evict = (SLogTupleKey *) + palloc(sizeof(SLogTupleKey) * max_evict); + + /* First pass: scan each partition under its writer lock to collect keys */ + for (part = 0; part < SLogNumPartitions && nkeys < max_evict; part++) + { + SLogFlatPartition *fp = SLogGetPartitionByIndex(part); + SLogFlatHashScanState scan; + const SLogFlatBucket *bucket; + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + + SLogFlatHashScanInit(&scan); + while ((bucket = SLogFlatHashScanNext(fp->hash, &scan)) != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + bool all_committed = true; + bool has_any_op = false; + int i; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (!entry->ops[i].in_use) + continue; + has_any_op = true; + + if (TransactionIdIsInProgress(entry->ops[i].xid) || + !TransactionIdDidCommit(entry->ops[i].xid)) + { + all_committed = false; + break; + } + } + + if (has_any_op && all_committed) + { + keys_to_evict[nkeys++] = bucket->key; + if (nkeys >= max_evict) + break; + } + } + + LWLockRelease(&fp->writer_lock.lock); + } + + /* Second pass: apply removals grouped by partition */ + if (nkeys > 0) + { + int i; + + for (part = 0; part < SLogNumPartitions; part++) + { + SLogFlatPartition *fp = SLogGetPartitionByIndex(part); + bool has_keys_for_part = false; + + /* Check if any keys belong to this partition */ + for (i = 0; i < nkeys; i++) + { + if (SLogFlatHashPartitionIndex(&keys_to_evict[i]) == part) + { + has_keys_for_part = true; + break; + } + } + if (!has_keys_for_part) + continue; + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + + for (i = 0; i < nkeys; i++) + { + SLogFlatOp flat_op; + + if (SLogFlatHashPartitionIndex(&keys_to_evict[i]) != part) + continue; + + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_REMOVE_ENTRY; + flat_op.key = keys_to_evict[i]; + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + } + LWLockRelease(&fp->writer_lock.lock); + } + } + + pfree(keys_to_evict); + + return nkeys; +} + +/* ---------------------------------------------------------------- + * Core Tuple sLog API + * ---------------------------------------------------------------- + */ + +/* + * SLogTupleInsert + * Record a tuple operation in the sLog. + * + * Inserts into the seqlock-protected flat hash (wait-free reads). + * Performs overflow handling before failing. + * + * Also adds the key to the backend-private tracking list for cleanup. + */ +bool +SLogTupleInsert(Oid relid, ItemPointer tid, TransactionId xid, + SLogOpType op_type, TransactionId subxid, + CommandId cid, TimestampTz commit_ts, + uint32 spec_token, LockTupleMode lock_mode) +{ + SLogTupleKey key; + SLogFlatOp flat_op; + SLogFlatPartition *fp; + int entries_before; + int entries_after; + + Assert(SLogState != NULL); + Assert(TransactionIdIsValid(xid)); + Assert(ItemPointerIsValid(tid)); + + /* Zero for deterministic hashing (ItemPointerData is 6 bytes) */ + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + /* + * Retained UPDATE markers are never evicted by the on-overflow path -- + * SLogTupleEvictCommitted() leaves any op whose xid is in-progress or not + * yet committed in CLOG. They are cleaned up by + * SLogTupleCleanupRetained(), driven by the UNDO background worker or, + * when it is disabled, by the access method calling + * SLogTupleMaybeCleanupRetained() outside any buffer-locked section. This + * function never triggers that sweep itself (see note below). + * + * Per-TID ops array overflow (SLOG_MAX_TUPLE_OPS=32 slots all full of + * retained entries on hot rows) is handled by flat_hash_apply_insert() + * which reclaims the oldest retained entry when no free slot exists. + */ + + { + /* Build the flat hash op */ + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_INSERT; + flat_op.key = key; + flat_op.xid = xid; + flat_op.subxid = subxid; + + /* + * The xid reclaim horizon is computed LAZILY, not on every insert. It + * is only consumed by flat_hash_apply_insert to reclaim a slot when a + * hot row's per-TID ops array is completely full; the common path + * (free slot, same-xid overwrite, or a slot freed by coalescing) + * never reads it. Computing it here would run a ProcArrayLock-shared + * xid-horizon scan on every CAS-update -- a measured hot-path cost -- + * for a value used only in the rare full-array case. + * + * So the fast path passes InvalidTransactionId, which now fail-safe + * DISABLES reclamation (see flat_hash_apply_insert): an invalid + * horizon frees NOTHING. This preserves the original safety + * invariant -- a stale or absent horizon must never cause a lost + * update -- in the most conservative direction possible. If the op + * is then dropped because the array was genuinely full, the caller + * (below) recomputes the real, authoritative horizon and retries; + * reclamation is thus attempted with a fresh horizon exactly when, + * and only when, it is actually needed. + */ + flat_op.reclaim_xid_horizon = InvalidTransactionId; + flat_op.tuple_op.xid = xid; + flat_op.tuple_op.subxid = subxid; + flat_op.tuple_op.op_type = op_type; + flat_op.tuple_op.cid = cid; + flat_op.tuple_op.commit_ts = commit_ts; + flat_op.tuple_op.spec_token = spec_token; + flat_op.tuple_op.lock_mode = lock_mode; + flat_op.tuple_op.in_use = true; + } + + /* Apply to flat hash via seqlock writer path (partition-local) */ + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + + /* Check capacity before insert (writer lock held: fp->hash is stable) */ + entries_before = fp->hash->num_entries; + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + + /* Check if insert succeeded */ + entries_after = fp->hash->num_entries; + + LWLockRelease(&fp->writer_lock.lock); + + /* + * Verify the op was actually stored. We must probe for THIS xid's op, + * not merely the bucket: on a hot row the bucket pre-exists (it holds + * other TIDs' / xids' markers), so num_entries is unchanged and the + * bucket is present even when flat_hash_apply_insert silently dropped our + * op because the per-TID ops array was full with nothing reclaimable. + * Testing only bucket presence (the old SLogFlatHashProbe != NULL) + * reports success for a dropped op, so no marker exists to stamp at + * PRE_COMMIT and the next concurrent writer clobbers this update -- a + * lost update. Probe the ops array for our xid instead. + */ + if (entries_after == entries_before) + { + bool op_stored; + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + op_stored = SLogFlatHashHasOpForXid(fp->hash, &key, xid); + LWLockRelease(&fp->writer_lock.lock); + + if (!op_stored) + { + /* + * The op was dropped: the per-TID ops array was full and the fast + * path passed an invalid horizon, which disables reclamation. On + * a hot row the array is full of THIS relation's own + * below-horizon UPDATE markers, which the reclaim path can free + * WITHOUT the cross-partition SLogTupleEvictCommitted() sweep. So + * recompute the real, authoritative horizon now (the + * ProcArrayLock scan we skipped on the fast path) and retry: + * flat_hash_apply_insert can now reclaim a settled marker and + * store our op in place. + */ + TransactionId horizon = GetOldestNonRemovableTransactionId(NULL); + + flat_op.reclaim_xid_horizon = horizon; + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + op_stored = SLogFlatHashHasOpForXid(fp->hash, &key, xid); + LWLockRelease(&fp->writer_lock.lock); + } + + if (!op_stored) + { + /* Table or per-TID array was full; try eviction */ + int evicted = SLogTupleEvictCommitted(); + + if (evicted > 0) + { + /* Retry */ + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + op_stored = SLogFlatHashHasOpForXid(fp->hash, &key, xid); + LWLockRelease(&fp->writer_lock.lock); + } + + if (!op_stored) + { + slog_overflow_warning_count++; + { + TimestampTz now = GetCurrentTimestamp(); + + if (slog_overflow_warning_count == 1 || + TimestampDifferenceExceeds(slog_overflow_last_warning, + now, 1000)) + { + elog(WARNING, "sLog tuple hash partition full " + "(%d entries); %d overflow(s) this transaction " + "on rel %u (visibility relies on UNCOMMITTED " + "flag + UNDO replay)", + SLogTupleNumEntries() / SLogNumPartitions, + slog_overflow_warning_count, relid); + slog_overflow_last_warning = now; + } + } + + SLogTupleTrackLocalOnly(relid, tid, xid, subxid); + return false; + } + } + } + + SLogTupleTrackKey(key, xid, subxid, op_type); + return true; +} + +/* + * SLogTupleInsertRecovery + * Record a tuple operation during WAL replay (recovery-safe). + * + * This is a simplified variant of SLogTupleInsert() designed for use during + * WAL redo on hot standbys. Key differences: + * - Does NOT call SLogTupleTrackKey() (no backend-local tracking needed) + * - Returns false silently if the hash is full (instead of ERROR/PANIC) + * - No retries with pg_usleep (would delay WAL replay) + * + * Used by an AM's WAL redo path to register UNCOMMITTED tuples in the + * per-tuple sLog so that the AM's visibility check can correctly determine + * visibility on standbys (where the sLog is otherwise never populated). + */ +bool +SLogTupleInsertRecovery(Oid relid, ItemPointer tid, TransactionId xid, + SLogOpType op_type) +{ + SLogTupleKey key; + SLogFlatOp clear_op; + SLogFlatOp flat_op; + + if (SLogState == NULL) + return false; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + /* + * Clear any pre-existing entry at this exact TID before registering the + * new op. A physical INSERT always targets a free line pointer, so any + * sLog entry already present at this TID belongs to a dead prior occupant + * (deleted and VACUUM-recycled). On the primary the prior occupant's + * sLog entry is removed by its commit/abort xact callback, but the + * standby redo path only ever inserts -- it never removes -- so without + * this the stale entry survives. flat_hash_apply_insert() only + * overwrites a same-xid op, so a stale op from the prior occupant's + * (different) xid would persist and make the AM's visibility check trip + * on TransactionIdDidAbort()/IsInProgress() for that dead xid, wrongly + * hiding the freshly inserted live tuple. + * + * Tombstoning the entry is safe and self-healing: + * flat_hash_apply_insert() reuses tombstoned buckets, so the immediately + * following INSERT recreates a clean entry holding only this insert's op. + */ + memset(&clear_op, 0, sizeof(clear_op)); + clear_op.kind = SLOG_FLAT_OP_REMOVE_ENTRY; + clear_op.key = key; + + /* Apply to flat hash */ + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_INSERT; + flat_op.key = key; + flat_op.xid = xid; + flat_op.tuple_op.xid = xid; + flat_op.tuple_op.subxid = InvalidTransactionId; + flat_op.tuple_op.op_type = op_type; + flat_op.tuple_op.cid = InvalidCommandId; + flat_op.tuple_op.commit_ts = 0; + flat_op.tuple_op.spec_token = 0; + flat_op.tuple_op.in_use = true; + + LWLockAcquire(SLOG_PART_WRITER_LOCK(&key), LW_EXCLUSIVE); + SLogFlatHashApply(SLogGetPartition(&key)->hash, &clear_op, sizeof(clear_op)); + SLogFlatHashApply(SLogGetPartition(&key)->hash, &flat_op, sizeof(flat_op)); + LWLockRelease(SLOG_PART_WRITER_LOCK(&key)); + + return true; +} + +/* + * Restore a prepared row lock without replacing other holders at this TID. + * Unlike physical INSERT replay, the tuple has not acquired a new occupant. + * Recovery must fail rather than silently lose a lock if the sLog is full. + */ +void +SLogTupleRestoreLock(Oid relid, ItemPointer tid, TransactionId xid, + LockTupleMode mode) +{ + SLogFlatOp op = {0}; + SLogFlatPartition *fp; + bool stored; + + op.kind = SLOG_FLAT_OP_INSERT; + op.key.relid = relid; + ItemPointerCopy(tid, &op.key.tid); + op.xid = xid; + op.tuple_op.xid = xid; + op.tuple_op.op_type = mode <= LockTupleShare ? + SLOG_OP_LOCK_SHARE : SLOG_OP_LOCK_EXCL; + op.tuple_op.cid = InvalidCommandId; + op.tuple_op.lock_mode = mode; + op.tuple_op.in_use = true; + fp = SLogGetPartition(&op.key); + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + SLogFlatHashApply(fp->hash, &op, sizeof(op)); + stored = SLogFlatHashHasOpForXid(fp->hash, &op.key, xid); + LWLockRelease(&fp->writer_lock.lock); + + if (!stored) + ereport(ERROR, + (errcode(ERRCODE_OUT_OF_MEMORY), + errmsg("could not restore prepared row lock in sLog"))); +} + +/* + * SLogTupleLookup + * Look up a tuple's sLog entry (copy semantics). + * + * Returns true if found, copying the full entry into *entry_out. + * WAIT-FREE: uses the seqlock read-side retry loop. + */ +bool +SLogTupleLookup(Oid relid, ItemPointer tid, SLogTupleEntry *entry_out) +{ + SLogTupleKey key; + SLogFlatPartition *fp; + bool found; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + SLOG_SEQ_READ_BEGIN(fp) + { + const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + + found = (bucket != NULL); + if (found && entry_out) + memcpy(entry_out, &bucket->entry, sizeof(SLogTupleEntry)); + } + SLOG_SEQ_READ_END(fp); + + return found; +} + +/* + * SLogTupleLookupFiltered + * Find sLog entries for a TID, optionally filtered by xid. + * + * WAIT-FREE: uses the seqlock read-side. If xid_filter is valid, returns + * only ops for that xid. If InvalidTransactionId, returns all active + * ops for this TID. + * + * Returns the number of ops written to ops_out. + */ +int +SLogTupleLookupFiltered(Oid relid, ItemPointer tid, + TransactionId xid_filter, + SLogTupleOp *ops_out, int max_ops) +{ + SLogTupleKey key; + SLogFlatPartition *fp; + int nfound = 0; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + SLOG_SEQ_READ_BEGIN(fp) + { + const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + + if (bucket != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + int i; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS && nfound < max_ops; i++) + { + if (!entry->ops[i].in_use) + continue; + + if (TransactionIdIsValid(xid_filter) && + !TransactionIdEquals(entry->ops[i].xid, xid_filter)) + continue; + + memcpy(&ops_out[nfound], &entry->ops[i], sizeof(SLogTupleOp)); + nfound++; + } + } + } + SLOG_SEQ_READ_END(fp); + + return nfound; +} + +/* + * SLogTupleRemove + * Remove operations for a specific xid from a tuple entry. + * + * Uses the seqlock writer path with external (writer_lock) serialization. + */ +void +SLogTupleRemove(Oid relid, ItemPointer tid, TransactionId xid) +{ + SLogTupleKey key; + SLogFlatOp op; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + memset(&op, 0, sizeof(op)); + op.kind = SLOG_FLAT_OP_REMOVE_XID; + op.key = key; + op.xid = xid; + + SLogPartApplyOne(SLogGetPartition(&key), &op); +} + +/* + * Width of the touched-partition bitmap, in uint64 words. Sized to cover + * SLOG_FLAT_MAX_PARTITIONS so the bitmap is a fixed on-stack array regardless + * of the runtime partition count. + */ +#define SLOG_PART_BITMAP_WORDS ((SLOG_FLAT_MAX_PARTITIONS + 63) / 64) + +/* + * SLogCollectTrackedPartitions + * Build a bitmap of the partitions touched by xid's shared tracked keys. + * + * Walks slog_tracked_keys once, setting one bit per distinct partition that + * holds a non-local_only entry for xid. Callers then iterate only the set + * partitions instead of sweeping all SLogNumPartitions, turning the batch + * apply from O(num_partitions * tracked_keys) into + * O(touched_partitions * tracked_keys) -- optimal for the common single-row, + * single-partition transaction. + * + * Returns the number of distinct partitions marked. + */ +static int +SLogCollectTrackedPartitions(TransactionId xid, + uint64 *bitmap) +{ + SLogTrackedKey *tk; + int nparts = 0; + + memset(bitmap, 0, sizeof(uint64) * SLOG_PART_BITMAP_WORDS); + + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + int part; + uint64 mask; + + if (!TransactionIdEquals(tk->xid, xid) || tk->local_only) + continue; + + part = SLogFlatHashPartitionIndex(&tk->key); + mask = UINT64CONST(1) << (part & 63); + if (!(bitmap[part >> 6] & mask)) + { + bitmap[part >> 6] |= mask; + nparts++; + } + } + + return nparts; +} + +/* + * SLogTupleRemoveByXid + * Remove all tuple sLog entries for a transaction. + * + * Uses the backend-local tracking list. Applies REMOVE_XID ops to the + * flat hash in a single writer batch. + */ +void +SLogTupleRemoveByXid(TransactionId xid) +{ + SLogTrackedKey *tk; + uint64 touched[SLOG_PART_BITMAP_WORDS] = {0}; + int part; + + if (SLogState == NULL) + return; + + /* + * Collect the partitions xid actually touches in one pass. Walking only + * those avoids an O(num_partitions * tracked_keys) sweep over every + * partition for what is usually a single-row transaction. + */ + if (SLogCollectTrackedPartitions(xid, touched) == 0) + return; + + /* Batch apply REMOVE_XID ops grouped by partition */ + for (part = 0; part < SLogNumPartitions; part++) + { + SLogFlatPartition *fp; + + if (!(touched[part >> 6] & (UINT64CONST(1) << (part & 63)))) + continue; + + fp = SLogGetPartitionByIndex(part); + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + SLogFlatOp flat_op; + + if (!TransactionIdEquals(tk->xid, xid) || tk->local_only) + continue; + if (SLogFlatHashPartitionIndex(&tk->key) != part) + continue; + + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_REMOVE_XID; + flat_op.key = tk->key; + flat_op.xid = xid; + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + } + LWLockRelease(&fp->writer_lock.lock); + } +} + +/* + * SLogTupleCommitByXid + * Handle commit for tuple sLog: remove ALL of the committing xid's ops + * (INSERT/DELETE/LOCK and UPDATE alike). + * + * Committed-UPDATE markers are NOT retained on the flat hash. + * Snapshot-isolation readers reconstruct the visible version by walking the + * durable UNDO log chain (via the AM's version-reconstruction walk), so at + * commit every op of the xid is removed -- identical to INSERT/DELETE/LOCK -- + * which also drains bucket table_full pressure. + * + * Uses the backend-local tracking list. Applies COMMIT_XID ops to the + * flat hash in batch. + */ +void +SLogTupleCommitByXid(TransactionId xid) +{ + SLogTrackedKey *tk; + uint64 touched[SLOG_PART_BITMAP_WORDS] = {0}; + + if (SLogState == NULL) + return; + + /* Fast path: INSERT-only transactions never touch the shared hash */ + if (!slog_has_shared_entries) + return; + + /* + * Collect the partitions xid touches in one pass; the COMMIT_XID batch + * below iterates only those rather than sweeping all SLogNumPartitions. + */ + if (SLogCollectTrackedPartitions(xid, touched) == 0) + return; + + /* + * Committed-UPDATE cross-backend before-images are not published to + * shared DSA, and committed-UPDATE markers are not retained on the flat + * hash. Snapshot-isolation readers reconstruct the visible version by + * walking the durable UNDO log chain (via the AM's version-reconstruction + * walk); the write-write conflict probe reads the head verptr on the + * on-page tuple and resolves it in the same chain. + * + * flat_hash_apply_commit_xid therefore removes every op of xid at commit + * -- identical to INSERT/DELETE/LOCK -- which drains bucket table_full. + */ + + /* Batch apply COMMIT_XID ops grouped by partition */ + { + int part; + + for (part = 0; part < SLogNumPartitions; part++) + { + SLogFlatPartition *fp; + + if (!(touched[part >> 6] & (UINT64CONST(1) << (part & 63)))) + continue; + + fp = SLogGetPartitionByIndex(part); + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + SLogFlatOp flat_op; + + if (!TransactionIdEquals(tk->xid, xid) || tk->local_only) + continue; + if (SLogFlatHashPartitionIndex(&tk->key) != part) + continue; + + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_COMMIT_XID; + flat_op.key = tk->key; + flat_op.xid = xid; + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + } + LWLockRelease(&fp->writer_lock.lock); + } + } +} + +/* + * SLogTupleRemoveByXidSingle + * Remove the sLog entry for a single tuple identified by (relid, tid, xid). + * + * Used by an AM's two-phase postcommit callback to clean up sLog entries + * one at a time (since the local tracking list is unavailable in the + * resolving backend). + */ +void +SLogTupleRemoveByXidSingle(Oid relid, ItemPointer tid, TransactionId xid) +{ + SLogTupleKey key; + SLogFlatOp flat_op; + + if (SLogState == NULL) + return; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + /* Apply to flat hash */ + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_REMOVE_XID; + flat_op.key = key; + flat_op.xid = xid; + + SLogPartApplyOne(SLogGetPartition(&key), &flat_op); +} + +/* + * SLogTupleMarkAbortedSingle + * Mark the sLog entry for a single tuple as ABORTED. + * + * Used by an AM's two-phase postabort callback to mark sLog entries + * one at a time (since the local tracking list is unavailable in the + * resolving backend). Only operates on tuples that already have a shared + * sLog entry (DELETE/UPDATE operations). + */ +void +SLogTupleMarkAbortedSingle(Oid relid, ItemPointer tid, TransactionId xid) +{ + SLogTupleKey key; + SLogFlatOp flat_op; + + if (SLogState == NULL) + return; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + /* Apply to flat hash */ + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_MARK_ABORTED; + flat_op.key = key; + flat_op.xid = xid; + + SLogPartApplyOne(SLogGetPartition(&key), &flat_op); +} + +/* + * SLogTupleRemoveBySubXid + * Handle subtransaction abort for tuple sLog. + * + * For entries that have a shared sLog entry, marks matching ops as ABORTED. + * For entries that only have local tracking (from SLogTupleTrackLocalOnly, + * used by INSERT), creates a shared ABORTED entry so visibility code can + * find it. + */ +void +SLogTupleRemoveBySubXid(TransactionId xid, TransactionId subxid) +{ + SLogTrackedKey *tk; + + if (SLogState == NULL) + return; + + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + SLogFlatOp flat_op; + + if (!TransactionIdEquals(tk->xid, xid)) + continue; + if (tk->subxid != subxid) + continue; + + memset(&flat_op, 0, sizeof(flat_op)); + + if (tk->local_only) + { + /* + * INSERT elision: no shared entry exists yet. Create one with + * SLOG_OP_ABORTED so visibility code can find it. + */ + flat_op.kind = SLOG_FLAT_OP_CREATE_ABORTED; + flat_op.key = tk->key; + flat_op.xid = xid; + flat_op.subxid = subxid; + } + else + { + /* Shared entry exists -- mark matching ops ABORTED */ + flat_op.kind = SLOG_FLAT_OP_MARK_ABORTED; + flat_op.key = tk->key; + flat_op.xid = xid; + } + + LWLockAcquire(SLOG_PART_WRITER_LOCK(&tk->key), LW_EXCLUSIVE); + SLogFlatHashApply(SLogGetPartition(&tk->key)->hash, &flat_op, + sizeof(flat_op)); + LWLockRelease(SLOG_PART_WRITER_LOCK(&tk->key)); + + /* + * Mark the backend-local tracked key as aborted so the AM's + * commit-time flag clearing skips it. Without this, a local-only + * INSERT tracked key still reads as a live INSERT at top-level + * commit, and the AM's uncommitted-flag clearing would clear the + * tuple's UNCOMMITTED flag and stamp a commit marker -- resurrecting + * a tuple that the savepoint rollback was supposed to discard. + */ + tk->op_type = SLOG_OP_ABORTED; + } +} + +/* + * SLogTupleUpdateSubXid + * Re-parent ops on subtransaction commit. + * + * When a subtransaction commits, its entries' subxid is updated to the + * parent's subxid so they survive subtransaction commit but are cleaned + * up at top-level commit. + */ +void +SLogTupleUpdateSubXid(TransactionId xid, + TransactionId old_subxid, + TransactionId new_subxid) +{ + SLogTrackedKey *tk; + + if (SLogState == NULL) + return; + + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (!TransactionIdEquals(tk->xid, xid)) + continue; + if (tk->subxid != old_subxid) + continue; + + /* Re-parent the local entry */ + tk->subxid = new_subxid; + + /* Also re-parent in the shared sLog if an entry exists */ + if (!tk->local_only) + { + SLogFlatOp flat_op; + + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_UPDATE_OP; + flat_op.key = tk->key; + flat_op.xid = xid; + flat_op.subxid = new_subxid; + flat_op.tuple_op.subxid = old_subxid; + + LWLockAcquire(SLOG_PART_WRITER_LOCK(&tk->key), LW_EXCLUSIVE); + SLogFlatHashApply(SLogGetPartition(&tk->key)->hash, &flat_op, + sizeof(flat_op)); + LWLockRelease(SLOG_PART_WRITER_LOCK(&tk->key)); + } + } +} + +/* + * SLogTupleMarkAborted + * Mark all ops for a transaction as SLOG_OP_ABORTED. + * + * Called at transaction abort. Entries remain so visibility checks can + * distinguish "committed (no entry)" from "aborted (UNDO pending)". + * + * For local-only entries (INSERT elision), we CREATE a shared ABORTED + * entry at abort time. This is safe because: + * (a) Abort is uncommon (vast majority of transactions commit) + * (b) The UNDO worker removes both the sLog entry and the page tuple, + * bounding the lifetime of these entries + * (c) If the hash is full, we log a warning; the UNDO worker will + * eventually remove the tuple physically, resolving the anomaly + */ +void +SLogTupleMarkAborted(TransactionId xid) +{ + SLogTrackedKey *tk; + int part; + int ops_since_publish; + + if (SLogState == NULL) + return; + + /* + * Process all entries grouped by partition. For each partition, acquire + * its writer lock once, apply all relevant ops, then release. + */ + for (part = 0; part < SLogNumPartitions; part++) + { + SLogFlatPartition *fp = SLogGetPartitionByIndex(part); + bool has_entries = false; + + /* Quick check: any local-only INSERT entries at all? */ + if (slog_insert_tids != NULL && slog_insert_tids->members > 0) + has_entries = true; /* conservative; filtered per-entry below */ + + /* Check linked-list entries */ + if (!has_entries) + { + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (!TransactionIdEquals(tk->xid, xid)) + continue; + if (SLogFlatHashPartitionIndex(&tk->key) == part) + { + has_entries = true; + break; + } + } + } + + if (!has_entries && + (slog_insert_tids == NULL || slog_insert_tids->members == 0)) + continue; + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + + /* + * Bound the seqlock odd-hold window on a very large rollback + * (hundreds of thousands of tuples). While seq is odd every + * wait-free reader of this partition spins; ending and re-beginning + * the seqlock every SLOG_ABORT_PUBLISH_BATCH ops lets those readers + * make progress. Correctness across the boundary: each tuple's abort + * visibility is resolved independently and each seq cycle leaves + * fp->hash consistent, so a reader that observes the even counter at + * a boundary sees a valid partial state. + */ + ops_since_publish = 0; + + /* Process backend-local INSERT-hash entries for this partition */ + if (slog_insert_tids != NULL) + { + sloginsert_iterator it; + SLogInsertTidEntry *ie; + + sloginsert_start_iterate(slog_insert_tids, &it); + while ((ie = sloginsert_iterate(slog_insert_tids, &it)) != NULL) + { + SLogFlatOp flat_op; + SLogTupleKey smkey; + + memset(&smkey, 0, sizeof(smkey)); + smkey.relid = ie->key.relid; + ItemPointerSet(&smkey.tid, + SLOG_DECODE_BLKNO(ie->key.encoded_tid), + SLOG_DECODE_OFFNUM(ie->key.encoded_tid)); + + /* Only process if this key belongs to current partition */ + if (SLogFlatHashPartitionIndex(&smkey) == part) + { + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_CREATE_ABORTED; + flat_op.key = smkey; + flat_op.xid = xid; + flat_op.subxid = InvalidTransactionId; + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + + if (++ops_since_publish >= SLOG_ABORT_PUBLISH_BATCH) + { + ops_since_publish = 0; + } + } + } + } + + /* Process linked-list entries for this partition */ + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + SLogFlatOp flat_op; + + if (!TransactionIdEquals(tk->xid, xid)) + continue; + if (SLogFlatHashPartitionIndex(&tk->key) != part) + continue; + + memset(&flat_op, 0, sizeof(flat_op)); + if (tk->local_only) + { + flat_op.kind = SLOG_FLAT_OP_CREATE_ABORTED; + flat_op.key = tk->key; + flat_op.xid = xid; + flat_op.subxid = tk->subxid; + } + else + { + flat_op.kind = SLOG_FLAT_OP_MARK_ABORTED; + flat_op.key = tk->key; + flat_op.xid = xid; + } + + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + + if (++ops_since_publish >= SLOG_ABORT_PUBLISH_BATCH) + { + ops_since_publish = 0; + } + } + + LWLockRelease(&fp->writer_lock.lock); + } +} + +/* + * SLogTupleRemoveByXidGlobal + * Remove ALL ops for a transaction by scanning the shared flat hash. + * + * Unlike SLogTupleRemoveByXid, this does not use the backend-local tracking + * list (which doesn't exist in the UNDO worker process). Used by the UNDO + * worker to clean up ABORTED entries after UNDO has been applied. + */ +void +SLogTupleRemoveByXidGlobal(TransactionId xid) +{ + SLogTupleKey *collected_keys; + int max_keys; + int part; + + if (SLogState == NULL) + return; + + max_keys = SLogTupleNumEntries(); + if (max_keys <= 0) + return; + + collected_keys = (SLogTupleKey *) + palloc(sizeof(SLogTupleKey) * max_keys); + + /* + * Process each partition while holding its exclusive writer lock. The + * writer lock serializes all writers for the partition, so the single + * copy is stable while we scan it. We apply REMOVE_XID inside one + * seqlock cycle. + */ + for (part = 0; part < SLogNumPartitions; part++) + { + SLogFlatPartition *fp = SLogGetPartitionByIndex(part); + SLogFlatHashScanState scan; + const SLogFlatBucket *bucket; + int nkeys = 0; + int i; + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + + /* Scan the single copy; stable because we hold the writer lock. */ + SLogFlatHashScanInit(&scan); + while ((bucket = SLogFlatHashScanNext(fp->hash, &scan)) != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (entry->ops[i].in_use && + TransactionIdEquals(entry->ops[i].xid, xid)) + { + if (nkeys < max_keys) + collected_keys[nkeys++] = bucket->key; + break; + } + } + } + + /* Drop the slots in one seq cycle. */ + if (nkeys > 0) + { + for (i = 0; i < nkeys; i++) + { + SLogFlatOp flat_op; + + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_REMOVE_XID; + flat_op.key = collected_keys[i]; + flat_op.xid = xid; + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + } + } + + LWLockRelease(&fp->writer_lock.lock); + } + + pfree(collected_keys); +} + +/* + * SLogTupleIterateByTid + * Call a callback for each active operation on a tuple. + * + * WAIT-FREE: uses the seqlock read-side. The in-use ops are copied into a + * local array under the seqlock retry loop; the callback then runs after a + * consistent read, so it may have side effects and receives pointers into + * the local copy (valid for the duration of this call). + */ +void +SLogTupleIterateByTid(Oid relid, ItemPointer tid, + SLogTupleIterCallback callback, void *arg) +{ + SLogTupleKey key; + SLogFlatPartition *fp; + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nops = 0; + int i; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + SLOG_SEQ_READ_BEGIN(fp) + { + const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + + if (bucket != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + if (entry->ops[i].in_use) + ops[nops++] = entry->ops[i]; + } + } + SLOG_SEQ_READ_END(fp); + + /* Consistent read complete; run the (side-effecting) callback. */ + for (i = 0; i < nops; i++) + if (!callback(&ops[i], arg)) + break; +} + +/* ---------------------------------------------------------------- + * Convenience wrappers + * ---------------------------------------------------------------- + */ + +/* + * SLogTupleHasEntry -- quick probe: does ANY active entry exist for this TID? + * WAIT-FREE: uses the seqlock read-side. + */ +bool +SLogTupleHasEntry(Oid relid, ItemPointer tid) +{ + SLogTupleKey key; + SLogFlatPartition *fp; + bool has_entry = false; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + SLOG_SEQ_READ_BEGIN(fp) + { + const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + + has_entry = (bucket != NULL && bucket->entry.nops > 0); + } + SLOG_SEQ_READ_END(fp); + + return has_entry; +} + +/* + * SLogTupleIsInsertedByMe -- check if current transaction inserted this tuple. + * + * Checks both the shared hash (normal case) and the backend-local tracking + * list (for local-only INSERTs that have no shared entry). + * + * Uses the top-level XID because SLogTupleTrackLocalOnly() always stores + * GetTopTransactionId(). This ensures correct results even when called + * from within a subtransaction (savepoint). + */ +bool +SLogTupleIsInsertedByMe(Oid relid, ItemPointer tid) +{ + SLogTupleOp op; + int nfound; + SLogTrackedKey *tk; + TransactionId myxid = GetTopTransactionIdIfAny(); + + if (!TransactionIdIsValid(myxid)) + return false; + + /* + * Special-marker TIDs (SpecTokenOffsetNumber 0xFFFE for speculative + * insertions, MovedPartitionsOffsetNumber 0xFFFD for cross-partition + * moves) can appear in a tuple's on-page t_ctid. All sLog entries are + * keyed by an on-page (block, offnum) with offnum <= MaxOffsetNumber, so + * this backend cannot have "inserted" the tuple under such a key. Bail + * out early to avoid tripping the encode-guard elog(ERROR) below. + */ + if (ItemPointerGetOffsetNumber(tid) > MaxOffsetNumber) + return false; + + /* Check shared hash first (normal case) */ + nfound = SLogTupleLookupFiltered(relid, tid, myxid, &op, 1); + if (nfound > 0 && op.op_type == SLOG_OP_INSERT) + return true; + + /* + * Check the backend-local INSERT hash (top-level local-only INSERTs). + */ + { + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + SLogInsertTidKey ikey; + + if (offnum > MaxOffsetNumber) + elog(ERROR, "offset %u exceeds page item limit %d in sLog TID encoding", + offnum, MaxOffsetNumber); + ikey.relid = relid; + ikey.encoded_tid = SLOG_ENCODE_TID(blkno, offnum); + if (slog_insert_tids != NULL && + sloginsert_lookup(slog_insert_tids, ikey) != NULL) + return true; + } + + /* + * Fall back to backend-local tracking list. This handles the overflow + * case where SLogTupleInsert returned false (hash full) but the INSERT + * was tracked locally via SLogTupleTrackLocalOnly or SLogTupleTrackKey. + * Also handles subtransaction local-only entries (which still use the + * linked list). + */ + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (!TransactionIdEquals(tk->xid, myxid)) + continue; + if (tk->key.relid != relid) + continue; + if (ItemPointerEquals(&tk->key.tid, tid)) + return true; + } + + return false; +} + +/* + * SLogTupleIsDeletedByMe -- check if current transaction deleted this tuple. + */ +bool +SLogTupleIsDeletedByMe(Oid relid, ItemPointer tid) +{ + SLogTupleOp op; + int nfound; + TransactionId myxid = GetCurrentTransactionIdIfAny(); + + if (!TransactionIdIsValid(myxid)) + return false; + + nfound = SLogTupleLookupFiltered(relid, tid, myxid, &op, 1); + return (nfound > 0 && + (op.op_type == SLOG_OP_DELETE || + op.op_type == SLOG_OP_UPDATE)); +} + +/* + * SLogTupleGetDirtyXid -- for SNAPSHOT_DIRTY, get the xid of the in-progress + * transaction operating on this tuple. + * + * Returns the xid of the first in-progress INSERT or DELETE/UPDATE entry + * found (excluding our own), or InvalidTransactionId if none. + * + * WAIT-FREE: uses the seqlock read-side. The ops are snapshotted into a + * local array inside the retry loop; the (LWLock-taking) transaction-status + * checks then run after a consistent read, never inside the retry loop. + */ +TransactionId +SLogTupleGetDirtyXid(Oid relid, ItemPointer tid, bool *is_insert) +{ + SLogTupleKey key; + SLogFlatPartition *fp; + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nops = 0; + TransactionId result = InvalidTransactionId; + int i; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + SLOG_SEQ_READ_BEGIN(fp) + { + const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + + if (bucket != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + if (entry->ops[i].in_use) + ops[nops++] = entry->ops[i]; + } + } + SLOG_SEQ_READ_END(fp); + + /* Consistent snapshot taken; resolve status outside the shared lock. */ + for (i = 0; i < nops; i++) + { + TransactionId xid = ops[i].xid; + SLogOpType op = ops[i].op_type; + + if (TransactionIdIsCurrentTransactionId(xid)) + continue; + if (!TransactionIdIsInProgress(xid)) + continue; + + if (is_insert) + *is_insert = (op == SLOG_OP_INSERT); + result = xid; + break; + } + + return result; +} + +/* + * SLogTupleGetDirtyWriterXid -- like SLogTupleGetDirtyXid, but returns only + * the xid of an in-progress *writer* (INSERT/UPDATE/DELETE), ignoring + * lock-only markers (LOCK_SHARE/LOCK_EXCL) and aborted markers. + * + * Used at the write-wait decision in the UPDATE/DELETE paths. An updater + * already holds the heavyweight LOCKTAG_TUPLE lock (LockTupleNoKeyExclusive -> + * ExclusiveLock), which serializes against conflicting lockers via the + * standard lock matrix: a key-share locker (AccessShareLock) is compatible and + * does not block, while a share/exclusive locker conflicts and blocks the + * updater on the lock manager. XactLockTableWait must therefore fire only for + * an actual in-progress writer -- never for a pure locker. Waiting on a + * locker's xid here while that locker is queued behind us for the same tuple + * ExclusiveLock manufactures a deadlock cycle that heap avoids via + * HEAP_XMAX_IS_LOCKED_ONLY. + * + * WAIT-FREE: uses the seqlock read-side, identical to SLogTupleGetDirtyXid. + */ +TransactionId +SLogTupleGetDirtyWriterXid(Oid relid, ItemPointer tid, bool *is_insert) +{ + SLogTupleKey key; + SLogFlatPartition *fp; + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nops = 0; + TransactionId result = InvalidTransactionId; + int i; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + SLOG_SEQ_READ_BEGIN(fp) + { + const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + + if (bucket != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + if (entry->ops[i].in_use) + ops[nops++] = entry->ops[i]; + } + } + SLOG_SEQ_READ_END(fp); + + /* Consistent snapshot taken; resolve status outside the shared lock. */ + for (i = 0; i < nops; i++) + { + TransactionId xid; + SLogOpType op = ops[i].op_type; + + /* Only real writers block another writer. */ + if (op != SLOG_OP_INSERT && + op != SLOG_OP_UPDATE && + op != SLOG_OP_DELETE) + continue; + + xid = ops[i].xid; + + if (TransactionIdIsCurrentTransactionId(xid)) + continue; + if (!TransactionIdIsInProgress(xid)) + continue; + + if (is_insert) + *is_insert = (op == SLOG_OP_INSERT); + result = xid; + break; + } + + return result; +} + +/* + * slog_tuplock_to_lockmode -- map a LockTupleMode to its heavyweight LOCKMODE. + * + * The four tuple-lock strengths MUST map to four distinct LOCKMODEs so the + * real conflict matrix (DoLockModesConflict) can distinguish a compatible + * KeyShare FK locker from a conflicting Share/Exclusive locker. + */ +static LOCKMODE +slog_tuplock_to_lockmode(LockTupleMode mode) +{ + switch (mode) + { + case LockTupleKeyShare: + return AccessShareLock; + case LockTupleShare: + return RowShareLock; + case LockTupleNoKeyExclusive: + return ExclusiveLock; + case LockTupleExclusive: + return AccessExclusiveLock; + } + elog(ERROR, "invalid tuple lock mode: %d", (int) mode); + return NoLock; /* keep compiler quiet */ +} + +/* + * SLogTupleGetWriteConflictXid -- find an in-progress transaction whose marker + * conflicts with a writer (UPDATE/DELETE) acquiring tuple lock my_mode. + * + * Unlike SLogTupleGetDirtyWriterXid (which only ever reports writers and + * silently ignores lock-only markers), this also reports a *locker* whose + * recorded LockTupleMode conflicts with my_mode under the standard heavyweight + * matrix. That is required for correctness: a SELECT ... FOR UPDATE locker + * leaves only a LOCK_EXCL marker (no on-page writer state), and an updater that + * consults a writer-only probe sails past it and clobbers the row the locker is + * protecting. The four-way mapping keeps a KeyShare FK locker (AccessShareLock) + * compatible with a NoKeyExclusive UPDATE (ExclusiveLock) while making FOR SHARE + * (RowShareLock) and FOR UPDATE (AccessExclusiveLock) correctly block it. + * + * Writers take priority over lockers in the returned xid so the caller's + * is_insert handling (TM_Invisible for an in-progress INSERT) is preserved; a + * conflicting locker is returned only when no in-progress writer exists. + * + * Returns the conflicting xid, or InvalidTransactionId if none. *is_insert is + * set true only when the returned xid is an in-progress INSERT writer. + * + * WAIT-FREE: uses the seqlock read-side, identical to SLogTupleGetDirtyWriterXid. + */ +TransactionId +SLogTupleGetWriteConflictXid(Oid relid, ItemPointer tid, + LockTupleMode my_mode, bool *is_insert) +{ + SLogTupleKey key; + SLogFlatPartition *fp; + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nops = 0; + TransactionId writer_xid = InvalidTransactionId; + bool writer_is_insert = false; + TransactionId locker_xid = InvalidTransactionId; + LOCKMODE my_lockmode = slog_tuplock_to_lockmode(my_mode); + int i; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + SLOG_SEQ_READ_BEGIN(fp) + { + const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + + if (bucket != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + if (entry->ops[i].in_use) + ops[nops++] = entry->ops[i]; + } + } + SLOG_SEQ_READ_END(fp); + + /* Consistent snapshot taken; resolve status outside the shared lock. */ + for (i = 0; i < nops; i++) + { + TransactionId xid; + SLogOpType op = ops[i].op_type; + + xid = ops[i].xid; + + if (TransactionIdIsCurrentTransactionId(xid)) + continue; + if (!TransactionIdIsInProgress(xid)) + continue; + + if (op == SLOG_OP_INSERT || + op == SLOG_OP_UPDATE || + op == SLOG_OP_DELETE) + { + /* A real writer: highest priority, stop scanning. */ + writer_xid = xid; + writer_is_insert = (op == SLOG_OP_INSERT); + break; + } + + if (op == SLOG_OP_LOCK_SHARE || op == SLOG_OP_LOCK_EXCL) + { + LOCKMODE locker_lockmode = + slog_tuplock_to_lockmode(ops[i].lock_mode); + + if (DoLockModesConflict(my_lockmode, locker_lockmode)) + locker_xid = xid; /* candidate; keep seeking a writer */ + } + } + + if (TransactionIdIsValid(writer_xid)) + { + if (is_insert) + *is_insert = writer_is_insert; + return writer_xid; + } + + if (is_insert) + *is_insert = false; + return locker_xid; +} + +/* + * SLogTupleHasLockConflict -- check if any active lock entries on this TID + * conflict with the requested lock mode. + */ +bool +SLogTupleHasLockConflict(Oid relid, ItemPointer tid, + TransactionId my_xid, + SLogOpType requested_lock) +{ + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nfound; + int i; + + nfound = SLogTupleLookupFiltered(relid, tid, InvalidTransactionId, + ops, SLOG_MAX_TUPLE_OPS); + + for (i = 0; i < nfound; i++) + { + if (TransactionIdEquals(ops[i].xid, my_xid)) + continue; + if (!TransactionIdIsInProgress(ops[i].xid)) + continue; + + /* Only lock/mutating entries can conflict */ + if (ops[i].op_type != SLOG_OP_LOCK_SHARE && + ops[i].op_type != SLOG_OP_LOCK_EXCL && + ops[i].op_type != SLOG_OP_DELETE && + ops[i].op_type != SLOG_OP_UPDATE) + continue; + + /* + * Lock compatibility matrix: SHARE vs SHARE: compatible SHARE vs + * EXCL/DELETE/UPDATE: conflict EXCL vs anything: conflict + */ + if (requested_lock == SLOG_OP_LOCK_SHARE) + { + if (ops[i].op_type == SLOG_OP_LOCK_EXCL || + ops[i].op_type == SLOG_OP_DELETE || + ops[i].op_type == SLOG_OP_UPDATE) + return true; + } + else if (requested_lock == SLOG_OP_LOCK_EXCL) + { + return true; + } + } + + return false; +} + +/* + * SLogTupleGetLockConflictXid -- like SLogTupleHasLockConflict, but also + * returns the xid of the *conflicting* transaction (the one whose marker + * actually conflicts with the requested lock). + * + * The waiter must XactLockTableWait on the conflicting xid specifically. + * Waiting on the first in-progress xid found on the TID (as a broad + * SLogTupleGetDirtyXid probe would return) can pick a compatible peer -- e.g. + * another KeyShare locker -- which is not blocking us and may itself be queued + * behind us, manufacturing a spurious mutual-wait deadlock cycle. + * + * my_mode is the precise LockTupleMode being acquired. Conflicts are decided + * with the real heavyweight matrix (DoLockModesConflict) over the four + * distinct tuple-lock strengths, exactly as SLogTupleGetWriteConflictXid does. + * The coarse two-value SLOG_OP_LOCK_SHARE/EXCL classification is not + * sufficient here: it made every in-progress UPDATE/DELETE marker conflict + * with a SHARE request, so an FK check's KeyShare probe (AccessShareLock) + * blocked behind a NoKeyExclusive UPDATE (ExclusiveLock) that heap treats as + * COMPATIBLE. Two concurrent FK inserts referencing the same parent row then + * waited on each other's xid with no lock cycle for the deadlock detector to + * break, hanging the backend forever (seen in the fk-deadlock isolation test). + * + * Returns true and sets *xid_out if a conflicting in-progress transaction + * exists; returns false otherwise (xid_out is set to InvalidTransactionId). + */ +bool +SLogTupleGetLockConflictXid(Oid relid, ItemPointer tid, + TransactionId my_xid, + LockTupleMode my_mode, + TransactionId *xid_out) +{ + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + LOCKMODE my_lockmode = slog_tuplock_to_lockmode(my_mode); + int nfound; + int i; + + *xid_out = InvalidTransactionId; + + nfound = SLogTupleLookupFiltered(relid, tid, InvalidTransactionId, + ops, SLOG_MAX_TUPLE_OPS); + + for (i = 0; i < nfound; i++) + { + if (TransactionIdEquals(ops[i].xid, my_xid)) + continue; + if (!TransactionIdIsInProgress(ops[i].xid)) + continue; + + switch (ops[i].op_type) + { + case SLOG_OP_LOCK_SHARE: + case SLOG_OP_LOCK_EXCL: + + /* + * A locker records its precise LockTupleMode, so compare the + * two strengths directly. + */ + if (DoLockModesConflict(my_lockmode, + slog_tuplock_to_lockmode(ops[i].lock_mode))) + { + *xid_out = ops[i].xid; + return true; + } + break; + + case SLOG_OP_UPDATE: + case SLOG_OP_DELETE: + + /* + * A writer holds at least NoKeyExclusive (ExclusiveLock); a + * DELETE holds Exclusive (AccessExclusiveLock). KeyShare + * remains compatible with a NoKeyExclusive UPDATE, which is + * what lets concurrent FK checks proceed. + */ + if (DoLockModesConflict(my_lockmode, + ops[i].op_type == SLOG_OP_DELETE ? + AccessExclusiveLock : ExclusiveLock)) + { + *xid_out = ops[i].xid; + return true; + } + break; + + default: + /* INSERT / ABORTED markers never block a lock request. */ + break; + } + } + + return false; +} + +/* + * SLogTupleHasAbortedEntry -- check if any aborted sLog op exists for a TID. + */ +bool +SLogTupleHasAbortedEntry(Oid relid, ItemPointer tid) +{ + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nfound; + int i; + + nfound = SLogTupleLookupFiltered(relid, tid, InvalidTransactionId, + ops, SLOG_MAX_TUPLE_OPS); + + for (i = 0; i < nfound; i++) + { + /* Explicitly marked ABORTED */ + if (ops[i].op_type == SLOG_OP_ABORTED) + return true; + + /* Skip our own transaction's entries for CLOG fallback */ + if (TransactionIdIsCurrentTransactionId(ops[i].xid)) + continue; + + /* CLOG fallback: completed but did not commit => aborted */ + if (!TransactionIdIsInProgress(ops[i].xid) && + TransactionIdDidAbort(ops[i].xid)) + return true; + } + + return false; +} + +/* ---------------------------------------------------------------- + * Backend-private tracking for tuple sLog cleanup + * ---------------------------------------------------------------- + */ + +/* + * SLogTupleTrackKey + * Remember a tuple key for cleanup at commit/abort. + * + * Allocated in TopTransactionContext so it's automatically freed + * when the transaction ends. + */ +void +SLogTupleTrackKey(SLogTupleKey key, TransactionId xid, TransactionId subxid, + SLogOpType op_type) +{ + MemoryContext oldcxt; + SLogTrackedKey *tk; + + oldcxt = MemoryContextSwitchTo(TopTransactionContext); + + tk = (SLogTrackedKey *) palloc(sizeof(SLogTrackedKey)); + memcpy(&tk->key, &key, sizeof(SLogTupleKey)); + tk->xid = xid; + tk->subxid = subxid; + tk->local_only = false; + slog_has_shared_entries = true; + tk->op_type = op_type; + tk->next = slog_tracked_keys; + slog_tracked_keys = tk; + + MemoryContextSwitchTo(oldcxt); +} + +/* + * SLogTupleTrackLocalOnly + * Lightweight local-only tracking (INSERTs only). + * + * Records (relid, tid, xid, subxid) in the per-backend local list WITHOUT + * creating a shared sLog entry. On subtransaction abort, + * SLogTupleRemoveBySubXid will create a shared ABORTED entry for visibility. + * + * OOM optimization: When not inside a subtransaction, records the TID in a + * backend-local open-addressing hash (simplehash) keyed by (relid, + * encoded_tid) instead of a 136-byte linked-list node, giving O(1) insert + * and probe and bounded per-backend memory under bulk INSERT. Subtransaction + * entries still use the linked list because subtxn abort needs per-entry + * subxid filtering. + */ +void +SLogTupleTrackLocalOnly(Oid relid, ItemPointer tid, + TransactionId xid, TransactionId subxid) +{ + MemoryContext oldcxt; + + /* + * Fast path: top-level transaction with no savepoint -> use the hash. The + * subxid is InvalidTransactionId in this case (top-level INSERTs always + * pass the top xid as both xid and subxid=Invalid). + */ + if (!IsSubTransaction()) + { + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + SLogInsertTidKey ikey; + bool found; + + if (offnum > MaxOffsetNumber) + elog(ERROR, "offset %u exceeds page item limit %d in sLog TID encoding", + offnum, MaxOffsetNumber); + ikey.relid = relid; + ikey.encoded_tid = SLOG_ENCODE_TID(blkno, offnum); + + /* + * Record the TID in the backend-local hash. Insert is amortized + * O(1); the table and its entries live in TopTransactionContext and + * are destroyed at transaction end. + */ + (void) sloginsert_insert(slog_insert_tids_ensure(), ikey, &found); + return; + } + + { + SLogTrackedKey *tk; + SLogTupleKey key; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + oldcxt = MemoryContextSwitchTo(TopTransactionContext); + + tk = (SLogTrackedKey *) palloc(sizeof(SLogTrackedKey)); + memcpy(&tk->key, &key, sizeof(SLogTupleKey)); + tk->xid = xid; + tk->subxid = subxid; + tk->local_only = true; + tk->op_type = SLOG_OP_INSERT; + tk->next = slog_tracked_keys; + slog_tracked_keys = tk; + + MemoryContextSwitchTo(oldcxt); + } +} + +/* + * SLogTupleUntrackLocalOnly + * Remove local-only INSERT tracking for (relid, tid). + * + * Counterpart to SLogTupleTrackLocalOnly. Used when a freshly inserted tuple + * must NOT be stamped with the inserting transaction's commit marker at commit + * time -- e.g. VACUUM FULL / CLUSTER copies a recently-dead tombstone into the + * new relation and rewrites it with its ORIGINAL delete timestamp. Leaving the + * INSERT tracked would let the AM's commit-time stamping clobber the tuple's + * commit metadata with the rewrite transaction's commit marker, resurrecting + * the deleted row for any reader whose snapshot predates that commit. + * + * Clears the backend-local INSERT-hash entry on the top-level fast path and + * also drops any matching linked-list node (the savepoint/fallback path). + */ +void +SLogTupleUntrackLocalOnly(Oid relid, ItemPointer tid) +{ + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + SLogTrackedKey **link; + + if (offnum > MaxOffsetNumber) + elog(ERROR, "offset %u exceeds page item limit %d in sLog TID encoding", + offnum, MaxOffsetNumber); + + /* Fast path: drop the backend-local INSERT-hash entry if present. */ + if (slog_insert_tids != NULL) + { + SLogInsertTidKey ikey; + + ikey.relid = relid; + ikey.encoded_tid = SLOG_ENCODE_TID(blkno, offnum); + (void) sloginsert_delete(slog_insert_tids, ikey); + } + + /* Fallback/subtxn path: unlink any matching local-only INSERT node. */ + link = &slog_tracked_keys; + while (*link != NULL) + { + SLogTrackedKey *tk = *link; + + if (tk->local_only && + tk->key.relid == relid && + ItemPointerGetBlockNumber(&tk->key.tid) == blkno && + ItemPointerGetOffsetNumber(&tk->key.tid) == offnum) + { + *link = tk->next; + pfree(tk); + continue; + } + link = &tk->next; + } +} + +/* + * SLogTupleCleanupRetained + * Free retained sLog entries that are no longer visible to any + * active snapshot. + * + * Committed UPDATE markers are not retained (commit_xid + * apply removes them), so under steady-state this routine primarily + * cleans up other retained state. Reclamation gates on the xid horizon + * (GetOldestNonRemovableTransactionId) so a marker is reclaimable only + * once its committing xid precedes the oldest active snapshot's xmin. + * + * Scans the flat hash under read-side, then applies CLEANUP_RETAINED ops. + * Walks every partition taking each writer lock LW_EXCLUSIVE, so callers MUST + * NOT hold a buffer content lock or other page-level critical section across + * this call. Driven by the UNDO background worker, or by + * SLogTupleMaybeCleanupRetained() when the worker is disabled. The + * reclamation decision is the xid horizon (GetOldestNonRemovableTransactionId) + * computed below. + */ +void +SLogTupleCleanupRetained(void) +{ + SLogTupleKey *collected_keys; + int max_keys = 256; + int part; + int i; + TransactionId reclaim_xid_horizon; + + if (SLogState == NULL) + return; + + /* + * Compute the xid horizon once for this pass. The read-side eligibility + * scan and the CLEANUP_RETAINED apply gate on the same horizon so the set + * of slots reclaimed here matches the set the apply drops. + */ + reclaim_xid_horizon = GetOldestNonRemovableTransactionId(NULL); + if (!TransactionIdIsValid(reclaim_xid_horizon)) + return; + + collected_keys = (SLogTupleKey *) + palloc(max_keys * sizeof(SLogTupleKey)); + + /* + * Process each partition independently while holding its exclusive writer + * lock. The writer lock serializes all writers (other cleanup runs and + * forward-path inserts) for the partition, so the single copy is stable + * to scan. We apply the CLEANUP_RETAINED ops inside seqlock cycles. + */ + for (part = 0; part < SLogNumPartitions; part++) + { + SLogFlatPartition *fp = SLogGetPartitionByIndex(part); + SLogFlatHashScanState scan; + const SLogFlatBucket *bucket; + int nkeys = 0; + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + + /* + * Scan the single copy to collect expired keys. Stable because we + * hold the writer lock. + */ + SLogFlatHashScanInit(&scan); + while ((bucket = SLogFlatHashScanNext(fp->hash, &scan)) != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + bool has_expired = false; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (!entry->ops[i].in_use) + continue; + if (entry->ops[i].op_type != SLOG_OP_UPDATE) + continue; + if (!TransactionIdIsValid(entry->ops[i].xid)) + continue; + + /* + * Reclaim once the xid precedes the oldest active snapshot's + * xmin (xid authority -- see the function header). This is + * the authoritative, self-healing gate: a below-horizon + * marker is reclaimable whether or not it was ever committed + * in CLOG, and an in-progress xid can never precede the + * horizon. + */ + if (!TransactionIdPrecedes(entry->ops[i].xid, + reclaim_xid_horizon)) + continue; + + if (!has_expired) + { + if (nkeys >= max_keys) + { + max_keys *= 2; + collected_keys = (SLogTupleKey *) + repalloc(collected_keys, + max_keys * sizeof(SLogTupleKey)); + } + collected_keys[nkeys++] = bucket->key; + has_expired = true; + } + } + } + + /* Null the now-dangling pointers and drop the slots. */ + if (nkeys > 0) + { + int ops_since_publish = 0; + + for (i = 0; i < nkeys; i++) + { + SLogFlatOp flat_op; + + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_CLEANUP_RETAINED; + flat_op.key = collected_keys[i]; + flat_op.reclaim_xid_horizon = reclaim_xid_horizon; + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + + /* + * Bound the seqlock odd-hold window so wait-free readers of + * this partition make progress. On a hot row many retained + * UPDATE markers expire at once, so nkeys can be large; a + * single uninterrupted odd window would spin every reader for + * its whole duration. Each key's reclamation is independent + * and each seq cycle leaves fp->hash consistent, so ending + * and re-beginning at a batch boundary is safe -- the + * partition LWLock still serializes us against other + * before-image freers. + */ + if (++ops_since_publish >= SLOG_ABORT_PUBLISH_BATCH) + { + ops_since_publish = 0; + } + } + } + + LWLockRelease(&fp->writer_lock.lock); + } + + pfree(collected_keys); +} + +/* + * SLogTupleMaybeCleanupRetained + * Throttled, self-clocking entry point for retained-entry cleanup. + * + * Intended for access methods to call from their DML paths when the UNDO + * background worker is disabled (max_logical_revert_workers = 0), so the DSA + * before-image area does not fill up under sustained high-TPS load. Unlike + * SLogTupleCleanupRetained(), the heavy global sweep here runs at most once + * every 5 seconds per backend; the common call is a counter increment that + * returns immediately. + * + * CRITICAL: the caller MUST NOT hold any buffer content lock or other + * page-level critical section across this call. When the throttle fires this + * walks every sLog partition taking each writer lock LW_EXCLUSIVE; running it + * under a buffer lock would serialize all writers to a hot page behind the + * sweep (the c>=2 hot-row UPDATE convoy this throttle was introduced to avoid). + */ +void +SLogTupleMaybeCleanupRetained(void) +{ + static TimestampTz slog_last_cleanup = 0; + static uint32 slog_cleanup_clock = 0; + TimestampTz now_ts; + + if (SLogState == NULL) + return; + + /* + * Sample the wall clock only once every SLOG_INSERT_CLOCK_PERIOD calls. + * The throttle below already bounds the heavy sweep to once per 5s; this + * just keeps the common-case cost down to a counter increment so the + * post-unlock call on every UPDATE does not add a clock_gettime() syscall + * to the hot path (matters for the >=HEAP perf target). + */ + if ((slog_cleanup_clock++ & (SLOG_INSERT_CLOCK_PERIOD - 1)) != 0) + return; + + now_ts = GetCurrentTimestamp(); + + if (now_ts - slog_last_cleanup > 5000000) /* 5 seconds */ + { + slog_last_cleanup = now_ts; + SLogTupleCleanupRetained(); + } +} + +/* + * SLogTupleAnyTracked + * True iff the current backend has tracked any tuple key (INSERT, + * UPDATE, or DELETE) for the current transaction -- i.e. this backend + * touched at least one tracked tuple since the last + * SLogTupleResetTracking(). + * + * Used by the AM's xact callback to decide whether a transaction that + * touched no tracked tuples should skip writing a durable commit-map entry. + */ +bool +SLogTupleAnyTracked(void) +{ + return slog_tracked_keys != NULL || + (slog_insert_tids != NULL && slog_insert_tids->members > 0); +} + +/* + * SLogTupleResetTracking + * Clear the backend-private tracking list and reset overflow state. + * + * Also frees the backend-local INSERT hash. The table lives in + * TopTransactionContext, so it would be freed at transaction end anyway. + * Explicit cleanup here allows earlier memory reclaim and makes the state + * consistent for any subsequent operations within the same backend lifetime. + */ +void +SLogTupleResetTracking(void) +{ + /* Destroy the backend-local INSERT hash */ + if (slog_insert_tids != NULL) + { + sloginsert_destroy(slog_insert_tids); + slog_insert_tids = NULL; + } + + slog_tracked_keys = NULL; + slog_has_shared_entries = false; + slog_overflow_warning_count = 0; + slog_overflow_last_warning = 0; +} + +/* + * SLogTupleIterateTrackedKeys + * Iterate over tracked keys for a given xid. + * + * Calls the callback for each tracked key matching the given xid. + * If the callback returns false, iteration stops early. + * Used by AM-specific pre-commit callbacks that need to touch pages. + * + * Iterates both hash-based INSERT entries (top-level) and + * linked-list entries. + */ +void +SLogTupleIterateTrackedKeys(TransactionId xid, + SLogTrackedKeyCallback callback, + void *arg) +{ + SLogTrackedKey *tk; + + /* Iterate backend-local INSERT-hash entries (top-level local-only) */ + if (slog_insert_tids != NULL) + { + sloginsert_iterator it; + SLogInsertTidEntry *ie; + + sloginsert_start_iterate(slog_insert_tids, &it); + while ((ie = sloginsert_iterate(slog_insert_tids, &it)) != NULL) + { + SLogTupleKey key; + + memset(&key, 0, sizeof(key)); + key.relid = ie->key.relid; + ItemPointerSet(&key.tid, + SLOG_DECODE_BLKNO(ie->key.encoded_tid), + SLOG_DECODE_OFFNUM(ie->key.encoded_tid)); + + if (!callback(&key, xid, InvalidTransactionId, true, arg)) + return; + } + } + + /* Iterate linked-list entries */ + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (!TransactionIdEquals(tk->xid, xid)) + continue; + + if (!callback(&tk->key, tk->xid, tk->subxid, tk->local_only, arg)) + break; + } +} + +/* + * SLogTupleCollectTrackedKeys + * Collect all tracked keys for a given xid into a palloc'd array. + * + * Returns the number of collected keys. The caller can then sort the + * array for batch processing (e.g. by relid/blockno to amortize buffer + * I/O at commit time). + * + * The returned array is allocated in the current memory context; the caller + * is responsible for pfree(). + */ +int +SLogTupleCollectTrackedKeys(TransactionId xid, SLogTrackedKeyInfo **out_keys) +{ + SLogTrackedKey *tk; + int count = 0; + int capacity = 64; + SLogTrackedKeyInfo *arr; + + /* + * A single very large transaction can touch tens of millions of tracked + * keys; at 48 bytes each the array crosses MaxAllocSize (1 GB) past ~22M + * entries. Use the huge-allocation API so bulk loads do not fail at + * commit. This array is transient and pfree'd by the caller. + */ + arr = (SLogTrackedKeyInfo *) MemoryContextAllocHuge(CurrentMemoryContext, + sizeof(SLogTrackedKeyInfo) * (Size) capacity); + + /* Collect backend-local INSERT-hash entries (top-level local-only) */ + if (slog_insert_tids != NULL) + { + sloginsert_iterator it; + SLogInsertTidEntry *ie; + + sloginsert_start_iterate(slog_insert_tids, &it); + while ((ie = sloginsert_iterate(slog_insert_tids, &it)) != NULL) + { + if (count >= capacity) + { + capacity *= 2; + arr = (SLogTrackedKeyInfo *) + repalloc_huge(arr, sizeof(SLogTrackedKeyInfo) * (Size) capacity); + } + + memset(&arr[count].key, 0, sizeof(SLogTupleKey)); + arr[count].key.relid = ie->key.relid; + ItemPointerSet(&arr[count].key.tid, + SLOG_DECODE_BLKNO(ie->key.encoded_tid), + SLOG_DECODE_OFFNUM(ie->key.encoded_tid)); + arr[count].xid = xid; + arr[count].subxid = InvalidTransactionId; + arr[count].local_only = true; + arr[count].op_type = SLOG_OP_INSERT; + count++; + } + } + + /* Collect linked-list entries */ + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (!TransactionIdEquals(tk->xid, xid)) + continue; + + if (count >= capacity) + { + capacity *= 2; + arr = (SLogTrackedKeyInfo *) + repalloc_huge(arr, sizeof(SLogTrackedKeyInfo) * (Size) capacity); + } + + arr[count].key = tk->key; + arr[count].xid = tk->xid; + arr[count].subxid = tk->subxid; + arr[count].local_only = tk->local_only; + arr[count].op_type = tk->op_type; + count++; + } + + *out_keys = arr; + return count; +} diff --git a/src/backend/access/undo/undo.c b/src/backend/access/undo/undo.c index 5b0dc4c6c8b01..78e10ad477a9c 100644 --- a/src/backend/access/undo/undo.c +++ b/src/backend/access/undo/undo.c @@ -32,6 +32,8 @@ */ #include "postgres.h" +#include "access/atm.h" +#include "access/logical_revert_worker.h" #include "access/undo.h" #include "access/undolog.h" #include "access/undormgr.h" @@ -104,6 +106,8 @@ UndoShmemSize(void) size = UndoLogShmemSize(); size = add_size(size, XactUndoShmemSize()); size = add_size(size, UndoWorkerShmemSize()); + size = add_size(size, LogicalRevertShmemSize()); + size = add_size(size, ATMShmemSize()); /* Per-backend UNDO engine. */ size = add_size(size, PbuUndoLogShmemSize()); @@ -213,6 +217,8 @@ UndoShmemInit(void) UndoLogShmemInit(); XactUndoShmemInit(); UndoWorkerShmemInit(); + LogicalRevertShmemInit(); + ATMShmemInit(); /* * Per-backend UNDO engine. @@ -259,7 +265,7 @@ UndoShmemInit(void) * function pointers, both rebuilt by RegisterUndoRmgrs() (idempotent: * InitUndoRmgrs() zeroes the table first). * - * XactUndoShmemInit() is a no-op and so omitted here. Any + * ATMShmemInit() 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 @@ -269,7 +275,9 @@ static void UndoShmemAttach_internal(void *arg) { UndoLogShmemInit(); + XactUndoShmemInit(); UndoWorkerShmemInit(); + LogicalRevertShmemInit(); /* Per-backend UNDO engine shmem (see comment above about self-attach). */ PbuUndoLogShmemInit(); diff --git a/src/backend/access/undo/undo_xlog.c b/src/backend/access/undo/undo_xlog.c index cd5a5b9588b31..6619bc86f3874 100644 --- a/src/backend/access/undo/undo_xlog.c +++ b/src/backend/access/undo/undo_xlog.c @@ -4,24 +4,26 @@ * UNDO resource manager WAL redo routines * * This module implements the WAL redo callback for the RM_UNDO_ID resource - * manager. It handles replay of: + * manager, the recovery-time tracking of transactions that wrote UNDO, the + * crash-recovery UNDO phase, and the reader that fetches UNDO batches back + * out of WAL. The records it replays: * - * 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) + * XLOG_UNDO_INFLIGHT - The UNDO chains in flight at a checkpoint. + * XLOG_UNDO_BATCH - A batch of UNDO records. Redo changes no page; + * it only tracks the batch as the chain head of + * its transaction until the commit or abort. + * XLOG_UNDO_APPLY_RECORD - A CLR (Compensation Log Record): the page + * change one UNDO record made when applied. * * 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. + * CLRs are physiological: the rm_undo callbacks register the target buffer + * with REGBUF_STANDARD and describe the change (mark a line pointer LP_DEAD, + * or restore a before-image). Redo goes through XLogReadBufferForRedo(), so + * a full-page image is restored when one was taken, and otherwise the change + * is replayed when the page LSN shows it is missing. A CLR does not mark + * the UNDO record itself as applied; see ApplyOneUndoRecord() for why + * re-applying UNDO is still safe. * * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group * Portions Copyright (c) 1994, Regents of the University of California @@ -33,7 +35,9 @@ */ #include "postgres.h" +#include "access/atm.h" #include "access/htup_details.h" +#include "access/transam.h" #include "access/twophase.h" #include "access/undo_xlog.h" #include "access/undolog.h" @@ -48,15 +52,16 @@ #include "storage/bufmgr.h" #include "storage/bufpage.h" #include "storage/itemid.h" +#include "utils/injection_point.h" #include "utils/memutils.h" +static void undo_recovery_publish_retention(void); + /* * 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. + * This function handles all UNDO resource manager WAL record types; see + * the file header for what each one does. */ void undo_redo(XLogReaderState *record) @@ -65,111 +70,8 @@ undo_redo(XLogReaderState *record) 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); - /* - * Extend the UNDO log file to the specified size. The file - * will be created if it doesn't exist. - * - * With append-only I/O, the smgr-managed file is no longer - * used -- UNDO data is written directly to the segment file. - */ - ExtendUndoLogFile(xlrec->log_number, xlrec->new_size); - } - break; case XLOG_UNDO_APPLY_RECORD: { @@ -225,10 +127,10 @@ undo_redo(XLogReaderState *record) { /* * Restore a table AM's before-image (FLUX). - * 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. + * 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); @@ -267,17 +169,6 @@ undo_redo(XLogReaderState *record) } 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: { @@ -293,7 +184,8 @@ undo_redo(XLogReaderState *record) * WAL record and can be re-read later via XLogReadRecord() * during the undo phase. */ - UndoRecoveryTrackBatch(xlrec->xid, record->ReadRecPtr, + UndoRecoveryTrackBatch(xlrec->xid, xlrec->dboid, + record->ReadRecPtr, xlrec->chain_prev, xlrec->persistence); @@ -306,87 +198,17 @@ undo_redo(XLogReaderState *record) } break; - case XLOG_UNDO_ROTATE: + case XLOG_UNDO_INFLIGHT: { - 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; - } - } + xl_undo_inflight *xlrec = (xl_undo_inflight *) XLogRecGetData(record); - /* 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); - } - } - } + for (uint32 i = 0; i < xlrec->nxacts; i++) + UndoRecoveryTrackInflight(&xlrec->xacts[i]); + undo_recovery_publish_retention(); } break; + default: elog(PANIC, "undo_redo: unknown op code %u", info); } @@ -406,8 +228,16 @@ undo_redo(XLogReaderState *record) typedef struct UndoRecoveryEntry { TransactionId xid; /* hash key */ + Oid dboid; /* database the transaction ran in */ XLogRecPtr last_batch_lsn[NUndoPersistenceLevels]; /* chain heads per * persistence level */ + + /* + * Oldest batch known to be in each chain: the chain_prev of the oldest + * batch redo saw, or that batch itself if it starts the chain. Redo + * starts at the last checkpoint and so may not see the first batches. + */ + XLogRecPtr first_batch_lsn[NUndoPersistenceLevels]; char status; /* in use */ } UndoRecoveryEntry; @@ -427,6 +257,78 @@ static int undo_recovery_capacity = 0; */ #define UNDO_RECOVERY_MAX_ENTRIES 1048576 +/* + * undo_recovery_entry - Find or create the recovery entry for xid + * + * Returns NULL if the entry would exceed UNDO_RECOVERY_MAX_ENTRIES. + */ +static UndoRecoveryEntry * +undo_recovery_entry(TransactionId xid, Oid dboid) +{ + int i; + UndoRecoveryEntry *entry; + + for (i = 0; i < undo_recovery_nentries; i++) + { + if (undo_recovery_entries[i].xid == xid) + return &undo_recovery_entries[i]; + } + + /* 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 NULL; + } + + 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; + entry->dboid = dboid; + for (i = 0; i < NUndoPersistenceLevels; i++) + { + entry->last_batch_lsn[i] = InvalidXLogRecPtr; + entry->first_batch_lsn[i] = InvalidXLogRecPtr; + } + return entry; +} + /* * UndoRecoveryTrackBatch - Record an UNDO batch during WAL redo * @@ -434,82 +336,98 @@ static int undo_recovery_capacity = 0; * transactions have UNDO data that may need rollback. */ void -UndoRecoveryTrackBatch(TransactionId xid, XLogRecPtr batch_lsn, +UndoRecoveryTrackBatch(TransactionId xid, Oid dboid, XLogRecPtr batch_lsn, XLogRecPtr chain_prev, UndoPersistenceLevel persistence) { - int i; - UndoRecoveryEntry *entry = NULL; + UndoRecoveryEntry *entry; if (!TransactionIdIsValid(xid)) return; - /* Find existing entry for this XID */ - for (i = 0; i < undo_recovery_nentries; i++) + entry = undo_recovery_entry(xid, dboid); + if (entry == NULL) + return; + + /* Update the chain head and the oldest known batch of this level */ + if (persistence < NUndoPersistenceLevels) { - if (undo_recovery_entries[i].xid == xid) - { - entry = &undo_recovery_entries[i]; - break; - } + XLogRecPtr first = XLogRecPtrIsValid(chain_prev) ? chain_prev : + batch_lsn; + + entry->last_batch_lsn[persistence] = batch_lsn; + if (!XLogRecPtrIsValid(entry->first_batch_lsn[persistence]) || + first < entry->first_batch_lsn[persistence]) + entry->first_batch_lsn[persistence] = first; } +} + +/* + * UndoRecoveryTrackInflight - Merge a checkpoint's in-flight chain + * + * Called from the XLOG_UNDO_INFLIGHT redo handler. The checkpointer read + * the chain after the redo pointer, so redo may already have seen newer + * batches of it: keep the newest head and the oldest first batch. A + * transaction whose outcome clog already knows finished between the read and + * the record; it is not a loser. + */ +void +UndoRecoveryTrackInflight(const xl_undo_inflight_xact *x) +{ + UndoRecoveryEntry *entry; + bool any = false; + + if (!TransactionIdIsValid(x->xid)) + return; + for (int i = 0; i < NUndoPersistenceLevels; i++) + any |= XLogRecPtrIsValid(x->last_batch_lsn[i]); + if (!any || TransactionIdDidCommit(x->xid) || TransactionIdDidAbort(x->xid)) + return; - /* Create new entry if needed */ + entry = undo_recovery_entry(x->xid, x->dboid); if (entry == NULL) + return; + + for (int i = 0; i < NUndoPersistenceLevels; i++) { - /* Safety cap: refuse to track more XIDs to prevent OOM */ - if (undo_recovery_nentries >= UNDO_RECOVERY_MAX_ENTRIES) - { - static bool warned = false; + if (XLogRecPtrIsValid(x->last_batch_lsn[i]) && + x->last_batch_lsn[i] > entry->last_batch_lsn[i]) + entry->last_batch_lsn[i] = x->last_batch_lsn[i]; + if (XLogRecPtrIsValid(x->first_batch_lsn[i]) && + (!XLogRecPtrIsValid(entry->first_batch_lsn[i]) || + x->first_batch_lsn[i] < entry->first_batch_lsn[i])) + entry->first_batch_lsn[i] = x->first_batch_lsn[i]; + } +} - 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; - } +/* + * undo_recovery_publish_retention - Keep the WAL of the tracked chains + * + * On a standby nothing else retains it: its restartpoints would recycle the + * segments that promotion's UNDO phase has to read. + */ +static void +undo_recovery_publish_retention(void) +{ + XLogRecPtr oldest = InvalidXLogRecPtr; - if (undo_recovery_nentries >= undo_recovery_capacity) - { - int new_capacity = (undo_recovery_capacity == 0) ? 64 : - undo_recovery_capacity * 2; - UndoRecoveryEntry *new_entries; + for (int i = 0; i < undo_recovery_nentries; i++) + { + UndoRecoveryEntry *e = &undo_recovery_entries[i]; - /* Clamp doubling to not exceed the safety cap */ - if (new_capacity > UNDO_RECOVERY_MAX_ENTRIES) - new_capacity = UNDO_RECOVERY_MAX_ENTRIES; + if (!TransactionIdIsValid(e->xid)) + continue; + for (int j = 0; j < NUndoPersistenceLevels; j++) + { + XLogRecPtr lsn = XLogRecPtrIsValid(e->first_batch_lsn[j]) ? + e->first_batch_lsn[j] : e->last_batch_lsn[j]; - 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; + if (XLogRecPtrIsValid(lsn) && + (!XLogRecPtrIsValid(oldest) || lsn < oldest)) + oldest = lsn; } - - 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; + UndoSetRecoveryBatchLSN(oldest); } /* @@ -569,6 +487,7 @@ typedef struct DeferredUndoXact TransactionId xid; Oid dboid; XLogRecPtr last_batch_lsn; + XLogRecPtr first_batch_lsn; /* oldest batch: WAL to retain */ struct DeferredUndoXact *next; } DeferredUndoXact; @@ -613,6 +532,7 @@ PerformUndoRecovery(void) { UndoRecoveryEntry *entry = &undo_recovery_entries[i]; bool any_skipped = false; + XLogRecPtr perm_first = InvalidXLogRecPtr; if (!TransactionIdIsValid(entry->xid)) continue; @@ -658,121 +578,46 @@ PerformUndoRecovery(void) if (j == UNDOPERSISTENCE_TEMP || j == UNDOPERSISTENCE_UNLOGGED) continue; - while (XLogRecPtrIsValid(batch_lsn)) + if (XLogRecPtrIsValid(batch_lsn)) { - UndoBatchData *batch; - char *pos; - char *end; + UndoChainWalkResult res; - 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); - total_records++; - - /* - * If any UNDO record was skipped (e.g., due to - * syscache not being initialized), mark this - * transaction for deferred processing by the - * logical revert worker. - */ - if (result == UNDO_APPLY_SKIPPED) - any_skipped = true; - } - } + /* + * Same walk, and so the same newest-first order, as online + * rollback. A batch that cannot be read (fsync=off, or WAL + * recycled too early) ends the walk with a WARNING rather + * than a PANIC, which would make the cluster unrecoverable; + * whatever it covered is left for VACUUM. + */ + (void) UndoWalkChain(batch_lsn, InvalidXLogRecPtr, &res); + total_records += res.applied + res.skipped + res.failed; - pos += header.urec_len; - } + /* + * A record skipped here (an index AM needs the syscache, + * which the startup process does not have) or one that failed + * is handed to the logical revert worker, which walks the + * whole chain again; the rm_undo callbacks are idempotent. + */ + if (res.skipped > 0 || res.failed > 0) + any_skipped = true; - /* Follow chain to previous batch */ + /* + * The deferred entry must retain WAL from the chain's first + * batch. A complete walk read it (oldest_batch). One that + * stopped early did not, but the batch it could not read and + * the oldest batch redo knows of are no newer than the ones + * it did read; take the oldest of all. + */ + if (j == UNDOPERSISTENCE_PERMANENT) { - 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; + perm_first = entry->first_batch_lsn[j]; + if (XLogRecPtrIsValid(res.oldest_batch) && + res.oldest_batch < perm_first) + perm_first = res.oldest_batch; + if (!res.complete && + XLogRecPtrIsValid(res.unread_batch) && + res.unread_batch < perm_first) + perm_first = res.unread_batch; } } } @@ -780,9 +625,14 @@ PerformUndoRecovery(void) /* * If any UNDO records were skipped (e.g., due to syscache not being * initialized during early recovery), track this transaction for - * deferred processing. We cannot apply it during recovery, so we add - * it to an in-memory list that will be applied after recovery - * completes (when InRedo is set to false). + * deferred processing. ATMAddAborted() writes WAL, which isn't + * allowed yet, so we put the entry into the shared ATM without WAL + * now and remember it on an in-memory list that + * FlushDeferredUndoXacts() WAL-logs once recovery is over. + * + * The entry has to be in the ATM now, not later: the end-of-recovery + * checkpoint runs before FlushDeferredUndoXacts(), and only the ATM + * keeps that checkpoint from recycling the WAL that holds this chain. * * Use the permanent persistence level's last_batch_lsn for tracking. * TEMP and UNLOGGED are skipped during crash recovery anyway. @@ -796,12 +646,22 @@ PerformUndoRecovery(void) DeferredUndoXact *deferred = (DeferredUndoXact *) palloc(sizeof(DeferredUndoXact)); + /* + * The database comes from the WAL, not MyDatabaseId: the + * startup process is connected to no database, and the revert + * worker that will pick this up only serves entries of its + * own database. + */ deferred->xid = entry->xid; - deferred->dboid = MyDatabaseId; + deferred->dboid = entry->dboid; deferred->last_batch_lsn = perm_lsn; + deferred->first_batch_lsn = perm_first; deferred->next = deferred_undo_xacts; deferred_undo_xacts = deferred; + ATMAddAbortedNoWAL(deferred->xid, deferred->dboid, perm_lsn, + perm_first); + deferred_xacts++; ereport(LOG, (errmsg("UNDO recovery: transaction %u deferred to " @@ -837,17 +697,17 @@ PerformUndoRecovery(void) } /* - * FlushDeferredUndoXacts - Apply UNDO chains deferred during recovery + * FlushDeferredUndoXacts - WAL-log the transactions deferred to the ATM * - * 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). + * Called after recovery completes (when InRedo is false). PerformUndoRecovery() + * already put each deferred transaction into the Aborted Transaction Map + * without WAL; this writes the XLOG_ATM_ABORT records for them (ATMAddAborted() + * leaves the existing entries untouched), so standbys learn about them too. + * The logical revert worker of each entry's database processes them + * asynchronously. * - * 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. Until an asynchronous applier exists, the deferred - * chains are reported and dropped here so recovery can complete; the WAL - * holding them is retained by UndoGetOldestBatchLSN's other checks. + * This must be called after InRedo is set to false because ATMAddAborted() + * writes WAL, which is not allowed during recovery. */ void FlushDeferredUndoXacts(void) @@ -858,19 +718,25 @@ FlushDeferredUndoXacts(void) if (deferred_undo_xacts == NULL) return; - /* Walk the list, reporting each deferred transaction. */ + ereport(LOG, + (errmsg("flushing deferred UNDO transactions to ATM"))); + + /* Walk the list and add each transaction to the ATM */ while (deferred_undo_xacts != NULL) { deferred = deferred_undo_xacts; deferred_undo_xacts = deferred->next; + + ATMAddAborted(deferred->xid, deferred->dboid, deferred->last_batch_lsn, + deferred->first_batch_lsn); 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", + ereport(LOG, + (errmsg("added %d deferred transaction(s) to ATM for async UNDO processing", count))); } @@ -886,7 +752,9 @@ FlushDeferredUndoXacts(void) * 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. + * Returns NULL if the record cannot be read or is not an UNDO batch. In the + * latter case, a record that was read but is not a well-formed UNDO batch, + * *malformed is set: reading it again will not help. */ /* * Module-level cached XLogReader for UndoReadBatchFromWAL. @@ -962,7 +830,7 @@ UndoValidateBatchLSN(XLogRecPtr batch_lsn) } UndoBatchData * -UndoReadBatchFromWAL(XLogRecPtr batch_lsn) +UndoReadBatchFromWAL(XLogRecPtr batch_lsn, bool *malformed) { XLogRecord *record_hdr; char *errormsg = NULL; @@ -972,9 +840,20 @@ UndoReadBatchFromWAL(XLogRecPtr batch_lsn) Size record_len; Size payload_offset; + *malformed = false; + if (!XLogRecPtrIsValid(batch_lsn)) return NULL; + /* Test hook: simulate a batch that cannot be read right now. */ + if (IS_INJECTION_POINT_ATTACHED("undo-read-batch-fail")) + { + ereport(WARNING, + (errmsg("UNDO batch at %X/%X: read failed (injection point)", + LSN_FORMAT_ARGS(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 @@ -1082,6 +961,7 @@ UndoReadBatchFromWAL(XLogRecPtr batch_lsn) ereport(WARNING, (errmsg("UNDO batch record at %X/%X too short: %zu bytes", LSN_FORMAT_ARGS(batch_lsn), record_len))); + *malformed = true; return NULL; } @@ -1095,6 +975,7 @@ UndoReadBatchFromWAL(XLogRecPtr batch_lsn) LSN_FORMAT_ARGS(batch_lsn), XLogRecGetRmid(undo_batch_reader), XLogRecGetInfo(undo_batch_reader) & ~XLR_INFO_MASK))); + *malformed = true; return NULL; } @@ -1113,6 +994,7 @@ UndoReadBatchFromWAL(XLogRecPtr batch_lsn) "record data (offset %zu, record_len %zu)", LSN_FORMAT_ARGS(batch_lsn), xlrec->total_len, payload_offset, record_len))); + *malformed = true; return NULL; } diff --git a/src/backend/access/undo/undoapply.c b/src/backend/access/undo/undoapply.c index 437ec4804e877..f180981bba6b5 100644 --- a/src/backend/access/undo/undoapply.c +++ b/src/backend/access/undo/undoapply.c @@ -37,12 +37,11 @@ /* * 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. + * Dispatches to the appropriate resource manager's rm_undo callback and + * returns its result. A record of an unknown resource manager is an error: + * it cannot be applied here. */ -static bool +static UndoApplyResult ApplyOneUndoRecord(UndoRecordHeader *header, char *payload, UndoRecPtr urec_ptr) { @@ -53,16 +52,18 @@ ApplyOneUndoRecord(UndoRecordHeader *header, char *payload, * 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. + * applied marker, and nothing records how far a chain walk got. A chain + * can therefore be walked again after a crash part-way through, or by the + * revert worker after a partial inline apply, so every rm_undo must be + * idempotent. They are so semantically, not by page LSN: they read the + * target page with ReadBuffer (not XLogReadBufferForRedo) and act only if + * the page still shows the effect being undone. nbtree and hash kill + * only a slot that is still LP_NORMAL and matches the record's heap TID + * and key digest, so an entry already marked dead is "not found", a + * successful no-op; FILEOPS acts only on an object that still shows the + * operation's effect, recorded in the payload. A CLR + * (XLOG_UNDO_APPLY_RECORD) only makes the page change itself crash-safe + * through ordinary redo; it does not mark the UNDO record as applied. */ /* @@ -75,7 +76,7 @@ ApplyOneUndoRecord(UndoRecordHeader *header, char *payload, (errmsg("UNDO rollback: unknown RM ID %u for record at %llu, skipping", header->urec_rmid, (unsigned long long) urec_ptr))); - return false; + return UNDO_APPLY_ERROR; } /* @@ -92,29 +93,22 @@ ApplyOneUndoRecord(UndoRecordHeader *header, char *payload, 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; - } + + return result; } /* @@ -148,18 +142,85 @@ ApplyUndoChainFromWAL(XLogRecPtr last_batch_lsn) * * Pass InvalidXLogRecPtr as stop_at_lsn to walk the entire chain (top-level * rollback / recovery semantics). + * + * Returns true only if the whole chain was walked and no record failed + * (UNDO_APPLY_ERROR, or an unknown resource manager). A record an RM skipped + * because its target is gone (UNDO_APPLY_SKIPPED) counts as done. On false + * the caller must hand the chain to the logical revert worker rather than + * treat it as applied; every rm_undo is idempotent, so re-walking the records + * that did apply is harmless. */ 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; + UndoChainWalkResult res; if (!XLogRecPtrIsValid(last_batch_lsn)) return false; + return ApplyUndoChain(last_batch_lsn, stop_at_lsn, &res) == UNDO_CHAIN_DONE; +} + +/* + * ApplyUndoChain - Apply an UNDO chain and report how it went + * + * UndoWalkChain() plus a summary log line. Callers that must tell a chain + * they could not read (worth retrying) from records that failed to apply + * (not worth retrying) use this instead of ApplyUndoChainFromWALBounded(). + */ +UndoChainOutcome +ApplyUndoChain(XLogRecPtr last_batch_lsn, XLogRecPtr stop_at_lsn, + UndoChainWalkResult *res) +{ + UndoChainOutcome outcome; + + outcome = UndoWalkChain(last_batch_lsn, stop_at_lsn, res); + + if (outcome != UNDO_CHAIN_DONE) + ereport(WARNING, + (errmsg("UNDO rollback from WAL: %d batches, %d records applied, " + "%d skipped, %d failed%s", + res->batches, res->applied, res->skipped, res->failed, + res->complete ? "" : ", chain not fully read"))); + else + ereport(DEBUG1, + (errmsg("UNDO rollback from WAL: %d batches, %d records applied, " + "%d skipped", + res->batches, res->applied, res->skipped))); + + return outcome; +} + +/* + * UndoWalkChain - The chain walk shared by every UNDO apply path + * + * Walks the chain backward from last_batch_lsn via chain_prev, newest batch + * first, stopping before any batch at or below stop_at_lsn (pass + * InvalidXLogRecPtr for the whole chain). Within each batch the records are + * applied newest first too: they are serialized oldest first, so a forward + * scan collects their start offsets and the offsets are then visited in + * reverse. Each record goes to its resource manager via ApplyOneUndoRecord(). + * + * Online rollback (ApplyUndoChainFromWALBounded) and the crash-recovery UNDO + * phase (PerformUndoRecovery) both use this, so they apply in the same order. + * + * *res receives the per-result record counts and whether the whole chain + * could be walked: an unreadable batch, a malformed record, or a chain_prev + * that does not point strictly backward ends the walk with complete = false + * (and unread_batch set; malformed tells the last two, and a batch that is not + * a well-formed UNDO batch, from a read failure), after a WARNING. Nothing + * is thrown for those, so a damaged chain cannot take the server down from + * inside an abort or from the startup process. The return value summarizes it; see UndoChainOutcome. + */ +UndoChainOutcome +UndoWalkChain(XLogRecPtr last_batch_lsn, XLogRecPtr stop_at_lsn, + UndoChainWalkResult *res) +{ + XLogRecPtr batch_lsn; + + memset(res, 0, sizeof(*res)); + res->complete = true; + ereport(DEBUG1, (errmsg("applying UNDO chain from WAL starting at %X/%X " "(stop boundary %X/%X)", @@ -185,17 +246,20 @@ ApplyUndoChainFromWALBounded(XLogRecPtr last_batch_lsn, XLogRecPtr stop_at_lsn) INJECTION_POINT("undo-apply-before-batch", NULL); - batch = UndoReadBatchFromWAL(batch_lsn); + batch = UndoReadBatchFromWAL(batch_lsn, &res->malformed); if (batch == NULL) { ereport(WARNING, (errmsg("UNDO rollback: could not read batch at %X/%X, " "stopping chain walk", LSN_FORMAT_ARGS(batch_lsn)))); + res->complete = false; + res->unread_batch = batch_lsn; break; } - batches_processed++; + res->batches++; + res->oldest_batch = batch_lsn; /* * Walk through records within this batch in reverse order. @@ -246,6 +310,9 @@ ApplyUndoChainFromWALBounded(XLogRecPtr last_batch_lsn, XLogRecPtr stop_at_lsn) ereport(WARNING, (errmsg("UNDO rollback: truncated record in batch at %X/%X", LSN_FORMAT_ARGS(batch_lsn)))); + res->complete = false; + res->malformed = true; + res->unread_batch = batch_lsn; break; } @@ -257,6 +324,9 @@ ApplyUndoChainFromWALBounded(XLogRecPtr last_batch_lsn, XLogRecPtr stop_at_lsn) ereport(WARNING, (errmsg("UNDO rollback: invalid record size %u in batch at %X/%X", hdr.urec_len, LSN_FORMAT_ARGS(batch_lsn)))); + res->complete = false; + res->malformed = true; + res->unread_batch = batch_lsn; break; } @@ -290,10 +360,18 @@ ApplyUndoChainFromWALBounded(XLogRecPtr last_batch_lsn, XLogRecPtr stop_at_lsn) if (header.urec_payload_len > 0) payload = record_starts[i] + SizeOfUndoRecordHeader; - if (ApplyOneUndoRecord(&header, payload, InvalidUndoRecPtr)) - records_applied++; - else - records_skipped++; + switch (ApplyOneUndoRecord(&header, payload, InvalidUndoRecPtr)) + { + case UNDO_APPLY_SUCCESS: + res->applied++; + break; + case UNDO_APPLY_SKIPPED: + res->skipped++; + break; + case UNDO_APPLY_ERROR: + res->failed++; + break; + } } MemoryContextSwitchTo(oldctx); @@ -302,26 +380,32 @@ ApplyUndoChainFromWALBounded(XLogRecPtr last_batch_lsn, XLogRecPtr stop_at_lsn) INJECTION_POINT("undo-apply-after-batch", NULL); - /* Follow chain to previous batch */ - batch_lsn = batch->header.chain_prev; - UndoFreeBatchData(batch); - } + /* + * Follow the chain to the previous batch. chain_prev must point + * strictly backward (or be invalid, at the start of the chain); + * anything else is a corrupt chain that would loop forever. + */ + { + XLogRecPtr next_lsn = batch->header.chain_prev; - /* 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))); + UndoFreeBatchData(batch); + if (XLogRecPtrIsValid(next_lsn) && next_lsn >= batch_lsn) + { + ereport(WARNING, + (errmsg("UNDO rollback: chain_prev %X/%X of batch at %X/%X " + "does not point backward; stopping chain walk", + LSN_FORMAT_ARGS(next_lsn), + LSN_FORMAT_ARGS(batch_lsn)))); + res->complete = false; + res->malformed = true; + res->unread_batch = next_lsn; + break; + } + batch_lsn = next_lsn; + } } - return (batches_processed > 0); + if (!res->complete) + return res->malformed ? UNDO_CHAIN_MALFORMED : UNDO_CHAIN_INCOMPLETE; + return res->failed > 0 ? UNDO_CHAIN_RECORD_ERROR : UNDO_CHAIN_DONE; } diff --git a/src/backend/access/undo/undoinsert.c b/src/backend/access/undo/undoinsert.c index 47ceb92877aea..d15f9dbe2883d 100644 --- a/src/backend/access/undo/undoinsert.c +++ b/src/backend/access/undo/undoinsert.c @@ -43,6 +43,7 @@ #include "access/undo_xlog.h" #include "access/xloginsert.h" #include "access/xlog.h" +#include "miscadmin.h" #include "utils/injection_point.h" /* @@ -104,6 +105,7 @@ UndoRecordSetInsert(UndoRecordSet *uset) /* Build the batch header */ xlrec.xid = uset->xid; + xlrec.dboid = MyDatabaseId; xlrec.chain_prev = uset->last_batch_lsn; xlrec.nrecords = (uint32) uset->nrecords; xlrec.total_len = (uint32) uset->buffer_size; diff --git a/src/backend/access/undo/undolog.c b/src/backend/access/undo/undolog.c index 5a91908349a02..da4b2ee6dbfde 100644 --- a/src/backend/access/undo/undolog.c +++ b/src/backend/access/undo/undolog.c @@ -27,6 +27,7 @@ */ #include "postgres.h" +#include "access/atm.h" #include "access/transam.h" #include "access/twophase.h" #include "access/undolog.h" @@ -108,6 +109,8 @@ UndoLogShmemInit(void) pg_atomic_init_u64(&UndoLogShared->total_discarded, 0); pg_atomic_init_u64(&UndoLogShared->undo_discard_horizon, InvalidXLogRecPtr); + pg_atomic_init_u64(&UndoLogShared->recovery_undo_lsn, + InvalidXLogRecPtr); /* Initialize per-backend first UNDO batch LSN slots */ for (i = 0; i < MaxBackends; i++) @@ -339,6 +342,19 @@ UndoClearBatchLSN(void) (uint64) InvalidXLogRecPtr); } +/* + * UndoSetRecoveryBatchLSN + * Publish the oldest first batch of the in-flight chains redo knows of. + * + * Called by the startup process. InvalidXLogRecPtr releases the hold. + */ +void +UndoSetRecoveryBatchLSN(XLogRecPtr lsn) +{ + if (UndoLogShared != NULL) + pg_atomic_write_u64(&UndoLogShared->recovery_undo_lsn, (uint64) lsn); +} + /* * UndoGetOldestBatchLSN * Return the oldest UNDO batch LSN that must be retained in WAL. @@ -359,6 +375,7 @@ XLogRecPtr UndoGetOldestBatchLSN(void) { XLogRecPtr oldest = InvalidXLogRecPtr; + XLogRecPtr atm_oldest; int i; if (UndoLogShared == NULL) @@ -377,11 +394,32 @@ UndoGetOldestBatchLSN(void) } } + /* In-flight chains known to redo (see UndoSetRecoveryBatchLSN) */ + { + XLogRecPtr lsn = (XLogRecPtr) + pg_atomic_read_u64(&UndoLogShared->recovery_undo_lsn); + + if (XLogRecPtrIsValid(lsn) && + (!XLogRecPtrIsValid(oldest) || lsn < oldest)) + oldest = lsn; + } + + /* + * Check ATM for aborted transactions whose UNDO chains haven't been + * applied yet. Their WAL segments must not be recycled. + */ + atm_oldest = ATMGetOldestUnrevertedLSN(); + if (XLogRecPtrIsValid(atm_oldest)) + { + if (!XLogRecPtrIsValid(oldest) || atm_oldest < oldest) + oldest = atm_oldest; + } + /* * 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. + * reads it. Neither the per-backend slot (cleared when the preparing + * backend exits) nor the ATM (prepared xacts aren't in it) covers this. */ { XLogRecPtr prep_oldest = TwoPhaseGetOldestUndoBatchLSN(); @@ -394,88 +432,6 @@ UndoGetOldestBatchLSN(void) return oldest; } -/* - * Legacy no-op stubs - * - * UNDO-in-WAL has no per-log segment files, no fd cache, and no - * per-backend write-pointer tracking: UNDO data lives in the WAL stream - * and durability/recycling are handled by WAL flush and the discard - * worker. The operations below are therefore inherently nothing-to-do in - * this mode, but they still have live callers in the shared transaction - * and discard paths (e.g. UndoLogCloseFiles / UndoFlushResetMaxWritePtr - * from xactundo.c, UndoLogDeleteSegmentFile from the discard worker, - * ExtendUndoLogFile from the undo_xlog.c redo path). We keep them as - * no-ops so those callers stay uniform across both UNDO modes rather than - * sprinkling mode checks at every call site; the no-op is the correct - * behaviour here, not a placeholder awaiting future work. - */ - -void -UndoLogSync(void) -{ - /* No-op: WAL sync handles durability */ -} - -void -UndoLogCloseFiles(void) -{ - /* No-op: no fd cache with UNDO-in-WAL */ -} - -void -UndoFlushResetMaxWritePtr(void) -{ - /* No-op: no per-backend write pointer tracking with UNDO-in-WAL */ -} - -UndoRecPtr -UndoFlushGetMaxWritePtr(void) -{ - /* No-op: no per-backend write pointer tracking with UNDO-in-WAL */ - return InvalidUndoRecPtr; -} - -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 */ -} - -bool -UndoLogTryPressureDiscard(void) -{ - /* No-op: no segment pressure with UNDO-in-WAL */ - return false; -} - -char * -UndoLogPath(uint32 log_number, char *path) -{ - /* Legacy: construct the path even though files no longer exist */ - snprintf(path, MAXPGPATH, "base/undo/%012u", log_number); - return path; -} - -void -ExtendUndoLogFile(uint32 log_number pg_attribute_unused(), - uint64 logical_end pg_attribute_unused()) -{ - /* No-op: no segment files with UNDO-in-WAL */ -} - -void -ExtendUndoLogSmgrFile(uint32 log_number pg_attribute_unused(), - uint64 logical_end pg_attribute_unused()) -{ - /* No-op: no smgr-managed UNDO files with UNDO-in-WAL */ -} - UndoRecPtr UndoLogGetInsertPtr(uint32 log_number) { diff --git a/src/backend/access/undo/undostats.c b/src/backend/access/undo/undostats.c index a15c9e55b04cd..2c4cb9c5a0377 100644 --- a/src/backend/access/undo/undostats.c +++ b/src/backend/access/undo/undostats.c @@ -3,15 +3,11 @@ * 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. + * The cluster-wide UNDO engine keeps its records in the WAL stream, so what + * there is to monitor is what holds that WAL back: in-flight transactions + * that wrote UNDO, aborted transactions waiting for the logical revert + * worker, and prepared transactions. pg_stat_get_undo_retention() reports + * them, using the same sources KeepLogSeg() does (UndoGetOldestBatchLSN). * * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group * Portions Copyright (c) 1994, Regents of the University of California @@ -23,330 +19,101 @@ */ #include "postgres.h" -#include "access/htup_details.h" +#include "access/slog.h" +#include "access/twophase.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 "access/xlog.h" +#include "access/xlogrecovery.h" #include "funcapi.h" #include "miscadmin.h" -#include "storage/lwlock.h" -#include "utils/acl.h" +#include "storage/proc.h" #include "utils/builtins.h" +#include "utils/pg_lsn.h" +#include "utils/tuplestore.h" -PG_FUNCTION_INFO_V1(pg_stat_get_undo_logs); -PG_FUNCTION_INFO_V1(pg_stat_get_undo_buffers); -PG_FUNCTION_INFO_V1(pg_undo_force_discard); - -/* - * 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); +PG_FUNCTION_INFO_V1(pg_stat_get_undo_retention); - /* Collect stats snapshot */ - stats = (UndoLogStat *) palloc(sizeof(UndoLogStat) * MAX_UNDO_LOGS); - nstats = GetUndoLogStats(stats, MAX_UNDO_LOGS); +#define UNDO_RETENTION_COLS 6 - 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) +static void +undo_retention_row(ReturnSetInfo *rsinfo, XLogRecPtr cur, const char *holder, + int pid, TransactionId xid, Oid dboid, XLogRecPtr lsn) { - 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)); + Datum values[UNDO_RETENTION_COLS]; + bool nulls[UNDO_RETENTION_COLS] = {0}; + + values[0] = CStringGetTextDatum(holder); + values[1] = Int32GetDatum(pid); + nulls[1] = (pid == 0); + values[2] = TransactionIdGetDatum(xid); + nulls[2] = !TransactionIdIsValid(xid); + values[3] = ObjectIdGetDatum(dboid); + nulls[3] = !OidIsValid(dboid); + values[4] = LSNGetDatum(lsn); + values[5] = Int64GetDatum(cur > lsn ? (int64) (cur - lsn) : 0); + + tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls); } /* - * 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. + * pg_stat_get_undo_retention - What is keeping UNDO WAL from being recycled * - * Arguments: - * force_rotate (bool) - If true, seal and rotate the active log first + * One row per holder, with the oldest WAL position it needs and how much WAL + * that retains: + * holder 'backend' - an in-flight transaction that has written UNDO + * (pid, xid and database of the backend) + * holder 'aborted' - an ATM entry waiting for the logical revert worker + * holder 'prepared' - the oldest UNDO batch of all prepared transactions * - * Returns the number of log segments freed (int4). - * - * Requires the pg_maintain role for access. + * The minimum oldest_lsn over all rows is what UndoGetOldestBatchLSN() feeds + * into KeepLogSeg(). */ Datum -pg_undo_force_discard(PG_FUNCTION_ARGS) +pg_stat_get_undo_retention(PG_FUNCTION_ARGS) { - bool force_rotate = PG_GETARG_BOOL(0); - int freed_count = 0; - TransactionId oldest_xid; - int i; + ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; + XLogRecPtr cur; + XLogRecPtr lsn; + SLogTxnEntry *entries; + int n; - /* 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"))); + InitMaterializedSRF(fcinfo, 0); if (UndoLogShared == NULL) - ereport(ERROR, - (errmsg("UNDO subsystem is not initialized"))); - - /* Optional rotation */ - if (force_rotate) - UndoLogSealAndRotate(UNDO_ROTATE_MANUAL); + return (Datum) 0; - /* Perform inline discard (same two passes as the discard worker) */ - oldest_xid = UndoWorkerGetOldestXid(); - if (!TransactionIdIsValid(oldest_xid)) - oldest_xid = ReadNextTransactionId(); + cur = RecoveryInProgress() ? GetXLogReplayRecPtr(NULL) : + GetXLogInsertRecPtr(); - /* First pass: advance discard pointers */ - for (i = 0; i < MAX_UNDO_LOGS; i++) + for (int i = 0; i < MaxBackends; i++) { - UndoLogControl *log = &UndoLogShared->logs[i]; + PGPROC *proc; - if (!log->in_use) + lsn = (XLogRecPtr) + pg_atomic_read_u64(&UndoLogShared->backend_undo_lsns[i]); + if (!XLogRecPtrIsValid(lsn)) 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); + proc = GetPGProcByNumber(i); + undo_retention_row(rsinfo, cur, "backend", proc->pid, proc->xid, + proc->databaseId, lsn); } - /* Second pass: lifecycle transitions */ - for (i = 0; i < MAX_UNDO_LOGS; i++) + n = SLogTxnSnapshotForCheckpoint(&entries); + for (int i = 0; i < n; i++) { - UndoLogControl *log = &UndoLogShared->logs[i]; - - if (!log->in_use) + if (entries[i].revert_complete) 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); + lsn = XLogRecPtrIsValid(entries[i].first_batch_lsn) ? + entries[i].first_batch_lsn : entries[i].last_batch_lsn; + undo_retention_row(rsinfo, cur, "aborted", 0, entries[i].xid, + entries[i].dboid, lsn); } - /* Wake the background worker for any remaining work */ - WakeUndoDiscardWorker(); + lsn = TwoPhaseGetOldestUndoBatchLSN(); + if (XLogRecPtrIsValid(lsn)) + undo_retention_row(rsinfo, cur, "prepared", 0, InvalidTransactionId, + InvalidOid, lsn); - PG_RETURN_INT32(freed_count); + return (Datum) 0; } diff --git a/src/backend/access/undo/undoworker.c b/src/backend/access/undo/undoworker.c index 0c29ab291b981..efa6b7d615b5a 100644 --- a/src/backend/access/undo/undoworker.c +++ b/src/backend/access/undo/undoworker.c @@ -193,20 +193,18 @@ UndoWorkerGetOldestXid(void) /* * 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. + * UNDO lives in the WAL stream, so "discard" means letting WAL be recycled; + * KeepLogSeg() computes that horizon itself at every checkpoint + * (UndoGetOldestBatchLSN). This only clears retention slots left behind by + * dead backends, which would otherwise pin WAL forever, records the horizon, + * and warns when it retains more than undo_max_wal_retention_size. */ 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(); @@ -247,118 +245,6 @@ perform_undo_discard(void) } } - /* - * 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. @@ -485,15 +371,8 @@ UndoWorkerMain(Datum main_arg) /* 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() and the sLog - * cleanup operate on shared memory and do not normally open a - * transaction; guarding avoids a spurious "AbortCurrentTransaction - * when not in transaction" path. - */ - if (IsTransactionState()) - AbortCurrentTransaction(); + /* Also clean up a failed transaction already in TRANS_ABORT. */ + AbortOutOfAnyTransaction(); /* Release any other resources we might still be holding. */ LWLockReleaseAll(); diff --git a/src/backend/access/undo/xactundo.c b/src/backend/access/undo/xactundo.c index 06900e5d6bc06..1d099f957b8c1 100644 --- a/src/backend/access/undo/xactundo.c +++ b/src/backend/access/undo/xactundo.c @@ -60,24 +60,36 @@ */ #include "postgres.h" +#include "access/atm.h" #include "access/undo.h" #include "access/undo_xlog.h" #include "access/xlog.h" #include "access/undolog.h" +#include "access/undormgr.h" #include "access/undorecord.h" #include "access/xact.h" #include "access/xactundo.h" #include "access/xlogdefs.h" +#include "access/xloginsert.h" #include "access/table.h" #include "catalog/pg_class.h" #include "miscadmin.h" +#include "storage/aio_subsys.h" +#include "storage/condition_variable.h" +#include "storage/lwlock.h" #include "storage/bufmgr.h" #include "storage/ipc.h" #include "utils/injection_point.h" #include "storage/lmgr.h" +#include "storage/proc.h" +#include "storage/shmem.h" +#include "storage/spin.h" #include "utils/memutils.h" #include "utils/rel.h" +/* GUC: UNDO bytes threshold for instant abort via ATM */ +int undo_instant_abort_threshold = 65536; + /* * Initial capacity for the dynamically-grown subtransaction stack. * Covers 99.9% of workloads without needing reallocation. The stack @@ -100,12 +112,24 @@ typedef struct XactUndoSubTransactionState * batches. */ XLogRecPtr last_batch_lsn[NUndoPersistenceLevels]; + + /* Parent's chain_bytes[] at subtransaction start, restored likewise. */ + Size chain_bytes[NUndoPersistenceLevels]; } XactUndoSubTransactionState; /* Backend-private undo state. */ typedef struct XactUndoData { bool has_undo; /* has this xact generated any undo? */ + + /* + * Has this xact written UNDO that only its own abort may apply? Index + * UNDO (nbtree, hash) just marks aborted entries dead, so it can be left + * to the logical revert worker. Anything else -- FILEOPS restoring files + * -- must be undone by the time ROLLBACK returns, whatever its size; see + * undo_instant_abort_threshold. + */ + bool needs_inline_abort; int subxact_depth; /* 0 = top-level, 1+ = savepoints */ int subxact_capacity; /* allocated slots in subxact_stack */ @@ -128,6 +152,21 @@ typedef struct XactUndoData */ XLogRecPtr last_batch_lsn[NUndoPersistenceLevels]; + /* + * Bytes of UNDO written to WAL per persistence level, i.e. the size of + * the chain that last_batch_lsn[] heads. The record sets themselves + * cannot tell us this: every flush resets their buffer. + * AtAbort_XactUndo() compares this against undo_instant_abort_threshold. + */ + Size chain_bytes[NUndoPersistenceLevels]; + + /* + * The first (oldest) batch of each chain. A rollback handed to the ATM + * or the 2PC state must keep WAL from here on, not only from the head: + * the revert worker reads every batch back to this one. + */ + XLogRecPtr first_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 @@ -141,6 +180,21 @@ typedef struct XactUndoData static XactUndoData XactUndo; static bool subxact_callback_registered = false; +/* + * Per-backend copy of the current transaction's UNDO chains, for the + * checkpointer (see LogXactUndoInflight). Only the owning backend writes its + * slot, under the slot's spinlock; xid is invalid when it has none. + */ +typedef struct XactUndoInflightSlot +{ + slock_t mutex; + xl_undo_inflight_xact chain; +} XactUndoInflightSlot; + +static XactUndoInflightSlot *XactUndoInflight = NULL; + +static void PublishXactUndoChain(TransactionId xid); + /* * Compile-time guard: xl_xact_prepare.last_batch_lsn[3] must match * NUndoPersistenceLevels. Both headers are available here; xact.h avoids @@ -163,26 +217,112 @@ static void XactUndo_SubXactCallback(SubXactEvent event, SubTransactionId mySubi * 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. + * One XactUndoInflightSlot per backend. */ Size XactUndoShmemSize(void) { - return 0; + return mul_size(MaxBackends, sizeof(XactUndoInflightSlot)); } /* * 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. */ + bool found; + + XactUndoInflight = (XactUndoInflightSlot *) + ShmemInitStruct("UNDO In-flight Chains", XactUndoShmemSize(), &found); + if (!found) + { + for (int i = 0; i < MaxBackends; i++) + { + SpinLockInit(&XactUndoInflight[i].mutex); + XactUndoInflight[i].chain.xid = InvalidTransactionId; + } + } +} + +/* + * PublishXactUndoChain + * Make this backend's chain heads visible to LogXactUndoInflight(). + * + * xid invalid clears the slot. Called in critical sections: no palloc. + */ +static void +PublishXactUndoChain(TransactionId xid) +{ + XactUndoInflightSlot *slot; + + if (XactUndoInflight == NULL || MyProcNumber < 0 || + MyProcNumber >= MaxBackends) + return; + slot = &XactUndoInflight[MyProcNumber]; + if (!TransactionIdIsValid(xid) && !TransactionIdIsValid(slot->chain.xid)) + return; + + SpinLockAcquire(&slot->mutex); + slot->chain.xid = xid; + slot->chain.dboid = MyDatabaseId; + for (int i = 0; i < NUndoPersistenceLevels; i++) + { + slot->chain.first_batch_lsn[i] = XactUndo.first_batch_lsn[i]; + slot->chain.last_batch_lsn[i] = XactUndo.last_batch_lsn[i]; + } + SpinLockRelease(&slot->mutex); +} + +/* + * LogXactUndoInflight + * WAL-log the UNDO chains of every transaction in flight. + * + * Called by the checkpointer after the checkpoint's redo pointer has been + * determined. Redo starts at that pointer and so does not see the batches a + * transaction wrote before it; this record tells recovery about them. A + * transaction that commits or aborts after we read its slot is still listed; + * redo ignores the entry if the transaction's outcome is already known, and + * otherwise the commit or abort record, which follows this one, removes it. + */ +void +LogXactUndoInflight(void) +{ + xl_undo_inflight *rec; + uint32 n = 0; + + if (XactUndoInflight == NULL) + return; + + rec = palloc(SizeOfUndoInflight + + MaxBackends * sizeof(xl_undo_inflight_xact)); + for (int i = 0; i < MaxBackends; i++) + { + XactUndoInflightSlot *slot = &XactUndoInflight[i]; + + SpinLockAcquire(&slot->mutex); + if (TransactionIdIsValid(slot->chain.xid)) + rec->xacts[n++] = slot->chain; + SpinLockRelease(&slot->mutex); + } + + /* + * With a standby, written even when empty: the standby recomputes from it + * the WAL it must keep for the in-flight chains + * (UndoSetRecoveryBatchLSN). Unimportant, so that an idle system still + * skips checkpoints. + */ + if (n > 0 || XLogStandbyInfoActive()) + { + rec->nxacts = n; + XLogBeginInsert(); + XLogSetRecordFlags(XLOG_MARK_UNIMPORTANT); + XLogRegisterData(rec, SizeOfUndoInflight + + n * sizeof(xl_undo_inflight_xact)); + (void) XLogInsert(RM_UNDO_ID, XLOG_UNDO_INFLIGHT); + } + pfree(rec); } /* @@ -256,6 +396,8 @@ PrepareXactUndoData(XactUndoContext *ctx, char persistence, /* Remember that we've done something undo-related. */ XactUndo.has_undo = true; + if (rmid != UNDO_RMID_NBTREE && rmid != UNDO_RMID_HASH) + XactUndo.needs_inline_abort = true; /* The current (sub)transaction must have an xid; see below. */ (void) GetCurrentTransactionId(); @@ -291,6 +433,7 @@ PrepareXactUndoData(XactUndoContext *ctx, char persistence, { cur->start_location[i] = InvalidUndoRecPtr; cur->last_batch_lsn[i] = XactUndo.last_batch_lsn[i]; + cur->chain_bytes[i] = XactUndo.chain_bytes[i]; } } @@ -302,8 +445,8 @@ PrepareXactUndoData(XactUndoContext *ctx, char persistence, * 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. + * 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) @@ -355,6 +498,8 @@ PrepareXactUndoDataParts(XactUndoContext *ctx, char persistence, /* Remember that we've done something undo-related. */ XactUndo.has_undo = true; + if (rmid != UNDO_RMID_NBTREE && rmid != UNDO_RMID_HASH) + XactUndo.needs_inline_abort = true; /* The current (sub)transaction must have an xid; see below. */ (void) GetCurrentTransactionId(); @@ -377,6 +522,7 @@ PrepareXactUndoDataParts(XactUndoContext *ctx, char persistence, { cur->start_location[i] = InvalidUndoRecPtr; cur->last_batch_lsn[i] = XactUndo.last_batch_lsn[i]; + cur->chain_bytes[i] = XactUndo.chain_bytes[i]; } } @@ -425,15 +571,28 @@ InsertXactUndoData(XactUndoContext *ctx) { UndoRecordSet *uset = ctx->uset; UndoRecPtr ptr; + int saved_delay = 0; Assert(uset != NULL); + /* + * Hold off a checkpoint's redo-pointer phase while the batch is inserted + * and published for LogXactUndoInflight(): a batch that precedes the redo + * pointer must be visible to the checkpointer when it reads the slots. + */ + if (MyProc != NULL) + { + saved_delay = MyProc->delayChkptFlags; + MyProc->delayChkptFlags |= DELAY_CHKPT_START; + } + ptr = UndoRecordSetInsert(uset); if (UndoRecPtrIsValid(ptr)) { XactUndoSubTransactionState *cur = CURRENT_SUBXACT(); XactUndo.last_location[ctx->plevel] = ptr; + XactUndo.chain_bytes[ctx->plevel] += UndoRecordSetGetSize(uset); /* * Track the WAL LSN of the most recent UNDO batch for this @@ -449,6 +608,9 @@ InsertXactUndoData(XactUndoContext *ctx) */ Assert(XLogRecPtrIsValid(uset->last_batch_lsn)); XactUndo.last_batch_lsn[ctx->plevel] = uset->last_batch_lsn; + if (!XLogRecPtrIsValid(XactUndo.first_batch_lsn[ctx->plevel])) + XactUndo.first_batch_lsn[ctx->plevel] = uset->last_batch_lsn; + PublishXactUndoChain(uset->xid); /* Fix up subtransaction start location if needed */ if (cur->start_location[ctx->plevel] == (UndoRecPtr) 1) @@ -475,6 +637,9 @@ InsertXactUndoData(XactUndoContext *ctx) if (cur->start_location[ctx->plevel] == (UndoRecPtr) 1) cur->start_location[ctx->plevel] = InvalidUndoRecPtr; } + + if (MyProc != NULL) + MyProc->delayChkptFlags = saved_delay; } /* @@ -620,6 +785,19 @@ GetCurrentXactLastBatchLSN(UndoPersistenceLevel plevel) return XactUndo.last_batch_lsn[plevel]; } +/* + * GetCurrentXactFirstBatchLSN + * Get the WAL LSN of the oldest UNDO batch for a persistence level. + * + * WAL from here on must survive until the chain has been applied; PREPARE + * saves it so the prepared transaction keeps pinning it. + */ +XLogRecPtr +GetCurrentXactFirstBatchLSN(UndoPersistenceLevel plevel) +{ + return XactUndo.first_batch_lsn[plevel]; +} + /* * XActUndoUpdateLastBatchLSN * Record the LSN of an UNDO batch for the current transaction. @@ -658,8 +836,8 @@ XActUndoUpdateLastBatchLSN(XLogRecPtr lsn, UndoPersistenceLevel plevel) * last_batch_lsn): 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. + * FinishPreparedTransaction() feeds it to ATMAddAborted() 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 @@ -750,8 +928,8 @@ AtCommit_XactUndo(void) * 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 xact stays prepared, and ROLLBACK PREPARED feeds that LSN to + * ATMAddAborted() so the logical revert worker walks the chain. * * The in-memory UndoRecordSets, however, live in TopTransactionContext, which * PrepareTransaction() destroys (AtCommit_Memory) right after this. We must @@ -793,9 +971,9 @@ AtPrepare_XactUndo(void) * 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. + * The UNDO records are already in WAL. If we crash during rollback, + * before the abort record is written, recovery finds the transaction still + * incomplete and rolls it back from WAL again. */ void AtAbort_XactUndo(void) @@ -809,13 +987,12 @@ AtAbort_XactUndo(void) * 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 - * every one of the aborted index entries it describes would be left for - * VACUUM -- 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. + * head the apply path walks, are both only set by InsertXactUndoData(). A + * record that is still pending has therefore published nothing, and every + * one of the aborted index entries it describes would be left for VACUUM + * -- 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 @@ -850,8 +1027,18 @@ AtAbort_XactUndo(void) CollapseXactUndoSubTransactions(); /* - * UNDO application strategy: apply the transaction's cluster-wide UNDO - * chain synchronously in this backend, here at abort. + * UNDO application strategy: inline for small transactions, deferred for + * large ones. Controlled by undo_instant_abort_threshold GUC. + * + * For small transactions (< threshold bytes of UNDO): apply UNDO + * synchronously in this backend. This avoids ATM pool accumulation and + * eliminates the dependency on the background logical revert worker. + * + * For large transactions (>= threshold): register in the ATM for deferred + * asynchronous rollback by the logical revert worker. Only a transaction + * whose UNDO is all index UNDO may be deferred (needs_inline_abort): the + * effects of anything else, such as FILEOPS, must be gone when ROLLBACK + * returns. * * The BumpContext issue (pfree crashes during abort) is avoided by: - * Creating a temporary AllocSetContext for inline UNDO application - @@ -864,6 +1051,13 @@ AtAbort_XactUndo(void) if (XLogRecPtrIsValid(perm_lsn)) { + /* + * Size of the chain we are about to apply or defer. Only the + * permanent chain is ever applied, so only its size counts. + */ + Size total_undo_bytes = + XactUndo.chain_bytes[UNDOPERSISTENCE_PERMANENT]; + /* * The UNDO batch records were inserted into the WAL buffers * during this transaction but may not yet be flushed to disk. The @@ -883,74 +1077,51 @@ AtAbort_XactUndo(void) */ XLogFlush(XactLastRecEnd); + if (XactUndo.needs_inline_abort || + (undo_instant_abort_threshold > 0 && + total_undo_bytes < (Size) undo_instant_abort_threshold)) { /* - * Apply UNDO inline. Use a dedicated AllocSetContext to - * avoid BumpContext pfree issues. + * Small transaction: 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; + uint32 saved_holdoff; 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. - */ - if (!UndoValidateBatchLSN(perm_lsn)) { - elog(DEBUG1, "inline UNDO: last_batch_lsn %X/%X is not " - "a valid UNDO batch", - LSN_FORMAT_ARGS(perm_lsn)); - undo_applied = false; - goto inline_undo_done; - } + volatile bool undo_applied = false; - /* - * 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. + * 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. */ - BufferLockReleaseAll(); - LeaveInlineUndoApplyState(saved_trans_state); - FlushErrorState(); - undo_applied = false; - } - PG_END_TRY(); - LeaveInlineUndoApplyState(saved_trans_state); - - MemoryContextSwitchTo(old_ctx); - MemoryContextDelete(undo_ctx); + if (!UndoValidateBatchLSN(perm_lsn)) + { + elog(DEBUG1, "inline UNDO: last_batch_lsn %X/%X is not " + "a valid UNDO batch, deferring to ATM", + LSN_FORMAT_ARGS(perm_lsn)); + undo_applied = false; + goto inline_undo_done; + } - inline_undo_done: - if (!undo_applied) - { /* - * Inline UNDO failed (WAL recycled, wrong record type, or - * chain walk aborted). Retain the per-backend UNDO LSN - * slot so the WAL segment holding our UNDO data is not - * recycled; the slot is cleared at backend exit. + * 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. * * The error that is aborting us may have been raised * while this backend held buffer content locks, which @@ -960,22 +1131,95 @@ AtAbort_XactUndo(void) * does). LWLockReleaseAll does not cover them. */ BufferLockReleaseAll(); - elog(WARNING, "inline UNDO failed for xid %u", - GetCurrentTransactionId()); + saved_trans_state = EnterInlineUndoApplyState(); + saved_holdoff = InterruptHoldoffCount; + PG_TRY(); + { + undo_applied = ApplyUndoChainFromWAL(perm_lsn); + } + PG_CATCH(); + { + /* + * Already inside AbortTransaction: we cannot abort + * again or rethrow. Repeat its early cleanup and + * fall back to ATM; its ResourceOwnerRelease will + * release pins and other resources later. + * Release any content locks the failed chain left + * held before unwinding. The error reset + * InterruptHoldoffCount, but the abort that runs this + * still holds interrupts. + */ + InterruptHoldoffCount = saved_holdoff; + BufferLockReleaseAll(); + LWLockReleaseAll(); + XLogResetInsertion(); + ConditionVariableCancelSleep(); + pgaio_error_cleanup(); + 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, a record that could not be applied, or an + * error). Register in ATM for deferred processing by + * the revert worker; re-applying the records that did + * apply is harmless (see ApplyOneUndoRecord). + */ + elog(DEBUG1, "inline UNDO failed for xid %u, " + "deferring to ATM", + GetCurrentTransactionId()); + + if (ATMAddAborted(GetCurrentTransactionId(), + MyDatabaseId, perm_lsn, + XactUndo.first_batch_lsn[UNDOPERSISTENCE_PERMANENT])) + lsn_safely_held = true; /* ATM holds the LSN */ + else + elog(WARNING, "ATM full: could not record aborted transaction %u", GetCurrentTransactionId()); + + + + + } + else + { + lsn_safely_held = true; /* UNDO fully applied, WAL can + * be recycled */ + ereport(DEBUG2, + (errmsg("inline UNDO applied for xid %u " + "(%zu bytes)", + GetCurrentTransactionId(), + total_undo_bytes))); + } } + } + else + { + /* + * Large transaction or threshold=0: register in ATM for + * deferred rollback by the logical revert worker. + */ + if (ATMAddAborted(GetCurrentTransactionId(), + MyDatabaseId, perm_lsn, + XactUndo.first_batch_lsn[UNDOPERSISTENCE_PERMANENT])) + lsn_safely_held = true; else - { - lsn_safely_held = true; /* UNDO fully applied, WAL can be - * recycled */ - ereport(DEBUG2, - (errmsg("inline UNDO applied for xid %u", - GetCurrentTransactionId()))); - } + elog(WARNING, + "ATM full: could not record aborted transaction %u", + GetCurrentTransactionId()); } } } - INJECTION_POINT("undo-xact-abort-after-apply", NULL); + INJECTION_POINT("undo-xact-abort-after-atm", NULL); /* Free all per-persistence-level record sets. */ for (i = 0; i < NUndoPersistenceLevels; i++) @@ -1067,10 +1311,10 @@ AtSubCommit_XactUndo(int level) * 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. + * We apply the subtransaction's records synchronously, in this backend, so + * that the effects of the aborting subtransaction are reversed before control + * returns to the caller. If that fails, the subtransaction's part of the + * chain is handed to the logical revert worker through the ATM. * * Any AM-private per-subtransaction tracking state is cleaned up by the * owning AM's own SubXactCallback. @@ -1120,9 +1364,8 @@ AtSubAbort_XactUndo(int level) * 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. + * records, apply them synchronously. The parent transaction's records + * (before the subtransaction's start_location) are preserved. */ for (i = 0; i < NUndoPersistenceLevels; i++) { @@ -1160,6 +1403,7 @@ AtSubAbort_XactUndo(int level) { XLogRecPtr sub_head = XactUndo.last_batch_lsn[i]; XLogRecPtr parent_head = cur->last_batch_lsn[i]; + volatile bool applied = false; /* * Invariant: the current head must be strictly newer (larger) @@ -1183,6 +1427,7 @@ AtSubAbort_XactUndo(int level) MemoryContext undo_ctx; MemoryContext old_ctx; int saved_trans_state; + uint32 saved_holdoff; undo_ctx = AllocSetContextCreate(TopMemoryContext, "Subxact UNDO Apply", @@ -1197,19 +1442,30 @@ AtSubAbort_XactUndo(int level) */ BufferLockReleaseAll(); saved_trans_state = EnterInlineUndoApplyState(); + saved_holdoff = InterruptHoldoffCount; PG_TRY(); { - ApplyUndoChainFromWALBounded(sub_head, parent_head); + applied = ApplyUndoChainFromWALBounded(sub_head, + parent_head); } PG_CATCH(); { /* + * Already inside AbortSubTransaction, so do not abort + * again or rethrow. Repeat its early cleanup; its + * ResourceOwnerRelease will release the rest. * Release any content locks the failed subxact UNDO chain - * left held before unwinding. + * left held before unwinding, and hold interrupts again + * as the subtransaction abort did (see AtAbort_XactUndo). */ + InterruptHoldoffCount = saved_holdoff; BufferLockReleaseAll(); - LeaveInlineUndoApplyState(saved_trans_state); + LWLockReleaseAll(); + XLogResetInsertion(); + ConditionVariableCancelSleep(); + pgaio_error_cleanup(); FlushErrorState(); + applied = false; } PG_END_TRY(); LeaveInlineUndoApplyState(saved_trans_state); @@ -1219,10 +1475,68 @@ AtSubAbort_XactUndo(int level) } else elog(DEBUG1, "subxact UNDO: head %X/%X is not a valid UNDO " - "batch, skipping eager rollback", + "batch, deferring to ATM", LSN_FORMAT_ARGS(sub_head)); + /* + * If the chain could not be applied in full, hand exactly this + * subtransaction's part of it to the logical revert worker. The + * parent may go on to commit, in which case nothing else would + * ever revert it. The entry is keyed by the subtransaction's own + * xid, so it cannot collide with a later entry for the top-level + * transaction, and bounded by parent_head, so the worker leaves + * the parent's batches alone. We cannot raise an error here (we + * are already aborting), and rolling the parent back instead + * would discard work the user did not roll back. + * + * Only the permanent chain can be handed off, as for a top-level + * abort. A revert worker cannot reach this backend's temporary + * relations, and an unlogged relation's UNDO is not applied by + * crash recovery either (its contents are reset), so there is no + * later point at which those chains could be completed; all we + * can do is report them. (No relation that writes UNDO today is + * temporary or unlogged: see RelationUsesIndexUndo().) + */ + if (!applied && i != UNDOPERSISTENCE_PERMANENT) + ereport(WARNING, + (errmsg("could not apply the %s UNDO of rolled-back subtransaction %u", + i == UNDOPERSISTENCE_TEMP ? "temporary" : "unlogged", + GetCurrentTransactionIdIfAny()), + errdetail("Its effects are left in place."))); + else if (!applied) + { + TransactionId subxid = GetCurrentTransactionIdIfAny(); + + /* + * Its oldest batch is the first one after parent_head; WAL + * from parent_head on covers it (or from the transaction's + * first batch, if the parent had written none). + */ + XLogRecPtr first = XLogRecPtrIsValid(parent_head) ? + parent_head : XactUndo.first_batch_lsn[i]; + + Assert(TransactionIdIsValid(subxid)); + if (!ATMAddAbortedBounded(subxid, MyDatabaseId, sub_head, + parent_head, first)) + elog(WARNING, + "ATM full: could not record rolled-back subtransaction %u", + subxid); + } + XactUndo.last_batch_lsn[i] = parent_head; + XactUndo.chain_bytes[i] = cur->chain_bytes[i]; + + /* + * The next batch chains onto the record set's last batch: point + * it back at the parent's, or a later walk of the chain would + * reach the batches just applied and apply them again. + */ + if (XactUndo.record_set[i] != NULL) + { + XactUndo.record_set[i]->last_batch_lsn = parent_head; + XactUndo.record_set[i]->prev_undo_ptr = (UndoRecPtr) parent_head; + } + PublishXactUndoChain(GetTopTransactionIdIfAny()); } /* @@ -1323,6 +1637,7 @@ XactUndo_SubXactCallback(SubXactEvent event, SubTransactionId mySubid, 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]; + s->chain_bytes[i] = XactUndo.chain_bytes[i]; } } break; @@ -1395,6 +1710,7 @@ ResetXactUndo(void) int i; XactUndo.has_undo = false; + XactUndo.needs_inline_abort = false; XactUndo.subxact_depth = 0; /* @@ -1417,6 +1733,7 @@ ResetXactUndo(void) { XactUndo.subxact_stack[0].start_location[i] = InvalidUndoRecPtr; XactUndo.subxact_stack[0].last_batch_lsn[i] = InvalidXLogRecPtr; + XactUndo.subxact_stack[0].chain_bytes[i] = 0; } } @@ -1425,7 +1742,15 @@ ResetXactUndo(void) XactUndo.record_set[i] = NULL; XactUndo.last_location[i] = InvalidUndoRecPtr; XactUndo.last_batch_lsn[i] = InvalidXLogRecPtr; + XactUndo.chain_bytes[i] = 0; + XactUndo.first_batch_lsn[i] = InvalidXLogRecPtr; } + + /* + * The chain is now the commit's, the abort's (applied, or handed to the + * ATM) or the 2PC state's; none of those needs the in-flight record. + */ + PublishXactUndoChain(InvalidTransactionId); } /* diff --git a/src/backend/commands/dbcommands.c b/src/backend/commands/dbcommands.c index 99549a79e74d3..ca16ce8bd73e3 100644 --- a/src/backend/commands/dbcommands.c +++ b/src/backend/commands/dbcommands.c @@ -220,6 +220,8 @@ CreateDatabaseUsingWalLog(Oid src_dboid, Oid dst_dboid, /* Copy relation storage from source to the destination. */ CreateAndCopyRelationData(srcrlocator, dstrlocator, relinfo->permanent); + INJECTION_POINT("createdb-wal-log-after-copy-relation", NULL); + /* Release the relation locks. */ UnlockRelationId(&srcrelid, AccessShareLock); UnlockRelationId(&dstrelid, AccessShareLock); @@ -473,10 +475,13 @@ CreateDirAndVersionFile(char *dbpath, Oid dbid, Oid tsid, bool isRedo) sprintf(buf, "%s\n", PG_MAJORVERSION); nbytes = strlen(PG_MAJORVERSION) + 1; - /* Create database directory. */ + /* + * Create database directory. The copy fills it through smgr, so an abort + * or crash before commit must remove the whole tree. + */ if (!isRedo) { - FileOpsMkdir(dbpath, pg_dir_create_mode); + FileOpsMkdirTree(dbpath, pg_dir_create_mode, dbid); } else { @@ -676,6 +681,8 @@ CreateDatabaseUsingFileCopy(Oid src_dboid, Oid dst_dboid, Oid src_tsid, */ copydir(srcpath, dstpath, false, true); + INJECTION_POINT("createdb-file-copy-after-copydir", NULL); + /* Record the filesystem change in XLOG */ { xl_dbase_create_file_copy_rec xlrec; @@ -2268,10 +2275,9 @@ movedb(const char *dbname, const char *tblspcname) * 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. + * database remains in src_tblspcoid. copydir() below recreates + * dst_dbpath with FileOpsMkdirTree(), so an abort or a crash before + * commit removes the partial copy. */ if (rmdir(dst_dbpath) != 0) elog(ERROR, "could not remove directory \"%s\": %m", diff --git a/src/backend/postmaster/bgworker.c b/src/backend/postmaster/bgworker.c index bbbc9c16078dd..785a29e8dca60 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/logical_revert_worker.h" #include "access/undoworker.h" #include "access/perbackend/pbu_undoworker.h" #include "access/perbackend/pbu_discardworker.h" @@ -170,6 +171,14 @@ static const struct .fn_name = "DataChecksumsWorkerMain", .fn_addr = DataChecksumsWorkerMain }, + { + .fn_name = "LogicalRevertWorkerMain", + .fn_addr = LogicalRevertWorkerMain + }, + { + .fn_name = "LogicalRevertLauncherMain", + .fn_addr = LogicalRevertLauncherMain + }, { .fn_name = "UndoWorkerMain", .fn_addr = UndoWorkerMain diff --git a/src/backend/postmaster/postmaster.c b/src/backend/postmaster/postmaster.c index f53aa9c404d5f..1fd9ba697cc62 100644 --- a/src/backend/postmaster/postmaster.c +++ b/src/backend/postmaster/postmaster.c @@ -89,6 +89,8 @@ #include #endif +#include "access/logical_revert_worker.h" +#include "access/undolog.h" #include "access/xlog.h" #include "access/xlog_internal.h" #include "access/xlogrecovery.h" @@ -924,6 +926,13 @@ PostmasterMain(int argc, char *argv[]) */ ApplyLauncherRegister(); + /* + * The Logical Revert Launcher wires up the async physical-undo-apply + * path. The launcher scans pg_database once at startup and spawns a + * per-database LogicalRevertWorker which drains the ATM. + */ + LogicalRevertLauncherRegister(); + /* * Register the shared memory needs of all core subsystems. */ diff --git a/src/backend/storage/file/README b/src/backend/storage/file/README index cba6f1dd6233a..ef400330c661e 100644 --- a/src/backend/storage/file/README +++ b/src/backend/storage/file/README @@ -31,6 +31,7 @@ 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 + FileOpsMkdirTree -- mkdir() now; UNDO removes the whole tree 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 @@ -45,7 +46,6 @@ 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. @@ -100,12 +100,7 @@ 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. +One deliberate exception: SYMLINK redo is an intentional no-op. FileOpsSymlink is used only for tablespace symlinks; standby/crash-recovery replay of those is owned by @@ -155,14 +150,16 @@ 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). + at_commit (FileOpsDelete, FileOpsRmdir, FileOpsRmdirRecursive). + at_commit = true removes on commit (dropping state the transaction is + logically deleting); at_commit = false removes only on abort. -FileOpsRmtree defers its tree walk to execute time (removing whatever ended -up on disk by commit/abort), where FileOpsRmdirRecursive enumerates contents -at registration time. +FileOpsMkdirTree creates a directory that the transaction then fills by +other means (smgr for CREATE DATABASE, copydir() for FILE_COPY and ALTER +DATABASE SET TABLESPACE). Its UNDO record and its abort-time pending op +remove the whole tree, whatever ended up in it, where FileOpsMkdir's only +rmdir() an empty directory. For a database directory the UNDO first drops +that database's shared buffers and pending fsync requests. The API hides platform differences: PG_O_DIRECT for unbuffered I/O, pg_fsync() for the correct per-platform flush, fsync_parent_path() for diff --git a/src/backend/storage/file/copydir.c b/src/backend/storage/file/copydir.c index 3c716c84bb0e3..4eb46d3498a41 100644 --- a/src/backend/storage/file/copydir.c +++ b/src/backend/storage/file/copydir.c @@ -25,6 +25,7 @@ #include #include "access/xlog.h" +#include "common/file_perm.h" #include "common/file_utils.h" #include "miscadmin.h" #include "pgstat.h" @@ -48,11 +49,9 @@ static void clone_file(const char *fromfile, const char *tofile); * 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. + * destination is created with FileOpsMkdirTree(), so that a transaction + * abort, or a crash before commit, removes the partial copy. Recovery + * callers pass false because they are themselves the recovery action. */ void copydir(const char *fromdir, const char *todir, bool recurse, @@ -63,21 +62,13 @@ copydir(const char *fromdir, const char *todir, bool recurse, char fromfile[MAXPGPATH * 2]; char tofile[MAXPGPATH * 2]; - if (MakePGDirectory(todir) != 0) + if (register_for_abort_cleanup && !RecoveryInProgress()) + FileOpsMkdirTree(todir, pg_dir_create_mode, InvalidOid); + else if (MakePGDirectory(todir) != 0) ereport(ERROR, (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) @@ -100,11 +91,8 @@ copydir(const char *fromdir, const char *todir, bool recurse, { /* * 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. + * register_for_abort_cleanup: removing the top-level destination + * tree removes them too. */ if (recurse) copydir(fromfile, tofile, true, false); diff --git a/src/backend/storage/file/fileops.c b/src/backend/storage/file/fileops.c index 09d8ffe1ae769..9dd21e17d8a59 100644 --- a/src/backend/storage/file/fileops.c +++ b/src/backend/storage/file/fileops.c @@ -20,6 +20,26 @@ * WAL and UNDO are written only when XLogIsNeeded() (wal_level >= replica); * at wal_level = minimal there is no crash or 2PC safety. * + * An immediate op performs its system call FIRST, outside any critical + * section, and WAL-logs itself only once the call has succeeded: the forward + * WAL record and its UNDO record are inserted and flushed together in a short + * critical section (fileops_log()). So an ordinary filesystem error + * (permission denied, no space, ...) is a plain ERROR, as it is for the same + * call without FILEOPS, and a failed op leaves nothing in the WAL for redo or + * a standby to replay. + * + * This is NOT atomic. Between the system call and the WAL flush there is a + * window in which the change is on disk with no record of it: a crash there + * leaves the change in place, unknown to redo, to a standby and to rollback, + * just as the bare system call would. Nothing reverts it. The + * opposite order (log, then act inside a critical section) closes that window + * only by turning every filesystem error into a PANIC. + * + * Reverting checks the object at the recorded path before touching it (see + * fileops_undo.c), but a path, its type and, where recorded, its inode are + * evidence that the object is the one this transaction made, not proof: + * something else can have replaced it in between. + * * 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 @@ -88,12 +108,14 @@ #include "port/pg_xattr.h" #include "storage/fd.h" #include "storage/fileops.h" +#include "storage/proc.h" #include "utils/injection_point.h" #include "utils/memutils.h" /* Head of the pending file operations linked list */ static PendingFileOp *pendingFileOps = NULL; +static Size inverseBytes = 0; static void FileOpsExecOne(PendingFileOp *pending, bool isCommit, XLogRecPtr *max_lsn); @@ -127,6 +149,57 @@ fileops_fsync_parent(const char *fname, int elevel) } } +/* + * fileops_pathlen - Length of a path including its terminating NUL + * + * WAL and UNDO records store path lengths in a uint16 and the UNDO payload + * buffers are sized for MAXPGPATH, so refuse a longer path rather than + * truncate it (or overrun a buffer). Called before the system call, so a + * refused path changes nothing. + */ +static int +fileops_pathlen(const char *path) +{ + size_t len = strlen(path) + 1; + + /* Reserve room before any mutation; abort itself must never exceed WAL. */ + if (inverseBytes + 4 * MAXPGPATH + 2 * FILEOPS_MAX_DATA_BYTES > + FILEOPS_MAX_ABORT_BYTES) + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("too much transactional file operation undo data"))); + + if (len > MAXPGPATH) + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("file operation path length %zu exceeds maximum %d", + len, MAXPGPATH))); + return (int) len; +} + +/* + * fileops_log - WAL-log an immediate operation that has already succeeded + * + * The caller has made the filesystem change, prepared its UNDO record (if + * any, undo_ctx != NULL) with PrepareXactUndoData(), and registered the + * forward record's data after XLogBeginInsert(). Insert both and flush them + * through the end of the UNDO batch, so that a crash leaves either both or + * neither. Only this is a critical section; see the file header comment. + */ +static void +fileops_log(uint8 info, XactUndoContext *undo_ctx) +{ + START_CRIT_SECTION(); + (void) XLogInsert(RM_FILEOPS_ID, info); + if (undo_ctx != NULL) + { + InsertXactUndoData(undo_ctx); + CleanupXactUndoInsertion(undo_ctx); + } + XLogFlush(XactLastRecEnd); + END_CRIT_SECTION(); +} + /* * AddPendingFileOp - Add a new pending file operation to the list * @@ -198,12 +271,95 @@ AddPendingFileOpWithData(PendingFileOpType type, const char *path, MemoryContextSwitchTo(oldcxt); } +/* Keep the same inverse for online abort, abort redo, and crash losers. */ +static void +fileops_prepare_undo(XactUndoContext *ctx, char persistence, uint8 rmid, + uint16 info, Oid reloid, const char *payload, Size len) +{ + AddPendingFileOpWithData(PENDING_FILEOP_INVERSE, "", NULL, + info, payload, len, false); + inverseBytes += len + 2 * sizeof(uint32); + PrepareXactUndoData(ctx, persistence, rmid, info, reloid, payload, len); +} + +/* Serialize in execution (newest-first) order, without alignment assumptions. */ +void +FileOpsGetAbortData(char **data, uint32 *len) +{ + PendingFileOp *p; + StringInfoData buf; + int level = GetCurrentTransactionNestLevel(); + + *data = NULL; + *len = 0; + if (inverseBytes == 0) + return; + initStringInfo(&buf); + for (p = pendingFileOps; p; p = p->next) + { + uint32 info = p->length; + uint32 size = p->data_len; + + if (p->type != PENDING_FILEOP_INVERSE || p->nestLevel < level) + continue; + appendBinaryStringInfo(&buf, &info, sizeof(info)); + appendBinaryStringInfo(&buf, &size, sizeof(size)); + appendBinaryStringInfo(&buf, p->data, size); + } + *data = buf.data; + *len = buf.len; +} + +/* + * CreateCheckPoint waits for this flag after choosing its redo pointer. Even + * though inverses ride in abort WAL, a checkpoint must not omit that record + * until the corresponding filesystem changes have been fsynced. A database + * directory tree can make this interval long; there is no retry loop here. + */ +void +FileOpsBeginAbort(void) +{ + MyProc->delayChkptFlags |= DELAY_CHKPT_START; +} + +void +FileOpsEndAbort(void) +{ + MyProc->delayChkptFlags &= ~DELAY_CHKPT_START; +} + +/* Apply the self-contained abort payload, also used during WAL replay. */ +void +FileOpsApplyAbort(const char *data, uint32 len) +{ + FileOpsResetAbortFailure(); + while (len > 0) + { + uint32 info; + uint32 size; + + if (len < 2 * sizeof(uint32)) + elog(PANIC, "invalid FILEOPS abort payload"); + memcpy(&info, data, sizeof(info)); + memcpy(&size, data + sizeof(info), sizeof(size)); + data += 2 * sizeof(uint32); + len -= 2 * sizeof(uint32); + if (size > len) + elog(PANIC, "invalid FILEOPS inverse length"); + FileOpsApplyInverse(info, data, size); + data += size; + len -= size; + } +} + /* * FreePendingFileOp - Free a pending file operation entry */ static void FreePendingFileOp(PendingFileOp *pending) { + if (pending->type == PENDING_FILEOP_INVERSE) + inverseBytes -= pending->data_len + 2 * sizeof(uint32); if (pending->path) pfree(pending->path); if (pending->newpath) @@ -276,116 +432,60 @@ FileOpsCancelPendingDelete(const char *path, bool at_commit) * - 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. + * Returns the file descriptor; failing to create the file is an ERROR. */ 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; + int pathlen = fileops_pathlen(path); 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) + 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 (register_delete && !XLogIsNeeded()) + AddPendingFileOp(PENDING_FILEOP_DELETE, path, NULL, 0, false); + + 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 (XLogIsNeeded()) + { + xl_fileops_create xlrec; + char undo_payload[sizeof(FileopsUndoCreate) + MAXPGPATH]; FileopsUndoCreate *hdr = (FileopsUndoCreate *) undo_payload; + XactUndoContext undo_ctx; 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, + fileops_prepare_undo(&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))); + fileops_log(XLOG_FILEOPS_CREATE, &undo_ctx); - 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)); + INJECTION_POINT("fileops-create-after-wal-flush", NULL); } - if (need_wal) - END_CRIT_SECTION(); - - if (register_delete) - AddPendingFileOp(PENDING_FILEOP_DELETE, path, NULL, 0, false); - return fd; } @@ -436,20 +536,48 @@ FileOpsRename(const char *oldpath, const char *newpath) return 0; } +/* Capture a bounded range before changing it, including shrink tails. */ +static char * +fileops_before_image(int fd, const char *path, off_t offset, Size len) +{ + char *data; + + if (len > FILEOPS_MAX_DATA_BYTES) + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("file operation before-image exceeds %d bytes", + FILEOPS_MAX_DATA_BYTES))); + data = palloc(len ? len : 1); + if (len && pg_pread(fd, data, len, offset) != len) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not read before-image of file \"%s\": %m", path))); + return data; +} + /* * 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. + * Bounded before-images restore overwritten bytes and the original length. + * + * Returns 0; a failed write is an ERROR. */ int FileOpsWrite(const char *path, off_t offset, const void *data, uint32 len) { int fd; ssize_t nbytes; - bool need_wal = XLogIsNeeded(); + int pathlen = fileops_pathlen(path); + struct stat st; + char *before; + uint32 before_len; + char *payload; + Size payload_len; + FileopsUndoWrite *hdr; + XactUndoContext undo_ctx; Assert(!IsInParallelMode()); @@ -459,39 +587,29 @@ FileOpsWrite(const char *path, off_t offset, const void *data, uint32 len) (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); - } + if (offset < 0 || len > FILEOPS_MAX_DATA_BYTES || + offset > PG_INT64_MAX - len) + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("invalid or excessive transactional file write range"))); + if (fstat(fd, &st) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not stat file \"%s\": %m", path))); + before_len = offset < st.st_size ? Min((off_t) len, st.st_size - offset) : 0; + before = fileops_before_image(fd, path, offset, before_len); + payload_len = sizeof(FileopsUndoWrite) + pathlen + before_len + len; + payload = palloc0(payload_len); + hdr = (FileopsUndoWrite *) payload; + hdr->orig_length = st.st_size; + hdr->new_length = Max(st.st_size, offset + len); + hdr->offset = offset; + hdr->before_len = before_len; + hdr->after_len = len; + hdr->path_len = pathlen; + memcpy(payload + sizeof(*hdr), path, pathlen); + memcpy(payload + sizeof(*hdr) + pathlen, before, before_len); + memcpy(payload + sizeof(*hdr) + pathlen + before_len, data, len); nbytes = pg_pwrite(fd, data, len, offset); if (nbytes != (ssize_t) len) @@ -517,14 +635,35 @@ FileOpsWrite(const char *path, off_t offset, const void *data, uint32 len) 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))); + if (XLogIsNeeded()) + { + xl_fileops_write xlrec; + + fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT, + UNDO_RMID_FILEOPS, FILEOPS_UNDO_WRITE, + InvalidOid, payload, payload_len); + xlrec.orig_length = st.st_size; + xlrec.before_len = before_len; + xlrec.offset = offset; + xlrec.len = len; + xlrec.path_len = (uint16) pathlen; + + XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfFileOpsWrite); + XLogRegisterData(path, pathlen); + XLogRegisterData(data, len); + if (before_len) + XLogRegisterData(before, before_len); + fileops_log(XLOG_FILEOPS_WRITE, &undo_ctx); + } + + pfree(before); + pfree(payload); return 0; } @@ -532,7 +671,8 @@ FileOpsWrite(const char *path, off_t offset, const void *data, uint32 len) * FileOpsTruncate - Truncate a file within a transaction * * Executed immediately and WAL-logged. Uses ftruncate() on POSIX, - * SetEndOfFile() on Windows. File is fsynced after truncation. + * SetEndOfFile() on Windows. File is fsynced after truncation. The + * original size is restored if the transaction aborts. */ void FileOpsTruncate(const char *path, off_t length) @@ -540,7 +680,9 @@ FileOpsTruncate(const char *path, off_t length) int fd; struct stat st; off_t orig_length = 0; - bool need_wal = XLogIsNeeded(); + int pathlen = fileops_pathlen(path); + char *before; + uint32 before_len; Assert(!IsInParallelMode()); @@ -548,76 +690,18 @@ FileOpsTruncate(const char *path, off_t length) 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 (length < 0) + ereport(ERROR, (errmsg("negative file truncation length"))); + before_len = orig_length > length ? + Min(orig_length - length, (off_t) FILEOPS_MAX_DATA_BYTES + 1) : 0; + before = fileops_before_image(fd, path, length, before_len); + if (ftruncate(fd, length) < 0) { int save_errno = errno; @@ -630,6 +714,14 @@ FileOpsTruncate(const char *path, off_t length) path, (long long) length))); } + /* + * Register abort-time pending op to restore original size on rollback. + * This provides immediate rollback without waiting for the background + * UNDO worker. + */ + if (!XLogIsNeeded()) + AddPendingFileOp(PENDING_FILEOP_TRUNCATE, path, NULL, orig_length, false); + if (enableFsync && pg_fsync(fd) != 0) { int save_errno = errno; @@ -642,20 +734,42 @@ FileOpsTruncate(const char *path, off_t length) 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); + if (XLogIsNeeded()) + { + xl_fileops_truncate xlrec; + XactUndoContext undo_ctx; + char *payload = palloc0(sizeof(FileopsUndoTruncate) + pathlen + before_len); + FileopsUndoTruncate *hdr = (FileopsUndoTruncate *) payload; + + xlrec.before_len = before_len; + xlrec.length = length; + xlrec.orig_length = orig_length; + + hdr->before_len = before_len; + hdr->orig_length = orig_length; + hdr->new_length = length; + hdr->path_len = (uint16) pathlen; + memcpy(payload + sizeof(FileopsUndoTruncate), path, pathlen); + memcpy(payload + sizeof(FileopsUndoTruncate) + pathlen, before, before_len); + fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT, + UNDO_RMID_FILEOPS, FILEOPS_UNDO_TRUNCATE, + InvalidOid, payload, + sizeof(FileopsUndoTruncate) + pathlen + before_len); + + XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfFileOpsTruncate); + XLogRegisterData(path, pathlen); + if (before_len) + XLogRegisterData(before, before_len); + fileops_log(XLOG_FILEOPS_TRUNCATE, &undo_ctx); + pfree(payload); + } + pfree(before); } /* @@ -666,102 +780,62 @@ FileOpsTruncate(const char *path, off_t length) * (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. + * Returns 0 on success, -1 on failure with errno left set, so the caller + * can tell ENOENT from other errors and report it as it would a plain + * chmod() failure (tablespace.c does). Nothing is logged or registered + * when it fails, because nothing changed. */ int FileOpsChmod(const char *path, mode_t mode) { struct stat st; mode_t orig_mode; - bool need_wal; + int pathlen = fileops_pathlen(path); Assert(!IsInParallelMode()); - /* Capture original mode for UNDO before chmod, and preflight ENOENT */ + /* Capture original mode for UNDO before chmod */ 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 (chmod(path, mode) < 0) + return -1; /* leave errno set by chmod() for the caller */ + + /* + * Register abort-time pending op to restore original mode on rollback. + * Store mode in the length field (mode_t fits in off_t). + */ + if (!XLogIsNeeded()) + AddPendingFileOp(PENDING_FILEOP_CHMOD, path, NULL, (off_t) orig_mode, false); - if (need_wal) + if (XLogIsNeeded()) { 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->new_mode = mode & 07777; 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, + fileops_prepare_undo(&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); + fileops_log(XLOG_FILEOPS_CHMOD, &undo_ctx); - XLogFlush(lsn); - - INJECTION_POINT_CACHED("fileops-chmod-after-wal-flush", NULL); + INJECTION_POINT("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; } @@ -772,7 +846,7 @@ FileOpsChmod(const char *path, mode_t mode) * On POSIX: chown(). On Windows: no-op with WARNING (Windows uses * ACLs for ownership, not uid/gid). * - * Returns 0 on success. + * Returns 0; a failed chown() is an ERROR. */ int FileOpsChown(const char *path, uid_t uid, gid_t gid) @@ -780,7 +854,7 @@ 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(); + int pathlen = fileops_pathlen(path); Assert(!IsInParallelMode()); @@ -791,67 +865,49 @@ FileOpsChown(const char *path, uid_t uid, gid_t gid) 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) +#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))); + return 0; /* nothing changed, so nothing to log */ +#endif + + if (XLogIsNeeded()) { 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; + /* chown() leaves an id of -1 unchanged */ + hdr->new_uid = (uid == (uid_t) -1) ? orig_uid : uid; + hdr->new_gid = (gid == (gid_t) -1) ? orig_gid : 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, + fileops_prepare_undo(&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); + fileops_log(XLOG_FILEOPS_CHOWN, &undo_ctx); } -#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; } @@ -862,77 +918,95 @@ FileOpsSync(const char *path) } /* - * 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). + * fileops_mkdir - Create a directory within a transaction * - * Returns 0 on success. + * Shared by FileOpsMkdir() and FileOpsMkdirTree(); see there. */ -int -FileOpsMkdir(const char *path, mode_t mode) +static int +fileops_mkdir(const char *path, mode_t mode, uint16 flags, Oid dboid) { - bool need_wal = XLogIsNeeded(); + int pathlen = fileops_pathlen(path); 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) + if (MakePGDirectory(path) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create directory \"%s\": %m", path))); + + /* Remove the directory (or tree) if the transaction aborts */ + if (!XLogIsNeeded()) + AddPendingFileOp((flags & FILEOPS_MKDIR_TREE) ? + PENDING_FILEOP_RMTREE : PENDING_FILEOP_RMDIR, + path, NULL, 0, false); + + if (enableFsync) + fileops_fsync_parent(path, data_sync_elevel(ERROR)); + + if (XLogIsNeeded()) { xl_fileops_mkdir xlrec; - int pathlen; XactUndoContext undo_ctx; char payload[sizeof(FileopsUndoMkdir) + MAXPGPATH]; FileopsUndoMkdir *hdr = (FileopsUndoMkdir *) payload; - XLogRecPtr lsn; + struct stat st; xlrec.mode = mode; - pathlen = (int) strlen(path) + 1; + xlrec.flags = flags; + if (stat(path, &st) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not stat directory \"%s\": %m", path))); + hdr->dev = st.st_dev; + hdr->ino = st.st_ino; + hdr->dboid = dboid; + hdr->flags = flags; 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, + fileops_prepare_undo(&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); + fileops_log(XLOG_FILEOPS_MKDIR, &undo_ctx); } - 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(); + return 0; +} - /* Register rmdir-on-abort so directory is cleaned up on rollback */ - AddPendingFileOp(PENDING_FILEOP_RMDIR, path, NULL, 0, false); +/* + * FileOpsMkdir - Create a directory within a transaction + * + * Immediate execution; the directory is removed (rmdir) if the transaction + * aborts, so it must then be empty. + * Uses MakePGDirectory() pattern (mkdir with pg_dir_create_mode). + * On Windows: _mkdir() (no mode parameter, permissions from parent). + * + * Returns 0; a failed mkdir() is an ERROR. + */ +int +FileOpsMkdir(const char *path, mode_t mode) +{ + return fileops_mkdir(path, mode, 0, InvalidOid); +} - return 0; +/* + * FileOpsMkdirTree - Create a directory the transaction will fill + * + * Like FileOpsMkdir(), but if the transaction aborts the whole tree is + * removed, including files created in it by other means (smgr, copydir()). + * For a database directory pass its OID as dboid: before the tree is removed + * by the UNDO record, that database's shared buffers and pending fsync + * requests are dropped, as they would be by DROP DATABASE. + */ +int +FileOpsMkdirTree(const char *path, mode_t mode, Oid dboid) +{ + return fileops_mkdir(path, mode, FILEOPS_MKDIR_TREE, dboid); } /* @@ -953,27 +1027,6 @@ FileOpsRmdir(const char *path, bool at_commit) 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. * @@ -1032,74 +1085,57 @@ FileOpsRmdirRecursive(const char *path, bool at_commit) * junction points via DeviceIoControl(). Note: junction points only * work for directories on Windows. * - * Returns 0 on success. + * Returns 0; a failed symlink() is an ERROR. */ int FileOpsSymlink(const char *target, const char *linkpath) { - bool need_wal = XLogIsNeeded(); + int targetlen = fileops_pathlen(target); + int linkpathlen = fileops_pathlen(linkpath); 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) + if (symlink(target, linkpath) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create symbolic link \"%s\" -> \"%s\": %m", + linkpath, target))); + + /* Register delete-on-abort to clean up the symlink on rollback */ + if (!XLogIsNeeded()) + AddPendingFileOp(PENDING_FILEOP_DELETE, linkpath, NULL, 0, false); + + if (enableFsync) + fileops_fsync_parent(linkpath, data_sync_elevel(ERROR)); + + if (XLogIsNeeded()) { xl_fileops_symlink xlrec; - int targetlen; - int linkpathlen; XactUndoContext undo_ctx; - char payload[sizeof(FileopsUndoSymlink) + MAXPGPATH]; + char payload[sizeof(FileopsUndoSymlink) + 2 * MAXPGPATH]; FileopsUndoSymlink *hdr = (FileopsUndoSymlink *) payload; - XLogRecPtr lsn; - targetlen = (int) strlen(target) + 1; - linkpathlen = (int) strlen(linkpath) + 1; xlrec.target_len = (uint16) targetlen; + /* the target lets the UNDO recognize the link it would remove */ hdr->linkpath_len = (uint16) linkpathlen; + hdr->target_len = (uint16) targetlen; memcpy(payload + sizeof(FileopsUndoSymlink), linkpath, linkpathlen); - - /* PrepareXactUndoData() may palloc; must run before the crit section */ - PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT, + memcpy(payload + sizeof(FileopsUndoSymlink) + linkpathlen, target, + targetlen); + fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT, UNDO_RMID_FILEOPS, FILEOPS_UNDO_SYMLINK, InvalidOid, payload, - sizeof(FileopsUndoSymlink) + linkpathlen); - - START_CRIT_SECTION(); + sizeof(FileopsUndoSymlink) + linkpathlen + + targetlen); 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); + fileops_log(XLOG_FILEOPS_SYMLINK, &undo_ctx); } - 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; } @@ -1109,74 +1145,64 @@ FileOpsSymlink(const char *target, const char *linkpath) * Immediate execution. Registers delete-on-abort for cleanup. * On POSIX: link(). On Windows: CreateHardLinkA() (NTFS only). * - * Returns 0 on success. + * Returns 0; a failed link() is an ERROR. */ int FileOpsLink(const char *oldpath, const char *newpath) { - bool need_wal = XLogIsNeeded(); + int oldpathlen = fileops_pathlen(oldpath); + int newpathlen = fileops_pathlen(newpath); + struct stat st; 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. + * The new link will be this inode; the UNDO removes newpath only while it + * still is. */ - if (need_wal) + if (stat(oldpath, &st) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not stat file \"%s\": %m", oldpath))); + + if (link(oldpath, newpath) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create hard link \"%s\" -> \"%s\": %m", + newpath, oldpath))); + + /* Register delete-on-abort to clean up the link on rollback */ + if (!XLogIsNeeded()) + AddPendingFileOp(PENDING_FILEOP_DELETE, newpath, NULL, 0, false); + + if (enableFsync) + fileops_fsync_parent(newpath, data_sync_elevel(ERROR)); + + if (XLogIsNeeded()) { 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->dev = st.st_dev; + hdr->ino = st.st_ino; 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, + fileops_prepare_undo(&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); + fileops_log(XLOG_FILEOPS_LINK, &undo_ctx); } - 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; } @@ -1191,40 +1217,10 @@ FileOpsLink(const char *oldpath, const char *newpath) * - 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). + * Returns 0 on success. Returns -1 with errno set if the filesystem does + * not support extended attributes (ENOTSUP/EOPNOTSUPP) or refuses this one + * (EPERM/EACCES); any other failure is an ERROR. Nothing is logged or + * registered when it fails, because nothing changed. */ int FileOpsSetXattr(const char *path, const char *name, @@ -1233,95 +1229,70 @@ FileOpsSetXattr(const char *path, const char *name, bool had_value = false; char *orig_value = NULL; ssize_t orig_value_len = 0; - bool need_wal; - struct stat st; + int pathlen = fileops_pathlen(path); + ssize_t vlen; 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. + * Capture existing xattr value before overwriting, for the abort-time + * pending op and the UNDO record. If the attribute doesn't exist, + * had_value stays false. */ + vlen = pg_getxattr(path, name, NULL, 0); + if (vlen > 0) { - 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 */ + orig_value = (char *) palloc(vlen); + orig_value_len = pg_getxattr(path, name, orig_value, vlen); + if (orig_value_len >= 0) had_value = true; - } - else if (errno == ENOTSUP || errno == EOPNOTSUPP) + else { - /* - * Whole-filesystem capability failure; safe to preflight (see - * above) - */ - return -1; + pfree(orig_value); + orig_value = NULL; + orig_value_len = 0; } - - /* - * else: ENODATA/PG_ENOATTR (attribute absent) or other; - * had_value=false - */ } + else if (vlen == 0) + { + /* Attribute exists but has zero-length value */ + had_value = true; + } + + if (pg_setxattr(path, name, value, len) < 0) + { + int save_errno = errno; - need_wal = XLogIsNeeded(); + if (orig_value) + pfree(orig_value); + errno = save_errno; + if (errno == ENOTSUP || errno == EOPNOTSUPP || + errno == EPERM || errno == EACCES) + return -1; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not set extended attribute \"%s\" on \"%s\": %m", + name, path))); + } /* - * 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. + * Register abort-time pending op to restore/remove xattr on rollback. If + * had_value: restore original value. If !had_value: remove the xattr. */ - if (need_wal) + if (!XLogIsNeeded() && had_value) + AddPendingFileOpWithData(PENDING_FILEOP_SETXATTR, path, name, + 0, orig_value, (size_t) orig_value_len, false); + else if (!XLogIsNeeded()) + AddPendingFileOp(PENDING_FILEOP_REMOVEXATTR, path, name, 0, false); + + if (XLogIsNeeded()) { xl_fileops_setxattr xlrec; - int pathlen; - int namelen; + int namelen = (int) strlen(name) + 1; 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; @@ -1330,18 +1301,14 @@ FileOpsSetXattr(const char *path, const char *name, 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. - */ + (had_value ? orig_value_len : 0) + len; 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->new_value_len = (uint32) len; hdr->had_value = had_value; memcpy(payload + sizeof(FileopsUndoSetxattr), path, pathlen); @@ -1349,13 +1316,14 @@ FileOpsSetXattr(const char *path, const char *name, if (had_value && orig_value_len > 0) memcpy(payload + sizeof(FileopsUndoSetxattr) + pathlen + namelen, orig_value, orig_value_len); + if (len > 0) + memcpy(payload + sizeof(FileopsUndoSetxattr) + pathlen + namelen + + hdr->orig_value_len, value, len); - PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT, + fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT, UNDO_RMID_FILEOPS, FILEOPS_UNDO_SETXATTR, InvalidOid, payload, payload_len); - START_CRIT_SECTION(); - XLogBeginInsert(); XLogRegisterData(&xlrec, SizeOfFileOpsSetxattr); XLogRegisterData(path, pathlen); @@ -1363,38 +1331,10 @@ FileOpsSetXattr(const char *path, const char *name, 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); + fileops_log(XLOG_FILEOPS_SETXATTR, &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); @@ -1405,119 +1345,90 @@ FileOpsSetXattr(const char *path, const char *name, * FileOpsRemoveXattr - Remove an extended attribute from a file * * Immediate execution, WAL-logged. - * Uses pg_removexattr() portability layer. + * Uses pg_removexattr() portability layer. Removing an attribute that does + * not exist (PG_ENOATTR) succeeds. * * 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. + * this filesystem/platform (errno left as ENOTSUP/EOPNOTSUPP); any other + * failure is an ERROR. Nothing is logged or registered when it fails. */ int FileOpsRemoveXattr(const char *path, const char *name) { char *saved_value = NULL; ssize_t saved_value_len = 0; - bool need_wal; + int pathlen = fileops_pathlen(path); + ssize_t vlen; 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. + * Capture existing xattr value before removal, to restore it on rollback. + * A NULL saved_value means there was none. */ + vlen = pg_getxattr(path, name, NULL, 0); + if (vlen > 0) { - 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) { - 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 */ + pfree(saved_value); + saved_value = NULL; 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 - */ } + else if (vlen == 0) + { + /* Attribute exists but has zero-length value */ + saved_value_len = 0; + saved_value = (char *) palloc(1); /* non-NULL marker */ + } + + if (pg_removexattr(path, name) < 0 && errno != PG_ENOATTR) + { + int save_errno = errno; - need_wal = XLogIsNeeded(); + if (saved_value) + pfree(saved_value); + errno = save_errno; + if (errno == ENOTSUP || errno == EOPNOTSUPP) + return -1; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not remove extended attribute \"%s\" from \"%s\": %m", + name, path))); + } /* - * 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. + * 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 (need_wal) + if (!XLogIsNeeded() && saved_value != NULL) + AddPendingFileOpWithData(PENDING_FILEOP_SETXATTR, path, name, + 0, saved_value, (size_t) saved_value_len, false); + + if (XLogIsNeeded()) { xl_fileops_removexattr xlrec; - int pathlen; - int namelen; + int namelen = (int) strlen(name) + 1; 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) + if (had_value) { + Size payload_len; 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; @@ -1532,53 +1443,22 @@ FileOpsRemoveXattr(const char *path, const char *name) memcpy(payload + sizeof(FileopsUndoRemovexattr) + pathlen + namelen, saved_value, saved_value_len); - PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT, + fileops_prepare_undo(&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); + fileops_log(XLOG_FILEOPS_REMOVEXATTR, had_value ? &undo_ctx : NULL); + if (payload) 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); @@ -1712,6 +1592,8 @@ FileOpsExecOne(PendingFileOp *pending, bool isCommit, XLogRecPtr *max_lsn) { switch (pending->type) { + case PENDING_FILEOP_INVERSE: + break; case PENDING_FILEOP_CREATE: /* Creates are executed immediately, nothing to do */ break; @@ -1746,9 +1628,7 @@ FileOpsExecOne(PendingFileOp *pending, bool isCommit, XLogRecPtr *max_lsn) * 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. + * standby, diverging the two. */ XLogFlush(*max_lsn); } @@ -1832,13 +1712,17 @@ FileOpsExecOne(PendingFileOp *pending, bool isCommit, XLogRecPtr *max_lsn) 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. + * Abort-time removal of a FileOpsMkdirTree() directory and + * whatever ended up in it. The UNDO record, applied earlier + * in the abort, or the caller's own error cleanup has usually + * removed it already. */ + { + struct stat st; + + if (lstat(pending->path, &st) != 0 || !S_ISDIR(st.st_mode)) + break; + } if (!rmtree(pending->path, true)) ereport(WARNING, (errmsg("could not remove directory tree \"%s\"", @@ -2049,20 +1933,29 @@ typedef struct 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 + * pending at_commit=true op (RENAME/DELETE/RMDIR) 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 (ATMAddAborted() in - * FinishPreparedTransaction()). + * The immediate-operation inverse list is serialized separately. ROLLBACK + * PREPARED includes it in its abort record and applies it before releasing + * the prepared transaction's locks. */ void FileOpsAtPrepare(void) { PendingFileOp *pending; + char *data; + uint32 len; + + FileOpsGetAbortData(&data, &len); + if (len) + RegisterTwoPhaseRecord(TWOPHASE_RM_FILEOPS_ID, + FILEOPS_TWOPHASE_INVERSES, data, len); + if (data) + pfree(data); + for (pending = pendingFileOps; pending != NULL; pending = pending->next) { @@ -2074,17 +1967,7 @@ FileOpsAtPrepare(void) 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, handed - * to the async revert machinery via ATMAddAborted() in - * FinishPreparedTransaction(). Serializing them here too would - * double-revert and race with that async path, so skip them. - */ + /* Immediate inverses were serialized as one blob above. */ if (!pending->at_commit) continue; @@ -2184,21 +2067,23 @@ void fileops_twophase_postcommit(FullTransactionId fxid, uint16 info, void *recdata, uint32 len) { - fileops_twophase_do(true, recdata, len); + if (info != FILEOPS_TWOPHASE_INVERSES) + 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. + * Immediate inverses are applied with the abort decision, not here. Deferred + * commit-time operations are discarded. */ void fileops_twophase_postabort(FullTransactionId fxid, uint16 info, void *recdata, uint32 len) { - fileops_twophase_do(false, recdata, len); + /* Inverses were applied with the durable abort decision, before unlock. */ + if (info != FILEOPS_TWOPHASE_INVERSES) + fileops_twophase_do(false, recdata, len); } /* diff --git a/src/backend/storage/file/fileops_undo.c b/src/backend/storage/file/fileops_undo.c index 5d8a92438c45a..6ed41d18ad2fc 100644 --- a/src/backend/storage/file/fileops_undo.c +++ b/src/backend/storage/file/fileops_undo.c @@ -4,25 +4,26 @@ * 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. + * operations. The inverse payloads cover structural changes and bounded + * before-images for overwritten or truncated file contents. * * On transaction abort, each FILEOPS UNDO record reverses the structural - * change made by the corresponding FileOps* function: + * change made by the corresponding FileOps* function, if the object still + * shows that change (see fileops_undo_apply): * - CREATE: unlink the created file - * - RENAME: rename back to the original path - * - TRUNCATE: ftruncate to original length + * - WRITE: restore overwritten bytes and original length + * - TRUNCATE: restore removed bytes and original length * - CHMOD: chmod to original mode * - CHOWN: chown to original uid/gid - * - MKDIR: rmdir the created directory + * - MKDIR: rmdir the created directory, or remove its whole tree * - 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. + * DELETE, RENAME 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 @@ -39,9 +40,15 @@ #include "access/undormgr.h" #include "access/xact.h" +#include "access/transam.h" +#include "access/xlog.h" +#include "access/xlogutils.h" #include "port/pg_xattr.h" +#include "miscadmin.h" +#include "storage/bufmgr.h" #include "storage/fd.h" #include "storage/fileops.h" +#include "storage/md.h" #include "utils/injection_point.h" /* @@ -49,6 +56,9 @@ * shared between this file (apply side) and fileops.c (insert side). */ +static bool applyingAbort = false; +static bool abortApplyFailed = false; + /* Forward declarations */ static UndoApplyResult fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, @@ -98,19 +108,95 @@ FileopsUndoRmgrInit(void) PendingPhysOpsAtSubAbort_hook = AtSubAbort_FileOps; } +/* + * fileops_undo_lstat - lstat() for an UNDO action + * + * Returns 1 if path exists, 0 if it does not (nothing left to undo), and -1 + * after a WARNING for any other failure. + */ +static int +fileops_undo_lstat(const char *op, const char *path, struct stat *st) +{ + if (lstat(path, st) == 0) + return 1; + if (errno == ENOENT) + return 0; + ereport(WARNING, + (errcode_for_file_access(), + errmsg("FILEOPS UNDO %s: could not stat \"%s\": %m", op, path))); + return -1; +} + +/* + * fileops_undo_superseded - Report an object that no longer shows the effect + * being undone: it was already reverted by an earlier walk of this chain, or + * changed since. Either way it is not ours to touch. + */ +static UndoApplyResult +fileops_undo_superseded(const char *op, const char *path) +{ + ereport(DEBUG2, + (errmsg("FILEOPS UNDO %s: \"%s\" was changed since, leaving it", + op, path))); + return UNDO_APPLY_SUCCESS; +} + +/* + * fileops_xattr_is - Does attribute name of path hold exactly value? + * + * Returns 1 if so, 0 if not (or the attribute is absent), and -1 with errno + * set if it cannot be read. + */ +static int +fileops_xattr_is(const char *path, const char *name, const char *value, + uint32 len) +{ + ssize_t cur_len; + char *cur; + int result; + + cur_len = pg_getxattr(path, name, NULL, 0); + if (cur_len < 0) + return (errno == PG_ENOATTR) ? 0 : -1; + if ((size_t) cur_len != len) + return 0; + if (len == 0) + return 1; + + cur = palloc(len); + cur_len = pg_getxattr(path, name, cur, len); + if (cur_len < 0) + result = (errno == PG_ENOATTR || errno == ERANGE) ? 0 : -1; + else + result = ((size_t) cur_len == len && memcmp(cur, value, len) == 0); + pfree(cur); + return result; +} + /* * fileops_undo_apply - Apply a single FILEOPS UNDO record * - * Reverses the structural filesystem operation. + * Reverses the structural filesystem operation, but only if the object still + * shows its effect, as recorded in the payload. The same chain can be walked + * again later (see storage/fileops.h), when the object may already have been + * reverted, or changed by someone else; then the record is a no-op. A CREATE + * or MKDIR cannot tell its own file or directory from one created again at + * the same path, since the inode is not known when the UNDO is written, and + * a REMOVEXATTR cannot tell an attribute it removed from one removed again. */ 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; + struct stat st; Assert(rmid == UNDO_RMID_FILEOPS); + /* Abort WAL owns completed aborts; generic UNDO is for crash losers. */ + if (!applyingAbort && !InRecovery && TransactionIdDidAbort(xid)) + return abortApplyFailed ? UNDO_APPLY_ERROR : UNDO_APPLY_SUCCESS; + INJECTION_POINT("fileops-undo-apply-begin", NULL); switch (info) @@ -125,46 +211,75 @@ fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, memcpy(&hdr, payload, sizeof(FileopsUndoCreate)); path = payload + sizeof(FileopsUndoCreate); - if (unlink(path) < 0 && errno != ENOENT) + switch (fileops_undo_lstat("CREATE", path, &st)) { - ereport(WARNING, - (errcode_for_file_access(), - errmsg("FILEOPS UNDO CREATE: could not unlink \"%s\": %m", - path))); - result = UNDO_APPLY_ERROR; + case 0: + break; + case -1: + result = UNDO_APPLY_ERROR; + break; + default: + if (!S_ISREG(st.st_mode)) + result = fileops_undo_superseded("CREATE", path); + else 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; + } + break; } - else - ereport(DEBUG2, - (errmsg("FILEOPS UNDO CREATE: unlinked \"%s\"", - path))); } break; - case FILEOPS_UNDO_RENAME: + case FILEOPS_UNDO_WRITE: { - FileopsUndoRename hdr; - const char *oldpath; - const char *newpath; + FileopsUndoWrite hdr; + const char *path; + const char *before; + const char *after; + char *current; + int fd; - if (payload_len < sizeof(FileopsUndoRename)) + if (payload_len < sizeof(hdr)) 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) + memcpy(&hdr, payload, sizeof(hdr)); + if (payload_len != sizeof(hdr) + hdr.path_len + + hdr.before_len + hdr.after_len) + return UNDO_APPLY_ERROR; + path = payload + sizeof(hdr); + before = path + hdr.path_len; + after = before + hdr.before_len; + if (fileops_undo_lstat("WRITE", path, &st) != 1) + break; + if (!S_ISREG(st.st_mode) || st.st_size != hdr.new_length) + break; + fd = BasicOpenFile(path, O_RDWR | PG_BINARY); + if (fd < 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; + errmsg("FILEOPS UNDO WRITE: could not open \"%s\": %m", path))); + return UNDO_APPLY_ERROR; + } + current = palloc(hdr.after_len ? hdr.after_len : 1); + if (pg_pread(fd, current, hdr.after_len, hdr.offset) == hdr.after_len && + memcmp(current, after, hdr.after_len) == 0) + { + if ((hdr.before_len && + pg_pwrite(fd, before, hdr.before_len, hdr.offset) != hdr.before_len) || + ftruncate(fd, hdr.orig_length) != 0) + { + ereport(WARNING, + (errcode_for_file_access(), + errmsg("FILEOPS UNDO WRITE: could not restore \"%s\": %m", path))); + result = UNDO_APPLY_ERROR; + } } - else - ereport(DEBUG2, - (errmsg("FILEOPS UNDO RENAME: renamed \"%s\" back to \"%s\"", - newpath, oldpath))); + pfree(current); + close(fd); } break; @@ -179,29 +294,53 @@ fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, memcpy(&hdr, payload, sizeof(FileopsUndoTruncate)); path = payload + sizeof(FileopsUndoTruncate); - fd = BasicOpenFile(path, O_RDWR | PG_BINARY); - if (fd < 0) + switch (fileops_undo_lstat("TRUNCATE", path, &st)) { - 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))); + case 0: + break; + case -1: result = UNDO_APPLY_ERROR; - } - close(fd); + break; + default: + if (!S_ISREG(st.st_mode) || st.st_size != hdr.new_length) + { + result = fileops_undo_superseded("TRUNCATE", path); + break; + } + 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 (hdr.before_len && + pg_pwrite(fd, path + hdr.path_len, hdr.before_len, + hdr.new_length) != hdr.before_len) + { + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not restore bytes of file \"%s\": %m", path))); + result = UNDO_APPLY_ERROR; + } + 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; } } break; @@ -216,13 +355,26 @@ fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, memcpy(&hdr, payload, sizeof(FileopsUndoChmod)); path = payload + sizeof(FileopsUndoChmod); - if (chmod(path, hdr.orig_mode) < 0 && errno != ENOENT) + switch (fileops_undo_lstat("CHMOD", path, &st)) { - ereport(WARNING, - (errcode_for_file_access(), - errmsg("FILEOPS UNDO CHMOD: could not restore mode of \"%s\": %m", - path))); - result = UNDO_APPLY_ERROR; + case 0: + break; + case -1: + result = UNDO_APPLY_ERROR; + break; + default: + if ((st.st_mode & 07777) != hdr.new_mode) + result = fileops_undo_superseded("CHMOD", path); + else 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; } } break; @@ -238,14 +390,26 @@ fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, memcpy(&hdr, payload, sizeof(FileopsUndoChown)); path = payload + sizeof(FileopsUndoChown); - if (chown(path, hdr.orig_uid, hdr.orig_gid) < 0 && - errno != ENOENT) + switch (fileops_undo_lstat("CHOWN", path, &st)) { - ereport(WARNING, - (errcode_for_file_access(), - errmsg("FILEOPS UNDO CHOWN: could not restore ownership of \"%s\": %m", - path))); - result = UNDO_APPLY_ERROR; + case 0: + break; + case -1: + result = UNDO_APPLY_ERROR; + break; + default: + if (st.st_uid != hdr.new_uid || st.st_gid != hdr.new_gid) + result = fileops_undo_superseded("CHOWN", path); + else 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; + } + break; } #endif } @@ -261,13 +425,44 @@ fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, memcpy(&hdr, payload, sizeof(FileopsUndoMkdir)); path = payload + sizeof(FileopsUndoMkdir); - if (rmdir(path) < 0 && errno != ENOENT) + switch (fileops_undo_lstat("MKDIR", path, &st)) { - ereport(WARNING, - (errcode_for_file_access(), - errmsg("FILEOPS UNDO MKDIR: could not rmdir \"%s\": %m", - path))); - result = UNDO_APPLY_ERROR; + case 0: + break; + case -1: + result = UNDO_APPLY_ERROR; + break; + default: + if (!S_ISDIR(st.st_mode)) + result = fileops_undo_superseded("MKDIR", path); + else if ((hdr.flags & FILEOPS_MKDIR_TREE) && + !InRecovery && + (st.st_dev != hdr.dev || st.st_ino != hdr.ino)) + result = fileops_undo_superseded("MKDIR", path); + else if (hdr.flags & FILEOPS_MKDIR_TREE) + { + if (OidIsValid(hdr.dboid)) + { + DropDatabaseBuffers(hdr.dboid); + ForgetDatabaseSyncRequests(hdr.dboid); + } + if (!rmtree(path, true)) + { + ereport(WARNING, + (errmsg("FILEOPS UNDO MKDIR: could not remove directory tree \"%s\"", + path))); + result = UNDO_APPLY_ERROR; + } + } + else 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; } } break; @@ -276,13 +471,39 @@ fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, { FileopsUndoSymlink hdr; const char *linkpath; + const char *target; + char cur[MAXPGPATH]; + ssize_t cur_len; if (payload_len < sizeof(FileopsUndoSymlink)) return UNDO_APPLY_ERROR; memcpy(&hdr, payload, sizeof(FileopsUndoSymlink)); + if (payload_len < sizeof(FileopsUndoSymlink) + + hdr.linkpath_len + hdr.target_len) + return UNDO_APPLY_ERROR; linkpath = payload + sizeof(FileopsUndoSymlink); + target = linkpath + hdr.linkpath_len; - if (unlink(linkpath) < 0 && errno != ENOENT) + cur_len = readlink(linkpath, cur, sizeof(cur)); + if (cur_len < 0) + { + if (errno == ENOENT) + break; + if (errno == EINVAL) /* not a symlink any more */ + { + result = fileops_undo_superseded("SYMLINK", linkpath); + break; + } + ereport(WARNING, + (errcode_for_file_access(), + errmsg("FILEOPS UNDO SYMLINK: could not read symbolic link \"%s\": %m", + linkpath))); + result = UNDO_APPLY_ERROR; + } + else if ((size_t) cur_len + 1 != hdr.target_len || + memcmp(cur, target, cur_len) != 0) + result = fileops_undo_superseded("SYMLINK", linkpath); + else if (unlink(linkpath) < 0 && errno != ENOENT) { ereport(WARNING, (errcode_for_file_access(), @@ -303,13 +524,25 @@ fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, memcpy(&hdr, payload, sizeof(FileopsUndoLink)); newpath = payload + sizeof(FileopsUndoLink); - if (unlink(newpath) < 0 && errno != ENOENT) + switch (fileops_undo_lstat("LINK", newpath, &st)) { - ereport(WARNING, - (errcode_for_file_access(), - errmsg("FILEOPS UNDO LINK: could not unlink \"%s\": %m", - newpath))); - result = UNDO_APPLY_ERROR; + case 0: + break; + case -1: + result = UNDO_APPLY_ERROR; + break; + default: + if (!InRecovery && (st.st_dev != hdr.dev || st.st_ino != hdr.ino)) + result = fileops_undo_superseded("LINK", newpath); + else 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; } } break; @@ -319,31 +552,56 @@ fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, FileopsUndoSetxattr hdr; const char *path; const char *name; + const char *orig_value; + const char *new_value; if (payload_len < sizeof(FileopsUndoSetxattr)) return UNDO_APPLY_ERROR; memcpy(&hdr, payload, sizeof(FileopsUndoSetxattr)); + if (payload_len < sizeof(FileopsUndoSetxattr) + hdr.path_len + + hdr.name_len + hdr.orig_value_len + hdr.new_value_len) + return UNDO_APPLY_ERROR; path = payload + sizeof(FileopsUndoSetxattr); name = path + hdr.path_len; + orig_value = name + hdr.name_len; + new_value = orig_value + hdr.orig_value_len; - if (hdr.had_value) + switch (fileops_undo_lstat("SETXATTR", path, &st)) { - /* 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); + case 0: + break; + case -1: + result = UNDO_APPLY_ERROR; + break; + default: + switch (fileops_xattr_is(path, name, new_value, + hdr.new_value_len)) + { + case 0: + result = fileops_undo_superseded("SETXATTR", + path); + break; + case -1: + result = fileops_undo_xattr_failed("SETXATTR", + path, name); + break; + default: + if (hdr.had_value) + { + if (pg_setxattr(path, name, orig_value, + hdr.orig_value_len) < 0 && + errno != ENOENT) + result = fileops_undo_xattr_failed("SETXATTR", + path, name); + } + else if (pg_removexattr(path, name) < 0 && + errno != ENOENT && + errno != PG_ENOATTR) + result = fileops_undo_xattr_failed("SETXATTR", + path, name); + break; + } + break; } } break; @@ -362,11 +620,28 @@ fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, 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); + switch (fileops_undo_lstat("REMOVEXATTR", path, &st)) + { + case 0: + break; + case -1: + result = UNDO_APPLY_ERROR; + break; + default: + /* Restore the removed xattr, unless it is back */ + if (pg_getxattr(path, name, NULL, 0) >= 0) + result = fileops_undo_superseded("REMOVEXATTR", + path); + else if (errno != PG_ENOATTR) + result = fileops_undo_xattr_failed("REMOVEXATTR", + path, name); + else if (pg_setxattr(path, name, value, + hdr.value_len) < 0 && + errno != ENOENT) + result = fileops_undo_xattr_failed("REMOVEXATTR", + path, name); + break; + } } break; @@ -381,6 +656,75 @@ fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, return result; } +/* Failure status belongs to this abort, not the lifetime of the backend. */ +void +FileOpsResetAbortFailure(void) +{ + abortApplyFailed = false; +} + +/* + * Apply an abort-WAL inverse with the same identity tests as loser recovery. + * Ordinary syscall failures remain warnings. A failed inverse can therefore + * leave an online effect until recovery replays the abort record; callers must + * not infer successful filesystem rollback merely from transaction abort. + */ +void +FileOpsApplyInverse(uint16 info, const char *data, uint32 len) +{ + const char *path = NULL; + struct stat st; + Size hdrlen = 0; + + applyingAbort = true; + if (fileops_undo_apply(UNDO_RMID_FILEOPS, info, InvalidTransactionId, + InvalidOid, data, len, InvalidUndoRecPtr) != UNDO_APPLY_SUCCESS) + abortApplyFailed = true; + applyingAbort = false; + switch (info) + { + case FILEOPS_UNDO_CREATE: hdrlen = sizeof(FileopsUndoCreate); break; + case FILEOPS_UNDO_WRITE: hdrlen = sizeof(FileopsUndoWrite); break; + case FILEOPS_UNDO_TRUNCATE: hdrlen = sizeof(FileopsUndoTruncate); break; + case FILEOPS_UNDO_CHMOD: hdrlen = sizeof(FileopsUndoChmod); break; + case FILEOPS_UNDO_CHOWN: hdrlen = sizeof(FileopsUndoChown); break; + case FILEOPS_UNDO_MKDIR: hdrlen = sizeof(FileopsUndoMkdir); break; + case FILEOPS_UNDO_SYMLINK: hdrlen = sizeof(FileopsUndoSymlink); break; + case FILEOPS_UNDO_LINK: hdrlen = sizeof(FileopsUndoLink); break; + case FILEOPS_UNDO_SETXATTR: hdrlen = sizeof(FileopsUndoSetxattr); break; + case FILEOPS_UNDO_REMOVEXATTR: hdrlen = sizeof(FileopsUndoRemovexattr); break; + } + if (hdrlen == 0 || len <= hdrlen) + elog(PANIC, "invalid FILEOPS inverse payload"); + path = data + hdrlen; + if (enableFsync) + { + char parent[MAXPGPATH]; + char *sep; + +#ifdef USE_INJECTION_POINTS + if (IS_INJECTION_POINT_ATTACHED("fileops-inverse-fsync-fail")) + { + errno = EIO; + ereport(data_sync_elevel(ERROR), + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\": %m", path))); + } +#endif + if (lstat(path, &st) == 0 && !S_ISLNK(st.st_mode)) + fsync_fname_ext(path, S_ISDIR(st.st_mode), true, WARNING); + strlcpy(parent, path, sizeof(parent)); + sep = strrchr(parent, '/'); + if (sep == NULL) + strlcpy(parent, ".", sizeof(parent)); + else if (sep == parent) + parent[1] = '\0'; + else + *sep = '\0'; + fsync_fname_ext(parent, true, true, WARNING); + } +} + /* * fileops_undo_desc - Describe a FILEOPS UNDO record for debugging */ @@ -395,9 +739,6 @@ fileops_undo_desc(StringInfo buf, uint8 rmid, uint16 info, case FILEOPS_UNDO_CREATE: opname = "CREATE"; break; - case FILEOPS_UNDO_RENAME: - opname = "RENAME"; - break; case FILEOPS_UNDO_TRUNCATE: opname = "TRUNCATE"; break; diff --git a/src/backend/utils/activity/wait_event_names.txt b/src/backend/utils/activity/wait_event_names.txt index d840a5185f833..b179c3e38e01a 100644 --- a/src/backend/utils/activity/wait_event_names.txt +++ b/src/backend/utils/activity/wait_event_names.txt @@ -424,6 +424,8 @@ 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." UndoWorker "Waiting to access or modify UNDO worker shared memory queue." +AbortedTxnMap "Waiting to access the Aborted Transaction Map." +SecondaryLog "Waiting to access the Secondary Log (sLog)." UndoLogPBU "Waiting to access or modify per-backend UNDO log state." # No "ABI_compatibility" region here as WaitEventLWLock has its own C code. diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index 1e76b1cf60b8a..e2dd236a8ab59 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -1946,6 +1946,16 @@ max => 'MAX_KILOBYTES', }, +{ name => 'logical_revert_naptime', type => 'int', context => 'PGC_SIGHUP', group => 'VACUUM_AUTOVACUUM', + short_desc => 'Time between ATM scan cycles in the logical revert worker.', + long_desc => 'The logical revert worker sleeps for this many milliseconds between scans of the ATM for unreverted aborted transactions.', + flags => 'GUC_UNIT_MS', + variable => 'logical_revert_naptime', + boot_val => '1000', + min => '100', + max => 'INT_MAX', +}, + { name => 'maintenance_io_concurrency', type => 'int', context => 'PGC_USERSET', group => 'RESOURCES_IO', short_desc => 'A variant of "effective_io_concurrency" that is used for maintenance work.', long_desc => '0 disables simultaneous requests.', @@ -2039,6 +2049,15 @@ max => 'MAX_BACKENDS', }, +{ name => 'max_logical_revert_workers', type => 'int', context => 'PGC_POSTMASTER', group => 'VACUUM_AUTOVACUUM', + short_desc => 'Maximum number of logical revert background workers.', + long_desc => 'Sets the maximum number of logical revert workers that apply UNDO chains for aborted transactions. Acts as a hard ceiling on the number of concurrently running per-database revert workers. Set to 0 to disable the logical revert launcher entirely.', + variable => 'max_logical_revert_workers', + boot_val => '2', + min => '0', + max => '64', +}, + { name => 'max_notify_queue_pages', type => 'int', context => 'PGC_POSTMASTER', group => 'RESOURCES_DISK', short_desc => 'Sets the maximum number of allocated pages for NOTIFY / LISTEN queue.', variable => 'max_notify_queue_pages', @@ -2759,6 +2778,24 @@ boot_val => '""', }, +{ name => 'slog_dsa_max_size_mb', type => 'int', context => 'PGC_POSTMASTER', group => 'WAL_SETTINGS', + short_desc => 'Maximum shared memory for the sLog DSA area.', + long_desc => 'Sets the maximum size (in MB) of the DSA area used by the sLog to back its aborted-transaction radix tree. When this limit is reached, new entries cannot be stored and a WARNING is emitted.', + variable => 'slog_dsa_max_size_mb', + boot_val => '256', + min => '1', + max => '16384', +}, + +{ name => 'slog_num_partitions', type => 'int', context => 'PGC_POSTMASTER', group => 'WAL_SETTINGS', + short_desc => 'Number of sLog flat hash partitions (0 = auto from CPU count).', + long_desc => 'Controls the number of partitions for the optional sLog tuple tracking hash. More partitions reduce writer lock contention. Set 0 for auto-sizing (4x CPUs, clamped 16-256, power of 2). Requires restart.', + variable => 'slog_num_partitions', + boot_val => '0', + min => '0', + max => '256', +}, + { name => 'ssl', type => 'bool', context => 'PGC_SIGHUP', group => 'CONN_AUTH_SSL', short_desc => 'Enables SSL connections.', variable => 'EnableSSL', @@ -3315,6 +3352,16 @@ max => '4096', }, +{ name => 'undo_instant_abort_threshold', type => 'int', context => 'PGC_USERSET', group => 'WAL_SETTINGS', + short_desc => 'UNDO size threshold for ATM instant abort.', + long_desc => 'When the UNDO a transaction wrote is at least this many bytes, abort uses ATM instant abort (O(1) with asynchronous cleanup by the logical revert worker) instead of synchronous rollback. Set to 0 to always use ATM instant abort. Applies only to transactions whose UNDO is all index UNDO; a transaction that also wrote other UNDO, such as for file operations, is always rolled back synchronously.', + variable => 'undo_instant_abort_threshold', + boot_val => '65536', + min => '0', + max => 'INT_MAX', +}, + + { 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.', diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c index 3b3b08c051f20..59c9221cf592f 100644 --- a/src/backend/utils/misc/guc_tables.c +++ b/src/backend/utils/misc/guc_tables.c @@ -31,6 +31,8 @@ #include "access/commit_ts.h" #include "access/gin.h" +#include "access/logical_revert_worker.h" +#include "access/slog.h" #include "access/slru.h" #include "access/toast_compression.h" #include "access/twophase.h" diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index 6dddf08c04339..d69d90bce9248 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -917,12 +917,17 @@ # - UNDO - +#undo_instant_abort_threshold = 65536 # bytes; 0 = always use ATM instant abort #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 #undo_rollback_overflow_size = 16MB # rollbacks larger than this go to the # size-ordered undo worker queue +#slog_dsa_max_size_mb = 256 # MB; max sLog DSA area size (aborted-txn tree) +#slog_num_partitions = 0 # 0 = auto (4x CPUs); requires restart +#max_logical_revert_workers = 2 # 0 disables the logical revert launcher +#logical_revert_naptime = 1s # time between ATM scan cycles #------------------------------------------------------------------------------ diff --git a/src/bin/pg_waldump/rmgrdesc.c b/src/bin/pg_waldump/rmgrdesc.c index fbe815570b332..a79a07c772922 100644 --- a/src/bin/pg_waldump/rmgrdesc.c +++ b/src/bin/pg_waldump/rmgrdesc.c @@ -21,6 +21,7 @@ #include "access/nbtxlog.h" #include "access/rmgr.h" #include "access/spgxlog.h" +#include "access/atm_xlog.h" #include "access/undo_xlog.h" #include "access/xact.h" #include "access/xlog_internal.h" diff --git a/src/bin/pg_waldump/t/001_basic.pl b/src/bin/pg_waldump/t/001_basic.pl index c16afa1faadd7..db68ddf1febec 100644 --- a/src/bin/pg_waldump/t/001_basic.pl +++ b/src/bin/pg_waldump/t/001_basic.pl @@ -98,6 +98,7 @@ LogicalMessage XLOG2 Undo +ATM FileOps UndoLog UndoAction$/, diff --git a/src/include/access/atm.h b/src/include/access/atm.h new file mode 100644 index 0000000000000..7eeb4a6cb2311 --- /dev/null +++ b/src/include/access/atm.h @@ -0,0 +1,73 @@ +/*------------------------------------------------------------------------- + * + * atm.h + * Aborted Transaction Map for CTR (Constant-Time Recovery) + * + * The ATM is a shared-memory structure that tracks aborted transactions + * whose UNDO chains have not yet been applied (Logical + * Revert). It drives the background Logical Revert worker. + * + * The ATM is now backed by the sLog (Secondary Log) shared-memory hash + * tables defined in access/slog.h. All ATM functions are thin wrappers + * around sLog operations, preserving the existing API and WAL format. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/atm.h + * + *------------------------------------------------------------------------- + */ +#ifndef ATM_H +#define ATM_H + +#include "access/atm_xlog.h" +#include "access/transam.h" +#include "datatype/timestamp.h" +#include "storage/lwlock.h" + +/* Shared memory sizing and initialization */ +extern Size ATMShmemSize(void); +extern void ATMShmemInit(void); + +/* Core API */ +extern bool ATMGetLastBatchLSN(TransactionId xid, XLogRecPtr *lsn_out); +extern bool ATMAddAborted(TransactionId xid, Oid dboid, + XLogRecPtr last_batch_lsn, + XLogRecPtr first_batch_lsn); +extern bool ATMAddAbortedBounded(TransactionId xid, Oid dboid, + XLogRecPtr last_batch_lsn, + XLogRecPtr stop_lsn, + XLogRecPtr first_batch_lsn); +extern void ATMAddAbortedNoWAL(TransactionId xid, Oid dboid, + XLogRecPtr last_batch_lsn, + XLogRecPtr first_batch_lsn); +extern void ATMForget(TransactionId xid); +extern void ATMMarkReverted(TransactionId xid); + +/* Iteration for Logical Revert worker */ +extern bool ATMGetNextUnreverted(Oid dboid, TransactionId after_xid, + TransactionId *xid_out, + XLogRecPtr *lsn_out, XLogRecPtr *stop_lsn_out); +extern int ATMCollectUnrevertedDatabases(Oid *dboids, int max_dboids); + +/* WAL retention: oldest batch LSN across unreverted entries */ +extern XLogRecPtr ATMGetOldestUnrevertedLSN(void); + +/* Recovery support */ +extern void ATMRecoveryFinalize(void); + +/* + * Crash-safety: persist the ATM at each checkpoint and reload it at startup + * before the redo pass. The ATM lives only in shared memory and is otherwise + * reconstructed solely by replaying XLOG_ATM_ABORT / XLOG_ATM_FORGET during + * redo; a checkpoint that advances the redo pointer past an un-forgotten + * XLOG_ATM_ABORT would make crash recovery miss that abort, silently losing a + * guaranteed rollback. CheckPointATM() durably snapshots the map; startup + * calls ATMReloadFromCheckpoint() before redo so atm_redo's XLOG_ATM_FORGET + * replays can correctly remove entries forgotten after the checkpoint. + */ +extern void CheckPointATM(void); +extern void ATMReloadFromCheckpoint(void); + +#endif /* ATM_H */ diff --git a/src/include/access/atm_xlog.h b/src/include/access/atm_xlog.h new file mode 100644 index 0000000000000..cfbf22e743a15 --- /dev/null +++ b/src/include/access/atm_xlog.h @@ -0,0 +1,54 @@ +/*------------------------------------------------------------------------- + * + * atm_xlog.h + * Aborted Transaction Map XLOG resource manager definitions + * + * This header is safe for inclusion from frontend code (e.g., pg_waldump). + * For the full ATM API, include "access/atm.h" instead. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/atm_xlog.h + * + *------------------------------------------------------------------------- + */ +#ifndef ATM_XLOG_H +#define ATM_XLOG_H + +#include "access/xlogreader.h" +#include "lib/stringinfo.h" + +/* WAL record types for RM_ATM_ID */ +#define XLOG_ATM_ABORT 0x00 +#define XLOG_ATM_FORGET 0x10 + +/* WAL record structures */ +typedef struct xl_atm_abort +{ + TransactionId xid; + XLogRecPtr last_batch_lsn; /* LSN of last UNDO batch for this xid */ + XLogRecPtr stop_lsn; /* revert only batches newer than this, or + * InvalidXLogRecPtr for the whole chain */ + XLogRecPtr first_batch_lsn; /* oldest batch of the chain; WAL from + * here on is retained until the entry is + * forgotten */ + Oid dboid; + Oid reloid; /* InvalidOid (kept for struct layout) */ +} xl_atm_abort; + +#define SizeOfXlAtmAbort (offsetof(xl_atm_abort, reloid) + sizeof(Oid)) + +typedef struct xl_atm_forget +{ + TransactionId xid; +} xl_atm_forget; + +#define SizeOfXlAtmForget sizeof(xl_atm_forget) + +/* Resource manager functions */ +extern void atm_redo(XLogReaderState *record); +extern void atm_desc(StringInfo buf, XLogReaderState *record); +extern const char *atm_identify(uint8 info); + +#endif /* ATM_XLOG_H */ diff --git a/src/include/access/logical_revert_worker.h b/src/include/access/logical_revert_worker.h new file mode 100644 index 0000000000000..43dc92c052911 --- /dev/null +++ b/src/include/access/logical_revert_worker.h @@ -0,0 +1,43 @@ +/*------------------------------------------------------------------------- + * + * logical_revert_worker.h + * Background worker for timer-driven Logical Revert via ATM scan + * + * The Logical Revert worker periodically scans the ATM (Aborted Transaction + * Map) for entries whose UNDO chains have not yet been applied, opens the + * target relation, applies the UNDO chain via the per-AM apply callback, + * marks the ATM entry as reverted, emits an XLOG_ATM_FORGET WAL record, and + * removes the entry from the ATM. + * + * This worker is timer-driven (periodic scan) rather than event-driven + * (queue-based). + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/logical_revert_worker.h + * + *------------------------------------------------------------------------- + */ +#ifndef LOGICAL_REVERT_WORKER_H +#define LOGICAL_REVERT_WORKER_H + +#include "postgres.h" + +/* Shared memory sizing and initialization */ +extern Size LogicalRevertShmemSize(void); +extern void LogicalRevertShmemInit(void); + +/* Worker entry points */ +extern void LogicalRevertWorkerMain(Datum main_arg); + +/* Launch a logical revert worker for a specific database */ +extern void StartLogicalRevertWorker(Oid dboid); +extern void LogicalRevertLauncherMain(Datum main_arg); +extern void LogicalRevertLauncherRegister(void); + +/* GUC parameters */ +extern int logical_revert_naptime; +extern int max_logical_revert_workers; + +#endif /* LOGICAL_REVERT_WORKER_H */ diff --git a/src/include/access/rmgrlist.h b/src/include/access/rmgrlist.h index 7e49fa2339d3c..12fd6975b3a9b 100644 --- a/src/include/access/rmgrlist.h +++ b/src/include/access/rmgrlist.h @@ -49,6 +49,7 @@ 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) +PG_RMGR(RM_ATM_ID, "ATM", atm_redo, atm_desc, atm_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/slog.h b/src/include/access/slog.h new file mode 100644 index 0000000000000..9232a7610e794 --- /dev/null +++ b/src/include/access/slog.h @@ -0,0 +1,353 @@ +/*------------------------------------------------------------------------- + * + * slog.h + * Secondary Log (sLog) for shared-memory tracking + * + * The sLog provides a shared-memory Aborted Transaction Map (ATM) for the + * UNDO subsystem's Constant-Time Recovery: + * + * Transaction radix tree - Aborted transaction entries keyed by + * (xid, reloid) packed into a uint64, ordered for efficient xid-based + * range operations. Protected by a single LWLock (modifications are + * infrequent). + * + * An optional per-tuple flat-hash extension (the tuple sLog) adds + * bounded-recovery uncommitted-writer tracking for in-place-update table + * AMs. It shares this subsystem's shared-memory segment and initialization + * but is not required by the UNDO core; its API is declared where it is + * defined. + * + * ENGINE-AGNOSTIC: the tuple sLog is independent of which UNDO engine an AM + * uses. It keys purely on physical tuple identity (relid, tid) and holds no + * reference to the cluster-wide or the per-backend UNDO engine. Any + * in-place-MVCC AM may use it, regardless of UNDO engine. Cleanup is driven + * by the consumer AM (or its UNDO worker) at commit/abort via the public + * SLogTupleRemoveByXid* entry points; a per-backend AM drives the same + * removals from the per-backend rollback path (execute_undo_actions) exactly + * as an AM using its transaction hooks would. + * + * WAL: Transaction sLog reuses existing RM_ATM_ID records. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/slog.h + * + *------------------------------------------------------------------------- + */ +#ifndef SLOG_H +#define SLOG_H + +#include "access/transam.h" +#include "access/xlogdefs.h" +#include "datatype/timestamp.h" +#include "nodes/lockoptions.h" +#include "storage/itemptr.h" +#include "storage/lwlock.h" +#include "storage/relfilelocator.h" +#include "utils/dsa.h" + +/* + * Maximum concurrent operations on a single TID. + * + * Under EPQ retry patterns with high concurrency, each backend that sees + * TM_Updated calls table_tuple_lock which adds a LOCK_EXCL sLog entry. + * These entries persist until the owning transaction commits, so with N + * concurrent backends hitting the same hot row, up to N-1 LOCK entries + * can coexist. Additionally, committed in-place UPDATE markers are retained + * (one per committing xid) until the oldest-snapshot horizon advances past + * them, so a hot row updated repeatedly while a long reader holds a snapshot + * accumulates markers on top of the live LOCK entries. 128 slots handles + * realistic OLTP concurrency plus retained-marker headroom; per-TID + * reclamation of the oldest retained marker kicks in before the array fills. + */ +#define SLOG_MAX_TUPLE_OPS 128 + +/* + * Tuple hash auto-sizing constants. + * + * All DML operations (INSERT/DELETE/UPDATE/LOCK) attempt to create shared + * hash entries. On overflow (hash full), the operation proceeds gracefully + * with local-only tracking rather than crashing. Auto-sizing formula: + * MaxBackends * SLOG_TUPLE_PER_BACKEND_SLOTS, clamped. + * + * The per-backend slot count must be large enough to accommodate OLTP + * workloads where UPDATE before-images are retained until eviction. + * With transactions touching ~25 rows, and retained entries from committed + * transactions accumulating between eviction passes, 1024 slots per backend + * provides adequate headroom. + */ +#define SLOG_TUPLE_PER_BACKEND_SLOTS 1024 +#define SLOG_TUPLE_MIN_ENTRIES 4096 +#define SLOG_TUPLE_MAX_ENTRIES 4194304 + +/* + * sLog entry types for tuple operations. + */ +typedef enum SLogOpType +{ + SLOG_ENTRY_ABORTED_TXN = 0, /* Transaction-level abort entry */ + SLOG_OP_INSERT = 1, + SLOG_OP_DELETE = 2, + SLOG_OP_UPDATE = 3, + SLOG_OP_LOCK_SHARE = 4, + SLOG_OP_LOCK_EXCL = 5, + SLOG_OP_ABORTED = 6, /* Tuple-level: op was aborted, UNDO pending */ +} SLogOpType; + +/* ---------------------------------------------------------------- + * Transaction sLog structures + * + * SLogTxnEntry is used as the public output type for lookups. + * Internally, the ATM radix tree stores only the data fields; the key + * (xid, reloid) is implicit in the tree path. + * ---------------------------------------------------------------- + */ + +/* + * SLogTxnEntry - Public output structure for transaction lookups. + * Callers receive copies of this via SLogTxnLookup(). + */ +typedef struct SLogTxnEntry +{ + TransactionId xid; + Oid reloid; + XLogRecPtr last_batch_lsn; /* LSN of last UNDO batch for this xid */ + XLogRecPtr stop_lsn; /* apply only batches newer than this, or + * InvalidXLogRecPtr for the whole chain */ + XLogRecPtr first_batch_lsn; /* oldest batch of the chain: pins WAL */ + Oid dboid; /* database OID */ + TimestampTz abort_time; /* when transaction aborted */ + bool revert_complete; /* has Logical Revert finished? */ +} SLogTxnEntry; + +/* ---------------------------------------------------------------- + * Tuple sLog structures + * ---------------------------------------------------------------- + */ + +/* + * SLogTupleKey - Hash key for the tuple flat hash. + * + * Note: ItemPointerData is 6 bytes. Always memset(&key, 0, sizeof(key)) + * before populating to ensure deterministic byte hashing. + */ +typedef struct SLogTupleKey +{ + Oid relid; + ItemPointerData tid; +} SLogTupleKey; + +/* + * SLogTupleOp - Single operation on a tuple. + * + * Uses an in_use slot-based model for O(1) removal without array compaction + * under the exclusive lock. + */ +typedef struct SLogTupleOp +{ + TransactionId xid; + TransactionId subxid; /* subtransaction ID, or InvalidTransactionId */ + SLogOpType op_type; + CommandId cid; + TimestampTz commit_ts; /* 0 if not yet committed */ + uint32 spec_token; /* speculative insertion token, or 0 */ + bool in_use; /* slot occupied? */ + + /* + * Precise tuple-lock strength for LOCK_SHARE/LOCK_EXCL markers. The + * SLOG_OP_LOCK_* op_type only distinguishes shared vs. exclusive intent; + * this field preserves the full four-way LockTupleMode so a concurrent + * updater can apply the real heavyweight conflict matrix (a KeyShare FK + * locker is compatible with a NoKeyExclusive UPDATE, but a + * Share/Exclusive locker conflicts and must block it). Ignored for + * non-lock ops. + */ + LockTupleMode lock_mode; +} SLogTupleOp; + +/* + * SLogTupleEntry - Value in the tuple flat hash. + */ +typedef struct SLogTupleEntry +{ + SLogTupleKey key; /* hash key */ + int nops; /* number of active operations */ + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; +} SLogTupleEntry; + +/* Callback type for tuple iteration */ +typedef bool (*SLogTupleIterCallback) (const SLogTupleOp *op, void *arg); + +/* ---------------------------------------------------------------- + * Shared state + * + * The transaction radix tree is allocated in shared memory; its internal + * structures are opaque to callers. The SLogSharedState is defined in + * slog.c. + * ---------------------------------------------------------------- + */ + +/* ---------------------------------------------------------------- + * API: Shared memory + * ---------------------------------------------------------------- + */ +extern Size SLogShmemSize(void); +extern void SLogShmemRequest(void); +extern void SLogShmemInit(void); + +/* ---------------------------------------------------------------- + * API: Transaction sLog + * ---------------------------------------------------------------- + */ +extern bool SLogTxnInsert(TransactionId xid, Oid reloid, Oid dboid, + XLogRecPtr last_batch_lsn, XLogRecPtr stop_lsn, + XLogRecPtr first_batch_lsn); +extern bool SLogTxnLookup(TransactionId xid, Oid reloid, + SLogTxnEntry *entry_out); +extern bool SLogTxnLookupByXid(TransactionId xid, XLogRecPtr *lsn_out); +extern void SLogTxnRemove(TransactionId xid, Oid reloid); +extern void SLogTxnRemoveByXid(TransactionId xid); +extern void SLogTxnMarkReverted(TransactionId xid); +extern bool SLogTxnGetNextUnreverted(Oid dboid, TransactionId after_xid, + TransactionId *xid_out, + XLogRecPtr *lsn_out, + XLogRecPtr *stop_lsn_out); +extern int SLogTxnCollectUnrevertedDatabases(Oid *dboids, int max_dboids); +extern XLogRecPtr SLogTxnGetOldestUnrevertedLSN(void); +extern int SLogTxnSnapshotForCheckpoint(SLogTxnEntry **entries_out); +extern void SLogRecoveryFinalize(int *total_out, int *unreverted_out); + +/* DSA lifecycle (shared-memory area backing the aborted-txn radix tree) */ +extern void SLogEnsureDsaAttached(void); + +/* GUC: maximum DSA size (in MB) */ +extern int slog_dsa_max_size_mb; + +/* ---------------------------------------------------------------- + * API: Tuple sLog (optional per-tuple tracking extension, slog_tuple.c) + * ---------------------------------------------------------------- + */ + +/* Dynamic sizing */ +extern int SLogTupleNumEntries(void); + +/* Core operations */ +extern bool SLogTupleInsert(Oid relid, ItemPointer tid, TransactionId xid, + SLogOpType op_type, TransactionId subxid, + CommandId cid, TimestampTz commit_ts, + uint32 spec_token, LockTupleMode lock_mode); +extern bool SLogTupleInsertRecovery(Oid relid, ItemPointer tid, + TransactionId xid, SLogOpType op_type); +extern void SLogTupleRestoreLock(Oid relid, ItemPointer tid, + TransactionId xid, LockTupleMode mode); +extern bool SLogTupleLookup(Oid relid, ItemPointer tid, + SLogTupleEntry *entry_out); +extern void SLogTupleRemove(Oid relid, ItemPointer tid, TransactionId xid); +extern void SLogTupleRemoveByXid(TransactionId xid); +extern void SLogTupleRemoveBySubXid(TransactionId xid, TransactionId subxid); +extern void SLogTupleIterateByTid(Oid relid, ItemPointer tid, + SLogTupleIterCallback callback, void *arg); + +/* Filtered lookup (xid_filter=InvalidTransactionId means all) */ +extern int SLogTupleLookupFiltered(Oid relid, ItemPointer tid, + TransactionId xid_filter, + SLogTupleOp *ops_out, int max_ops); + +/* Subtransaction re-parenting on subxact commit */ +extern void SLogTupleUpdateSubXid(TransactionId xid, + TransactionId old_subxid, + TransactionId new_subxid); + +/* Mark all ops for xid as SLOG_OP_ABORTED */ +extern void SLogTupleMarkAborted(TransactionId xid); + +/* Global removal for UNDO worker (no backend-local list) */ +extern void SLogTupleRemoveByXidGlobal(TransactionId xid); + +/* Lightweight local-only tracking (INSERTs only) */ +extern void SLogTupleTrackLocalOnly(Oid relid, ItemPointer tid, + TransactionId xid, TransactionId subxid); +extern void SLogTupleUntrackLocalOnly(Oid relid, ItemPointer tid); + +/* Convenience wrappers */ +extern bool SLogTupleHasEntry(Oid relid, ItemPointer tid); +extern bool SLogTupleIsInsertedByMe(Oid relid, ItemPointer tid); +extern bool SLogTupleIsDeletedByMe(Oid relid, ItemPointer tid); +extern TransactionId SLogTupleGetDirtyXid(Oid relid, ItemPointer tid, + bool *is_insert); +extern TransactionId SLogTupleGetDirtyWriterXid(Oid relid, ItemPointer tid, + bool *is_insert); +extern TransactionId SLogTupleGetWriteConflictXid(Oid relid, ItemPointer tid, + LockTupleMode my_mode, + bool *is_insert); +extern bool SLogTupleHasLockConflict(Oid relid, ItemPointer tid, + TransactionId my_xid, + SLogOpType requested_lock); +extern bool SLogTupleGetLockConflictXid(Oid relid, ItemPointer tid, + TransactionId my_xid, + LockTupleMode my_mode, + TransactionId *xid_out); +extern bool SLogTupleHasAbortedEntry(Oid relid, ItemPointer tid); + + +/* Backend-private tracking for cleanup at commit/abort */ +extern void SLogTupleTrackKey(SLogTupleKey key, TransactionId xid, + TransactionId subxid, SLogOpType op_type); +extern void SLogTupleResetTracking(void); +extern bool SLogTupleAnyTracked(void); + +/* + * SLogTrackedKeyInfo -- public snapshot of a tracked key for batch processing. + * + * Returned by SLogTupleCollectTrackedKeys() so that callers (e.g. an AM's + * commit-time stamping) can sort and batch-process tuples without knowledge + * of the internal tracked-key linked-list structure. + */ +typedef struct SLogTrackedKeyInfo +{ + SLogTupleKey key; + TransactionId xid; + TransactionId subxid; + bool local_only; + SLogOpType op_type; +} SLogTrackedKeyInfo; + +/* Collect tracked keys into a sortable array (for batch commit processing) */ +extern int SLogTupleCollectTrackedKeys(TransactionId xid, + SLogTrackedKeyInfo **out_keys); + +/* Iterate tracked keys (for AM-specific pre-commit callbacks) */ +typedef bool (*SLogTrackedKeyCallback) (const SLogTupleKey *key, + TransactionId xid, + TransactionId subxid, + bool local_only, + void *arg); +extern void SLogTupleIterateTrackedKeys(TransactionId xid, + SLogTrackedKeyCallback callback, + void *arg); + +/* Commit retention: retain committed UPDATE entries with before-images */ +extern void SLogTupleCommitByXid(TransactionId xid); + +/* Per-tuple operations for two-phase commit resolution */ +extern void SLogTupleRemoveByXidSingle(Oid relid, ItemPointer tid, + TransactionId xid); +extern void SLogTupleMarkAbortedSingle(Oid relid, ItemPointer tid, + TransactionId xid); + +/* Cleanup retained entries when no longer needed by any snapshot */ +extern void SLogTupleCleanupRetained(void); + +/* + * Throttled cleanup trigger for access methods to call from DML paths OUTSIDE + * any buffer-locked critical section (drives cleanup when the UNDO worker is + * disabled). Cheap to call repeatedly; fires the global sweep at most once + * every few seconds per backend. + */ +extern void SLogTupleMaybeCleanupRetained(void); + +/* GUC: number of sLog flat hash partitions (0 = auto based on CPU count) */ +extern int slog_num_partitions; + +#endif /* SLOG_H */ diff --git a/src/include/access/slog_flathash.h b/src/include/access/slog_flathash.h new file mode 100644 index 0000000000000..35700fe724a96 --- /dev/null +++ b/src/include/access/slog_flathash.h @@ -0,0 +1,282 @@ +/*------------------------------------------------------------------------- + * + * slog_flathash.h + * LWLock-protected flat open-addressing hash for sLog tuple tracking. + * + * This provides read access to sLog tuple entries under the per-partition + * writer_lock LWLock. The flat hash uses open addressing with linear probing + * and tombstone markers for deletions. + * + * Architecture: A SINGLE copy of the hash lives in shared memory, guarded + * by a per-partition writer_lock LWLock (SLogFlatPartition.writer_lock). + * Writers take the lock LW_EXCLUSIVE and mutate the single copy in place; + * readers take it LW_SHARED for the duration of a read, copy data into local + * variables, and release. Reader/writer exclusion is entirely by the LWLock + * (this replaces an earlier seqlock reader/writer scheme; the measured + * read:write ratio was not read-dominated enough for the seqlock to pay off). + * + * Scan semantics: SLogFlatHashScanInit/ScanNext iterate all occupied + * buckets linearly. Read-side scans must run inside SLOG_SEQ_READ_BEGIN/END + * (which take writer_lock LW_SHARED) and may only copy data into locals; a + * writer holds writer_lock LW_EXCLUSIVE and mutates fp->hash directly. For + * write operations that need global scans (eviction, xid removal), the + * pattern is: scan fp->hash under the writer lock (stable, no reader can tear + * it), collect keys, then apply write ops. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/slog_flathash.h + * + *------------------------------------------------------------------------- + */ +#ifndef SLOG_FLATHASH_H +#define SLOG_FLATHASH_H + +#include "access/slog.h" +#include "port/atomics.h" +#include "storage/lwlock.h" +#include "storage/s_lock.h" + +/* + * Bucket states encoded via hash_val: + * 0 = empty (never used) + * UINT32_MAX = tombstone (deleted) + * anything else = occupied with that hash value + */ +#define SLOG_FLAT_EMPTY 0 +#define SLOG_FLAT_TOMBSTONE UINT32_MAX + +/* + * SLogFlatBucket - One bucket in the flat open-addressing hash table. + * + * Layout: hash_val marks the state, key is the lookup key, entry contains + * the full SLogTupleEntry data (ops array etc). + */ +typedef struct SLogFlatBucket +{ + uint32 hash_val; /* 0=empty, TOMBSTONE=deleted, else hash */ + SLogTupleKey key; /* (relid, tid) */ + uint16 padding; /* alignment padding */ + SLogTupleEntry entry; /* nops + ops[SLOG_MAX_TUPLE_OPS] */ +} SLogFlatBucket; + +/* + * SLogFlatHash - The flat hash table header, followed by buckets[]. + * + * A single copy lives in shared memory, guarded by the partition writer_lock. + */ +typedef struct SLogFlatHash +{ + int32 capacity; /* number of buckets (power of 2) */ + int32 num_entries; /* current live entries */ + int32 num_tombstones; /* tombstone count (for load factor) */ + int32 padding; + SLogFlatBucket buckets[FLEXIBLE_ARRAY_MEMBER]; +} SLogFlatHash; + +/* + * Operation kinds for the flat-hash apply dispatcher (SLogFlatHashApply). + */ +typedef enum SLogFlatOpKind +{ + SLOG_FLAT_OP_INSERT, /* Insert/update a single op slot */ + SLOG_FLAT_OP_REMOVE_XID, /* Remove all ops for xid from entry */ + SLOG_FLAT_OP_REMOVE_ENTRY, /* Remove entire entry (tombstone it) */ + SLOG_FLAT_OP_MARK_ABORTED, /* Mark all ops for xid as ABORTED */ + SLOG_FLAT_OP_UPDATE_OP, /* Update a specific op slot in-place */ + SLOG_FLAT_OP_COMMIT_XID, /* Handle commit retention for an entry */ + SLOG_FLAT_OP_CLEANUP_RETAINED, /* Remove old retained entries */ + SLOG_FLAT_OP_CREATE_ABORTED, /* Create a new ABORTED entry (for + * local-only) */ +} SLogFlatOpKind; + +/* + * SLogFlatOp - A single operation to be applied to the flat hash. + * + * Passed to SLogFlatHashApply(), which mutates the single flat-hash copy. + */ +typedef struct SLogFlatOp +{ + SLogFlatOpKind kind; + SLogTupleKey key; /* which entry */ + TransactionId xid; /* target xid */ + TransactionId subxid; /* for subxid operations */ + TransactionId reclaim_xid_horizon; /* INSERT: oldest active snapshot + * xmin; a committed UPDATE marker may + * be reclaimed only if its xid + * precedes this (visible to all + * snapshots) */ + SLogTupleOp tuple_op; /* the op to insert/update (for INSERT) */ +} SLogFlatOp; + +/* ---------------------------------------------------------------- + * Partitioned flat hash: 32-way sharding to reduce writer lock contention. + * + * Each partition has its own single flat-hash copy guarded by a writer_lock + * LWLock (LW_SHARED reads, LW_EXCLUSIVE writes) and its own writer lock. Key + * routing: + * hash(key) % NUM_PARTITIONS. This reduces writer lock contention + * proportionally to the number of partitions. + * + * The partition count is determined at startup by the slog_num_partitions + * GUC (default: 0 = auto-size based on CPU count). The heuristic targets + * 4x the number of CPUs, clamped to [16, 256], rounded to next power of 2. + * This ensures that at peak concurrency each CPU core has ~4 partitions to + * spread writes across, minimizing writer lock wait time. + * ---------------------------------------------------------------- + */ + +/* Default partition count used only before SLogShmemInit sets the real value */ +#define SLOG_FLAT_DEFAULT_PARTITIONS 32 +#define SLOG_FLAT_MIN_PARTITIONS 16 +#define SLOG_FLAT_MAX_PARTITIONS 256 + +/* + * SLogFlatPartition - Per-partition state. + * + * Each partition owns a slice of the total flat hash capacity. Reads take + * writer_lock LW_SHARED; writes take it LW_EXCLUSIVE and + * mutate the single copy (*hash) in place. + */ +typedef struct SLogFlatPartition +{ + SLogFlatHash *hash; /* single flat-hash copy (in shmem) */ + LWLockPadded writer_lock; /* per-partition writer serialization */ +} SLogFlatPartition; + +/* + * Read-side locking helpers -- "copy into locals, then act after". + * + * Usage: + * SLOG_SEQ_READ_BEGIN(fp) + * { + * ... probe fp->hash and memcpy/read into LOCAL variables ONLY ... + * } + * SLOG_SEQ_READ_END(fp); + * ... now act on the locals ... + * + * Reads take the partition writer_lock in LW_SHARED for the duration of the + * body, so the writer (which holds it LW_EXCLUSIVE) cannot mutate fp->hash + * underneath the reader; the body therefore runs exactly once. It must + * only write caller-provided output locals and must NOT return/goto/elog out + * of the block, or the shared lock would leak. + */ +#define SLOG_SEQ_READ_BEGIN(fp) \ + do { \ + LWLockAcquire(&(fp)->writer_lock.lock, LW_SHARED); + +#define SLOG_SEQ_READ_END(fp) \ + LWLockRelease(&(fp)->writer_lock.lock); \ + } while (0) + +/* + * Compute the shared memory size needed for the flat hash data + * (the single copy guarded by the writer_lock). + */ +extern Size SLogFlatHashDataSize(int capacity); + +/* + * Compute the total shared memory needed for one partition's flat hash + * (the single copy; the writer_lock is embedded in + * SLogFlatPartition). + */ +extern Size SLogFlatHashShmemSize(int capacity, int max_backends); + +/* + * Compute the total shared memory needed for all partitions. + */ +extern Size SLogFlatHashPartitionedShmemSize(int total_capacity, + int max_backends); + +/* + * Initialize the flat hash in a pre-allocated data block. + * Called during SLogShmemInit to set up the single copy. + */ +extern void SLogFlatHashInit(void *data, int capacity); + +/* + * Apply one SLogFlatOp to the (single) flat-hash copy. Called by writers + * holding the partition writer_lock LW_EXCLUSIVE. + */ +extern void SLogFlatHashApply(void *data, const void *operation, Size op_size); + +/* + * Hash computation for SLogTupleKey. + */ +extern uint32 SLogFlatHashComputeHash(const SLogTupleKey *key); + +/* + * Runtime partition count; set during SLogShmemInit() based on + * the slog_num_partitions GUC. Declared in slog.c. + */ +extern int SLogNumPartitions; + +/* + * Compute which partition a key belongs to. + */ +static inline int +SLogFlatHashPartitionIndex(const SLogTupleKey *key) +{ + return (int) (SLogFlatHashComputeHash(key) % (uint32) SLogNumPartitions); +} + +/* + * Probe the flat hash for a key. Returns pointer to the bucket if found, + * NULL if not found. Only valid during a read-side or write-side critical + * section. + */ +extern SLogFlatBucket *SLogFlatHashProbe(const SLogFlatHash *ht, + const SLogTupleKey *key); + +/* + * Return true iff the entry for key holds an in-use op for xid. Detects a + * silently-dropped op (per-TID array full) where the bucket itself exists. + * Only valid during a read-side or write-side critical section. + */ +extern bool SLogFlatHashHasOpForXid(const SLogFlatHash *ht, + const SLogTupleKey *key, + TransactionId xid); + +/* + * Find a bucket for insertion (first empty or tombstone slot on probe chain). + * Returns NULL if the table is full (all slots on probe chain occupied). + * Only valid during write-side critical section. + */ +extern SLogFlatBucket *SLogFlatHashProbeForInsert(SLogFlatHash *ht, + const SLogTupleKey *key, + uint32 hash_val); + +/* + * Scan API for iterating all occupied buckets. + * + * Used by global-scan operations (eviction, xid removal, cleanup) that + * need to visit every entry. The scan iterates linearly over the bucket + * array, skipping EMPTY and TOMBSTONE slots. + * + * Usage pattern: + * SLogFlatHashScanState state; + * const SLogFlatBucket *bucket; + * + * SLogFlatHashScanInit(&state); + * while ((bucket = SLogFlatHashScanNext(ht, &state)) != NULL) + * { + * // process bucket->entry + * } + * + * The scan must be performed inside a read-side critical section + * (SLOG_SEQ_READ_BEGIN/END, which take writer_lock LW_SHARED, read into + * locals only) or by a writer holding writer_lock LW_EXCLUSIVE. For write + * operations, scan fp->hash under the writer lock + * (stable) to collect keys, then apply write ops. + */ +typedef struct SLogFlatHashScanState +{ + int32 current_index; +} SLogFlatHashScanState; + +extern void SLogFlatHashScanInit(SLogFlatHashScanState *state); +extern const SLogFlatBucket *SLogFlatHashScanNext(const SLogFlatHash *ht, + SLogFlatHashScanState *state); + +#endif /* SLOG_FLATHASH_H */ diff --git a/src/include/access/slog_internal.h b/src/include/access/slog_internal.h new file mode 100644 index 0000000000000..74d08db3b7a05 --- /dev/null +++ b/src/include/access/slog_internal.h @@ -0,0 +1,68 @@ +/*------------------------------------------------------------------------- + * + * slog_internal.h + * Shared-state definitions private to the sLog implementation + * + * The sLog is split across two translation units that share one + * shared-memory segment: slog.c (the always-present transaction Aborted + * Transaction Map) and slog_tuple.c (the optional per-tuple flat-hash + * tracking extension). This header exposes the shared state struct and the + * few globals both files touch, so it is deliberately NOT part of the public + * sLog API in access/slog.h. Only the two sLog .c files include it. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/slog_internal.h + * + *------------------------------------------------------------------------- + */ +#ifndef SLOG_INTERNAL_H +#define SLOG_INTERNAL_H + +#include "access/slog_flathash.h" +#include "storage/lwlock.h" +#include "utils/dsa.h" + +/* + * Initial size for the sLog DSA area (backs the aborted-txn radix tree). + * Grows dynamically as needed up to slog_dsa_max_size_mb. + */ +#define SLOG_DSA_INIT_SIZE (512 * 1024) /* 512 KB */ + +/* + * Shared state for the whole sLog subsystem. + * + * The transaction ATM fields are always used. The tuple flat-hash fields + * (tuple_partitions, num_partitions) are used only when the tuple sLog + * extension is compiled in and an access method has opted in; they are + * initialized unconditionally in SLogShmemInit() so the two files can share + * one segment without an init-ordering dependency. + */ +typedef struct SLogSharedState +{ + /* Transaction ATM (adaptive radix tree in the DSA area below) */ + dsa_pointer atm_handle; /* RT handle; InvalidDsaPointer until init */ + LWLockPadded txn_lock; /* single LWLock serializing ATM access */ + + /* + * Tuple flat hash: N-way partitioned for reduced writer contention. + * Partition count determined at startup by slog_num_partitions GUC. + */ + SLogFlatPartition *tuple_partitions; /* palloc'd array in shmem */ + int num_partitions; /* actual partition count */ + + /* DSA area backing the aborted-txn radix tree */ + dsa_area *dsa_area; /* set during SLogShmemInit, NULL until then */ + char dsa_space[SLOG_DSA_INIT_SIZE]; +} SLogSharedState; + +/* The single shared-state instance (defined in slog.c). */ +extern SLogSharedState *SLogState; + +/* Tuple-hash shmem sizing/init helpers (defined in slog_tuple.c). */ +extern Size SLogTupleShmemSize(void); +extern void SLogTupleShmemRequest(void); +extern void SLogTupleShmemInit(void); + +#endif /* SLOG_INTERNAL_H */ diff --git a/src/include/access/undo_bufmgr.h b/src/include/access/undo_bufmgr.h index b0c3736b73122..2d4c60eca5700 100644 --- a/src/include/access/undo_bufmgr.h +++ b/src/include/access/undo_bufmgr.h @@ -69,14 +69,6 @@ */ #define UndoLogForkNum MAIN_FORKNUM -/* - * UNDO_FORKNUM is reserved for future use when the smgr layer is - * extended to support undo-specific file management (Task #5). - * It is defined in buf_internals.h as a constant but not currently - * used in buffer operations. - */ - - /* ---------------------------------------------------------------- * Undo log to RelFileLocator mapping * ---------------------------------------------------------------- diff --git a/src/include/access/undo_xlog.h b/src/include/access/undo_xlog.h index 327f67e489a7f..5b797c3f7948e 100644 --- a/src/include/access/undo_xlog.h +++ b/src/include/access/undo_xlog.h @@ -7,9 +7,6 @@ * 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). @@ -46,67 +43,13 @@ /* * 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. + * These are the info codes for UNDO WAL records. The upper 4 bits identify + * the operation; the low 4 bits are reserved for generic WAL 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)) +#define XLOG_UNDO_INFLIGHT 0x70 /* UNDO chains in flight at a + * checkpoint */ /* * xl_undo_apply - CLR for physical UNDO application (physiological) @@ -152,51 +95,6 @@ typedef struct 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) * @@ -216,6 +114,9 @@ typedef struct xl_undo_rotate typedef struct xl_undo_batch { TransactionId xid; /* Owning transaction */ + Oid dboid; /* Database the transaction runs in; recovery + * hands loser chains to that database's + * logical revert worker */ XLogRecPtr chain_prev; /* LSN of previous batch for this xact * (InvalidXLogRecPtr if first batch) */ uint32 nrecords; /* Number of UNDO records in batch */ @@ -228,6 +129,30 @@ typedef struct xl_undo_batch #define SizeOfUndoBatch (offsetof(xl_undo_batch, persistence) + sizeof(UndoPersistenceLevel)) +/* + * xl_undo_inflight - UNDO chains of transactions in flight at a checkpoint + * + * Written by every online checkpoint after its redo pointer. Crash recovery + * starts redo there and so replays none of the batches written before it; a + * transaction whose batches all precede the redo pointer is known to + * recovery only through this record. + */ +typedef struct xl_undo_inflight_xact +{ + TransactionId xid; /* top-level transaction */ + Oid dboid; /* database it runs in */ + XLogRecPtr first_batch_lsn[NUndoPersistenceLevels]; + XLogRecPtr last_batch_lsn[NUndoPersistenceLevels]; +} xl_undo_inflight_xact; + +typedef struct xl_undo_inflight +{ + uint32 nxacts; + xl_undo_inflight_xact xacts[FLEXIBLE_ARRAY_MEMBER]; +} xl_undo_inflight; + +#define SizeOfUndoInflight offsetof(xl_undo_inflight, xacts) + /* * xl_undo_chain_state - UNDO chain state for prepared transactions * @@ -268,7 +193,8 @@ typedef struct UndoBatchData } UndoBatchData; extern bool UndoValidateBatchLSN(XLogRecPtr batch_lsn); -extern UndoBatchData *UndoReadBatchFromWAL(XLogRecPtr batch_lsn); +extern UndoBatchData *UndoReadBatchFromWAL(XLogRecPtr batch_lsn, + bool *malformed); extern void UndoFreeBatchData(UndoBatchData *batch); extern void UndoResetBatchReader(void); @@ -279,10 +205,12 @@ extern void UndoResetBatchReader(void); * redo completes, incomplete transactions can be identified and their * UNDO chains walked for rollback. */ -extern void UndoRecoveryTrackBatch(TransactionId xid, XLogRecPtr batch_lsn, +extern void UndoRecoveryTrackBatch(TransactionId xid, Oid dboid, + XLogRecPtr batch_lsn, XLogRecPtr chain_prev, UndoPersistenceLevel persistence); extern void UndoRecoveryRemoveXid(TransactionId xid); +extern void UndoRecoveryTrackInflight(const xl_undo_inflight_xact *x); extern bool UndoRecoveryNeeded(void); extern void PerformUndoRecovery(void); extern void FlushDeferredUndoXacts(void); diff --git a/src/include/access/undolog.h b/src/include/access/undolog.h index 02ad54cdb934d..4e2a89e2eb855 100644 --- a/src/include/access/undolog.h +++ b/src/include/access/undolog.h @@ -50,12 +50,6 @@ #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 @@ -115,6 +109,14 @@ typedef struct UndoLogSharedData */ pg_atomic_uint64 undo_discard_horizon; + /* + * Oldest first batch of the in-flight UNDO chains that redo knows of + * (InvalidXLogRecPtr if none). Set by the startup process, so that a + * standby's restartpoints retain the WAL that crash recovery or promotion + * will walk; no backend of the standby holds those chains. + */ + pg_atomic_uint64 recovery_undo_lsn; + /* * Per-backend first UNDO batch LSN. * @@ -139,11 +141,6 @@ StaticAssertDecl(sizeof(XLogRecPtr) == sizeof(uint64), 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_rollback_overflow_size; extern int undo_max_wal_retention_size; @@ -171,26 +168,11 @@ extern void UndoSetDiscardHorizon(XLogRecPtr horizon); extern void UndoRegisterBatchLSN(XLogRecPtr batch_lsn); extern void UndoClearBatchLSN(void); extern XLogRecPtr UndoGetOldestBatchLSN(void); +extern void UndoSetRecoveryBatchLSN(XLogRecPtr lsn); /* * Utility functions */ extern UndoRecPtr UndoLogGetInsertPtr(uint32 log_number); extern UndoRecPtr UndoLogGetDiscardPtr(uint32 log_number); -extern char *UndoLogPath(uint32 log_number, char *path); - -/* - * Legacy no-op stubs -- retained for callers not yet fully updated. - * These are all no-ops in the UNDO-in-WAL architecture. - */ -extern void UndoLogSync(void); -extern void UndoLogCloseFiles(void); -extern void ExtendUndoLogFile(uint32 log_number, uint64 new_size); -extern void ExtendUndoLogSmgrFile(uint32 log_number, uint64 logical_end); -extern UndoRecPtr UndoFlushGetMaxWritePtr(void); -extern void UndoFlushResetMaxWritePtr(void); -extern void UndoLogSealAndRotate(uint8 trigger); -extern void UndoLogDeleteSegmentFile(uint32 log_number); -extern bool UndoLogTryPressureDiscard(void); - #endif /* UNDOLOG_H */ diff --git a/src/include/access/undorecord.h b/src/include/access/undorecord.h index c37ab2de6d6ec..b8d5b547dfbbc 100644 --- a/src/include/access/undorecord.h +++ b/src/include/access/undorecord.h @@ -215,8 +215,50 @@ extern Size UndoRecordSetGetSize(UndoRecordSet *uset); extern int UndoRecordSetGetNumRecords(UndoRecordSet *uset); /* UNDO application during rollback */ + +/* + * Outcome of walking an UNDO chain. + * + * UNDO_CHAIN_DONE: every batch was read and no record failed. + * UNDO_CHAIN_RECORD_ERROR: every batch was read, but some record returned + * UNDO_APPLY_ERROR (or belongs to an unknown rmgr). Walking the chain + * again would fail the same way. + * UNDO_CHAIN_INCOMPLETE: a batch could not be read, so the records it and + * all older batches hold were not applied at all. The read may succeed + * later. + * UNDO_CHAIN_MALFORMED: a batch was read but is not a well-formed part of + * the chain (not an UNDO batch, a truncated record or bad record + * length, or a chain_prev that does not point backward). The older + * batches were not applied, and walking the chain again cannot help. + */ +typedef enum UndoChainOutcome +{ + UNDO_CHAIN_DONE, + UNDO_CHAIN_RECORD_ERROR, + UNDO_CHAIN_INCOMPLETE, + UNDO_CHAIN_MALFORMED, +} UndoChainOutcome; + +typedef struct UndoChainWalkResult +{ + int batches; /* batches read */ + int applied; /* records returning UNDO_APPLY_SUCCESS */ + int skipped; /* records returning UNDO_APPLY_SKIPPED */ + int failed; /* UNDO_APPLY_ERROR, or unknown rmgr */ + bool complete; /* walked to the end (or stop) of the chain */ + bool malformed; /* if !complete, because of a malformed batch */ + XLogRecPtr oldest_batch; /* LSN of the oldest batch read */ + XLogRecPtr unread_batch; /* if !complete, the batch that stopped it */ +} UndoChainWalkResult; + +extern UndoChainOutcome UndoWalkChain(XLogRecPtr last_batch_lsn, + XLogRecPtr stop_at_lsn, + UndoChainWalkResult *res); extern bool ApplyUndoChainFromWAL(XLogRecPtr last_batch_lsn); extern bool ApplyUndoChainFromWALBounded(XLogRecPtr last_batch_lsn, XLogRecPtr stop_at_lsn); +extern UndoChainOutcome ApplyUndoChain(XLogRecPtr last_batch_lsn, + XLogRecPtr stop_at_lsn, + UndoChainWalkResult *res); #endif /* UNDORECORD_H */ diff --git a/src/include/access/undormgr.h b/src/include/access/undormgr.h index 8933798553668..02b65e94efc50 100644 --- a/src/include/access/undormgr.h +++ b/src/include/access/undormgr.h @@ -45,7 +45,7 @@ #define UNDO_RMID_INVALID 0 #define UNDO_RMID_NBTREE 1 #define UNDO_RMID_HASH 3 -/* 2, 4, and 5 are reserved by out-of-core consumers (see their own headers) */ +/* In-tree consumers define FILEOPS (2) and FLUX (5) in their own headers. */ #define MAX_UNDO_RMGRS 256 diff --git a/src/include/access/undostats.h b/src/include/access/undostats.h index 9a5a55e3956b3..ece7f8349ba7e 100644 --- a/src/include/access/undostats.h +++ b/src/include/access/undostats.h @@ -1,10 +1,7 @@ /*------------------------------------------------------------------------- * * undostats.h - * UNDO log statistics collection and reporting - * - * Provides monitoring and observability for the UNDO subsystem, - * including per-log statistics and buffer cache statistics. + * UNDO monitoring functions * * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group * Portions Copyright (c) 1994, Regents of the University of California @@ -16,48 +13,8 @@ #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); - -/* - * pg_undo_force_discard is declared via PG_FUNCTION_INFO_V1 in - * undostats.c. Do not redeclare it here: on Windows that emits a - * __declspec(dllimport) prototype that conflicts with the implicit - * dllexport from the V1 info macro. Catalog references go through - * pg_proc by name. - */ +extern Datum pg_stat_get_undo_retention(PG_FUNCTION_ARGS); #endif /* UNDOSTATS_H */ diff --git a/src/include/access/xact.h b/src/include/access/xact.h index 30bc6e3356157..2187018c71993 100644 --- a/src/include/access/xact.h +++ b/src/include/access/xact.h @@ -212,6 +212,7 @@ typedef struct SavedTransactionCharacteristics #define XACT_XINFO_HAS_ORIGIN (1U << 5) #define XACT_XINFO_HAS_AE_LOCKS (1U << 6) #define XACT_XINFO_HAS_GID (1U << 7) +#define XACT_XINFO_HAS_FILEOPS (1U << 9) #define XACT_XINFO_HAS_DROPPED_STATS (1U << 8) /* @@ -395,6 +396,13 @@ typedef struct xl_xact_prepare * be bumped. See StaticAssertDecl in xactundo.c for compile-time guard. */ XLogRecPtr last_batch_lsn[3]; + + /* + * Oldest batch of the permanent-level chain. WAL from here on is + * retained while the transaction stays prepared, and handed to the ATM + * by ROLLBACK PREPARED, because the revert worker reads the whole chain. + */ + XLogRecPtr undo_first_batch_lsn; } xl_xact_prepare; #define SizeOfXactPrepare sizeof(xl_xact_prepare) @@ -468,6 +476,8 @@ typedef struct xl_xact_parsed_abort XLogRecPtr origin_lsn; TimestampTz origin_timestamp; + uint32 fileops_len; + const char *fileops; } xl_xact_parsed_abort; @@ -559,7 +569,8 @@ extern XLogRecPtr XactLogAbortRecord(TimestampTz abort_time, int ndroppedstats, xl_xact_stats_item *droppedstats, int xactflags, TransactionId twophase_xid, - const char *twophase_gid); + const char *twophase_gid, + const char *fileops, uint32 fileops_len); extern void xact_redo(XLogReaderState *record); /* xactdesc.c */ diff --git a/src/include/access/xactundo.h b/src/include/access/xactundo.h index c0421bcba520d..4dd4c718fc1c8 100644 --- a/src/include/access/xactundo.h +++ b/src/include/access/xactundo.h @@ -122,10 +122,25 @@ extern void AtProcExit_XactUndo(void); extern bool XactUndoHasUnrecoverableUndo(void); +/* Checkpoint: WAL-log the UNDO chains of in-flight transactions */ +extern void LogXactUndoInflight(void); + /* Undo chain traversal for rollback */ extern UndoRecPtr GetCurrentXactUndoRecPtr(UndoPersistenceLevel plevel); extern XLogRecPtr GetCurrentXactLastBatchLSN(UndoPersistenceLevel plevel); +extern XLogRecPtr GetCurrentXactFirstBatchLSN(UndoPersistenceLevel plevel); extern void XActUndoUpdateLastBatchLSN(XLogRecPtr lsn, UndoPersistenceLevel plevel); +/* + * GUC: UNDO bytes threshold for instant abort via ATM. + * + * Transactions with estimated UNDO bytes >= this threshold use ATM instant + * abort (deferred rollback via Logical Revert worker). Transactions below + * the threshold use synchronous rollback inline during transaction abort. + * + * A value of 0 means always use ATM instant abort regardless of size. + */ +extern int undo_instant_abort_threshold; + #endif /* XACTUNDO_H */ diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index f07a2acca9638..567adf7ad979e 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -12778,26 +12778,22 @@ 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', +# UNDO subsystem statistics +{ oid => '9410', descr => 'statistics: what is retaining UNDO WAL', + proname => 'pg_stat_get_undo_retention', prorows => '16', proretset => 't', provolatile => 'v', proparallel => 'r', prorettype => 'record', proargtypes => '', - proallargtypes => '{int4,int8,int8,int8,xid,text}', + proallargtypes => '{text,int4,xid,oid,pg_lsn,int8}', 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', + proargnames => '{holder,pid,xid,dboid,oldest_lsn,retained_bytes}', + prosrc => 'pg_stat_get_undo_retention' }, +{ oid => '9411', descr => 'statistics: per-backend undo logs', + proname => 'pg_stat_get_undo_logs', prorows => '16', proretset => 't', 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' }, + proallargtypes => '{oid,text,text,text,text,text,xid,int4}', + proargmodes => '{o,o,o,o,o,o,o,o}', + proargnames => '{logno,persistence,tablespace,discard,insert,end,xid,pid}', + prosrc => 'pg_stat_get_undo_logs' }, ] diff --git a/src/include/storage/buf_internals.h b/src/include/storage/buf_internals.h index 868f834246e1a..726ab75dfe992 100644 --- a/src/include/storage/buf_internals.h +++ b/src/include/storage/buf_internals.h @@ -146,20 +146,6 @@ StaticAssertDecl(MAX_BACKENDS_BITS <= (BUF_LOCK_BITS - 2), StaticAssertDecl(BM_MAX_USAGE_COUNT < (UINT64CONST(1) << BUF_USAGECOUNT_BITS), "BM_MAX_USAGE_COUNT doesn't fit in BUF_USAGECOUNT_BITS bits"); -/* - * Reserved fork number for UNDO log buffers. - * - * This constant is reserved for future use when the smgr layer is extended - * to support undo-specific file management. Currently, undo buffers use - * MAIN_FORKNUM (following ZHeap's UndoLogForkNum convention) because the - * smgr layer sizes internal arrays to MAX_FORKNUM+1. Undo buffers are - * distinguished from regular relation data by using a pseudo-database OID - * (UNDO_DB_OID = 9) in the BufferTag's dbOid field. - * - * See src/include/access/undo_bufmgr.h for the undo buffer manager API. - */ -#define UNDO_FORKNUM 5 - /* * Buffer tag identifies which disk block the buffer contains. * diff --git a/src/include/storage/copydir.h b/src/include/storage/copydir.h index 31faa51cd6af7..b979ee4258b7d 100644 --- a/src/include/storage/copydir.h +++ b/src/include/storage/copydir.h @@ -25,11 +25,11 @@ extern PGDLLIMPORT int file_copy_method; /* * 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 + * If register_for_abort_cleanup is true, the destination is created with + * FileOpsMkdirTree(). Forward-path callers in transactional commands + * (CREATE DATABASE STRATEGY=FILE_COPY, ALTER DATABASE SET TABLESPACE) should + * pass true so that a transaction abort, or a crash before commit, 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. */ diff --git a/src/include/storage/fileops.h b/src/include/storage/fileops.h index e8ab366acd5b6..e91a2ad41b404 100644 --- a/src/include/storage/fileops.h +++ b/src/include/storage/fileops.h @@ -70,11 +70,12 @@ typedef enum PendingFileOpType PENDING_FILEOP_CHOWN, PENDING_FILEOP_MKDIR, PENDING_FILEOP_RMDIR, - PENDING_FILEOP_RMTREE, /* recursive removal at execute time */ + PENDING_FILEOP_RMTREE, /* abort: remove a FileOpsMkdirTree() tree */ PENDING_FILEOP_SYMLINK, PENDING_FILEOP_LINK, PENDING_FILEOP_SETXATTR, - PENDING_FILEOP_REMOVEXATTR + PENDING_FILEOP_REMOVEXATTR, + PENDING_FILEOP_INVERSE } PendingFileOpType; typedef struct PendingFileOp @@ -111,6 +112,17 @@ typedef struct PendingFileOp extern void FileOpsCancelPendingDelete(const char *path, bool at_commit); extern void FileOpsSync(const char *path); +/* Abort WAL carries a length-prefixed sequence of inverse payloads. */ +#define FILEOPS_MAX_ABORT_BYTES (16 * 1024 * 1024) +#define FILEOPS_MAX_DATA_BYTES (1024 * 1024) +#define FILEOPS_TWOPHASE_INVERSES 1 +extern void FileOpsGetAbortData(char **data, uint32 *len); +extern void FileOpsApplyAbort(const char *data, uint32 len); +extern void FileOpsResetAbortFailure(void); +extern void FileOpsApplyInverse(uint16 info, const char *data, uint32 len); +extern void FileOpsBeginAbort(void); +extern void FileOpsEndAbort(void); + /* Transaction lifecycle hooks */ extern void FileOpsDoPendingOps(bool isCommit); extern void AtSubCommit_FileOps(void); @@ -186,6 +198,8 @@ typedef struct xl_fileops_write { off_t offset; /* write offset in file */ uint32 len; /* data length */ + off_t orig_length; + uint32 before_len; uint16 path_len; /* length of path (including NUL) */ /* variable-length path follows, then data */ } xl_fileops_write; @@ -202,12 +216,12 @@ extern int FileOpsWrite(const char *path, off_t offset, typedef struct xl_fileops_truncate { off_t length; /* new file length */ - off_t orig_length; /* pre-truncate length (crash-revert - * before-state) */ + off_t orig_length; /* pre-truncate length */ + uint32 before_len; /* variable-length path follows */ } xl_fileops_truncate; -#define SizeOfFileOpsTruncate (offsetof(xl_fileops_truncate, orig_length) + sizeof(off_t)) +#define SizeOfFileOpsTruncate (offsetof(xl_fileops_truncate, before_len) + sizeof(uint32)) /* File truncation API */ extern void FileOpsTruncate(const char *path, off_t length); @@ -248,10 +262,17 @@ extern int FileOpsChown(const char *path, uid_t uid, gid_t gid); typedef struct xl_fileops_mkdir { mode_t mode; /* directory permission mode */ + uint16 flags; /* FILEOPS_MKDIR_* */ /* variable-length path follows */ } xl_fileops_mkdir; -#define SizeOfFileOpsMkdir (offsetof(xl_fileops_mkdir, mode) + sizeof(mode_t)) +#define SizeOfFileOpsMkdir (offsetof(xl_fileops_mkdir, flags) + sizeof(uint16)) + +/* + * The transaction fills the directory itself (with files FILEOPS does not + * track, e.g. through smgr), so undoing the mkdir removes the whole tree. + */ +#define FILEOPS_MKDIR_TREE 0x0001 /* * xl_fileops_rmdir - WAL record for directory removal @@ -266,21 +287,10 @@ typedef struct xl_fileops_rmdir /* Directory lifecycle API */ extern int FileOpsMkdir(const char *path, mode_t mode); +extern int FileOpsMkdirTree(const char *path, mode_t mode, Oid dboid); 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 */ @@ -359,8 +369,8 @@ extern void FileopsUndoRmgrInit(void); #define UNDO_RMID_FILEOPS 2 /* FILEOPS UNDO subtypes (stored in urec_info) */ +#define FILEOPS_UNDO_WRITE 0x0002 #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 @@ -375,77 +385,116 @@ extern void FileopsUndoRmgrInit(void); * * These structs define the on-disk layout of UNDO record payloads for each * FILEOPS operation. Variable-length paths and data follow the fixed header. + * + * A chain can be walked more than once (an inline rollback that fails part + * way is handed whole to the logical revert worker, and so is a crash-recovery + * chain with a skipped record), and the object may have changed in between. + * So each payload also records what the operation left behind (its post-state), + * and fileops_undo_apply() reverts only an object still in that state. + * + * There is no RENAME subtype: FileOpsRename() is deferred to commit and so has + * nothing to undo. */ -/* CREATE undo payload: just the path to unlink */ +/* CREATE undo payload: the path to unlink, if it is still a regular file */ typedef struct FileopsUndoCreate { uint16 path_len; /* including NUL */ /* followed by path */ } FileopsUndoCreate; -/* RENAME undo payload: oldpath and newpath for reverse rename */ -typedef struct FileopsUndoRename +/* WRITE undo: path, before-image, then after-image. */ +typedef struct FileopsUndoWrite { - uint16 oldpath_len; - uint16 newpath_len; - /* followed by oldpath, then newpath */ -} FileopsUndoRename; - -/* TRUNCATE undo payload: path + original length */ + off_t orig_length; + off_t new_length; + off_t offset; + uint32 before_len; + uint32 after_len; + uint16 path_len; +} FileopsUndoWrite; + +/* TRUNCATE undo payload: path, followed by the bytes removed by a shrink. */ typedef struct FileopsUndoTruncate { off_t orig_length; + off_t new_length; + uint32 before_len; uint16 path_len; /* followed by path */ } FileopsUndoTruncate; -/* CHMOD undo payload: path + original mode */ +/* CHMOD undo payload: path + original and new mode */ typedef struct FileopsUndoChmod { mode_t orig_mode; + mode_t new_mode; uint16 path_len; /* followed by path */ } FileopsUndoChmod; -/* CHOWN undo payload: path + original uid/gid */ +/* CHOWN undo payload: path + original and new uid/gid */ typedef struct FileopsUndoChown { uid_t orig_uid; gid_t orig_gid; + uid_t new_uid; + gid_t new_gid; uint16 path_len; /* followed by path */ } FileopsUndoChown; -/* MKDIR undo payload: just the path to rmdir */ +/* + * MKDIR undo payload: the path to rmdir, if it is still a directory. + * + * With FILEOPS_MKDIR_TREE the whole tree is removed instead, after dropping + * the shared buffers and pending fsync requests of database dboid (if valid), + * as a database directory's files are written through shared buffers. Since + * that destroys whatever is in the tree, outside recovery it is done only + * while the path is still the directory created (dev, ino): a later walk of + * the chain must not remove a directory created again at the same path, e.g. + * by a retried ALTER DATABASE SET TABLESPACE. Crash recovery cannot rely on + * the inode (redo on a standby or a restored backup creates the directory + * anew), and needs not: until the transaction's locks are gone no other + * transaction can have made that path. Even so, the inode is evidence that + * the directory is ours, not proof. + */ typedef struct FileopsUndoMkdir { + dev_t dev; /* the directory created */ + ino_t ino; + Oid dboid; /* database filling the tree, or InvalidOid */ + uint16 flags; /* FILEOPS_MKDIR_* */ uint16 path_len; /* followed by path */ } FileopsUndoMkdir; -/* SYMLINK undo payload: just the linkpath to unlink */ +/* SYMLINK undo payload: the linkpath to unlink, if it still points to target */ typedef struct FileopsUndoSymlink { uint16 linkpath_len; - /* followed by linkpath */ + uint16 target_len; + /* followed by linkpath, then target */ } FileopsUndoSymlink; -/* LINK undo payload: just the newpath to unlink */ +/* LINK undo payload: the newpath to unlink, if it is still the linked inode */ typedef struct FileopsUndoLink { + dev_t dev; /* device and inode of the link source */ + ino_t ino; uint16 newpath_len; /* followed by newpath */ } FileopsUndoLink; -/* SETXATTR undo payload: path + name + original value (or empty if new) */ +/* SETXATTR undo payload: path + name + original value (if any) + new value */ typedef struct FileopsUndoSetxattr { uint16 path_len; uint16 name_len; uint32 orig_value_len; /* 0 if xattr didn't exist before */ + uint32 new_value_len; bool had_value; /* true if xattr existed before setxattr */ - /* followed by path, name, original value (if had_value) */ + /* followed by path, name, original value (if had_value), new value */ } FileopsUndoSetxattr; /* REMOVEXATTR undo payload: path + name + removed value */ diff --git a/src/include/storage/lwlocklist.h b/src/include/storage/lwlocklist.h index 3b8301ebdd47b..59c46be80f75f 100644 --- a/src/include/storage/lwlocklist.h +++ b/src/include/storage/lwlocklist.h @@ -143,4 +143,6 @@ PG_LWLOCKTRANCHE(AIO_URING_COMPLETION, AioUringCompletion) PG_LWLOCKTRANCHE(SHMEM_INDEX, ShmemIndex) PG_LWLOCKTRANCHE(UNDO_LOG, UndoLog) PG_LWLOCKTRANCHE(UNDO_WORKER, UndoWorker) +PG_LWLOCKTRANCHE(ATM, AbortedTxnMap) +PG_LWLOCKTRANCHE(SLOG, SecondaryLog) PG_LWLOCKTRANCHE(UNDO_LOG_PBU, UndoLogPBU) diff --git a/src/include/storage/subsystemlist.h b/src/include/storage/subsystemlist.h index 9b995f9bad799..f9c2ce9f05c60 100644 --- a/src/include/storage/subsystemlist.h +++ b/src/include/storage/subsystemlist.h @@ -91,3 +91,12 @@ PG_SHMEM_SUBSYSTEM(AioShmemCallbacks) /* UNDO subsystem */ PG_SHMEM_SUBSYSTEM(UndoShmemCallbacks) + +/* + * sLog: the UNDO subsystem's shared-memory Aborted Transaction Map (and an + * optional per-tuple tracking hash a consumer AM may extend it with). + * Registered as its own subsystem (not nested inside UndoShmemCallbacks) so + * the generic UNDO subsystem has no compile-time or link-time dependency on + * any consumer. See slog.c's SLogShmemCallbacks for details. + */ +PG_SHMEM_SUBSYSTEM(SLogShmemCallbacks) 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 index 70af37106ace4..69100c7527288 100644 --- a/src/test/modules/test_fileops/Makefile +++ b/src/test/modules/test_fileops/Makefile @@ -5,6 +5,8 @@ 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) diff --git a/src/test/modules/test_fileops/meson.build b/src/test/modules/test_fileops/meson.build index 0c7b1102dabd7..4a3d776aa4f4a 100644 --- a/src/test/modules/test_fileops/meson.build +++ b/src/test/modules/test_fileops/meson.build @@ -31,9 +31,21 @@ tests += { ], }, 'tap': { + 'env': { + 'enable_injection_points': get_option('injection_points') ? 'yes' : 'no', + }, 'tests': [ 't/001_fileops_apply_failure.pl', + 't/002_fileops_large_rollback.pl', + 't/003_fileops_atm_reapply.pl', + 't/004_fileops_fs_errors.pl', + 't/005_fileops_checkpoint_loser.pl', + 't/006_fileops_rollback_prepared_crash.pl', 't/007_fileops_commit_crash.pl', + 't/007_fileops_abort_redo.pl', + 't/008_fileops_standby_abort.pl', + 't/009_fileops_reuse.pl', + 't/016_fileops_inverse_fsync.pl', ], }, } 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 index 969fbc6c053b0..4299e67fdb051 100644 --- a/src/test/modules/test_fileops/t/001_fileops_apply_failure.pl +++ b/src/test/modules/test_fileops/t/001_fileops_apply_failure.pl @@ -4,8 +4,8 @@ # so the abort hands the chain to the logical revert worker instead of # treating it 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. +# The transaction truncates a file, which is then made read-only behind its +# back, so the truncation's UNDO cannot reopen it for writing. use strict; use warnings FATAL => 'all'; @@ -31,8 +31,7 @@ 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: $!"; +chmod(0444, $path) or die "chmod $path: $!"; $bg->query('ROLLBACK'); $bg->quit; diff --git a/src/test/modules/test_fileops/t/002_fileops_large_rollback.pl b/src/test/modules/test_fileops/t/002_fileops_large_rollback.pl new file mode 100644 index 0000000000000..3feaa4bce91e8 --- /dev/null +++ b/src/test/modules/test_fileops/t/002_fileops_large_rollback.pl @@ -0,0 +1,47 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# A transaction whose FILEOPS UNDO is larger than undo_instant_abort_threshold +# must still be rolled back by its own ROLLBACK, not handed to the logical +# revert worker: filesystem effects have to be gone when ROLLBACK returns (and +# with no revert worker they would otherwise never go away). + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('fileops_large_rollback'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +max_logical_revert_workers = 0 +undo_instant_abort_threshold = 1024 +}); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); +my $dir = $node->data_dir; + +# 50 creates are several kB of FILEOPS UNDO, well over the threshold. +$node->safe_psql( + 'postgres', qq{ +BEGIN; +SELECT test_fileops_create('$dir/large_rb_' || g, 384) + FROM generate_series(1, 50) g; +ROLLBACK; +}); + +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM generate_series(1, 50) g WHERE test_fileops_file_exists('$dir/large_rb_' || g)" + ), + '0', + 'ROLLBACK removed every file the transaction created'); +is( $node->safe_psql( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + 't', + 'nothing was left for the logical revert worker'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/003_fileops_atm_reapply.pl b/src/test/modules/test_fileops/t/003_fileops_atm_reapply.pl new file mode 100644 index 0000000000000..3bcbf4e15ee92 --- /dev/null +++ b/src/test/modules/test_fileops/t/003_fileops_atm_reapply.pl @@ -0,0 +1,82 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# A FILEOPS UNDO chain can be walked more than once: the inline rollback +# reverts part of it, fails, and hands the whole chain to the logical revert +# worker, which walks it again later. By then the reverted files may have +# been changed again. The second walk must leave them alone. + +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_atm_reapply'); +$node->init; +# FILEOPS writes UNDO only when WAL is needed. No revert worker until the +# later changes are in place. +$node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +max_logical_revert_workers = 0 +logical_revert_naptime = 100ms +}); +$node->start; +$node->safe_psql('postgres', + 'CREATE EXTENSION injection_points; CREATE EXTENSION test_fileops'); + +my %path; +for my $f (qw(m t l)) +{ + $path{$f} = $node->safe_psql('postgres', + "SELECT test_fileops_create_tempfile('reapply_$f.dat')"); + chmod(0644, $path{$f}) or die "chmod $path{$f}: $!"; +} +my $link = "$path{l}.lnk"; + +# One batch per operation. The rollback applies the newest batch (the +# truncate), then the injection point fails it, and the chain goes to the ATM. +my $bg = $node->background_psql('postgres'); +$bg->query_safe('SELECT injection_points_set_local()'); +$bg->query_safe( + "SELECT injection_points_attach('undo-apply-after-batch', 'error')"); +$bg->query_safe('BEGIN'); +$bg->query_safe("SELECT test_fileops_link('$path{l}', '$link')"); +$bg->query_safe("SELECT test_fileops_chmod('$path{m}', 384)"); # 0600 +$bg->query_safe("SELECT test_fileops_truncate('$path{t}', 10)"); +$bg->query('ROLLBACK'); +$bg->quit; + +is( $node->safe_psql( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), + 't', + 'the failed inline rollback was handed to the ATM'); +is(-s $path{t}, 1024, 'truncate was reverted'); +is((stat($path{m}))[2] & 07777, 0644, 'chmod was reverted'); +ok(!-e $link, 'link was reverted'); + +# Later changes to the reverted files, which a second walk must not undo. +chmod(0640, $path{m}) or die "chmod $path{m}: $!"; +open(my $fh, '>>', $path{t}) or die "open $path{t}: $!"; +print $fh 'Y' x 1024; +close($fh); +# a different inode: a new link to the same one looks like ours +link($path{m}, $link) or die "link $link: $!"; + +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->restart; +ok( $node->poll_query_until( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + 'revert worker drained the ATM'); + +is(-s $path{t}, 2048, 'later write to the truncated file survives'); +is((stat($path{m}))[2] & 07777, 0640, 'later chmod survives'); +ok(-e $link, 'later link survives'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/004_fileops_fs_errors.pl b/src/test/modules/test_fileops/t/004_fileops_fs_errors.pl new file mode 100644 index 0000000000000..8b111037b4d28 --- /dev/null +++ b/src/test/modules/test_fileops/t/004_fileops_fs_errors.pl @@ -0,0 +1,107 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# An ordinary filesystem error in an immediate FileOps operation (permission +# denied, no space, ...) must be a plain ERROR, exactly as without FILEOPS: +# the server must not PANIC, and the failed operation must leave nothing in +# the WAL for a standby to replay. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($windows_os) +{ + plan skip_all => 'POSIX permission semantics required'; +} + +my $node = PostgreSQL::Test::Cluster->new('fileops_fs_errors'); +$node->init; +$node->append_conf('postgresql.conf', "wal_level = replica\n"); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); + +my $datadir = $node->data_dir; +my $ro = "$datadir/ro_dir"; +mkdir($ro) or die "mkdir $ro: $!"; +my $file = $node->safe_psql('postgres', + "SELECT test_fileops_create_tempfile('fserr.dat')"); +my $rofile = $node->safe_psql('postgres', + "SELECT test_fileops_create_tempfile('fserr_ro.dat')"); +my $has_xattr = $node->safe_psql('postgres', + "SELECT test_fileops_setxattr('$rofile', 'user.pgtest', 'v')"); +chmod(0500, $ro) or die "chmod $ro: $!"; +chmod(0444, $rofile) or die "chmod $rofile: $!"; + +my $start_time = + $node->safe_psql('postgres', 'SELECT pg_postmaster_start_time()'); +my $start_lsn = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + +my %cases = ( + create => "SELECT test_fileops_create('$ro/new.dat', 384)", + write => "SELECT test_fileops_write('/dev/full', 0, 'x')", + truncate => "SELECT test_fileops_truncate('$rofile', 10)", + chmod => "SELECT test_fileops_chmod('/', 448)", + chown => "SELECT test_fileops_chown('$file', 0, 0)", + mkdir => "SELECT test_fileops_mkdir('$ro/sub', 448)", + symlink => "SELECT test_fileops_symlink('$file', '$ro/lnk')", + link => "SELECT test_fileops_link('$file', '$ro/hard')",); + +if ($has_xattr eq 't') +{ + $cases{setxattr} = + "SELECT test_fileops_setxattr('$rofile', 'user.pgtest2', 'v')"; + $cases{removexattr} = + "SELECT test_fileops_removexattr('$rofile', 'user.pgtest')"; +} + +foreach my $op (sort keys %cases) +{ + my ($ret, $stdout, $stderr) = $node->psql('postgres', $cases{$op}); + + # The xattr wrappers report a refused operation as false. + ok( ($stderr =~ /ERROR:/ && $stderr !~ /PANIC|FATAL/) + || ($stdout eq 'f' && $stderr eq ''), + "$op: a filesystem error is an ERROR, not a PANIC" + ) or diag("stdout: $stdout\nstderr: $stderr"); + is($node->safe_psql('postgres', 'SELECT pg_postmaster_start_time()'), + $start_time, "$op: the server did not restart"); +} + +unlike( + slurp_file($node->logfile), + qr/PANIC|terminated by signal/, + 'no PANIC or backend crash in the server log'); + +# CREATE TABLESPACE on a directory we do not own fails as it does without +# FILEOPS. "/" is owned by root, and the server never runs as root. +my ($ret, $stdout, $stderr) = + $node->psql('postgres', "CREATE TABLESPACE t LOCATION '/'"); +like( + $stderr, + qr/ERROR: could not set permissions on directory "\/": Operation not permitted/, + 'CREATE TABLESPACE on a root-owned directory gives the usual error'); +is($node->safe_psql('postgres', 'SELECT pg_postmaster_start_time()'), + $start_time, 'the server survived CREATE TABLESPACE'); + +# Nothing the failed operations attempted reached the WAL. +my $end_lsn = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); +my ($walout, $walerr); +IPC::Run::run [ + 'pg_waldump', + '--path' => "$datadir/pg_wal", + '--start' => $start_lsn, + '--end' => $end_lsn, + '--rmgr' => 'FileOps' + ], + '>' => \$walout, + '2>' => \$walerr; +is($walout, '', 'no FILEOPS WAL record was written for a failed operation'); + +chmod(0700, $ro); +chmod(0644, $rofile); +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/005_fileops_checkpoint_loser.pl b/src/test/modules/test_fileops/t/005_fileops_checkpoint_loser.pl new file mode 100644 index 0000000000000..4b782975835ee --- /dev/null +++ b/src/test/modules/test_fileops/t/005_fileops_checkpoint_loser.pl @@ -0,0 +1,103 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# Crash recovery must roll back a transaction whose UNDO was all written +# before the last checkpoint's redo pointer. Redo starts there and so +# replays none of its UNDO batches; the checkpoint itself has to say that +# the transaction was in flight. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('fileops_ckpt_loser'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +autovacuum = off +}); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); +$node->safe_psql('postgres', + 'CREATE TABLE ct (id int primary key) WITH (index_undo = on)'); +$node->safe_psql('postgres', 'INSERT INTO ct SELECT generate_series(1, 10)'); + +my $datadir = $node->data_dir; + +# Leave transactions in flight, all of their UNDO written, and checkpoint. +sub start_losers +{ + my ($tag) = @_; + my $bg = $node->background_psql('postgres'); + $bg->query_safe('BEGIN'); + $bg->query_safe("SELECT test_fileops_create('$tag.dat', 384)"); + my $xid1 = $bg->query_safe('SELECT pg_current_xact_id()'); + + my $bg2 = $node->background_psql('postgres'); + $bg2->query_safe('BEGIN'); + $bg2->query_safe('INSERT INTO ct SELECT generate_series(1001, 1300)'); + # Writes the pending index UNDO batch. + $bg2->query_safe('SAVEPOINT s'); + my $xid2 = $bg2->query_safe('SELECT pg_current_xact_id()'); + + $node->safe_psql('postgres', 'CHECKPOINT'); + $node->safe_psql('postgres', 'SELECT pg_switch_wal()'); + return ($bg, $bg2, $xid1, $xid2); +} + +my ($bg, $bg2, $xid1, $xid2) = start_losers('ckpt'); +ok(-e "$datadir/ckpt.dat", 'the in-flight FILEOPS create is on disk'); + +my $offset = -s $node->logfile; +$node->stop('immediate'); +$bg->{run}->finish; +$bg2->{run}->finish; +$node->start; + +ok(!-e "$datadir/ckpt.dat", + 'crash recovery reverted a FILEOPS create made before the checkpoint'); +my $log = slurp_file($node->logfile, $offset); +like( + $log, + qr/UNDO recovery: rolling back transaction $xid1\b/, + 'the FILEOPS transaction was found as a loser'); +like( + $log, + qr/UNDO recovery: rolling back transaction $xid2\b/, + 'the index UNDO transaction was found as a loser'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM ct'), + '10', 'the uncommitted rows are not visible'); + +# The same on a standby that is promoted: the standby's last restartpoint +# can likewise follow the loser's batches. +$node->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('fileops_ckpt_standby'); +$standby->init_from_backup($node, 'bk', has_streaming => 1); +$standby->start; + +($bg, $bg2, $xid1, $xid2) = start_losers('sb'); +$node->wait_for_catchup($standby); +$standby->safe_psql('postgres', 'CHECKPOINT'); +my $sdatadir = $standby->data_dir; +ok(-e "$sdatadir/sb.dat", 'the standby replayed the in-flight create'); + +$offset = -s $standby->logfile; +$node->stop('immediate'); +$bg->{run}->finish; +$bg2->{run}->finish; +$standby->stop('immediate'); +$standby->start; +$standby->promote; + +ok(!-e "$sdatadir/sb.dat", + 'promotion reverted a FILEOPS create made before the restartpoint'); +$log = slurp_file($standby->logfile, $offset); +like( + $log, + qr/UNDO recovery: rolling back transaction $xid1\b/, + 'the standby found the FILEOPS transaction as a loser'); + +$standby->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/006_fileops_rollback_prepared_crash.pl b/src/test/modules/test_fileops/t/006_fileops_rollback_prepared_crash.pl new file mode 100644 index 0000000000000..7e26b8701582f --- /dev/null +++ b/src/test/modules/test_fileops/t/006_fileops_rollback_prepared_crash.pl @@ -0,0 +1,95 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# A crash during ROLLBACK PREPARED must not lose the rollback. After the +# abort record nothing is left prepared, so the chain must already be in the +# ATM by then. Before the abort record the transaction stays prepared, and +# nothing may be reverted until it is rolled back again; it may also be +# committed instead. + +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_rp_crash'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +shared_preload_libraries = 'injection_points' +max_prepared_transactions = 5 +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 $file = $node->safe_psql('postgres', + "SELECT test_fileops_create_tempfile('rp.dat')"); + +sub crash_in_rollback_prepared +{ + my ($ip) = @_; + $node->safe_psql('postgres', "SELECT test_fileops_chmod('$file', 420)"); + $node->safe_psql( + 'postgres', qq{ +BEGIN; +SELECT test_fileops_chmod('$file', 448); +PREPARE TRANSACTION 'rp'; +}); + is( $node->safe_psql('postgres', "SELECT test_fileops_get_mode('$file')"), + '448', + "$ip: the prepared chmod is on disk"); + $node->safe_psql('postgres', + "SELECT injection_points_attach('$ip', 'wait')"); + my $bg = $node->background_psql('postgres', on_error_stop => 0); + $bg->query_until( + qr/start/, qq(\\echo start +ROLLBACK PREPARED 'rp'; +)); + $node->wait_for_event('client backend', $ip); + $node->stop('immediate'); + $bg->{run}->finish; + $node->start; +} + +crash_in_rollback_prepared('rollback-prepared-after-abort-record'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM pg_prepared_xacts'), + '0', 'after the abort record: no longer prepared'); +ok( $node->poll_query_until( + 'postgres', "SELECT test_fileops_get_mode('$file') = 420"), + 'after the abort record: the chmod is reverted'); + +crash_in_rollback_prepared('rollback-prepared-before-abort-record'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM pg_prepared_xacts'), + '1', 'before the abort record: still prepared'); +sleep(2); +is($node->safe_psql('postgres', "SELECT test_fileops_get_mode('$file')"), + '448', 'before the abort record: nothing reverted while still prepared'); +$node->safe_psql('postgres', "ROLLBACK PREPARED 'rp'"); +ok( $node->poll_query_until( + 'postgres', "SELECT test_fileops_get_mode('$file') = 420"), + 'before the abort record: ROLLBACK PREPARED again reverts the chmod'); + +# Or the transaction is committed after all, and keeps its change. +crash_in_rollback_prepared('rollback-prepared-before-abort-record'); +$node->safe_psql('postgres', "COMMIT PREPARED 'rp'"); +sleep(2); +is($node->safe_psql('postgres', "SELECT test_fileops_get_mode('$file')"), + '448', 'before the abort record: COMMIT PREPARED keeps the chmod'); +is( $node->safe_psql( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + 't', + 'and leaves no ATM entry behind'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/007_fileops_abort_redo.pl b/src/test/modules/test_fileops/t/007_fileops_abort_redo.pl new file mode 100644 index 0000000000000..f423dc79ca1d3 --- /dev/null +++ b/src/test/modules/test_fileops/t/007_fileops_abort_redo.pl @@ -0,0 +1,110 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('abort_redo'); +$node->init; +$node->append_conf('postgresql.conf', "wal_level = replica\nautovacuum = off\ncheckpoint_timeout = '1h'\nmax_logical_revert_workers = 0\n"); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); +my $dir = $node->data_dir; +$node->safe_psql('postgres', q{ +BEGIN; +SELECT test_fileops_create('base.dat', 384); +SELECT test_fileops_write('base.dat', 0, 'committed-base'); +COMMIT; +CHECKPOINT; +BEGIN; +SELECT test_fileops_create('abort.dat', 384); +SELECT test_fileops_write('abort.dat', 0, 'aborted'); +ROLLBACK; +BEGIN; SELECT test_fileops_mkdir('abortdir', 448); ROLLBACK; +BEGIN; SELECT test_fileops_chmod('base.dat', 448); ROLLBACK; +BEGIN; SELECT test_fileops_write('base.dat', 0, 'OVERWRITTEN!!!'); ROLLBACK; +BEGIN; SELECT test_fileops_truncate('base.dat', 3); ROLLBACK; +}); +for my $restart (0 .. 2) +{ + if ($restart) + { + $node->stop('immediate'); + $node->start; + } + ok(!-e "$dir/abort.dat", "$restart: aborted create absent"); + ok(!-e "$dir/abortdir", "$restart: aborted mkdir absent"); + is((stat("$dir/base.dat"))[2] & 07777, 0600, "$restart: original mode"); + is(-s "$dir/base.dat", 14, "$restart: original size"); + is(slurp_file("$dir/base.dat"), 'committed-base', "$restart: original bytes"); +} + +# Stop with a durable abort decision but no inverses applied yet. A +# concurrently requested checkpoint must wait, not omit the abort from redo. +if ($ENV{enable_injection_points} eq 'yes') +{ + $node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); + for my $checkpoint (0, 1) + { + $node->safe_psql('postgres', 'CHECKPOINT'); + $node->safe_psql('postgres', + "SELECT injection_points_attach('fileops-abort-after-flush', 'wait')"); + my $bg = $node->background_psql('postgres', on_error_stop => 0); + $bg->query_until(qr/start/, qq(\\echo start +BEGIN; +SELECT test_fileops_create('mid.dat', 384); +SELECT test_fileops_write('mid.dat', 0, 'uncommitted'); +SELECT test_fileops_truncate('base.dat', 3); +ROLLBACK; +)); + $node->wait_for_event('client backend', 'fileops-abort-after-flush'); + my $ckpt; + if ($checkpoint) + { + $ckpt = $node->background_psql('postgres', on_error_stop => 0); + $ckpt->query_until(qr/checkpoint/, "\\echo checkpoint\nCHECKPOINT;\n"); + $node->wait_for_event('checkpointer', 'CheckpointDelayStart'); + } + $node->stop('immediate'); + $bg->{run}->finish; + $ckpt->{run}->finish if $ckpt; + $node->start; + ok(!-e "$dir/mid.dat", "$checkpoint: interrupted abort removed create"); + is(slurp_file("$dir/base.dat"), 'committed-base', + "$checkpoint: interrupted abort restored shrink bytes"); + $node->stop('immediate'); + $node->start; + ok(!-e "$dir/mid.dat", "$checkpoint: repeated recovery keeps create absent"); + } +} + +# A WARNING is not a durable retry obligation: fixing permissions does not +# retry this inverse online. A crash before the next checkpoint replays it. +$node->safe_psql('postgres', 'CHECKPOINT'); +my $failed = $node->background_psql('postgres'); +$failed->query_safe('BEGIN'); +$failed->query_safe("SELECT test_fileops_truncate('base.dat', 3)"); +chmod(0400, "$dir/base.dat") or die "chmod: $!"; +my $log_offset = -s $node->logfile; +$failed->query('ROLLBACK'); +$failed->quit; +like(slurp_file($node->logfile, $log_offset), + qr/WARNING:.*FILEOPS UNDO TRUNCATE: could not open/, + 'failed inverse reports a warning'); +chmod(0600, "$dir/base.dat") or die "chmod: $!"; +is(slurp_file("$dir/base.dat"), 'com', 'failed inverse remains unapplied online'); +$node->stop('immediate'); +$node->start; +is(slurp_file("$dir/base.dat"), 'committed-base', + 'abort redo retries failed inverse after crash'); + +# Refuse excessive before-images before touching the file. +my ($ret, $out, $err) = $node->psql('postgres', + "SELECT test_fileops_write('base.dat', 0, repeat('x', 1048577))"); +like($err, qr/ERROR:.*excessive transactional file write range/, + 'oversized write is an ordinary error'); +is(slurp_file("$dir/base.dat"), 'committed-base', 'refused write changes nothing'); +$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 index b01c61073ac84..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 @@ -36,19 +36,24 @@ sub commit_and_crash { my ($sql) = @_; $node->safe_psql('postgres', - "SELECT injection_points_attach('fileops-commit-before-pending-ops', 'wait')"); + "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->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_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; +commit_and_crash( + q{BEGIN; SELECT test_fileops_delete('del.dat'); SELECT test_fileops_rename('ren.dat', 'ren2.dat'); SELECT test_fileops_rmdir('rmd'); @@ -58,23 +63,31 @@ sub commit_and_crash # 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'; + 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", + 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'); + ok(!-e "$datadir/rmd", + 'committed FileOpsRmdir happened despite the crash'); } # DROP TABLESPACE is safe: its XLOG_TBLSPC_DROP precedes the commit record # and its redo removes the directories. (In-place, so they are under # pg_tblspc.) -$node->safe_psql('postgres', 'SET allow_in_place_tablespaces = on; CREATE TABLESPACE ts LOCATION \'\''); -my $tsoid = $node->safe_psql('postgres', "SELECT oid FROM pg_tablespace WHERE spcname = 'ts'"); +$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'); +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'); diff --git a/src/test/modules/test_fileops/t/008_fileops_standby_abort.pl b/src/test/modules/test_fileops/t/008_fileops_standby_abort.pl new file mode 100644 index 0000000000000..64a20d3812655 --- /dev/null +++ b/src/test/modules/test_fileops/t/008_fileops_standby_abort.pl @@ -0,0 +1,59 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf('postgresql.conf', "max_prepared_transactions = 5\n"); +$primary->start; +$primary->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); +$primary->backup('backup'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'backup', has_streaming => 1); +$standby->start; +$primary->safe_psql('postgres', q{ +BEGIN; +SELECT test_fileops_create('base.dat', 384); +SELECT test_fileops_write('base.dat', 0, 'committed-base'); +COMMIT; +BEGIN; +SELECT test_fileops_create('abort.dat', 384); +SELECT test_fileops_write('abort.dat', 0, 'aborted'); +ROLLBACK; +BEGIN; SELECT test_fileops_mkdir('abortdir', 448); ROLLBACK; +BEGIN; SELECT test_fileops_chmod('base.dat', 448); ROLLBACK; +BEGIN; SELECT test_fileops_write('base.dat', 0, 'OVERWRITTEN!!!'); ROLLBACK; +BEGIN; SELECT test_fileops_truncate('base.dat', 3); ROLLBACK; +}); +$primary->safe_psql('postgres', q{ +BEGIN; +SELECT test_fileops_write('base.dat', 0, 'prepared'); +SELECT test_fileops_truncate('base.dat', 2); +PREPARE TRANSACTION 'p'; +ROLLBACK PREPARED 'p'; +BEGIN; +SAVEPOINT a; +SELECT test_fileops_chmod('base.dat', 448); +ROLLBACK TO a; +COMMIT; +}); +$primary->wait_for_catchup($standby); +for my $stage ('streaming', 'promoted') +{ + $standby->promote if $stage eq 'promoted'; + for my $node ($primary, $standby) + { + my $dir = $node->data_dir; + my $name = $node->name; + ok(!-e "$dir/abort.dat", "$stage $name: aborted create absent"); + ok(!-e "$dir/abortdir", "$stage $name: aborted mkdir absent"); + is((stat("$dir/base.dat"))[2] & 07777, 0600, "$stage $name: original mode"); + is(slurp_file("$dir/base.dat"), 'committed-base', "$stage $name: original bytes"); + } +} +$standby->stop; +$primary->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/009_fileops_reuse.pl b/src/test/modules/test_fileops/t/009_fileops_reuse.pl new file mode 100644 index 0000000000000..8c9861497efb1 --- /dev/null +++ b/src/test/modules/test_fileops/t/009_fileops_reuse.pl @@ -0,0 +1,70 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('reuse'); +$node->init; +$node->append_conf('postgresql.conf', + "wal_level = replica\nmax_prepared_transactions = 5\ncheckpoint_timeout = '1h'\n" +); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops; CHECKPOINT'); +$node->safe_psql( + 'postgres', q{ +BEGIN; +SELECT test_fileops_create('base.dat', 384); +SELECT test_fileops_write('base.dat', 0, 'original'); +COMMIT; +BEGIN; +SAVEPOINT a; +SELECT test_fileops_chmod('base.dat', 448); +SAVEPOINT b; +SELECT test_fileops_write('base.dat', 0, 'changed!'); +RELEASE b; +ROLLBACK TO a; +COMMIT; +BEGIN; +SELECT test_fileops_write('base.dat', 0, 'prepared'); +SELECT test_fileops_truncate('base.dat', 2); +PREPARE TRANSACTION 'bytes'; +}); +$node->stop('immediate'); +$node->start; +$node->safe_psql('postgres', "ROLLBACK PREPARED 'bytes'"); +my $dir = $node->data_dir; +for my $kind ('plain', 'sub', 'prepared') +{ + my $end = + $kind eq 'prepared' ? "PREPARE TRANSACTION 'p'; ROLLBACK PREPARED 'p';" + : $kind eq 'sub' ? 'ROLLBACK TO s; COMMIT;' + : 'ROLLBACK;'; + my $savepoint = $kind eq 'sub' ? 'SAVEPOINT s;' : ''; + $node->safe_psql('postgres', + "BEGIN; $savepoint SELECT test_fileops_create('$kind.dat', 384); SELECT test_fileops_write('$kind.dat', 0, 'aborted'); $end" + ); + ok( $node->poll_query_until( + 'postgres', "SELECT NOT test_fileops_file_exists('$kind.dat')"), + "$kind: abort removed file"); + $node->safe_psql('postgres', + "BEGIN; SELECT test_fileops_create('$kind.dat', 384); SELECT test_fileops_write('$kind.dat', 0, 'T2-committed'); COMMIT;" + ); +} +for my $restart (1 .. 2) +{ + $node->stop('immediate'); + $node->start; + is(slurp_file("$dir/base.dat"), + 'original', "$restart: nested and prepared bytes restored"); + is((stat("$dir/base.dat"))[2] & 07777, + 0600, "$restart: nested mode restored"); + for my $kind ('plain', 'sub', 'prepared') + { + is(slurp_file("$dir/$kind.dat"), + 'T2-committed', "$restart $kind: committed reuse survives"); + } +} +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/016_fileops_inverse_fsync.pl b/src/test/modules/test_fileops/t/016_fileops_inverse_fsync.pl new file mode 100644 index 0000000000000..c16af9334747a --- /dev/null +++ b/src/test/modules/test_fileops/t/016_fileops_inverse_fsync.pl @@ -0,0 +1,79 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Abort applies FILEOPS inverses after the abort record is flushed and clog +# says aborted. A failed fsync of an inverse must PANIC there even with +# data_sync_retry = on: an ERROR would re-enter AbortTransaction and log a +# second abort record. Crash recovery replays the abort record instead. + +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('inverse_fsync'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +max_logical_revert_workers = 0 +autovacuum = off +fsync = on +data_sync_retry = on +restart_after_crash = on +}); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); +$node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); +my $dir = $node->data_dir; +my $start_lsn = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + +$node->safe_psql('postgres', + "SELECT injection_points_attach('fileops-inverse-fsync-fail', 'notice')"); +my ($ret, $stdout, $stderr) = $node->psql( + 'postgres', q{ +BEGIN; +SELECT test_fileops_create('inverse.dat', 384); +ROLLBACK; +}); + +# The server restarts after the PANIC, which also drops the attachment. +$node->poll_query_until('postgres', 'SELECT 1', '1'); +my $log = slurp_file($node->logfile); +like( + $log, + qr/PANIC: could not fsync file ".*inverse\.dat"/, + 'failed inverse fsync during abort is a PANIC'); +unlike( + $log, + qr/AbortTransaction while in ABORT state/, + 'abort is not re-entered'); + +my $end_lsn = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); +my ($walout, $walerr); +IPC::Run::run [ + 'pg_waldump', + '--path' => "$dir/pg_wal", + '--start' => $start_lsn, + '--end' => $end_lsn, + '--rmgr' => 'Transaction' + ], + '>' => \$walout, + '2>' => \$walerr; +my $aborts = () = $walout =~ /desc: ABORT/g; +is($aborts, 1, 'exactly one abort record'); +ok(!-e "$dir/inverse.dat", 'crash recovery removed the aborted create'); + +$node->safe_psql('postgres', + "BEGIN; SELECT test_fileops_create('after.dat', 384); ROLLBACK;"); +ok(!-e "$dir/after.dat", 'later aborts still apply their inverses'); + +$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 index 80545574727a7..09544f76f864b 100644 --- a/src/test/modules/test_fileops/test_fileops--1.0.sql +++ b/src/test/modules/test_fileops/test_fileops--1.0.sql @@ -63,6 +63,12 @@ CREATE FUNCTION test_fileops_data_dir() RETURNS text AS 'MODULE_PATHNAME' LANGUAGE C STRICT; +-- Read the oldest un-reverted ATM last_batch_lsn (the LSN that pins UNDO WAL +-- against recycling via ATMGetOldestUnrevertedLSN -> KeepLogSeg). NULL when +-- no un-reverted entry exists. Test-only, for the retention-invariant test. +CREATE FUNCTION test_fileops_atm_oldest_lsn() + RETURNS pg_lsn + AS 'MODULE_PATHNAME' LANGUAGE C; -- Deferred delete at commit (uses FileOpsDelete, at_commit=true) CREATE FUNCTION test_fileops_delete(filepath text) RETURNS void diff --git a/src/test/modules/test_fileops/test_fileops.c b/src/test/modules/test_fileops/test_fileops.c index c871d974427e9..ae3de459f9561 100644 --- a/src/test/modules/test_fileops/test_fileops.c +++ b/src/test/modules/test_fileops/test_fileops.c @@ -24,6 +24,7 @@ #include "funcapi.h" #include "miscadmin.h" #include "port/pg_xattr.h" +#include "access/atm.h" #include "access/xlogdefs.h" #include "storage/fd.h" #include "storage/fileops.h" @@ -44,10 +45,32 @@ 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_atm_oldest_lsn); PG_FUNCTION_INFO_V1(test_fileops_write); PG_FUNCTION_INFO_V1(test_fileops_chown); PG_FUNCTION_INFO_V1(test_fileops_symlink); +/* + * test_fileops_atm_oldest_lsn - Read the oldest un-reverted ATM last_batch_lsn. + * + * This is the exact value ATMGetOldestUnrevertedLSN() feeds into + * UndoGetOldestBatchLSN() -> KeepLogSeg(), i.e. the LSN that pins UNDO WAL + * against recycling. Returning it lets a recovery test assert, after crash + + * restart, that the ATM was faithfully reconstructed and still pins the WAL -- + * NOT merely that the WAL file happens to exist on disk. Returns NULL when no + * un-reverted entry exists. + */ +Datum +test_fileops_atm_oldest_lsn(PG_FUNCTION_ARGS) +{ + XLogRecPtr lsn = ATMGetOldestUnrevertedLSN(); + + if (XLogRecPtrIsInvalid(lsn)) + PG_RETURN_NULL(); + + PG_RETURN_LSN(lsn); +} + PG_FUNCTION_INFO_V1(test_fileops_delete); PG_FUNCTION_INFO_V1(test_fileops_rename); PG_FUNCTION_INFO_V1(test_fileops_mkdir); diff --git a/src/test/modules/test_idxundo/t/002_idxundo_abort_threshold.pl b/src/test/modules/test_idxundo/t/002_idxundo_abort_threshold.pl new file mode 100644 index 0000000000000..a443c7a39c699 --- /dev/null +++ b/src/test/modules/test_idxundo/t/002_idxundo_abort_threshold.pl @@ -0,0 +1,86 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# undo_instant_abort_threshold: an abort whose UNDO is at least this many bytes +# is handed to the logical revert worker through the ATM; a smaller one is +# applied inline by the aborting backend. The revert launcher is disabled so a +# deferred abort stays observable in the ATM. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_threshold'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +max_logical_revert_workers = 0 +undo_instant_abort_threshold = 65536 +}); +$node->start; + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION test_idxundo; +CREATE TABLE t (id int primary key) WITH (index_undo = on); +CREATE TABLE big (id int primary key) WITH (index_undo = on); +}); + +# Small abort: a handful of index entries, far below the threshold. +my $small_xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO t SELECT g FROM generate_series(1, 10) g; +SELECT pg_current_xact_id(); +ROLLBACK; +}); +is( $node->safe_psql( + 'postgres', 'SELECT count(*) FROM idxundo_atm_entries()'), + '0', + 'small abort is applied inline, not queued in the ATM'); +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '10', 'small abort marked its index entries dead inline'); + +# Large abort: 5000 index UNDO records, several flushed batches and well over +# 64kB of UNDO in total. +my $big_xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO big SELECT g FROM generate_series(1, 5000) g; +SELECT pg_current_xact_id(); +ROLLBACK; +}); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM idxundo_atm_entries() WHERE xid = '$big_xid'"), + '1', + 'abort above undo_instant_abort_threshold is queued in the ATM'); +is( $node->safe_psql('postgres', "SELECT idxundo_count_dead_all('big_pkey')"), + '0', + 'large abort was not applied inline'); + +# The retention view reports what holds UNDO WAL back: the queued abort, and an +# in-flight transaction that has written UNDO. +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_stat_get_undo_retention() WHERE holder = 'aborted' AND xid = '$big_xid' AND oldest_lsn IS NOT NULL AND retained_bytes > 0" + ), + '1', + 'pg_stat_get_undo_retention reports the queued abort'); +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +$bg->query_safe('INSERT INTO t VALUES (100001)'); +$bg->query_safe('SAVEPOINT s'); # writes the deferred index UNDO batch +my $pid = $bg->query_safe('SELECT pg_backend_pid()'); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_stat_get_undo_retention() WHERE holder = 'backend' AND pid = $pid" + ), + '1', + 'pg_stat_get_undo_retention reports an in-flight transaction'); +$bg->query_safe('ROLLBACK'); +$bg->quit; + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/003_idxundo_apply_failure.pl b/src/test/modules/test_idxundo/t/003_idxundo_apply_failure.pl new file mode 100644 index 0000000000000..973b3ad17e43f --- /dev/null +++ b/src/test/modules/test_idxundo/t/003_idxundo_apply_failure.pl @@ -0,0 +1,144 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# An UNDO record that fails to apply must not be counted as applied. +# +# Inline rollback (top-level abort and ROLLBACK TO SAVEPOINT) that cannot apply +# its whole chain hands it to the logical revert worker through the ATM. For a +# subtransaction the hand-off covers only the subtransaction's part of the +# chain: the parent goes on to commit, and its work must not be reverted. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_apply_failure'); +$node->init; +# No revert worker at first, so the hand-offs stay observable in the ATM. +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 0'); +$node->start; + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION test_idxundo; +CREATE TABLE t (id int primary key) WITH (index_undo = on); +CREATE TABLE p (id int primary key) WITH (index_undo = on); +CREATE TABLE c (id int primary key) WITH (index_undo = on); +}); + +# Top-level abort whose chain holds one record that cannot be applied. +my $xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO t SELECT g FROM generate_series(1, 10) g; +SELECT idxundo_write_failing_undo(); +SELECT pg_current_xact_id(); +ROLLBACK; +}); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM idxundo_atm_entries() WHERE xid = '$xid' AND stop_lsn IS NULL" + ), + '1', + 'abort whose inline apply failed is handed to the ATM'); +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '10', 'the records that could be applied were applied inline'); + +# ROLLBACK TO SAVEPOINT whose chain holds such a record; the parent commits. +$node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO p SELECT g FROM generate_series(1, 10) g; +SAVEPOINT s; +INSERT INTO c SELECT g FROM generate_series(1, 10) g; +SELECT idxundo_write_failing_undo(); +ROLLBACK TO SAVEPOINT s; +COMMIT; +}); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM idxundo_atm_entries() WHERE stop_lsn IS NOT NULL" + ), + '1', + 'subtransaction whose inline apply failed hands its bounded chain to the ATM' +); +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('c_pkey')"), + '10', 'the subtransaction records that could be applied were applied'); +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('p_pkey')"), + '0', 'the parent\'s index entries are untouched'); + +# A savepoint whose failing chain is a temporary one. It cannot be handed +# off: a revert worker cannot reach this backend's temporary relations. (Nor +# can recovery, which never applies TEMP or UNLOGGED UNDO.) A failed chain at +# two levels would also have collided on one ATM key. +$node->safe_psql( + 'postgres', q{ +BEGIN; +SAVEPOINT s; +SELECT idxundo_write_failing_undo('t'); +ROLLBACK TO SAVEPOINT s; +COMMIT; +}); +is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM idxundo_atm_entries()"), + '2', + 'a failed temporary chain is not handed to the ATM'); +like( + slurp_file($node->logfile), + qr/could not apply the temporary UNDO of rolled-back subtransaction/, + 'the failed temporary chain is reported'); + +# An inline apply that throws (rather than reporting a failed record) falls +# back to the ATM too, without leaving the abort's interrupt holdoff unbalanced. +SKIP: +{ + skip 'Injection points not supported by this build', 2 + unless $ENV{enable_injection_points} eq 'yes'; + + $node->safe_psql( + 'postgres', q{ +CREATE EXTENSION injection_points; +SELECT injection_points_set_local(); +SELECT injection_points_attach('undo-apply-after-batch', + 'test_idxundo', 'idxundo_error_with_resources', ''::bytea); +BEGIN; +INSERT INTO t SELECT g FROM generate_series(11, 20) g; +ROLLBACK; +BEGIN; +INSERT INTO p SELECT g FROM generate_series(11, 20) g; +SAVEPOINT s; +INSERT INTO c SELECT g FROM generate_series(11, 20) g; +ROLLBACK TO SAVEPOINT s; +COMMIT; +}); + is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM idxundo_atm_entries()"), + '4', + 'aborts whose inline apply threw are handed to the ATM'); + is($node->safe_psql('postgres', 'SELECT count(*) FROM p'), + '20', 'the parent of the subtransaction committed'); +} + +# Now let the revert worker run. The ATM survives the restart; the worker +# retries both chains (the failing record fails again, in a process that +# does not know its resource manager) and forgets them. Walking the +# subtransaction's entry must stop at the parent's part of the chain. +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->restart; +ok( $node->poll_query_until( + 'postgres', 'SELECT count(*) = 0 FROM idxundo_atm_entries()'), + 'logical revert worker processed both hand-offs'); +is( $node->safe_psql('postgres', "SELECT idxundo_count_dead_all('p_pkey')"), + '0', + 'reverting the subtransaction did not revert its committed parent'); +is( $node->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; +SELECT count(*) FROM p WHERE id BETWEEN 1 AND 20; +}), + $node->safe_psql('postgres', 'SELECT count(*) FROM p'), + 'the committed parent\'s rows are reachable through the index'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/004_idxundo_apply_order.pl b/src/test/modules/test_idxundo/t/004_idxundo_apply_order.pl new file mode 100644 index 0000000000000..2422f6f96efdc --- /dev/null +++ b/src/test/modules/test_idxundo/t/004_idxundo_apply_order.pl @@ -0,0 +1,86 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# UNDO records are applied newest first -- within a batch as well as across +# batches -- both by an aborting backend and by crash recovery. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_order'); +$node->init; +$node->append_conf('postgresql.conf', + "shared_preload_libraries = 'test_idxundo'"); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_idxundo'); + +# Returns the record numbers applied for transaction $xid, in log order. +sub applied_order +{ + my ($xid) = @_; + my @seq = slurp_file($node->logfile) =~ + /test_idxundo: applying UNDO record (\d+) of transaction $xid\b/g; + return join(',', @seq); +} + +# Online rollback. +my $xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +SELECT idxundo_write_ordered_undo(3); +SELECT idxundo_write_ordered_undo(2); +SELECT pg_current_xact_id(); +ROLLBACK; +}); +($xid) = $xid =~ /(\d+)\s*$/; +# The log is written asynchronously; wait until the last (oldest) of the five +# records shows up. Records are numbered per call, so record 1 appears twice; +# wait for the second occurrence via a pattern that spans all five. +$node->wait_for_log( + qr/(?s:test_idxundo: applying UNDO record \d+ of transaction $xid\b.*){5}/ +); +is(applied_order($xid), '2,1,3,2,1', 'abort applies UNDO newest first'); + +# Crash recovery. +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +$bg->query_safe('SELECT idxundo_write_ordered_undo(3)'); +$bg->query_safe('SELECT idxundo_write_ordered_undo(2)'); +$xid = $bg->query_safe('SELECT pg_current_xact_id()'); +$node->safe_psql('postgres', 'SELECT pg_switch_wal()'); +$node->stop('immediate'); +$node->start; +is(applied_order($xid), '2,1,3,2,1', + 'crash recovery applies UNDO newest first'); + +# ROLLBACK TO applies the subtransaction's batch; a later abort of the +# transaction must apply each remaining record exactly once and not the +# subtransaction's again. The values tell the batches apart. +$node->restart; +my $before = -s $node->logfile; +$xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +SELECT idxundo_write_ordered_undo(1); +SAVEPOINT s; +SELECT idxundo_write_ordered_undo(3); +ROLLBACK TO s; +SELECT idxundo_write_ordered_undo(2); +SELECT pg_current_xact_id(); +ROLLBACK; +}); +($xid) = $xid =~ /(\d+)\s*$/; +$node->wait_for_log( + qr/(?s:test_idxundo: applying UNDO record \d+ of transaction $xid\b.*){6}/, + $before); +sleep(1); +my @seq = slurp_file($node->logfile, $before) =~ + /test_idxundo: applying UNDO record (\d+) of transaction $xid\b/g; +is(join(',', @seq), + '3,2,1,2,1,1', + 'ROLLBACK TO, then ROLLBACK: every record applied once, newest first'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/005_idxundo_atm_retention.pl b/src/test/modules/test_idxundo/t/005_idxundo_atm_retention.pl new file mode 100644 index 0000000000000..3d93303031750 --- /dev/null +++ b/src/test/modules/test_idxundo/t/005_idxundo_atm_retention.pl @@ -0,0 +1,103 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# The WAL holding a deferred rollback's UNDO must survive until the revert +# worker has applied it -- all of it, not only the newest batch. +# +# An abort handed to the ATM, and a prepared transaction, must pin WAL from the +# OLDEST batch of their chain. With 1MB WAL segments a large transaction's +# chain spans many segments; checkpoints taken before the revert worker runs +# must not recycle any of them, or the worker reverts only part of the chain. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_retention'); +$node->init(extra => ['--wal-segsize=1']); +$node->append_conf( + 'postgresql.conf', q{ +max_logical_revert_workers = 0 +max_prepared_transactions = 2 +min_wal_size = 2MB +max_wal_size = 4MB +checkpoint_timeout = 1h +}); +$node->start; +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION test_idxundo; +CREATE TABLE a (id int primary key) WITH (index_undo = on); +CREATE TABLE p (id int primary key) WITH (index_undo = on); +}); + +my $nrows = 40000; + +# Recycle every WAL segment nothing pins. +sub recycle_wal +{ + for (1 .. 3) + { + $node->safe_psql('postgres', + 'SELECT pg_switch_wal(); CREATE TABLE junk AS SELECT g FROM generate_series(1, 20000) g; DROP TABLE junk; CHECKPOINT;' + ); + } +} + +# Run the revert worker (it is off until now) and check that it reverted the +# whole chain of $table's index. Each phase has a single deferred chain, so no +# other entry's pin can cover for a missing one. +sub revert_and_check +{ + my ($table, $what) = @_; + + $node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); + $node->restart; + ok( $node->poll_query_until( + 'postgres', 'SELECT count(*) = 0 FROM idxundo_atm_entries()'), + "revert worker drained the ATM ($what)"); + + # A few entries may be left for VACUUM by the bounded right-link walk; a + # recycled batch would lose up to a thousand at a time. + cmp_ok( + $node->safe_psql('postgres', + "SELECT idxundo_count_dead_all('${table}_pkey')"), + '>=', $nrows - 200, + "the whole $what chain was reverted"); + + $node->append_conf('postgresql.conf', 'max_logical_revert_workers = 0'); + $node->restart; +} + +# A large abort goes to the ATM, and only the ATM entry pins its WAL. +$node->safe_psql( + 'postgres', qq{ +BEGIN; +INSERT INTO a SELECT g FROM generate_series(1, $nrows) g; +ROLLBACK; +}); +is($node->safe_psql('postgres', 'SELECT count(*) FROM idxundo_atm_entries()'), + '1', 'large abort is queued in the ATM'); +recycle_wal(); +revert_and_check('a', 'aborted'); + +# A large prepared transaction: only the 2PC state pins its WAL, until ROLLBACK +# PREPARED hands it to the ATM. +$node->safe_psql( + 'postgres', qq{ +BEGIN; +INSERT INTO p SELECT g FROM generate_series(1, $nrows) g; +PREPARE TRANSACTION 'big'; +}); +recycle_wal(); +$node->safe_psql('postgres', "ROLLBACK PREPARED 'big'"); +recycle_wal(); +revert_and_check('p', 'rolled-back prepared'); + +unlike(slurp_file($node->logfile), + qr/could not read batch|no longer exists \(recycled/, + 'no UNDO batch was recycled before it was applied'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/006_idxundo_revert_retry.pl b/src/test/modules/test_idxundo/t/006_idxundo_revert_retry.pl new file mode 100644 index 0000000000000..27d7ae3d5de30 --- /dev/null +++ b/src/test/modules/test_idxundo/t/006_idxundo_revert_retry.pl @@ -0,0 +1,128 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# The logical revert worker must not forget an ATM entry whose UNDO chain it +# could not read in full. A read failure that goes away (simulated with an +# injection point) leaves the entry queued, and the next pass reverts it. + +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('idxundo_revert_retry'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +max_logical_revert_workers = 2 +logical_revert_naptime = 100ms +}); +$node->start; +# No autovacuum: it would remove the aborted entries before the worker does. +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION injection_points; +CREATE EXTENSION test_idxundo; +CREATE TABLE warm (id int primary key) WITH (index_undo = on); +CREATE TABLE t (id int primary key) + WITH (index_undo = on, autovacuum_enabled = off); +}); + +# Get a revert worker running for this database: the launcher starts one at +# startup for a database with ATM entries (otherwise only once a minute), and +# the worker then stays. +$node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO warm SELECT g FROM generate_series(1, 5000) g; +ROLLBACK; +}); +$node->restart; +ok( $node->poll_query_until( + 'postgres', 'SELECT count(*) = 0 FROM idxundo_atm_entries()'), + 'revert worker is running'); + +$node->safe_psql('postgres', + "SELECT injection_points_attach('undo-read-batch-fail', 'notice')"); + +# A large abort goes to the ATM; the injection point makes every read of its chain fail. +my $xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO t SELECT g FROM generate_series(1, 5000) g; +SELECT pg_current_xact_id(); +ROLLBACK; +}); +($xid) = $xid =~ /(\d+)\s*$/; + +$node->wait_for_log( + qr/could not read the whole UNDO chain of transaction $xid, will retry/); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM idxundo_atm_entries() WHERE xid = '$xid'"), + '1', + 'entry whose chain could not be read stays in the ATM'); + +# The failure clears; the worker retries and reverts the whole chain. +$node->safe_psql('postgres', + "SELECT injection_points_detach('undo-read-batch-fail')"); +ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 0 FROM idxundo_atm_entries() WHERE xid = '$xid'"), + 'entry is reverted and forgotten on retry'); +cmp_ok($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '>=', 4900, 'the whole chain was reverted'); + +# ERROR must abort, not commit a transaction with outstanding resources. +$node->safe_psql( + 'postgres', q{ +SELECT injection_points_attach('logical-revert-process-error', + 'test_idxundo', 'idxundo_error_with_resources', ''::bytea); +}); +my $worker_pid = $node->safe_psql( + 'postgres', q{ +SELECT pid FROM pg_stat_activity WHERE backend_type = 'logical revert worker'; +}); +my $error_xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO t SELECT g FROM generate_series(10001, 15000) g; +SELECT pg_current_xact_id(); +ROLLBACK; +}); +$node->wait_for_log( + qr/test_idxundo: error with resources at logical-revert-process-error/); +$node->safe_psql('postgres', + "SELECT injection_points_detach('logical-revert-process-error')"); +ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 0 FROM idxundo_atm_entries() WHERE xid = '$error_xid'" + ), + 'worker retries entry after ERROR'); +is( $node->safe_psql( + 'postgres', q{ +SELECT pid FROM pg_stat_activity WHERE backend_type = 'logical revert worker'; +}), + $worker_pid, + 'same worker survives ERROR'); +$node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO t SELECT g FROM generate_series(20001, 25000) g; +ROLLBACK; +}); +ok( $node->poll_query_until( + 'postgres', 'SELECT count(*) = 0 FROM idxundo_atm_entries()'), + 'next entry is also processed'); +unlike( + slurp_file($node->logfile), + qr/Assertion|WARNING:.*(?:buffer.*(?:pin|leak)|relcache reference leak)|TRAP:/, + 'no assertion or resource leak'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/007_idxundo_2pc_database.pl b/src/test/modules/test_idxundo/t/007_idxundo_2pc_database.pl new file mode 100644 index 0000000000000..1496f72807d16 --- /dev/null +++ b/src/test/modules/test_idxundo/t/007_idxundo_2pc_database.pl @@ -0,0 +1,66 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# ROLLBACK PREPARED hands a large prepared transaction's index UNDO to the +# logical revert worker of the database the transaction was PREPARED in. +# +# Finishing a prepared transaction from another database is refused, so the +# database must come from the 2PC state, not the finishing session -- which +# also covers a transaction recovered from the 2PC state after a crash. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_2pc_db'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +max_prepared_transactions = 2 +max_logical_revert_workers = 0 +}); +$node->start; +$node->safe_psql('postgres', 'CREATE DATABASE db1'); +$node->safe_psql('postgres', 'CREATE EXTENSION test_idxundo'); +$node->safe_psql( + 'db1', q{ +CREATE EXTENSION test_idxundo; +CREATE TABLE t (id int primary key) WITH (index_undo = on); +}); +my $db1 = $node->safe_psql('postgres', + "SELECT oid FROM pg_database WHERE datname = 'db1'"); + +$node->safe_psql( + 'db1', q{ +BEGIN; +INSERT INTO t SELECT g FROM generate_series(1, 5000) g; +PREPARE TRANSACTION 'p1'; +}); + +my ($rc, $out, $err) = $node->psql('postgres', "ROLLBACK PREPARED 'p1'"); +like( + $err, + qr/prepared transaction belongs to another database/, + 'ROLLBACK PREPARED from another database is refused'); + +# Crash, so the transaction is recovered from its 2PC state, then roll it +# back. The ATM entry must name db1. +$node->stop('immediate'); +$node->start; +$node->safe_psql('db1', "ROLLBACK PREPARED 'p1'"); +is( $node->safe_psql('postgres', 'SELECT dboid FROM idxundo_atm_entries()'), + $db1, + 'the rolled-back prepared transaction is queued for its own database'); + +# The revert worker for db1 reverts it. +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->restart; +ok( $node->poll_query_until( + 'postgres', 'SELECT count(*) = 0 FROM idxundo_atm_entries()'), + 'the revert worker for that database drained it'); +cmp_ok($node->safe_psql('db1', "SELECT idxundo_count_dead_all('t_pkey')"), + '>=', 4900, 'the prepared transaction\'s index entries were reverted'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/008_idxundo_revert_liveness.pl b/src/test/modules/test_idxundo/t/008_idxundo_revert_liveness.pl new file mode 100644 index 0000000000000..5c2b9f41f5c38 --- /dev/null +++ b/src/test/modules/test_idxundo/t/008_idxundo_revert_liveness.pl @@ -0,0 +1,77 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# An ATM entry the logical revert worker cannot finish must not hold up the +# other entries of its database, and a malformed UNDO chain, which can never +# be walked, must be given up on rather than retried (and its WAL pinned) +# forever. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_revert_liveness'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +max_logical_revert_workers = 0 +logical_revert_naptime = 100ms +}); +$node->start; +# No autovacuum: it would remove the aborted entries before the worker does. +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION test_idxundo; +CREATE TABLE t (id int primary key) + WITH (index_undo = on, autovacuum_enabled = off); +}); + +# The oldest entry has a malformed chain: it points at a checkpoint record. +# Its xid wrote nothing, so treating it as aborted changes no data. +my $bad = $node->safe_psql('postgres', + 'BEGIN; SELECT pg_current_xact_id(); COMMIT;'); +($bad) = $bad =~ /(\d+)\s*$/; +$node->safe_psql('postgres', 'CHECKPOINT'); +is( $node->safe_psql( + 'postgres', + "SELECT idxundo_atm_add('$bad', checkpoint_lsn) FROM pg_control_checkpoint()" + ), + 't', + 'malformed entry queued'); + +# A newer, good entry: a large abort the worker has to revert. +my $good = $node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO t SELECT g FROM generate_series(1, 5000) g; +SELECT pg_current_xact_id(); +ROLLBACK; +}); +($good) = $good =~ /(\d+)\s*$/; +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM idxundo_atm_entries() WHERE xid = '$good'"), + '1', + 'large abort queued'); + +my $log_offset = -s $node->logfile; +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->restart; + +ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 0 FROM idxundo_atm_entries() WHERE xid = '$good'"), + 'the entry behind the malformed one is reverted'); +cmp_ok($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '>=', 4900, 'its whole chain was reverted'); + +$node->wait_for_log( + qr/giving up on transaction $bad, whose UNDO chain is malformed/, + $log_offset); +ok( $node->poll_query_until( + 'postgres', 'SELECT count(*) = 0 FROM idxundo_atm_entries()'), + 'the malformed entry is forgotten'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/009_idxundo_recovery_first_batch.pl b/src/test/modules/test_idxundo/t/009_idxundo_recovery_first_batch.pl new file mode 100644 index 0000000000000..38cda45e38667 --- /dev/null +++ b/src/test/modules/test_idxundo/t/009_idxundo_recovery_first_batch.pl @@ -0,0 +1,57 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# A loser transaction that crash recovery defers to the logical revert worker +# must keep the WAL of its whole UNDO chain, from the chain's first batch, +# even when the recovery walk stopped before reaching that batch. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_recovery_first_batch'); +$node->init; +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 0'); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_idxundo'); + +# Two batches, in different WAL segments, with a checkpoint between them so +# that redo starts after the first. The startup process does not know their +# resource manager, so recovery fails to apply them and defers the chain. +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +my $before = $bg->query_safe('SELECT pg_current_wal_insert_lsn()'); +$bg->query_safe('SELECT idxundo_write_failing_undo()'); +my $after = $bg->query_safe('SELECT pg_current_wal_insert_lsn()'); +my $first_seg = $bg->query_safe("SELECT pg_walfile_name('$before')"); +$bg->query_safe('SELECT pg_switch_wal()'); +$bg->query_safe('CHECKPOINT'); +$bg->query_safe('SELECT idxundo_write_failing_undo()'); +$bg->query_safe('SELECT pg_switch_wal()'); +$node->stop('immediate'); +$bg->quit; + +# Recovery does not need the first segment, but walking the chain does: take +# it away, so the walk stops at the first batch without reading it. +my $seg = $node->data_dir . "/pg_wal/$first_seg"; +rename($seg, "$seg.away") or die "rename $seg: $!"; +$node->start; +rename("$seg.away", $seg) or die "rename $seg back: $!"; + +ok( $node->log_contains('stopping chain walk'), + 'the recovery walk stopped before the first batch'); +is( $node->safe_psql( + 'postgres', q{SELECT count(*) FROM pg_stat_get_undo_retention() +WHERE holder = 'aborted'}), + '1', + 'the chain was deferred to the ATM'); +is( $node->safe_psql( + 'postgres', + qq{SELECT oldest_lsn >= '$before' AND oldest_lsn < '$after' +FROM pg_stat_get_undo_retention() WHERE holder = 'aborted'}), + 't', + 'the ATM entry retains WAL from the first batch of the chain'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index 573ccae2d1e19..c5108f52b10ad 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -81,9 +81,12 @@ tests += { 't/084_perbackend_helping_once.pl', 't/085_perbackend_worker_done.pl', 't/089_perbackend_rewrite_rollback.pl', + 't/090_fileops_createdb_crash.pl', 't/096_perbackend_redo_header_image.pl', 't/114_undo_recovery_error.pl', 't/117_undo_rewind_snapshot.pl', + 't/126_atm_full_abort.pl', + 't/134_undo_shmem_on_demand.pl', 't/152_undo_attach_order_after_checkpoint.pl', ], }, diff --git a/src/test/recovery/t/060_fileops_standby_divergence.pl b/src/test/recovery/t/060_fileops_standby_divergence.pl index 36aa0906ba07c..65a07fb873e04 100644 --- a/src/test/recovery/t/060_fileops_standby_divergence.pl +++ b/src/test/recovery/t/060_fileops_standby_divergence.pl @@ -28,7 +28,8 @@ my $primary = PostgreSQL::Test::Cluster->new('fileops_div_primary'); $primary->init(allows_streaming => 1); -$primary->append_conf("postgresql.conf", qq( +$primary->append_conf( + "postgresql.conf", qq( autovacuum = off wal_level = replica )); @@ -46,7 +47,8 @@ # 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( +$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); @@ -73,7 +75,8 @@ # 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( +$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'); diff --git a/src/test/recovery/t/061_fileops_2pc_deferred.pl b/src/test/recovery/t/061_fileops_2pc_deferred.pl index 2317825297a35..7d2abcf3b294e 100644 --- a/src/test/recovery/t/061_fileops_2pc_deferred.pl +++ b/src/test/recovery/t/061_fileops_2pc_deferred.pl @@ -67,10 +67,10 @@ sub wait_for_exists # - 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 $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"; +my $new_a = "$datadir/fo2pc_new_a.dat"; $node->safe_psql('postgres', qq{SELECT test_fileops_create_tempfile('fo2pc_del_a.dat')}); @@ -80,7 +80,8 @@ sub wait_for_exists 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( +$node->safe_psql( + 'postgres', qq( BEGIN; SELECT test_fileops_delete('$del_a'); SELECT test_fileops_rename('$ren_a', '$ren_a2'); @@ -89,24 +90,29 @@ sub wait_for_exists )); # 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($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'); +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'); +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 @@ -123,7 +129,8 @@ sub wait_for_exists $node->safe_psql('postgres', qq{SELECT test_fileops_create_tempfile('fo2pc_ren_b.dat')}); -$node->safe_psql('postgres', qq( +$node->safe_psql( + 'postgres', qq( BEGIN; SELECT test_fileops_delete('$del_b'); SELECT test_fileops_rename('$ren_b', '$ren_b2'); @@ -137,22 +144,25 @@ sub wait_for_exists $node->stop('immediate'); $node->start; -is( $node->safe_psql('postgres', q{SELECT gid FROM pg_prepared_xacts}), - 'fo2pc_rollback', - 'B: prepared xact survives crash'); +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)'); +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 via the async revert worker on ROLLBACK PREPARED, so poll. -wait_for_exists($new_b, 'f', 'B: ROLLBACK PREPARED undid the immediate create'); +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'); +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 index f1e08b7869d14..de57f699ef7ba 100644 --- a/src/test/recovery/t/062_fileops_recovery.pl +++ b/src/test/recovery/t/062_fileops_recovery.pl @@ -27,7 +27,8 @@ # Test 1: CREATE TABLE survives crash # ================================================================ -$node->safe_psql("postgres", qq( +$node->safe_psql( + "postgres", qq( CREATE TABLE fileops_test (id int, data text); INSERT INTO fileops_test VALUES (1, 'created_table'); )); @@ -35,22 +36,23 @@ $node->stop('immediate'); $node->start; -my $result = $node->safe_psql("postgres", - "SELECT data FROM fileops_test WHERE id = 1"); +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( +$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')"); +my $relpath = + $node->safe_psql("postgres", "SELECT pg_relation_filepath('drop_me')"); $node->safe_psql("postgres", "DROP TABLE drop_me"); @@ -58,8 +60,8 @@ $node->start; # Table should be gone -my ($ret, $stdout, $stderr) = $node->psql("postgres", - "SELECT * FROM drop_me"); +my ($ret, $stdout, $stderr) = + $node->psql("postgres", "SELECT * FROM drop_me"); isnt($ret, 0, 'dropped table is gone after crash recovery'); # ================================================================ @@ -67,7 +69,8 @@ # ================================================================ # This table is committed -$node->safe_psql("postgres", qq( +$node->safe_psql( + "postgres", qq( CREATE TABLE committed_table (id int); INSERT INTO committed_table VALUES (42); )); @@ -77,15 +80,15 @@ $node->start; # Committed table should exist -$result = $node->safe_psql("postgres", - "SELECT id FROM committed_table"); +$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( +$node->safe_psql( + "postgres", qq( CREATE TABLE t1 (id int); CREATE TABLE t2 (id int); CREATE TABLE t3 (id int); @@ -98,23 +101,21 @@ $node->stop('immediate'); $node->start; -$result = $node->safe_psql("postgres", - "SELECT id FROM t1"); +$result = $node->safe_psql("postgres", "SELECT id FROM t1"); is($result, '1', 't1 survives crash'); -($ret, $stdout, $stderr) = $node->psql("postgres", - "SELECT * FROM t2"); +($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"); +$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( +$node->safe_psql( + "postgres", qq( DROP TABLE IF EXISTS t1; DROP TABLE IF EXISTS t3; CREATE TABLE checkpoint_test (id int); @@ -126,15 +127,16 @@ $node->stop('immediate'); $node->start; -$result = $node->safe_psql("postgres", - "SELECT count(*) FROM checkpoint_test"); +$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( +$node->safe_psql( + "postgres", qq( DROP TABLE IF EXISTS checkpoint_test; CREATE TABLE multi_crash (id int); INSERT INTO multi_crash VALUES (1); @@ -143,7 +145,8 @@ $node->stop('immediate'); $node->start; -$node->safe_psql("postgres", qq( +$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); @@ -152,19 +155,18 @@ $node->stop('immediate'); $node->start; -$result = $node->safe_psql("postgres", - "SELECT count(*) FROM multi_crash"); +$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"); +$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( +$node->safe_psql( + "postgres", qq( DROP TABLE IF EXISTS multi_crash; DROP TABLE IF EXISTS multi_crash_2; SET allow_in_place_tablespaces = on; @@ -176,8 +178,7 @@ $node->stop('immediate'); $node->start; -$result = $node->safe_psql("postgres", - "SELECT id FROM ts_crash_table"); +$result = $node->safe_psql("postgres", "SELECT id FROM ts_crash_table"); is($result, '99', 'table in tablespace survives crash'); $result = $node->safe_psql("postgres", @@ -188,7 +189,8 @@ # Test 8: DROP TABLESPACE completes after crash # ================================================================ -$node->safe_psql("postgres", qq( +$node->safe_psql( + "postgres", qq( DROP TABLE ts_crash_table; DROP TABLESPACE fileops_crash_ts; )); @@ -204,7 +206,8 @@ # Test 9: CREATE DATABASE survives crash # ================================================================ -$node->safe_psql("postgres", qq( +$node->safe_psql( + "postgres", qq( CREATE DATABASE fileops_crash_db; )); @@ -224,7 +227,8 @@ # Set up primary + standby my $primary = PostgreSQL::Test::Cluster->new('fileops_primary'); $primary->init(allows_streaming => 1); -$primary->append_conf("postgresql.conf", qq( +$primary->append_conf( + "postgresql.conf", qq( autovacuum = off )); $primary->start; @@ -235,7 +239,8 @@ $standby->start; # Create table on primary and wait for standby to catch up -$primary->safe_psql("postgres", qq( +$primary->safe_psql( + "postgres", qq( CREATE TABLE standby_test (id int); INSERT INTO standby_test VALUES (1); )); @@ -243,8 +248,7 @@ $primary->wait_for_catchup($standby); # Verify on standby -$result = $standby->safe_psql("postgres", - "SELECT id FROM standby_test"); +$result = $standby->safe_psql("postgres", "SELECT id FROM standby_test"); is($result, '1', 'CREATE TABLE replicated to standby'); # Crash the standby @@ -252,14 +256,15 @@ $standby->start; # Add more data on primary -$primary->safe_psql("postgres", qq( +$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"); +$result = + $standby->safe_psql("postgres", "SELECT count(*) FROM standby_test"); is($result, '2', 'standby recovers and catches up after crash'); # Clean up primary/standby @@ -270,34 +275,15 @@ $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. +# Test 11/12: Crash right after an immediate op's WAL is flushed, before +# its transaction commits (FileOpsCreate, FileOpsChmod). # -# 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. +# An immediate op makes its filesystem change first and then flushes its +# WAL and UNDO records. The injection points pause the backend just after +# that flush, inside the still-open implicit transaction, and the postmaster +# is then hard-killed. Redo replays the change, and crash recovery applies +# the UNDO of the never-committed transaction, so the end state must be as +# if the transaction never ran: no orphaned file, no leftover mode change. # ================================================================ if ($ENV{enable_injection_points} eq 'yes') @@ -313,7 +299,7 @@ )); $inode->start; - if ($inode->check_extension('injection_points') + if ( $inode->check_extension('injection_points') && $inode->check_extension('test_fileops')) { $inode->safe_psql('postgres', 'CREATE EXTENSION injection_points'); @@ -338,11 +324,9 @@ $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)'); + # The file exists and its WAL is durable; the transaction is open. + ok(-e $create_path, + 'Test 11: file exists at the injection point (WAL durable)'); # Hard-kill the postmaster from exactly this window. The wrapping # implicit transaction is still in progress (blocked on the @@ -355,9 +339,9 @@ # 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, + ok( !-e $create_path, 'Test 11: FileOpsCreate of a not-committed transaction is ' - . 'cleanly reversed after a crash mid-critical-section'); + . 'reversed after a crash'); # Server is healthy after the reconciliation. my $result11 = $inode->safe_psql('postgres', 'SELECT 1'); @@ -367,7 +351,8 @@ # --- Test 12: FileOpsChmod ------------------------------------- $inode->safe_psql('postgres', - qq{SELECT test_fileops_create_tempfile('wal_ordering_chmod.dat')}); + 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 @@ -390,16 +375,15 @@ $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. + # chmod() has run and its WAL is durable; the transaction is open. 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)'); + is($mode_at_injection, '448', + 'Test 12: mode changed on disk at the injection point (WAL durable)' + ); - # Hard-kill mid-critical-section, same as Test 11. The chmod's - # wrapping implicit transaction never committed. + # Hard-kill, same as Test 11. The chmod's wrapping implicit + # transaction never committed. $inode->stop('immediate'); $inode->start; @@ -410,8 +394,8 @@ 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'); + 'Test 12: FileOpsChmod of a not-committed transaction is ' + . 'reversed after a crash'); # Server is healthy after the reconciliation. my $result12 = $inode->safe_psql('postgres', 'SELECT 1'); @@ -422,7 +406,7 @@ else { note - 'skipping Test 11/12 (injection_points or test_fileops extension not installed)'; + 'skipping Test 11/12 (injection_points or test_fileops extension not installed)'; } $inode->stop; diff --git a/src/test/recovery/t/063_fileops_2pc.pl b/src/test/recovery/t/063_fileops_2pc.pl index 23f242523d55e..068415d8b2377 100644 --- a/src/test/recovery/t/063_fileops_2pc.pl +++ b/src/test/recovery/t/063_fileops_2pc.pl @@ -57,20 +57,21 @@ sub wait_for_mode $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')}); +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( +$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')}); +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'}); @@ -79,14 +80,14 @@ sub wait_for_mode # ================================================================ # Test 2: COMMIT PREPARED keeps the chmod # ================================================================ -$node->safe_psql('postgres', qq( +$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')}); +$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. @@ -95,7 +96,8 @@ sub wait_for_mode # ================================================================ # Test 3: WAL retention across checkpoints while prepared # ================================================================ -$node->safe_psql('postgres', qq( +$node->safe_psql( + 'postgres', qq( BEGIN; SELECT test_fileops_chmod('$file', 384); -- 0600 PREPARE TRANSACTION 'fo_ckpt'; @@ -105,7 +107,7 @@ sub wait_for_mode for my $i (1 .. 20) { $node->safe_psql('postgres', - "CREATE TABLE junk_$i(x int); " + "CREATE TABLE junk_$i(x int); " . "INSERT INTO junk_$i SELECT g FROM generate_series(1,5000) g; " . "DROP TABLE junk_$i;"); } @@ -118,7 +120,8 @@ sub wait_for_mode # 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( +$node->safe_psql( + 'postgres', qq( BEGIN; SELECT test_fileops_chmod('$file', 448); -- 0700 PREPARE TRANSACTION 'fo_crash'; @@ -126,11 +129,9 @@ sub wait_for_mode $node->stop('immediate'); $node->start; -my $prep = $node->safe_psql('postgres', - q{SELECT gid FROM pg_prepared_xacts}); +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')}); +$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'}); @@ -138,8 +139,8 @@ sub wait_for_mode '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'); +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/072_perbackend_crash_undo.pl b/src/test/recovery/t/072_perbackend_crash_undo.pl index 68cc11cba43ba..b964995b38f2e 100644 --- a/src/test/recovery/t/072_perbackend_crash_undo.pl +++ b/src/test/recovery/t/072_perbackend_crash_undo.pl @@ -49,16 +49,19 @@ my $datadir = $node->data_dir; my $create_path = "$datadir/pbu_crash_create.dat"; -my $chmod_path = "$datadir/pbu_crash_chmod.dat"; +my $chmod_path = "$datadir/pbu_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('pbu_crash_chmod.dat')}); -$node->safe_psql('postgres', qq{SELECT test_fileops_chmod('$chmod_path', 420)}); -is($node->safe_psql('postgres', qq{SELECT test_fileops_get_mode('$chmod_path')}), - '420', 'baseline mode of pre-existing file is 0644'); +$node->safe_psql('postgres', + qq{SELECT test_fileops_chmod('$chmod_path', 420)}); +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 per-backend UNDO (create a new file and @@ -76,9 +79,12 @@ )); # 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)'); -is($node->safe_psql('postgres', qq{SELECT test_fileops_get_mode('$chmod_path')}), - '448', 'chmod applied on disk before crash (in-progress xact)'); +ok(-e $create_path, + 'created file exists on disk before crash (in-progress xact)'); +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. The background psql @@ -95,23 +101,30 @@ # - 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'); -is($node->safe_psql('postgres', qq{SELECT test_fileops_get_mode('$chmod_path')}), +ok( !-e $create_path, + 'FILEOPS COMMON-WAL CRASH RECOVERY: created file was REVERTED (unlinked) after crash' +); +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'); + 'FILEOPS COMMON-WAL CRASH RECOVERY: chmod was REVERTED (0700 -> 0644) after crash' +); # Recovery must be clean and must NOT have engaged the per-backend UNDO engine # for FILEOPS: the revert rides the cluster-wide UNDO engine on the common WAL. my $rlog = slurp_file($node->logfile); -unlike($rlog, qr/per-backend UNDO recovery: rolling back transaction/, +unlike( + $rlog, + qr/per-backend UNDO recovery: rolling back transaction/, 'FILEOPS revert did NOT go through the per-backend UNDO engine'); -unlike($rlog, qr/PANIC|inconsistent|corrupt/i, +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 per-backend crash-recovery undo'); +is($node->safe_psql('postgres', 'SELECT 1'), + '1', 'server operational after per-backend crash-recovery undo'); $node->stop; done_testing(); diff --git a/src/test/recovery/t/075_perbackend_nonpermanent.pl b/src/test/recovery/t/075_perbackend_nonpermanent.pl index 6f6e41e30bc4a..fcd1e843a1952 100644 --- a/src/test/recovery/t/075_perbackend_nonpermanent.pl +++ b/src/test/recovery/t/075_perbackend_nonpermanent.pl @@ -34,6 +34,15 @@ "$am unlogged: rollback restored every row"); } +# pg_stat_get_undo_logs() shows the per-backend undo logs: the rollbacks +# above wrote to an unlogged one. +is( $node->safe_psql( + 'postgres', + "SELECT count(*) > 0 FROM pg_stat_get_undo_logs() + WHERE persistence = 'unlogged'"), + 't', + 'pg_stat_get_undo_logs shows the unlogged undo log'); + # The numbers of the unlogged and temporary undo logs, as a SQL list. Their # pages are the undo pseudo-relations with these relfilenumbers. sub nonpermanent_lognos diff --git a/src/test/recovery/t/090_fileops_createdb_crash.pl b/src/test/recovery/t/090_fileops_createdb_crash.pl new file mode 100644 index 0000000000000..8772ad34ff933 --- /dev/null +++ b/src/test/recovery/t/090_fileops_createdb_crash.pl @@ -0,0 +1,113 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# A CREATE DATABASE that crashes, or fails, part way through its copy must +# leave no database directory behind, for either strategy. The directory +# is created by FILEOPS but filled by smgr (WAL_LOG) or copydir (FILE_COPY), +# so its UNDO must remove the whole tree, not just rmdir it. + +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('createdb_crash'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +shared_preload_libraries = 'injection_points' +wal_level = replica +autovacuum = off +}); +$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 DATABASE tmpl'); +$node->safe_psql('tmpl', + "CREATE TABLE t AS SELECT g, repeat('x', 200) v FROM generate_series(1, 20000) g" +); + +my $base = $node->data_dir . '/base'; + +# Directories under base/ that belong to no database. +sub orphans +{ + my %known = map { $_ => 1 } split /\n/, + $node->safe_psql('postgres', 'SELECT oid FROM pg_database'); + opendir(my $dh, $base) or die "opendir $base: $!"; + my @o = grep { /^\d+$/ && !$known{$_} } readdir($dh); + closedir($dh); + return join(' ', sort @o); +} + +my %point = ( + wal_log => 'createdb-wal-log-after-copy-relation', + file_copy => 'createdb-file-copy-after-copydir'); + +foreach my $strategy ('wal_log', 'file_copy') +{ + my $ip = $point{$strategy}; + my %before = map { $_ => 1 } split / /, orphans(); + + # Crash while the new directory holds copied files. + $node->safe_psql('postgres', + "SELECT injection_points_attach('$ip', 'wait')"); + my $bg = $node->background_psql('postgres', on_error_stop => 0); + $bg->query_until( + qr/start/, qq(\\echo start +CREATE DATABASE c_$strategy TEMPLATE tmpl STRATEGY $strategy; +)); + $node->wait_for_event('client backend', $ip); + + my ($new) = grep { !$before{$_} } split / /, orphans(); + ok(defined $new, "$strategy: the new database directory exists"); + $new //= 'none'; + my @files = glob("$base/$new/*"); + cmp_ok(scalar(@files), '>', 1, + "$strategy: it holds copied files at the crash"); + + my $log_offset = -s $node->logfile; + $node->stop('immediate'); + $bg->{run}->finish; + $node->start; + + is(orphans(), '', "$strategy: no database directory left after a crash"); + my $log = slurp_file($node->logfile, $log_offset); + unlike( + $log, + qr/WARNING: FILEOPS UNDO/, + "$strategy: crash recovery applied the UNDO cleanly"); + + # The same, with the copy failing instead. + $node->safe_psql('postgres', + "SELECT injection_points_attach('$ip', 'error')"); + my ($ret, $out, $err) = $node->psql('postgres', + "CREATE DATABASE e_$strategy TEMPLATE tmpl STRATEGY $strategy"); + like($err, qr/ERROR/, "$strategy: CREATE DATABASE failed mid-copy"); + $node->safe_psql('postgres', "SELECT injection_points_detach('$ip')"); + is(orphans(), '', "$strategy: no database directory left after an error"); + + # A later CREATE DATABASE still works. + $node->safe_psql('postgres', + "CREATE DATABASE ok_$strategy TEMPLATE tmpl STRATEGY $strategy"); + is($node->safe_psql("ok_$strategy", 'SELECT count(*) FROM t'), + '20000', "$strategy: a new CREATE DATABASE succeeds"); +} + +# No UNDO chain was left for the revert worker to retry forever. +sleep(1); +unlike( + slurp_file($node->logfile), + qr/could not rmdir .*Directory not empty/, + 'no failed rmdir of a non-empty directory'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/126_atm_full_abort.pl b/src/test/recovery/t/126_atm_full_abort.pl new file mode 100644 index 0000000000000..0fc0fcb8a01cd --- /dev/null +++ b/src/test/recovery/t/126_atm_full_abort.pl @@ -0,0 +1,125 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# An abort that cannot be recorded in a full ATM must not fail. +# +# ATM instant abort records the aborted transaction in the sLog radix tree, +# a DSA area capped by slog_dsa_max_size_mb. With the logical revert worker +# held, every instant abort adds an entry until the cap is reached. The +# registration runs inside AbortTransaction, so it must not raise an error +# there: the abort falls back (WARNING "ATM full"), the backend and server +# stay up, the aborted rows stay invisible, and they stay so after a crash +# restart. +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'; +} + +# The cap is reached after about 4500 entries. +my $naborts = 8000; +my $nclients = 4; + +my $node = PostgreSQL::Test::Cluster->new('atm_full_abort'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +logical_revert_naptime = 100 +slog_dsa_max_size_mb = 2 +undo_instant_abort_threshold = 0 +shared_preload_libraries = 'injection_points' +)); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('amcheck')) +{ + plan skip_all => 'injection_points or amcheck not installed'; +} +$node->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION amcheck; +CREATE TABLE t (id int PRIMARY KEY, v int) USING heap + WITH (index_undo = on, autovacuum_enabled = off); +INSERT INTO t VALUES (0, 0); +SELECT injection_points_attach('logical-revert-before-process', 'wait'); +)); + +# The injection point holds the revert worker at its first entry, whenever +# it starts, so the ATM only grows. + +my $log_offset = -s $node->logfile; + +# Many aborted transactions, each an ATM instant abort, from several +# clients at once, until well past the cap. +my @clients; +for my $c (0 .. $nclients - 1) +{ + my $h = $node->background_psql('postgres', on_error_stop => 0); + $h->query_safe('SET client_min_messages = error'); + $h->query_until( + qr/start/, qq(\\echo start +DO \$\$ +BEGIN + FOR i IN 1..$naborts / $nclients LOOP + INSERT INTO t VALUES (i * $nclients + $c, i); + ROLLBACK; + END LOOP; +END \$\$; +\\echo done +)); + push @clients, $h; +} +my $stderr = ''; +for my $h (@clients) +{ + $h->query_until(qr/done/, ''); + $stderr .= $h->{stderr}; + $h->quit; +} + +ok($node->log_contains(qr/WARNING: .*ATM full/, $log_offset), + 'full ATM reported as a WARNING'); +unlike( + $stderr, + qr/ERROR|FATAL|PANIC|server closed/, + 'no error while aborting into a full ATM'); +ok( !$node->log_contains( + qr/ERROR|PANIC|server process .* was terminated/, $log_offset), + 'no ERROR or PANIC in the server log'); + +# The aborted rows stay invisible, through the index and the heap. +sub check_rows +{ + my ($what) = @_; + is( $node->safe_psql( + 'postgres', qq( +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM t WHERE id BETWEEN -1 AND $naborts + $nclients;)), + '1', + "index scan sees only the committed row $what"); + is($node->safe_psql('postgres', 'SELECT count(*) FROM t'), + '1', "seq scan sees only the committed row $what"); + $node->safe_psql('postgres', q(SELECT bt_index_check('t_pkey', true))); + pass("t_pkey passes amcheck $what"); +} +check_rows('after the aborts'); + +# Crash and restart: redo must not resurrect anything either. +$node->stop('immediate'); +$node->start; +check_rows('after crash restart'); + +# The injection point did not survive the restart: the revert worker now +# drains what was recorded, and VACUUM removes the rest. +$node->safe_psql('postgres', 'VACUUM t'); +check_rows('after VACUUM'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/134_undo_shmem_on_demand.pl b/src/test/recovery/t/134_undo_shmem_on_demand.pl new file mode 100644 index 0000000000000..76f20b19c3972 --- /dev/null +++ b/src/test/recovery/t/134_undo_shmem_on_demand.pl @@ -0,0 +1,97 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# UNDO, the sLog and FLUX allocate no large fixed shared memory. +# +# A cluster that never uses FLUX or UNDO should cost about what master does. +# The transaction sLog (the ATM) reserved a 512 kB DSA region at startup and +# FLUX an 8 MB dirty-page map. The ATM now lives in a DSA created when it is +# first written, and the dirty-page map is gone. The 1 MB bound on what is +# left is a coarse guard, not the goal: see the commit message. The ATM must still work +# from there: an instant abort, the revert worker, a checkpoint and a crash +# restart. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +log_min_messages = debug1 +undo_instant_abort_threshold = 0 +logical_revert_naptime = 100 +)); +$node->start; + +my $fixed = + q{SELECT coalesce(sum(allocated_size), 0) FROM pg_shmem_allocations + WHERE name ~* 'undo|flux|slog|secondary log|aborted|revert'}; +my $created = qr/created the aborted transaction map/; + +is( $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM pg_shmem_allocations WHERE name = 'FLUX DirtyMap'} + ), + '0', + 'no fixed FLUX dirty map'); +cmp_ok( + $node->safe_psql( + 'postgres', + q{SELECT coalesce(sum(allocated_size), 0) FROM pg_shmem_allocations + WHERE name = 'Secondary Log State'}), + '<', + 1024, + 'transaction sLog reserves only its lock and handles'); +cmp_ok( + $node->safe_psql('postgres', $fixed), + '<', + 1024 * 1024, + 'UNDO, sLog and FLUX fixed shared memory below 1 MB'); + +# A checkpoint reads the ATM; that must not create it. Its DSA area is +# created by the first transaction that writes UNDO. +$node->safe_psql('postgres', 'CHECKPOINT'); +ok(!$node->log_contains($created), 'no ATM before use'); + +# An instant abort records the transaction in the ATM, creating its DSA, and +# the revert worker undoes it. +$node->safe_psql( + 'postgres', q{ +CREATE TABLE t (id int PRIMARY KEY, v int) USING heap + WITH (index_undo = on, autovacuum_enabled = off); +INSERT INTO t VALUES (0, 0); +}); +$node->safe_psql('postgres', + 'BEGIN; INSERT INTO t SELECT g, g FROM generate_series(1, 100) g; ROLLBACK;' +); +ok($node->log_contains($created), 'ATM created on use'); +ok( $node->poll_query_until( + 'postgres', + q{SELECT count(*) = 0 FROM pg_stat_get_undo_retention() + WHERE holder = 'aborted'}), + 'revert worker drained the ATM'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM t'), + '1', 'aborted rows invisible'); + +# A deferred abort outlives a checkpoint and a crash. +$node->safe_psql('postgres', + 'BEGIN; INSERT INTO t SELECT g, g FROM generate_series(101, 200) g; ROLLBACK;' +); +$node->safe_psql('postgres', 'CHECKPOINT'); +$node->stop('immediate'); +$node->start; +is($node->safe_psql('postgres', 'SELECT count(*) FROM t'), + '1', 'aborted rows stay invisible after a crash'); +$node->safe_psql('postgres', 'VACUUM t'); +is( $node->safe_psql( + 'postgres', + 'SET enable_seqscan = off; SELECT count(*) FROM t WHERE id >= 0'), + '1', + 'index agrees after a crash'); + +$node->stop; +done_testing(); diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 7202708a90144..a09d3f381070c 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -7,6 +7,8 @@ ASN1_OBJECT ASN1_OCTET_STRING ASN1_STRING ATAlterConstraint +AtmStateHeader +AtmStateRecord AV A_ArrayExpr A_Const @@ -841,7 +843,6 @@ FileopsUndoCreate FileopsUndoLink FileopsUndoMkdir FileopsUndoRemovexattr -FileopsUndoRename FileopsUndoSetxattr FileopsUndoSymlink FileopsUndoTruncate @@ -1582,6 +1583,7 @@ LOCKMASK LOCKMETHODID LOCKMODE LOCKTAG +LogicalRevertState LONG LONG_PTR LOOP @@ -2753,6 +2755,25 @@ SID_IDENTIFIER_AUTHORITY SID_NAME_USE SISeg SIZE_T +slog_atm_value_t +SLogFlatBucket +SLogFlatHash +SLogFlatHashScanState +SLogFlatOp +SLogFlatOpKind +SLogFlatPartition +SLogInsertTidEntry +SLogInsertTidKey +SLogOpType +SLogSharedState +SLogTrackedKey +SLogTrackedKeyCallback +SLogTrackedKeyInfo +SLogTupleEntry +SLogTupleIterCallback +SLogTupleKey +SLogTupleOp +SLogTxnEntry SMgrRelation SMgrRelationData SMgrSortArray @@ -3551,6 +3572,8 @@ XactUndoContext XactUndoData XactUndoRecordInfo XactUndoSubTransactionState +xl_atm_abort +xl_atm_forget xl_fileops_chmod xl_fileops_chown xl_fileops_create @@ -3564,14 +3587,11 @@ xl_fileops_setxattr xl_fileops_symlink xl_fileops_truncate xl_fileops_write -xl_undo_allocate xl_undo_apply xl_undo_batch xl_undo_chain_state -xl_undo_discard -xl_undo_extend -xl_undo_page_write -xl_undo_rotate +xl_undo_inflight +xl_undo_inflight_xact xl_undoapply_progress xl_undolog_attach xl_undolog_create @@ -3618,6 +3638,7 @@ XactCallback XactCallbackItem XactEvent XactLockTableWaitInfo +XactUndoInflightSlot XidBoundsViolation XidCacheStatus XidCommitStatus From 69df3ed691c9458466601f4e72b514a46af4fa69 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Sat, 19 Sep 2026 18:42:38 +0000 Subject: [PATCH 08/22] nbtree: mark aborted index inserts dead via UNDO When a transaction aborts, mark the B-tree leaf entries it inserted LP_DEAD during rollback, so an aborted transaction leaves no index work for VACUUM. Controlled per table by a new heap reloption, index_undo, which defaults to on: CREATE TABLE t (id int); CREATE INDEX ON t (id); BEGIN; INSERT INTO t SELECT g FROM generate_series(1,100) g; ROLLBACK; -- the 100 index entries are LP_DEAD now, with no VACUUM The heap tuples are unchanged: they are dead by xmin as always, and heap writes no UNDO. Only the index entries are cleaned up early. Each leaf insert writes one small INSERT_LEAF record into the cluster-wide UNDO-in-WAL stream: the index OID, the (block, offset) of the insert, and a discriminator -- the entry's heap TID, its length, and a 4-byte digest of its key bytes. The record does not carry the IndexTuple, so its size does not grow with the key width. Records are deferred (DeferXactUndoData()) and flushed as one XLOG_UNDO_BATCH per statement, and at every point that can consume the chain: commit, abort, subtransaction commit and abort, and PREPARE. On abort, AtAbort_XactUndo() walks the chain and dispatches each record to nbtree_undo_apply(), which marks the entry LP_DEAD and writes a CLR so the rollback is idempotent under crash recovery. The recorded offset is where the entry was, not where it is: a later lower-key insert shifts it right on the page, and a split can move it to a sibling. The apply path therefore searches -- recorded offset, then the page, then up to eight leaf right-links -- and kills a slot only if its heap TID, length and key digest all match. Any mismatch leaves the entry for VACUUM, which is safe; killing the wrong slot would make a committed row unreachable through the index. Structural changes -- splits, new roots, internal downlinks, posting-list splits, deduplication -- are not recorded or reversed, because concurrent readers may already have seen them. The aborted leaf entries are killed wherever the split moved them, and pages left empty are recycled by VACUUM as before. RelationUsesIndexUndo() is the single gate for the write path, and returns false (feature off for that relation, never an error) for: catalogs and TOAST, bootstrap, relations that are not WAL-logged (TEMP, UNLOGGED), and indexes created or reindexed in the current transaction, whose abort drops the whole index anyway -- the last is what keeps CREATE INDEX from writing a record per row. Index UNDO is a property of the relation, not of the table AM: it does not depend on whether the table AM writes its own UNDO, and plain heap uses it. Crash recovery replays the UNDO batches like any other WAL; a transaction with no commit record has its chain applied, deferred to the ATM and the logical revert worker where the syscache is not yet available. src/test/modules/test_idxundo covers rollback, page splits during an aborted transaction, subtransactions, statements larger than one batch, 2PC, and a kill -9 mid-transaction recovery test that checks index/heap agreement and amcheck. The hash and FLUX cases are added by the commits that add those features. Known limitation: ROLLBACK PREPARED does not drive the apply path, so the entries of a rolled-back prepared transaction are left for VACUUM as before. test_idxundo records this. Cost. Measured on a 32-vCPU EC2 instance, release build, 8 clients, each transaction inserting 10 random int keys into a heap table with one btree and one hash index (hash index UNDO is added by the next commit) and then committing or rolling back at the given rate; 60 s runs, index_undo on and off interleaved, median of 5: abort TPS on/off WAL/txn on/off index bytes per live row on/off 0% 27583 / 28692 -3.9% 3548 / 2364 67.5 / 65.7 40% 26509 / 31895 -16.9% 4142 / 2371 82.5 / 109.6 80% 25901 / 34153 -24.2% 4746 / 2370 179.6 / 344.9 Every one of the five paired runs was within 1.2 points of the median at 40% and 80%, and within 3.7 at 0%. The index-size effect grows with the abort rate (1.3x smaller at 40%, 1.9x at 80%); the throughput cost grows with it too, because an aborting transaction now does the rollback work VACUUM would otherwise have done later. At 0% aborts the cost is the WAL for records that are never needed. Workloads that commit nearly everything and can afford VACUUM can set index_undo = off per table. Under the same concurrent 80%-abort load (30 s), 3695 of 6.37M aborted btree entries (0.06%) were left live for VACUUM, the ones a split moved beyond the right-link search bound; with index_undo = off all of them are. After that run an index scan and a sequential scan returned the same 1571680 rows, so no committed entry was killed. Author: Greg Burd Changes since v2: - Flush pending index UNDO before the commit record [258728b1f4] - Make undo_instant_abort_threshold measure the real chain size [3f6a91bf0b] - Hand crash-recovery deferrals to the right database, keep their WAL [e4080312f5] - Stamp UNDO batches with the top-level xid [dd4bd04cc6] - Do not count failed UNDO records as applied [2f76b4dc36] - Apply UNDO newest first in crash recovery too [48a741faf2] - Retain a deferred rollback's WAL from its oldest batch [a6323be6b5] - Keep ATM entries whose UNDO chain could not be read [f8d3e4124e] - Hand off only the permanent chain of a failed subtransaction rollback [1bbdb8f2bd] - Keep one unfinished ATM entry from blocking the revert worker [09db3aa00d] - Retain a recovery-deferred chain from its real first batch [67730b0bc7] - Abort failed logical revert worker transactions before retrying [9151cde4a8] - Fail startup on errors applying per-backend undo [de2b3815ab] - Apply index UNDO only inline, while the aborting transaction runs [1a267a68f4] - Test_idxundo: count the dead entries after a rollback that splits leaves [41db174eee] - Nbtree: write index UNDO for an insert that splits its leaf [05e348c9f2] - Test_idxundo: wal_consistency_checking must cover index-UNDO CLRs [d42d5f87be] - Give the Undo rmgr a mask so CLR redo is consistency-checked [e367a010a3] - Recovery/124: create the test tables USING heap [7dce254c15] - Nbtree: describe index UNDO's exclusions without naming later features [c059b137a5] - ATM and revert worker: apply project formatting (2 files) [c00f78fc82] - Nbtree index UNDO: apply pgperltidy to its tests (2 files) [bf88bbd276] Tests: - Test that ROLLBACK PREPARED queues UNDO for the preparing database [92ca01e8ad] - Test that deferred index UNDO leaves a committed entry on a reused TID alone [5245c737fd] --- doc/src/sgml/undo.sgml | 6 +- src/backend/access/common/reloptions.c | 13 +- src/backend/access/nbtree/Makefile | 1 + src/backend/access/nbtree/meson.build | 1 + src/backend/access/nbtree/nbtinsert.c | 28 + src/backend/access/nbtree/nbtree_undo.c | 598 ++++++++++++++++++ src/backend/access/table/tableam.c | 107 ++++ src/backend/access/table/tableamapi.c | 9 + src/backend/access/transam/xact.c | 15 + src/backend/access/undo/README | 10 + .../access/undo/logical_revert_worker.c | 2 +- src/backend/access/undo/undo_xlog.c | 33 + src/backend/access/undo/undoapply.c | 18 +- src/backend/access/undo/xactundo.c | 29 +- src/include/access/nbtree.h | 12 + src/include/access/rmgrlist.h | 2 +- src/include/access/tableam.h | 41 ++ src/include/access/undo_xlog.h | 1 + src/include/access/undormgr.h | 11 + src/include/access/undormgrlist.h | 3 + src/include/utils/rel.h | 29 + src/test/modules/Makefile | 3 +- src/test/modules/meson.build | 1 + src/test/modules/test_idxundo/.gitignore | 4 + src/test/modules/test_idxundo/Makefile | 30 + .../test_idxundo/expected/test_idxundo.out | 542 ++++++++++++++++ src/test/modules/test_idxundo/meson.build | 52 ++ .../modules/test_idxundo/sql/test_idxundo.sql | 321 ++++++++++ .../test_idxundo/t/001_idxundo_crash.pl | 214 +++++++ .../t/005_idxundo_atm_retention.pl | 17 +- .../t/006_idxundo_revert_retry.pl | 5 +- .../t/007_idxundo_2pc_database.pl | 5 +- .../t/008_idxundo_revert_liveness.pl | 5 +- .../t/011_idxundo_wal_consistency.pl | 58 ++ .../test_idxundo/test_idxundo--1.0.sql | 37 ++ src/test/modules/test_idxundo/test_idxundo.c | 340 ++++++++++ .../modules/test_idxundo/test_idxundo.control | 4 + src/test/recovery/meson.build | 1 + .../recovery/t/124_index_undo_tid_reuse.pl | 206 ++++++ src/tools/pgindent/typedefs.list | 2 + 40 files changed, 2775 insertions(+), 41 deletions(-) create mode 100644 src/backend/access/nbtree/nbtree_undo.c create mode 100644 src/test/modules/test_idxundo/.gitignore create mode 100644 src/test/modules/test_idxundo/Makefile create mode 100644 src/test/modules/test_idxundo/expected/test_idxundo.out create mode 100644 src/test/modules/test_idxundo/meson.build create mode 100644 src/test/modules/test_idxundo/sql/test_idxundo.sql create mode 100644 src/test/modules/test_idxundo/t/001_idxundo_crash.pl create mode 100644 src/test/modules/test_idxundo/t/011_idxundo_wal_consistency.pl create mode 100644 src/test/modules/test_idxundo/test_idxundo--1.0.sql create mode 100644 src/test/modules/test_idxundo/test_idxundo.c create mode 100644 src/test/modules/test_idxundo/test_idxundo.control create mode 100644 src/test/recovery/t/124_index_undo_tid_reuse.pl diff --git a/doc/src/sgml/undo.sgml b/doc/src/sgml/undo.sgml index be93794a7dedb..18a0b1cb0cb5f 100644 --- a/doc/src/sgml/undo.sgml +++ b/doc/src/sgml/undo.sgml @@ -50,7 +50,11 @@ CREATE TABLE important_data ( Index UNDO is separate and is controlled per relation by the index_undo storage parameter, which reverses a table's index entries during ROLLBACK instead of leaving them for - VACUUM. It defaults to on: + VACUUM. Only a rollback the aborting session applies + itself does this; the index entries of a rollback deferred to the logical + revert worker (see undo_instant_abort_threshold) or + to crash recovery are still left for VACUUM. It + defaults to on: diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c index ea9a04179090e..bf8ff0fbcd5c2 100644 --- a/src/backend/access/common/reloptions.c +++ b/src/backend/access/common/reloptions.c @@ -153,6 +153,15 @@ static relopt_bool boolRelOpts[] = }, true }, + { + { + "index_undo", + "Reverses this table's index entries during ROLLBACK instead of leaving them for VACUUM", + RELOPT_KIND_HEAP, + AccessExclusiveLock + }, + true + }, /* list terminator */ {{NULL}} }; @@ -2117,7 +2126,9 @@ static const relopt_parse_elt stdRdOptionsTab[] = { {"vacuum_truncate", RELOPT_TYPE_TERNARY, offsetof(StdRdOptions, vacuum_truncate)}, {"vacuum_max_eager_freeze_failure_rate", RELOPT_TYPE_REAL, - offsetof(StdRdOptions, vacuum_max_eager_freeze_failure_rate)} + offsetof(StdRdOptions, vacuum_max_eager_freeze_failure_rate)}, + {"index_undo", RELOPT_TYPE_BOOL, + offsetof(StdRdOptions, index_undo)} }; /* diff --git a/src/backend/access/nbtree/Makefile b/src/backend/access/nbtree/Makefile index 0daf640af96c7..492bcf578c112 100644 --- a/src/backend/access/nbtree/Makefile +++ b/src/backend/access/nbtree/Makefile @@ -20,6 +20,7 @@ OBJS = \ nbtpreprocesskeys.o \ nbtreadpage.o \ nbtree.o \ + nbtree_undo.o \ nbtsearch.o \ nbtsort.o \ nbtsplitloc.o \ diff --git a/src/backend/access/nbtree/meson.build b/src/backend/access/nbtree/meson.build index 812f067e7101c..f526ef6531729 100644 --- a/src/backend/access/nbtree/meson.build +++ b/src/backend/access/nbtree/meson.build @@ -8,6 +8,7 @@ backend_sources += files( 'nbtpreprocesskeys.c', 'nbtreadpage.c', 'nbtree.c', + 'nbtree_undo.c', 'nbtsearch.c', 'nbtsort.c', 'nbtsplitloc.c', diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c index d5d964301c1b4..87baf77cd4331 100644 --- a/src/backend/access/nbtree/nbtinsert.c +++ b/src/backend/access/nbtree/nbtinsert.c @@ -1233,6 +1233,17 @@ _bt_insertonpg(Relation rel, BufferGetBlockNumber(buf), BufferGetBlockNumber(rbuf)); + /* + * Write the nbtree UNDO record for the insertion, as the no-split + * path below does. The new item is now on the left or the right + * half; record the left half's block, which is where the item would + * have gone. The apply searches that page and then moves right, the + * same way it finds an entry a later split moved. + */ + if (RelationUsesIndexUndo(rel, heaprel)) + NbtreeUndoLogInsert(rel, heaprel, buf, itup, itemsz, newitemoff, + isleaf); + /*---------- * By here, * @@ -1418,6 +1429,23 @@ _bt_insertonpg(Relation rel, END_CRIT_SECTION(); + /* + * Write an nbtree UNDO record for this insertion, so an abort can + * reverse the provisionally-inserted entry without waiting for + * VACUUM. Done after the critical section (UNDO insertion does I/O) + * but while the buffer lock is still held. + * + * Only when the parent table asks for it via the index_undo + * reloption. RelationUsesIndexUndo() applies every other condition + * too; non-WAL relations and indexes built in this transaction, for + * example, are excluded there. + */ + if (RelationUsesIndexUndo(rel, heaprel)) + { + NbtreeUndoLogInsert(rel, heaprel, buf, itup, + itemsz, newitemoff, isleaf); + } + /* Release subsidiary buffers */ if (BufferIsValid(metabuf)) _bt_relbuf(rel, metabuf); diff --git a/src/backend/access/nbtree/nbtree_undo.c b/src/backend/access/nbtree/nbtree_undo.c new file mode 100644 index 0000000000000..de463c6de5143 --- /dev/null +++ b/src/backend/access/nbtree/nbtree_undo.c @@ -0,0 +1,598 @@ +/*------------------------------------------------------------------------- + * + * nbtree_undo.c + * nbtree UNDO resource manager + * + * When a transaction aborts, the leaf entries it inserted into a B-tree are + * marked LP_DEAD during rollback, so they are not left for VACUUM. The heap + * tuples themselves are dead by xmin as usual; only the index work changes. + * This is done only by the aborting backend itself; a rollback applied later + * (logical revert worker, crash recovery) leaves the entries for VACUUM, see + * nbtree_undo_apply(). + * + * One record type is written: INSERT_LEAF, for each leaf-page entry. + * Structural changes -- page splits, new roots, internal downlinks, posting + * list splits, deduplication -- are not recorded and not reversed: + * concurrent readers may already have observed the new structure. The + * aborted leaf entries are killed individually wherever a split moved them, + * and any page emptied as a result is recycled by VACUUM as before. + * + * All hooks are guarded by RelationUsesIndexUndo() -- nbtree UNDO is enabled + * per relation by the parent table's index_undo reloption, independently of + * whether (or how) the parent table AM writes its own table UNDO. Records go + * to the cluster-wide UNDO-in-WAL stream; see nbtree_undo_write(). + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/nbtree/nbtree_undo.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/relation.h" +#include "access/xact.h" +#include "access/nbtree.h" +#include "access/undo_xlog.h" +#include "access/undorecord.h" +#include "access/undormgr.h" +#include "access/tableam.h" +#include "access/xactundo.h" +#include "access/xloginsert.h" +#include "access/xlogutils.h" +#include "common/hashfn.h" +#include "miscadmin.h" +#include "storage/bufmgr.h" +#include "storage/bufpage.h" +#include "storage/itemid.h" +#include "utils/rel.h" +#include "utils/relcache.h" + +/* + * How far right the apply path will follow leaf right-links looking for an + * entry that a page split moved off its original page. Splits move entries + * rightward, so the entry is usually on the recorded page or its immediate + * sibling; the bound keeps a corrupt or circular right-link chain from spinning + * during rollback at the cost of leaving a far-moved entry to VACUUM. + */ +#define NBTREE_UNDO_MAX_RIGHT_HOPS 8 + +/* + * NbtreeUndoInsertLeaf - Payload for leaf insert undo + * + * index_oid allows direct index open during rollback, eliminating + * the O(N_indexes) scan through RelationGetIndexList(). + * + * The record does not carry the inserted IndexTuple. Rollback never restores + * tuple data; all it needs is a DISCRIMINATOR, enough to be sure the slot it + * is about to mark LP_DEAD still holds this entry and not some later, possibly + * COMMITTED, one. Carrying the key would also make the record's size grow + * with the key width, paying for bytes nbtree's own insert record already + * logged. So the record stores: + * + * heap_tid -- the heap TID the entry points at. This is the strong half. + * (key, heap TID) is unique in a btree by construction, and heap + * TID alone is nearly so: the only entries in one index sharing + * a heap TID are entries for the same row, which differ in key. + * key_digest -- a hash over the key bytes, closing exactly that residual case. + * itup_sz -- the entry's length, a cheap discriminator that rejects a + * differently-sized tuple outright. uint16 suffices, since + * nbtree caps entries well under a page (BTMaxItemSize). + * + * The guarantee is "never mark a slot dead unless it provably still holds THIS + * entry": all three must match, and any mismatch means "leave it for VACUUM", + * which is safe. See nbtree_undo_verify_leaf_entry(). That proof holds only + * while the aborted heap tuple still holds the heap TID, which is why the + * record is applied only inline, by the aborting backend. + * + * The fields pack with no holes: 4 + 4 + 2 + 2 + 6 + 4 = 22 bytes. + */ +typedef struct NbtreeUndoInsertLeaf +{ + Oid index_oid; /* OID of the index relation */ + BlockNumber blkno; /* Page where tuple was inserted */ + OffsetNumber offset; /* Offset of the inserted tuple */ + uint16 itup_sz; /* Size of the index tuple */ + ItemPointerData heap_tid; /* Heap TID the entry points at */ + uint32 key_digest; /* Hash of the key bytes */ +} NbtreeUndoInsertLeaf; + +#define SizeOfNbtreeUndoInsertLeaf (offsetof(NbtreeUndoInsertLeaf, key_digest) + sizeof(uint32)) + +/* + * nbtree_undo_key_digest - hash the key bytes of an index entry + * + * The digest covers everything after the IndexTuple header, which is the key + * data and its null bitmap -- deliberately NOT t_tid, which holds the heap TID + * that is compared separately and exactly, and whose high bits carry the + * pivot/posting flags. + * + * A tuple with no key bytes at all (possible for a zero-column index entry) + * digests to 0, which is a legitimate value; the heap TID and length checks + * carry identification in that case. + */ +static inline uint32 +nbtree_undo_key_digest(IndexTuple itup, Size itup_sz) +{ + if (itup_sz <= sizeof(IndexTupleData)) + return 0; + + return hash_bytes((const unsigned char *) itup + sizeof(IndexTupleData), + (int) (itup_sz - sizeof(IndexTupleData))); +} + + +/* Forward declarations */ +static UndoApplyResult nbtree_undo_apply(uint8 rmid, uint16 info, + TransactionId xid, Oid reloid, + const char *payload, Size payload_len, + UndoRecPtr urec_ptr); +static void nbtree_undo_desc(StringInfo buf, uint8 rmid, uint16 info, + const char *payload, Size payload_len); + +/* The nbtree UNDO RM registration entry */ +static const UndoRmgrData nbtree_undo_rmgr = { + .rm_name = "nbtree", + .rm_undo = nbtree_undo_apply, + .rm_desc = nbtree_undo_desc, +}; + +/* + * NbtreeUndoRmgrInit - Register the nbtree UNDO resource manager + */ +void +NbtreeUndoRmgrInit(void) +{ + RegisterUndoRmgr(UNDO_RMID_NBTREE, &nbtree_undo_rmgr); +} + +/* + * nbtree_undo_write - Write one nbtree index-UNDO record. + * + * Index UNDO goes to the cluster-wide UNDO-in-WAL stream via the transaction's + * UndoRecordSet, exactly as FILEOPS writes its own records (see + * storage/file/fileops.c). It must go through the XactUndo* API rather than + * UndoRecordSetCreate/UndoRecordSetInsert directly, because that API is what + * makes the record reachable at rollback: + * + * - PrepareXactUndoDataParts() sets XactUndo.has_undo, without which + * AtAbort_XactUndo() returns immediately and applies nothing; + * - InsertXactUndoData() records the batch LSN in XactUndo.last_batch_lsn[], + * which is the chain head ApplyUndoChainFromWAL() walks backward from, and + * calls SetCurrentTransactionUndoRecPtr() so the next record chains onto + * this one. + * + * A batch written without that API is a valid XLOG_UNDO_BATCH record that + * rollback never reads, because no chain head points at it. + * + * There is deliberately ONE write path. Records are not routed on the + * parent table AM's am_undo_engine (index UNDO is a per-relation property -- + * see RelationUsesIndexUndo()). + * + * The record is DEFERRED rather than written immediately. Every index insert + * produces one, and writing each as its own XLOG_UNDO_BATCH would cost a WAL + * record and a WAL insertion lock acquisition per index entry. + * DeferXactUndoData() leaves the record in the UndoRecordSet buffer, so all of + * a statement's entries share one batch (see the batching note at the head of + * undoinsert.c). xactundo.c flushes at every point that can + * consume the chain -- transaction abort, subtransaction abort/commit, commit, + * PREPARE -- and on crossing undo_batch_size_kb / undo_batch_record_limit, so a + * deferred record is never the reason a rollback finds nothing to apply. + * + * The caller must NOT be in a critical section: PrepareXactUndoDataParts() can + * palloc. Deferral itself writes no WAL, so no critical section is needed + * here. + */ +static void +nbtree_undo_write(Relation heaprel, uint16 subtype, + const char *hdr, Size hdr_len, + const char *tup, Size tup_len) +{ + XactUndoContext undo_ctx; + + PrepareXactUndoDataParts(&undo_ctx, RELPERSISTENCE_PERMANENT, + UNDO_RMID_NBTREE, subtype, + RelationGetRelid(heaprel), + hdr, hdr_len, tup, tup_len); + + DeferXactUndoData(&undo_ctx); +} + +/* + * NbtreeUndoLogInsert - Write UNDO record for a leaf index tuple insertion + * + * Called from _bt_insertonpg() after the insertion has been WAL-logged. + * This records enough information to remove the inserted entry on abort. + * + * The record stores a DISCRIMINATOR (heap TID + key digest + length), not the + * inserted tuple: the apply path only ever uses it to prove the slot still holds + * this entry before killing it, because a later split, dedup, or lower-key + * insert can move the entry elsewhere and leave a COMMITTED entry at the + * recorded offset. See NbtreeUndoInsertLeaf and + * nbtree_undo_verify_leaf_entry(). + */ +void +NbtreeUndoLogInsert(Relation rel, Relation heaprel, Buffer buf, + IndexTuple itup, Size itemsz, OffsetNumber offset, + bool isleaf) +{ + NbtreeUndoInsertLeaf hdr; + ItemPointer heap_tid; + + /* + * Internal-page downlink insertions are not recorded. A downlink is + * needed for tree navigation and concurrent readers may already have + * descended through it, so it is never reversed. The aborted leaf + * entries under it are killed by their own INSERT_LEAF records, and a + * page a split left empty is recycled by VACUUM. + */ + if (!isleaf) + return; + + /* + * No heap TID means nothing to verify against at apply time, and + * nbtree_undo_verify_leaf_entry() would reject every slot -- so the + * record could only ever be a no-op. Don't write it. (A leaf entry + * always has one; this is a belt-and-braces check on the pivot/posting + * cases.) + */ + heap_tid = BTreeTupleGetHeapTID(itup); + if (heap_tid == NULL || BTreeTupleIsPosting(itup) || BTreeTupleIsPivot(itup)) + return; + + hdr.index_oid = RelationGetRelid(rel); + hdr.blkno = BufferGetBlockNumber(buf); + hdr.offset = offset; + hdr.itup_sz = (uint16) itemsz; + hdr.heap_tid = *heap_tid; + hdr.key_digest = nbtree_undo_key_digest(itup, itemsz); + + nbtree_undo_write(heaprel, NBTREE_UNDO_INSERT_LEAF, + (const char *) &hdr, SizeOfNbtreeUndoInsertLeaf, + NULL, 0); +} + + +/* + * nbtree_undo_verify_leaf_entry - is the entry at (page, offset) still the + * exact entry this UNDO record was written for? + * + * Killing an index entry by (block, offset) alone is unsafe: between the + * provisional insert and the rollback, a page split, a deduplication pass, or + * any insert of a lower key can shift the aborted entry to a different slot -- + * or move it to another page entirely -- leaving a COMMITTED entry in the slot + * the UNDO record names. Marking that slot LP_DEAD then destroys committed + * data: the row stays in the heap but becomes invisible to every index scan. + * + * So identity is settled POSITIVELY, against the discriminator the record + * carries (see NbtreeUndoInsertLeaf), never by trusting the offset. Three + * things must all agree: + * + * - the entry length, which rejects a differently-shaped tuple outright, + * - the heap TID, which distinguishes two entries with equal keys, + * - the key digest, which distinguishes two entries pointing at the same heap + * TID (possible across an in-place update of a different column). + * + * The record stores a 4-byte hash of the key rather than the key bytes. For + * the purpose this comparison serves the two are equivalent: a match means "this + * slot still holds the entry we inserted", and (length, heap TID, key hash) + * establishes that for every case that can arise in one index -- the only + * entries sharing a heap TID are entries for the same row, and those differ in + * key. A hash collision would additionally have to coincide with an identical + * heap TID and length on a slot the walk actually reaches. + * + * A posting-list tuple is never a match: dedup rewrote the slot, so the + * original single entry no longer exists there and the caller must not touch + * it. Likewise a pivot tuple (the page became internal) is never a match. + * + * Returns true only when the slot provably still holds the aborted entry. + */ +static bool +nbtree_undo_verify_leaf_entry(Page page, OffsetNumber offset, + const NbtreeUndoInsertLeaf *rec) +{ + ItemId lp; + IndexTuple pagetup; + ItemPointer page_tid; + + if (offset < FirstOffsetNumber || offset > PageGetMaxOffsetNumber(page)) + return false; + + lp = PageGetItemId(page, offset); + if (!ItemIdIsNormal(lp)) + return false; + + /* A record with no recorded length cannot be verified; refuse to guess. */ + if (rec->itup_sz < sizeof(IndexTupleData)) + return false; + + /* Size must agree before anything else is worth comparing. */ + if (ItemIdGetLength(lp) != rec->itup_sz) + return false; + + pagetup = (IndexTuple) PageGetItem(page, lp); + + /* + * Dedup/structural rewrites: a posting list or pivot in this slot means + * the original entry is not here any more. + */ + if (BTreeTupleIsPosting(pagetup) || BTreeTupleIsPivot(pagetup)) + return false; + + if (IndexTupleSize(pagetup) != rec->itup_sz) + return false; + + /* Heap TID must match. */ + page_tid = BTreeTupleGetHeapTID(pagetup); + if (page_tid == NULL) + return false; + if (!ItemPointerEquals(page_tid, &rec->heap_tid)) + return false; + + /* Key digest must match. */ + if (nbtree_undo_key_digest(pagetup, rec->itup_sz) != rec->key_digest) + return false; + + return true; +} + +/* + * nbtree_undo_find_leaf_entry - locate the aborted entry on this page + * + * The recorded offset is only a hint. Any insert of a lower key on the same + * page shifts every entry after it up by one, so by rollback time the aborted + * entry is very often a few slots away from where it was written -- and the + * recorded slot holds a different, possibly COMMITTED, entry. Trusting the + * offset therefore does not merely risk corruption (which + * nbtree_undo_verify_leaf_entry() prevents); it makes rollback silently + * ineffective, because verification fails and the aborted entry is left behind + * for VACUUM. Measured on a 500-row index with 200 aborted random-key inserts, + * offset-only matching reversed 11 of 200 entries. + * + * So check the hint first (the common case, and O(1)), then fall back to a + * linear scan of the page. The scan is safe: verification demands agreement on + * the length, the heap TID and the key digest, and no two legitimate entries in + * one index share all three -- that would be a duplicate entry for one row. + * Leaf pages hold a few hundred entries, and this runs once per aborted entry + * during rollback, which is not a hot path. + * + * Returns InvalidOffsetNumber if the entry is not on this page (it may have + * been moved to a sibling by a split, or already be gone). + */ +static OffsetNumber +nbtree_undo_find_leaf_entry(Page page, OffsetNumber hint, + const NbtreeUndoInsertLeaf *rec) +{ + OffsetNumber off, + maxoff; + + if (nbtree_undo_verify_leaf_entry(page, hint, rec)) + return hint; + + maxoff = PageGetMaxOffsetNumber(page); + for (off = FirstOffsetNumber; off <= maxoff; off++) + { + if (off != hint && + nbtree_undo_verify_leaf_entry(page, off, rec)) + return off; + } + + return InvalidOffsetNumber; +} + +/* + * nbtree_undo_apply - Apply a single nbtree UNDO record + * + * This is the rm_undo callback for the nbtree RM. + */ +static UndoApplyResult +nbtree_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, + const char *payload, Size payload_len, UndoRecPtr urec_ptr) +{ + Assert(rmid == UNDO_RMID_NBTREE); + + /* + * Apply only inline, while the aborting (sub)transaction is still + * running. The record finds its entry by heap TID, length and key + * digest, which identify it only while the aborted heap tuple holds the + * TID. Once the abort is visible -- the logical revert worker, crash + * recovery -- VACUUM may already have removed the aborted tuple and its + * entry, and a committed INSERT may have reused the TID with the same + * key; marking that entry dead would hide a committed row from index + * scans and let the unique index accept a duplicate. Leaving the entry + * to VACUUM is always safe. + */ + if (!UndoApplyInline) + return UNDO_APPLY_SKIPPED; + + switch (info) + { + case NBTREE_UNDO_INSERT_LEAF: + { + NbtreeUndoInsertLeaf hdr; + Relation indexrel; + Buffer buffer; + Page page; + BTPageOpaque opaque; + + if (payload_len < SizeOfNbtreeUndoInsertLeaf) + return UNDO_APPLY_ERROR; + + memcpy(&hdr, payload, SizeOfNbtreeUndoInsertLeaf); + + /* + * Open the index directly using the OID stored in the UNDO + * payload. This avoids the O(N_indexes) scan through + * RelationGetIndexList(). + */ + indexrel = try_relation_open(hdr.index_oid, RowExclusiveLock); + if (indexrel == NULL) + { + ereport(DEBUG2, + (errmsg("nbtree UNDO INSERT_LEAF: index %u no longer exists", + hdr.index_oid))); + return UNDO_APPLY_SKIPPED; + } + + if (RelationGetNumberOfBlocks(indexrel) <= hdr.blkno) + { + ereport(DEBUG2, + (errmsg("nbtree UNDO INSERT_LEAF: block %u beyond end of index %u", + hdr.blkno, hdr.index_oid))); + relation_close(indexrel, RowExclusiveLock); + return UNDO_APPLY_SKIPPED; + } + + /* + * Find the entry, starting at the recorded page and following + * right-links while it is not found. A page split moves the + * upper half of a leaf's entries to a new right sibling, so + * an aborted entry can legitimately be one or more pages to + * the right of where it was inserted; without following the + * chain those entries are never reversed. The walk is + * bounded so a corrupt or circular right-link chain cannot + * spin here during rollback, and it only ever moves right, + * which is the direction splits move entries. + * + * Only a slot that provably still holds THIS entry is killed. + * A relocated slot now holds a different -- possibly + * COMMITTED -- entry, and marking that dead would silently + * lose committed data: the row stays in the heap but no index + * scan can find it. An entry that is genuinely gone needs no + * action, so "not found" is a successful no-op. + */ + { + BlockNumber blkno = hdr.blkno; + OffsetNumber found = InvalidOffsetNumber; + int hops; + + buffer = InvalidBuffer; + + for (hops = 0; hops < NBTREE_UNDO_MAX_RIGHT_HOPS; hops++) + { + BlockNumber next; + + buffer = ReadBuffer(indexrel, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + opaque = BTPageGetOpaque(page); + + if (!P_ISLEAF(opaque)) + break; + + found = nbtree_undo_find_leaf_entry(page, hdr.offset, + &hdr); + if (found != InvalidOffsetNumber) + break; + + /* Not here -- try the right sibling, if any. */ + next = opaque->btpo_next; + if (P_RIGHTMOST(opaque) || next == blkno || + next >= RelationGetNumberOfBlocks(indexrel)) + break; + + UnlockReleaseBuffer(buffer); + buffer = InvalidBuffer; + blkno = next; + } + + if (!BufferIsValid(buffer)) + { + relation_close(indexrel, RowExclusiveLock); + return UNDO_APPLY_SUCCESS; + } + + page = BufferGetPage(buffer); + + if (found == InvalidOffsetNumber) + { + UnlockReleaseBuffer(buffer); + relation_close(indexrel, RowExclusiveLock); + return UNDO_APPLY_SUCCESS; + } + + hdr.blkno = blkno; + hdr.offset = found; + } + + { + ItemId lp = PageGetItemId(page, hdr.offset); + + START_CRIT_SECTION(); + + ItemIdMarkDead(lp); + + MarkBufferDirty(buffer); + + /* Generate physiological CLR */ + if (RelationNeedsWAL(indexrel)) + { + XLogRecPtr clr_lsn; + xl_undo_apply xlrec; + + xlrec.urec_ptr = urec_ptr; + xlrec.xid = xid; + xlrec.target_locator = indexrel->rd_locator; + xlrec.target_block = hdr.blkno; + xlrec.target_offset = hdr.offset; + xlrec.operation_type = info; + xlrec.clr_flags = UNDO_CLR_LP_DEAD; + xlrec.tuple_len = 0; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, + SizeOfUndoApply); + XLogRegisterBuffer(0, buffer, + REGBUF_STANDARD); + clr_lsn = XLogInsert(RM_UNDO_ID, + XLOG_UNDO_APPLY_RECORD); + PageSetLSN(page, clr_lsn); + } + + END_CRIT_SECTION(); + } + + UnlockReleaseBuffer(buffer); + relation_close(indexrel, RowExclusiveLock); + return UNDO_APPLY_SUCCESS; + } + + default: + ereport(WARNING, + (errmsg("nbtree UNDO: unknown subtype %u", info))); + return UNDO_APPLY_ERROR; + } +} + +/* + * nbtree_undo_desc - Describe an nbtree UNDO record for debugging + */ +static void +nbtree_undo_desc(StringInfo buf, uint8 rmid, uint16 info, + const char *payload, Size payload_len) +{ + const char *opname; + + switch (info) + { + case NBTREE_UNDO_INSERT_LEAF: + opname = "INSERT_LEAF"; + break; + default: + opname = "UNKNOWN"; + break; + } + + appendStringInfo(buf, "nbtree %s", opname); + + /* INSERT_LEAF payloads start with the index OID */ + if (payload_len >= sizeof(Oid) && info == NBTREE_UNDO_INSERT_LEAF) + { + Oid index_oid; + + memcpy(&index_oid, payload, sizeof(Oid)); + appendStringInfo(buf, " index %u", index_oid); + } +} diff --git a/src/backend/access/table/tableam.c b/src/backend/access/table/tableam.c index 7aaa8d8715059..53527c6727c0a 100644 --- a/src/backend/access/table/tableam.c +++ b/src/backend/access/table/tableam.c @@ -24,6 +24,8 @@ #include "access/syncscan.h" #include "access/tableam.h" #include "access/xact.h" +#include "catalog/catalog.h" +#include "miscadmin.h" #include "optimizer/optimizer.h" #include "optimizer/plancat.h" #include "port/pg_bitutils.h" @@ -780,3 +782,108 @@ table_block_relation_estimate_size(Relation rel, int32 *attr_widths, else *allvisfrac = (double) relallvisible / curpages; } + + +/* + * RelationUsesIndexUndo + * Should 'indexrel' write structural index UNDO for inserts driven by + * parent table 'heaprel'? + * + * This is the single gate for the index-UNDO write path (nbtinsert.c / + * hashinsert.c). Index UNDO is a per-RELATION property, enabled by the + * index_undo reloption on the table, not a table-AM engine property: the + * records go to the cluster-wide UNDO-in-WAL stream, tagged UNDO_RMID_NBTREE / + * UNDO_RMID_HASH, and are applied on rollback by ApplyUndoChainFromWAL() -> + * GetUndoRmgr(rmid)->rm_undo. None of that depends on how -- or whether -- the + * parent table AM writes its own table UNDO, so an AM need not declare + * am_supports_undo to get index UNDO, and the "a table AM's own UNDO must use + * UNDO_ENGINE_PERBACKEND" rule enforced by GetTableAmRoutine() is neither + * consulted nor weakened here. Plain heap can and does use index UNDO. + * + * Every condition is checked here rather than at the call sites, so none can be + * forgotten by a future index AM that joins the path. Because index UNDO is now + * ON BY DEFAULT, these exclusions are what the feature degrades to when it + * cannot safely apply -- each one returns false (feature OFF for this relation) + * rather than raising an error, so no workload becomes unrunnable: + * + * - Both relations must be permanent and WAL-logged. The rollback path + * recovers the records by re-reading WAL, so a relation whose changes are not + * in WAL has no chain to walk, and the apply path's CLR needs a WAL-logged + * index page to bump. This is what excludes TEMP and UNLOGGED tables. + * + * - System catalogs and any relation in a reserved (pg_catalog, pg_toast) + * namespace are excluded. Catalog mutation runs in contexts where the apply + * path's try_relation_open() is not safe to call (and bootstrap has no + * syscache at all), DDL rollback already restores catalog rows by other means, + * and a catalog index left with an extra LP_DEAD entry would be a far worse + * failure than the VACUUM it saves. + * + * - Bootstrap processing mode is excluded outright: initdb builds the catalogs + * before the UNDO machinery, syscache, or WAL retention bookkeeping the apply + * path depends on exist. + * + * - An index created in the current transaction is skipped: aborting the + * transaction removes the whole index, so per-entry UNDO for it could only + * ever be a no-op at apply time. Without this, CREATE INDEX / REINDEX on a + * large table would emit one UNDO record per indexed row for nothing. + * (nbtree passes heaprel = NULL during builds and so is already excluded, but + * hash's _hash_doinsert() receives a live heapRel from hashbuildCallback().) + * rd_firstRelfilelocatorSubid covers REINDEX, which keeps the index's OID but + * gives it a new relfilelocator in this transaction. + * + * A later commit adds one more condition here: a table AM that does its own + * index cleanup via delete-marking must be refused, since a second independent + * record for the same entry would double-apply. + */ +bool +RelationUsesIndexUndo(Relation indexrel, Relation heaprel) +{ + if (indexrel == NULL || heaprel == NULL) + return false; + + /* + * Never during bootstrap: the catalogs are being built and none of the + * machinery the apply path needs (syscache, UNDO engine, WAL retention) + * exists yet. + */ + if (IsBootstrapProcessingMode()) + return false; + + /* + * Never for catalogs. Degrade to OFF rather than error: an unconditional + * feature must leave DDL and catalog maintenance exactly as they were. + */ + if (IsCatalogRelation(heaprel) || IsCatalogRelation(indexrel) || + IsToastRelation(heaprel)) + return false; + + /* No WAL, no recoverable UNDO chain. Excludes TEMP and UNLOGGED. */ + if (!RelationNeedsWAL(heaprel) || !RelationNeedsWAL(indexrel)) + return false; + + /* A brand-new or REINDEXed index is dropped wholesale by the abort. */ + if (indexrel->rd_createSubid != InvalidSubTransactionId || + indexrel->rd_firstRelfilelocatorSubid != InvalidSubTransactionId) + return false; + + return RelationGetIndexUndoOption(heaprel); +} + +/* + * RelationUndoEngine + * Report which UNDO engine the relation's table AM writes UNDO to. + * + * Index AMs use this to route their index-UNDO writes to the same engine the + * parent table uses so a single transaction's UNDO is not split across engines. + * An AM that does not support UNDO maps to UNDO_ENGINE_NONE; one that supports + * UNDO uses the per-backend engine (the only engine a table AM may declare). + */ +UndoEngine +RelationUndoEngine(Relation rel) +{ + if (!rel->rd_tableam || !rel->rd_tableam->am_supports_undo) + return UNDO_ENGINE_NONE; + + Assert(rel->rd_tableam->am_undo_engine == UNDO_ENGINE_PERBACKEND); + return UNDO_ENGINE_PERBACKEND; +} diff --git a/src/backend/access/table/tableamapi.c b/src/backend/access/table/tableamapi.c index 72d2c662bf2be..a3357ba7a893d 100644 --- a/src/backend/access/table/tableamapi.c +++ b/src/backend/access/table/tableamapi.c @@ -36,6 +36,15 @@ GetTableAmRoutine(Oid amhandler) elog(ERROR, "table access method handler %u did not return a TableAmRoutine struct", amhandler); + /* + * Any AM that uses UNDO must write it to the per-backend engine, so + * reject an AM that declares UNDO support with any other engine. + */ + if (routine->am_supports_undo && + routine->am_undo_engine != UNDO_ENGINE_PERBACKEND) + elog(ERROR, "table access method handler %u declares UNDO support but not the per-backend UNDO engine", + amhandler); + /* * Assert that all required callbacks are present. That makes it a bit * easier to keep AMs up to date, e.g. when forward porting them to a new diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c index 7bee90e354871..f0c3183e05ad0 100644 --- a/src/backend/access/transam/xact.c +++ b/src/backend/access/transam/xact.c @@ -2654,6 +2654,21 @@ CommitTransaction(void) if (!is_parallel_worker) PreCommit_CheckForSerializationFailure(); + /* + * Write out any UNDO records the index AMs deferred for batching. + * + * This must happen before RecordTransactionCommit(). Until the commit + * record is durable this transaction is a crash-recovery loser, and + * recovery can only roll back what it finds in WAL. Once the commit + * record is written, the records are never needed again -- and writing + * them after it would be actively harmful: redo forgets the xid at the + * commit record and would then re-track it at the trailing batch, making + * a committed transaction look incomplete after a crash. Deferred + * triggers and ON COMMIT actions, which can insert index entries, have + * all run by now. This mirrors the flush in PrepareTransaction(). + */ + XactUndoFlushPending(); + /* Prevent cancel/die interrupt while cleaning up */ HOLD_INTERRUPTS(); diff --git a/src/backend/access/undo/README b/src/backend/access/undo/README index e342b2fed1c0b..3d89e630cd890 100644 --- a/src/backend/access/undo/README +++ b/src/backend/access/undo/README @@ -212,6 +212,16 @@ the whole chain again after the database opens. Transactions already in the ATM before the crash are back in it after redo and are reverted by the logical revert worker as during normal operation. +Index UNDO (nbtree, hash) is applied only inline, by the aborting backend +(UndoApplyInline, set by AtAbort_XactUndo() and AtSubAbort_XactUndo()). +Its records find their entry by heap TID, which is unique only while the +aborted heap tuple holds that TID. Every deferred apply above runs after +the abort is visible, when VACUUM may already have freed the TID for a +committed row with the same key, so there the index callbacks return +UNDO_APPLY_SKIPPED (which counts as done) and leave the entries for +VACUUM. An instant abort of index-only UNDO therefore does no index +work at all. + These components serve the cluster-wide engine only. The per-backend engine's rollback requests go to its own undo apply workers (see "The UNDO Store: Per-Backend UNDO Logs" above). diff --git a/src/backend/access/undo/logical_revert_worker.c b/src/backend/access/undo/logical_revert_worker.c index 1c70e44b676ff..1822e33379260 100644 --- a/src/backend/access/undo/logical_revert_worker.c +++ b/src/backend/access/undo/logical_revert_worker.c @@ -184,7 +184,7 @@ typedef struct RevertRetry TransactionId xid; int attempts; /* failed walks */ TimestampTz next_try; /* do not walk again before this */ -} RevertRetry; +} RevertRetry; #define LOGICAL_REVERT_RETRY_SLOTS 64 static RevertRetry revert_retries[LOGICAL_REVERT_RETRY_SLOTS]; diff --git a/src/backend/access/undo/undo_xlog.c b/src/backend/access/undo/undo_xlog.c index 6619bc86f3874..4e2c4206da034 100644 --- a/src/backend/access/undo/undo_xlog.c +++ b/src/backend/access/undo/undo_xlog.c @@ -36,6 +36,9 @@ #include "postgres.h" #include "access/atm.h" +#include "access/bufmask.h" +#include "access/hash.h" +#include "access/hash_xlog.h" #include "access/htup_details.h" #include "access/transam.h" #include "access/twophase.h" @@ -48,6 +51,8 @@ #include "access/xlogreader.h" #include "access/xlogrecovery.h" #include "access/xlogutils.h" +#include "access/nbtree.h" +#include "access/nbtxlog.h" #include "miscadmin.h" #include "storage/bufmgr.h" #include "storage/bufpage.h" @@ -1058,3 +1063,31 @@ UndoResetBatchReader(void) undo_batch_reader = NULL; } } + +/* + * undo_mask - mask a page changed by an Undo record before consistency checks + * + * Only XLOG_UNDO_APPLY_RECORD registers a block. Its target is a page of the + * table AM or index AM that applied the UNDO record, so hand the page to that + * AM's mask function, telling them apart by the special space the way + * pg_filedump does: hash pages end their 16-byte special space with + * HASHO_PAGE_ID, which no btree cycle ID can equal (MAX_BT_CYCLE_ID). + */ +void +undo_mask(char *pagedata, BlockNumber blkno) +{ + Page page = (Page) pagedata; + Size special = PageGetSpecialSize(page); + + if (special == MAXALIGN(sizeof(HashPageOpaqueData)) && + HashPageGetOpaque(page)->hasho_page_id == HASHO_PAGE_ID) + hash_mask(pagedata, blkno); + else if (special == MAXALIGN(sizeof(BTPageOpaqueData))) + btree_mask(pagedata, blkno); + else + { + mask_page_lsn_and_checksum(page); + mask_page_hint_bits(page); + mask_unused_space(page); + } +} diff --git a/src/backend/access/undo/undoapply.c b/src/backend/access/undo/undoapply.c index f180981bba6b5..8121ca262f765 100644 --- a/src/backend/access/undo/undoapply.c +++ b/src/backend/access/undo/undoapply.c @@ -34,6 +34,9 @@ #include "utils/injection_point.h" #include "utils/memutils.h" +/* See undormgr.h */ +bool UndoApplyInline = false; + /* * ApplyOneUndoRecord - Apply a single UNDO record via RM dispatch * @@ -57,13 +60,14 @@ ApplyOneUndoRecord(UndoRecordHeader *header, char *payload, * revert worker after a partial inline apply, so every rm_undo must be * idempotent. They are so semantically, not by page LSN: they read the * target page with ReadBuffer (not XLogReadBufferForRedo) and act only if - * the page still shows the effect being undone. nbtree and hash kill - * only a slot that is still LP_NORMAL and matches the record's heap TID - * and key digest, so an entry already marked dead is "not found", a - * successful no-op; FILEOPS acts only on an object that still shows the - * operation's effect, recorded in the payload. A CLR - * (XLOG_UNDO_APPLY_RECORD) only makes the page change itself crash-safe - * through ordinary redo; it does not mark the UNDO record as applied. + * the page still shows the effect being undone. FILEOPS acts only on an + * object that still shows the operation's effect, recorded in the + * payload. nbtree and hash apply their records only inline + * (UndoApplyInline) and skip them on every later walk, because after the + * abort is visible the heap TID that identifies their entry can belong to + * a committed row. A CLR (XLOG_UNDO_APPLY_RECORD) only makes the page + * change itself crash-safe through ordinary redo; it does not mark the + * UNDO record as applied. */ /* diff --git a/src/backend/access/undo/xactundo.c b/src/backend/access/undo/xactundo.c index 1d099f957b8c1..8147b2119205d 100644 --- a/src/backend/access/undo/xactundo.c +++ b/src/backend/access/undo/xactundo.c @@ -857,10 +857,8 @@ XactUndoHasUnrecoverableUndo(void) * 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. + * The UNDO records are already in WAL (XLOG_UNDO_BATCH); there is nothing to + * sync here. * * NB: This code MUST NOT FAIL, since it is run as a post-commit step. */ @@ -870,16 +868,14 @@ 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, the recovery UNDO - * phase, or (for a subxact-aborted chain) the revert worker can all 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. + * Any deferred batch was already written in CommitTransaction(), before + * the commit record. Nothing may be pending here: a batch written now + * would follow the commit record in WAL and make recovery treat this + * committed transaction as a loser. (Records added after the commit + * record would be useless anyway -- a committed transaction never applies + * its own UNDO -- so ResetXactUndo() below simply drops them.) */ - XactUndoFlushPending(); + Assert(!XactUndoHasPendingData()); if (!XactUndo.has_undo) { @@ -1132,6 +1128,7 @@ AtAbort_XactUndo(void) */ BufferLockReleaseAll(); saved_trans_state = EnterInlineUndoApplyState(); + UndoApplyInline = true; saved_holdoff = InterruptHoldoffCount; PG_TRY(); { @@ -1159,6 +1156,7 @@ AtAbort_XactUndo(void) undo_applied = false; } PG_END_TRY(); + UndoApplyInline = false; LeaveInlineUndoApplyState(saved_trans_state); MemoryContextSwitchTo(old_ctx); @@ -1172,7 +1170,8 @@ AtAbort_XactUndo(void) * type, a record that could not be applied, or an * error). Register in ATM for deferred processing by * the revert worker; re-applying the records that did - * apply is harmless (see ApplyOneUndoRecord). + * apply is harmless (see ApplyOneUndoRecord), and the + * worker leaves index entries to VACUUM. */ elog(DEBUG1, "inline UNDO failed for xid %u, " "deferring to ATM", @@ -1442,6 +1441,7 @@ AtSubAbort_XactUndo(int level) */ BufferLockReleaseAll(); saved_trans_state = EnterInlineUndoApplyState(); + UndoApplyInline = true; saved_holdoff = InterruptHoldoffCount; PG_TRY(); { @@ -1468,6 +1468,7 @@ AtSubAbort_XactUndo(int level) applied = false; } PG_END_TRY(); + UndoApplyInline = false; LeaveInlineUndoApplyState(saved_trans_state); MemoryContextSwitchTo(old_ctx); diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h index 3097e9bb1af9b..9011d71bcfa8c 100644 --- a/src/include/access/nbtree.h +++ b/src/include/access/nbtree.h @@ -1331,4 +1331,16 @@ extern IndexBuildResult *btbuild(Relation heap, Relation index, struct IndexInfo *indexInfo); extern void _bt_parallel_build_main(dsm_segment *seg, shm_toc *toc); +/* + * nbtree UNDO support (nbtree_undo.c) + */ + +/* nbtree UNDO subtype (stored in urec_info) */ +#define NBTREE_UNDO_INSERT_LEAF 0x0001 + +extern void NbtreeUndoRmgrInit(void); +extern void NbtreeUndoLogInsert(Relation rel, Relation heaprel, Buffer buf, + IndexTuple itup, Size itemsz, + OffsetNumber offset, bool isleaf); + #endif /* NBTREE_H */ diff --git a/src/include/access/rmgrlist.h b/src/include/access/rmgrlist.h index 12fd6975b3a9b..16cb679171819 100644 --- a/src/include/access/rmgrlist.h +++ b/src/include/access/rmgrlist.h @@ -48,7 +48,7 @@ PG_RMGR(RM_REPLORIGIN_ID, "ReplicationOrigin", replorigin_redo, replorigin_desc, PG_RMGR(RM_GENERIC_ID, "Generic", generic_redo, generic_desc, generic_identify, NULL, NULL, generic_mask, NULL) PG_RMGR(RM_LOGICALMSG_ID, "LogicalMessage", logicalmsg_redo, logicalmsg_desc, logicalmsg_identify, NULL, NULL, NULL, logicalmsg_decode) PG_RMGR(RM_XLOG2_ID, "XLOG2", xlog2_redo, xlog2_desc, xlog2_identify, NULL, NULL, NULL, xlog2_decode) -PG_RMGR(RM_UNDO_ID, "Undo", undo_redo, undo_desc, undo_identify, NULL, NULL, NULL, NULL) +PG_RMGR(RM_UNDO_ID, "Undo", undo_redo, undo_desc, undo_identify, NULL, NULL, undo_mask, NULL) PG_RMGR(RM_ATM_ID, "ATM", atm_redo, atm_desc, atm_identify, NULL, NULL, NULL, NULL) #ifdef USE_FILEOPS PG_RMGR(RM_FILEOPS_ID, "FileOps", fileops_redo, fileops_desc, fileops_identify, NULL, NULL, NULL, NULL) diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h index 2efe18b042189..480dbdb2b9d6d 100644 --- a/src/include/access/tableam.h +++ b/src/include/access/tableam.h @@ -308,6 +308,26 @@ typedef void (*IndexBuildCallback) (Relation index, bool tupleIsAlive, void *state); +/* + * UndoEngine -- which UNDO engine an AM writes its UNDO records to. + * + * This describes where a TABLE AM writes its own TABLE UNDO, and nothing else. + * Index UNDO is NOT routed on it: nbtree/hash write their structural UNDO to the + * cluster-wide UNDO-in-WAL stream, gated per relation by + * RelationUsesIndexUndo() (the index_undo reloption). A table AM therefore + * neither enables nor disables index UNDO by choosing an engine, and plain heap + * -- UNDO_ENGINE_NONE -- can and does use index UNDO. + * + * UNDO_ENGINE_NONE -- AM does not use UNDO (e.g. heap). + * UNDO_ENGINE_PERBACKEND -- per-backend UNDO engine (access/undo/perbackend). + * This is the only engine a table AM may declare. + */ +typedef enum UndoEngine +{ + UNDO_ENGINE_NONE = 0, + UNDO_ENGINE_PERBACKEND, +} UndoEngine; + /* * API struct for a table AM. Note this must be allocated in a * server-lifetime manner, typically as a static const struct, which then gets @@ -333,6 +353,19 @@ typedef struct TableAmRoutine */ bool am_supports_undo; + /* + * am_undo_engine: which UNDO engine this AM writes UNDO to. Only + * consulted when am_supports_undo is true. + * + * An AM with am_supports_undo = true MUST set this to + * UNDO_ENGINE_PERBACKEND; it is the only supported value. + * + * See RelationUndoEngine() for the mapping. The index UNDO write path + * does NOT route on this; see the UndoEngine comment above and + * RelationUsesIndexUndo(). + */ + UndoEngine am_undo_engine; + /* ------------------------------------------------------------------------ * Slot related callbacks. @@ -2171,4 +2204,12 @@ extern const TableAmRoutine *GetTableAmRoutine(Oid amhandler); extern const TableAmRoutine *GetHeapamTableAmRoutine(void); +/* ---------------------------------------------------------------------------- + * Functions in tableam.c + * ---------------------------------------------------------------------------- + */ + +extern bool RelationUsesIndexUndo(Relation indexrel, Relation heaprel); +extern UndoEngine RelationUndoEngine(Relation rel); + #endif /* TABLEAM_H */ diff --git a/src/include/access/undo_xlog.h b/src/include/access/undo_xlog.h index 5b797c3f7948e..1754a00026abe 100644 --- a/src/include/access/undo_xlog.h +++ b/src/include/access/undo_xlog.h @@ -169,6 +169,7 @@ typedef struct xl_undo_chain_state extern void undo_redo(XLogReaderState *record); extern void undo_desc(StringInfo buf, XLogReaderState *record); extern const char *undo_identify(uint8 info); +extern void undo_mask(char *pagedata, BlockNumber blkno); /* Two-phase commit support */ extern void undo_twophase_recover(FullTransactionId fxid, uint16 info, diff --git a/src/include/access/undormgr.h b/src/include/access/undormgr.h index 02b65e94efc50..df020fb283304 100644 --- a/src/include/access/undormgr.h +++ b/src/include/access/undormgr.h @@ -106,6 +106,17 @@ typedef struct UndoRmgrData /* Global registration table */ extern const UndoRmgrData *UndoRmgrs[MAX_UNDO_RMGRS]; +/* + * True while the aborting transaction applies its own UNDO inline, in + * AtAbort_XactUndo() or AtSubAbort_XactUndo(): the (sub)transaction's XID is + * still running, so nothing it wrote can have been pruned or vacuumed away + * yet. False for every deferred apply (the logical revert worker, crash + * recovery), which runs after the abort is visible. An rm_undo whose record + * identifies its target only by something a later transaction can reuse + * (a heap TID) must apply it only when this is set. + */ +extern PGDLLIMPORT bool UndoApplyInline; + /* Registration function (called during _PG_init or startup) */ extern void RegisterUndoRmgr(uint8 rmid, const UndoRmgrData *rmgr); diff --git a/src/include/access/undormgrlist.h b/src/include/access/undormgrlist.h index a9f8ec5afd730..4c76c465f69ff 100644 --- a/src/include/access/undormgrlist.h +++ b/src/include/access/undormgrlist.h @@ -32,5 +32,8 @@ /* there is deliberately not an #ifndef UNDORMGRLIST_H here */ +/* built-in index AM UNDO resource managers */ +UNDO_RMGR_INIT(NbtreeUndoRmgrInit) + /* FILEOPS: transactional filesystem operations UNDO rmgr (UNDO_RMID_FILEOPS=2) */ UNDO_RMGR_INIT(FileopsUndoRmgrInit) diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h index 7b8c2b1e3629e..c6720461d91d6 100644 --- a/src/include/utils/rel.h +++ b/src/include/utils/rel.h @@ -376,11 +376,40 @@ typedef struct StdRdOptions * to freeze. 0 if disabled, -1 if unspecified. */ double vacuum_max_eager_freeze_failure_rate; + + /* + * index_undo: reverse this table's index entries during ROLLBACK rather + * than leaving them for VACUUM. Defaults to ON -- see + * RelationGetIndexUndoOption(), which must therefore also return true + * when rd_options is NULL. See RelationUsesIndexUndo() in + * access/tableam.h -- index AMs consult that (not this field directly) so + * its exclusions (catalogs, non-WAL relations, fresh index builds, ...) + * cannot be bypassed. + */ + bool index_undo; } StdRdOptions; #define HEAP_MIN_FILLFACTOR 10 #define HEAP_DEFAULT_FILLFACTOR 100 +/* + * RelationGetIndexUndoOption + * Returns the relation's index_undo reloption. + * + * Index UNDO is ON by default, so a relation with no reloptions at all (the + * overwhelmingly common case -- rd_options is NULL unless something was SET) + * must read as enabled. Defaulting this to false instead would silently leave + * the feature off for every table that never had a reloption set, which is + * nearly all of them. + * + * Callers outside the relcache should use RelationUsesIndexUndo() in + * access/tableam.h, which also applies the catalog, WAL and other + * conditions. Note multiple eval of argument! + */ +#define RelationGetIndexUndoOption(relation) \ + ((relation)->rd_options ? \ + ((StdRdOptions *) (relation)->rd_options)->index_undo : true) + /* * RelationGetToastTupleTarget * Returns the relation's toast_tuple_target. Note multiple eval of argument! diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile index f16d803fa2c85..415e24c35ecda 100644 --- a/src/test/modules/Makefile +++ b/src/test/modules/Makefile @@ -4,7 +4,8 @@ subdir = src/test/modules top_builddir = ../../.. include $(top_builddir)/src/Makefile.global -SUBDIRS = \ +SUBDIRS = test_idxundo \ + \ brin \ commit_ts \ delay_execution \ diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build index 49cc09d041b6f..0729f987b7349 100644 --- a/src/test/modules/meson.build +++ b/src/test/modules/meson.build @@ -28,6 +28,7 @@ subdir('test_cplusplusext') subdir('test_custom_rmgrs') subdir('test_custom_stats') subdir('test_custom_types') +subdir('test_idxundo') subdir('test_ddl_deparse') subdir('test_dsa') subdir('test_dsm_registry') diff --git a/src/test/modules/test_idxundo/.gitignore b/src/test/modules/test_idxundo/.gitignore new file mode 100644 index 0000000000000..536e465d407f7 --- /dev/null +++ b/src/test/modules/test_idxundo/.gitignore @@ -0,0 +1,4 @@ +/log/ +/results/ +/tmp_check/ +/output_iso/ diff --git a/src/test/modules/test_idxundo/Makefile b/src/test/modules/test_idxundo/Makefile new file mode 100644 index 0000000000000..570fe565dd338 --- /dev/null +++ b/src/test/modules/test_idxundo/Makefile @@ -0,0 +1,30 @@ +# src/test/modules/test_idxundo/Makefile + +MODULE_big = test_idxundo +OBJS = \ + $(WIN32RES) \ + test_idxundo.o +PGFILEDESC = "test_idxundo - exercise index UNDO" + +EXTENSION = test_idxundo +DATA = test_idxundo--1.0.sql + +REGRESS = test_idxundo +# The end-to-end tests verify that ROLLBACK reversed the index entries by +# reading them back through the index, and check the result with amcheck. +EXTRA_INSTALL = contrib/amcheck src/test/modules/injection_points +export enable_injection_points +REGRESS_OPTS = --load-extension=amcheck + +TAP_TESTS = 1 + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/test_idxundo +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/src/test/modules/test_idxundo/expected/test_idxundo.out b/src/test/modules/test_idxundo/expected/test_idxundo.out new file mode 100644 index 0000000000000..683ffd3a375b8 --- /dev/null +++ b/src/test/modules/test_idxundo/expected/test_idxundo.out @@ -0,0 +1,542 @@ +-- --------------------------------------------------------------------------- +-- Index UNDO, end to end through the real abort path. +-- +-- A table created WITH (index_undo = on) makes its btree indexes write UNDO +-- into the cluster-wide UNDO-in-WAL stream. On ROLLBACK, AtAbort_XactUndo() +-- walks the chain and the apply callback marks the provisionally-inserted +-- entries LP_DEAD -- no VACUUM required. +-- --------------------------------------------------------------------------- +CREATE EXTENSION test_idxundo; +CREATE EXTENSION amcheck; +ERROR: extension "amcheck" already exists +-- =========================================================================== +-- Reloption plumbing +-- =========================================================================== +CREATE TABLE iu_opt (id int, val text) WITH (index_undo = on); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; + reloptions +----------------- + {index_undo=on} +(1 row) + +-- round-trips through ALTER ... SET / RESET +ALTER TABLE iu_opt SET (index_undo = off); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; + reloptions +------------------ + {index_undo=off} +(1 row) + +ALTER TABLE iu_opt SET (index_undo = on); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; + reloptions +----------------- + {index_undo=on} +(1 row) + +ALTER TABLE iu_opt RESET (index_undo); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; + reloptions +------------ + +(1 row) + +-- rejected where it has no meaning +CREATE INDEX iu_opt_idx ON iu_opt (id); +ALTER INDEX iu_opt_idx SET (index_undo = on); +ERROR: unrecognized parameter "index_undo" +DROP TABLE iu_opt; +-- =========================================================================== +-- The core promise: ROLLBACK reverses provisional entries, COMMIT keeps them +-- =========================================================================== +CREATE TABLE iu_btree (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_btree_idx ON iu_btree (id); +-- committed baseline +INSERT INTO iu_btree SELECT g, 'committed' FROM generate_series(1, 200) g; +-- Nothing is dead yet. +SELECT idxundo_count_dead_all('iu_btree_idx') AS dead_before; + dead_before +------------- + 0 +(1 row) + +-- An aborted insert. Inside the transaction the chain head must be published, +-- or AtAbort_XactUndo() would have nothing to walk (the defect that made this +-- feature a silent no-op). +BEGIN; +INSERT INTO iu_btree SELECT g, 'aborted' FROM generate_series(1001, 1100) g; +SELECT idxundo_undo_chain_published() AS chain_published; + chain_published +----------------- + t +(1 row) + +ROLLBACK; +-- The 100 aborted entries are now LP_DEAD, without any VACUUM. +SELECT idxundo_count_dead_all('iu_btree_idx') AS dead_after_rollback; + dead_after_rollback +--------------------- + 100 +(1 row) + +-- Every committed row is still reachable THROUGH THE INDEX. This is the +-- assertion that fails if apply killed the wrong slot. +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_btree WHERE id BETWEEN 1 AND 200; + committed_via_index +--------------------- + 200 +(1 row) + +SELECT count(*) AS aborted_via_index FROM iu_btree WHERE id BETWEEN 1001 AND 1100; + aborted_via_index +------------------- + 0 +(1 row) + +RESET enable_seqscan; +-- Index and heap agree, and the index is structurally sound. +SELECT count(*) AS heap_rows FROM iu_btree; + heap_rows +----------- + 200 +(1 row) + +SELECT bt_index_check('iu_btree_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- A committed insert is untouched by the same machinery. +BEGIN; +INSERT INTO iu_btree SELECT g, 'committed2' FROM generate_series(2001, 2050) g; +COMMIT; +SELECT idxundo_count_dead_all('iu_btree_idx') AS dead_after_commit; + dead_after_commit +------------------- + 100 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS committed2_via_index FROM iu_btree WHERE id BETWEEN 2001 AND 2050; + committed2_via_index +---------------------- + 50 +(1 row) + +RESET enable_seqscan; +SELECT bt_index_check('iu_btree_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- =========================================================================== +-- Opting out must leave the old behaviour exactly as it was +-- +-- index_undo defaults to ON, so opting out takes an EXPLICIT off. (A table with +-- no reloptions at all is covered by the default-ON cases below; that is the +-- common configuration, not this one.) +-- =========================================================================== +CREATE TABLE iu_off (id int, val text) WITH (index_undo = off); +CREATE INDEX iu_off_idx ON iu_off (id); +BEGIN; +INSERT INTO iu_off SELECT g, 'aborted' FROM generate_series(1, 100) g; +SELECT idxundo_undo_chain_published() AS chain_published_when_off; + chain_published_when_off +-------------------------- + f +(1 row) + +ROLLBACK; +-- No index UNDO was written, so the entries are left for VACUUM: not dead. +SELECT idxundo_count_dead_all('iu_off_idx') AS dead_when_off; + dead_when_off +--------------- + 0 +(1 row) + +SELECT bt_index_check('iu_off_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- =========================================================================== +-- Subtransactions: only the aborted subxact's entries are reversed +-- =========================================================================== +CREATE TABLE iu_sub (id int) WITH (index_undo = on); +CREATE INDEX iu_sub_idx ON iu_sub (id); +BEGIN; + INSERT INTO iu_sub SELECT g FROM generate_series(1, 50) g; -- keep + SAVEPOINT s1; + INSERT INTO iu_sub SELECT g FROM generate_series(501, 550) g; -- discard + ROLLBACK TO SAVEPOINT s1; + INSERT INTO iu_sub SELECT g FROM generate_series(101, 150) g; -- keep +COMMIT; +SET enable_seqscan = off; +SELECT count(*) AS kept_via_index FROM iu_sub WHERE id BETWEEN 1 AND 150; + kept_via_index +---------------- + 100 +(1 row) + +SELECT count(*) AS discarded_via_index FROM iu_sub WHERE id BETWEEN 501 AND 550; + discarded_via_index +--------------------- + 0 +(1 row) + +RESET enable_seqscan; +SELECT count(*) AS heap_rows FROM iu_sub; + heap_rows +----------- + 100 +(1 row) + +SELECT bt_index_check('iu_sub_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- =========================================================================== +-- Page splits during an aborted transaction: splits are not reversed, the +-- aborted leaf entries are killed wherever the split moved them, and the tree +-- must stay consistent. +-- =========================================================================== +CREATE TABLE iu_split (id int, pad text) WITH (index_undo = on); +CREATE INDEX iu_split_idx ON iu_split (id); +INSERT INTO iu_split SELECT g, repeat('c', 50) FROM generate_series(1, 500) g; +BEGIN; +-- Roll back inline rather than handing the batch to the revert worker, so the +-- dead count below is deterministic. +SET LOCAL undo_instant_abort_threshold = 2147483647; +-- Enough rows, interleaved with the committed keys, to force many leaf splits. +INSERT INTO iu_split SELECT g, repeat('a', 50) FROM generate_series(501, 5000) g; +-- Keep any rollback diagnostics, whose detail depends on page geometry, out of +-- the expected output. +SET client_min_messages = error; +ROLLBACK; +RESET client_min_messages; +-- Every aborted entry is dead, including those whose insert split its leaf. +SELECT idxundo_count_dead_all('iu_split_idx') AS dead_after_split_rollback; + dead_after_split_rollback +--------------------------- + 4500 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_split WHERE id BETWEEN 1 AND 500; + committed_via_index +--------------------- + 500 +(1 row) + +RESET enable_seqscan; +SELECT count(*) AS heap_rows FROM iu_split; + heap_rows +----------- + 500 +(1 row) + +SELECT bt_index_check('iu_split_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- =========================================================================== +-- Turning the option on AFTER rows already exist. +-- +-- The option only governs whether NEW inserts write UNDO; pre-existing entries +-- have no UNDO record and are therefore unaffected by a later rollback. That +-- is the documented behaviour, and it is safe: an entry with no UNDO record is +-- simply left to VACUUM, exactly as today. +-- =========================================================================== +CREATE TABLE iu_late (id int); +CREATE INDEX iu_late_idx ON iu_late (id); +INSERT INTO iu_late SELECT g FROM generate_series(1, 100) g; -- no UNDO written +ALTER TABLE iu_late SET (index_undo = on); +BEGIN; +INSERT INTO iu_late SELECT g FROM generate_series(201, 250) g; -- UNDO written +ROLLBACK; +-- Only the 50 post-ALTER entries are reversed; the 100 earlier ones are intact. +SELECT idxundo_count_dead_all('iu_late_idx') AS dead_after_rollback; + dead_after_rollback +--------------------- + 50 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS preexisting_via_index FROM iu_late WHERE id BETWEEN 1 AND 100; + preexisting_via_index +----------------------- + 100 +(1 row) + +RESET enable_seqscan; +SELECT bt_index_check('iu_late_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +DROP TABLE iu_btree, iu_off, iu_sub, iu_split, iu_late; +-- =========================================================================== +-- DEFERRED BATCHING SAFETY +-- +-- Index UNDO records are DEFERRED, not written per record: they accumulate in +-- one UndoRecordSet and a single XLOG_UNDO_BATCH carries a whole statement's +-- entries. That is the whole performance win, and it is only sound because +-- every path that can consume the chain flushes the pending batch first. +-- +-- A missed flush does not corrupt anything -- it makes ROLLBACK a SILENT NO-OP, +-- which is far worse, because it looks like success. So each case below aborts +-- work and asserts the entries were actually reversed, i.e. that the flush +-- happened at that choke point. A regression here shows up as +-- dead_after_rollback = 0. +-- =========================================================================== +CREATE TABLE iu_def (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_def_idx ON iu_def (id); +INSERT INTO iu_def SELECT g, 'committed' FROM generate_series(1, 200) g; +-- --- 1. error mid-statement ------------------------------------------------- +-- The error unwinds into AbortTransaction() -> AtAbort_XactUndo(). A partial +-- statement's deferred records must be flushed and applied exactly like a +-- completed statement's. +BEGIN; +INSERT INTO iu_def SELECT g, 'x' FROM generate_series(1001, 1100) g; +-- now fail, mid-transaction, with records pending +SELECT 1 / 0; +ERROR: division by zero +ROLLBACK; +SELECT idxundo_count_dead_all('iu_def_idx') AS dead_after_error; + dead_after_error +------------------ + 100 +(1 row) + +-- --- 2. subxact abort whose parent COMMITS ---------------------------------- +-- The one case that loses data permanently rather than deferring it: an aborted +-- subtransaction under a committing parent produces no top-level ATM entry, so +-- if its records never reached WAL nothing would ever revert them and the +-- entries would persist as though committed. AtSubAbort_XactUndo() must flush. +CREATE TABLE iu_sub2 (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_sub2_idx ON iu_sub2 (id); +INSERT INTO iu_sub2 SELECT g, 'committed' FROM generate_series(1, 100) g; +BEGIN; + SAVEPOINT s1; + INSERT INTO iu_sub2 SELECT g, 'rolled back' FROM generate_series(501, 560) g; + ROLLBACK TO SAVEPOINT s1; + INSERT INTO iu_sub2 SELECT g, 'kept' FROM generate_series(601, 620) g; +COMMIT; +-- The 60 subxact entries are reversed even though the top-level txn committed. +SELECT idxundo_count_dead_all('iu_sub2_idx') AS dead_after_subabort; + dead_after_subabort +--------------------- + 60 +(1 row) + +SET enable_seqscan = off; +-- Committed rows, both pre-savepoint and post-rollback-to-savepoint, survive. +SELECT count(*) AS committed_via_index FROM iu_sub2 WHERE id BETWEEN 1 AND 100; + committed_via_index +--------------------- + 100 +(1 row) + +SELECT count(*) AS kept_via_index FROM iu_sub2 WHERE id BETWEEN 601 AND 620; + kept_via_index +---------------- + 20 +(1 row) + +SELECT count(*) AS rolledback_via_index FROM iu_sub2 WHERE id BETWEEN 501 AND 560; + rolledback_via_index +---------------------- + 0 +(1 row) + +RESET enable_seqscan; +SELECT bt_index_check('iu_sub2_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- --- 3. ROLLBACK TO SAVEPOINT must not over-revert --------------------------- +-- A pending batch must never span a subtransaction boundary. If the flush +-- happened after the boundary was saved, the OUTER level's records would land +-- above it and be reverted along with the inner subtransaction's -- discarding +-- work that was never rolled back. Hence the flush at SUBXACT_EVENT_START_SUB. +CREATE TABLE iu_sub3 (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_sub3_idx ON iu_sub3 (id); +BEGIN; + -- outer-level work, deferred and still pending when the savepoint opens + INSERT INTO iu_sub3 SELECT g, 'outer' FROM generate_series(1, 40) g; + SAVEPOINT s2; + INSERT INTO iu_sub3 SELECT g, 'inner' FROM generate_series(101, 140) g; + ROLLBACK TO SAVEPOINT s2; +COMMIT; +-- Exactly the 40 inner entries died; the 40 outer ones committed. +SELECT idxundo_count_dead_all('iu_sub3_idx') AS dead_after_rollback_to_sp; + dead_after_rollback_to_sp +--------------------------- + 40 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS outer_via_index FROM iu_sub3 WHERE id BETWEEN 1 AND 40; + outer_via_index +----------------- + 40 +(1 row) + +SELECT count(*) AS inner_via_index FROM iu_sub3 WHERE id BETWEEN 101 AND 140; + inner_via_index +----------------- + 0 +(1 row) + +RESET enable_seqscan; +SELECT bt_index_check('iu_sub3_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- --- 4. nested subxacts ----------------------------------------------------- +CREATE TABLE iu_nest (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_nest_idx ON iu_nest (id); +BEGIN; + INSERT INTO iu_nest SELECT g, 'L0' FROM generate_series(1, 20) g; + SAVEPOINT a; + INSERT INTO iu_nest SELECT g, 'L1' FROM generate_series(101, 120) g; + SAVEPOINT b; + INSERT INTO iu_nest SELECT g, 'L2' FROM generate_series(201, 220) g; + RELEASE SAVEPOINT b; -- L2 merges into L1, still live + SAVEPOINT c; + INSERT INTO iu_nest SELECT g, 'L2c' FROM generate_series(301, 320) g; + ROLLBACK TO SAVEPOINT c; -- only L2c dies + RELEASE SAVEPOINT a; +COMMIT; +-- 20 dead (L2c only); L0, L1 and the released L2 all committed. +SELECT idxundo_count_dead_all('iu_nest_idx') AS dead_after_nested; + dead_after_nested +------------------- + 20 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS live_via_index FROM iu_nest WHERE id < 300; + live_via_index +---------------- + 60 +(1 row) + +SELECT count(*) AS dead_via_index FROM iu_nest WHERE id BETWEEN 301 AND 320; + dead_via_index +---------------- + 0 +(1 row) + +RESET enable_seqscan; +SELECT bt_index_check('iu_nest_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- --- 5. the memory bound: an intermediate flush must still publish the chain - +-- A statement bigger than undo_batch_record_limit (1000) / undo_batch_size_kb +-- flushes mid-statement. Each intermediate flush is a complete batch chained +-- onto the previous one, so rollback must walk EVERY chained batch, not just the +-- final partial one. If chaining were broken the count would collapse to +-- roughly one batch worth (<=1000), which is what this guards. +CREATE TABLE iu_big (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_big_idx ON iu_big (id); +BEGIN; +-- This much UNDO is over undo_instant_abort_threshold, which would hand the +-- abort to the logical revert worker; keep it inline so the walk is observable. +SET LOCAL undo_instant_abort_threshold = 2147483647; +INSERT INTO iu_big SELECT g, 'big' FROM generate_series(1, 5000) g; +SELECT idxundo_undo_chain_published() AS chain_published; + chain_published +----------------- + t +(1 row) + +ROLLBACK; +-- All 5000 reversed, i.e. every chained batch was walked. +SELECT idxundo_count_dead_all('iu_big_idx') AS dead_after_big_rollback; + dead_after_big_rollback +------------------------- + 5000 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS survivors_via_index FROM iu_big; + survivors_via_index +--------------------- + 0 +(1 row) + +RESET enable_seqscan; +SELECT bt_index_check('iu_big_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +-- --- 6. 2PC: PREPARE must not lose the deferred batch ------------------------ +-- PrepareTransaction() captures GetCurrentXactLastBatchLSN() into the 2PC state +-- before AtPrepare_XactUndo() runs, and that snapshot is the only chain head +-- ROLLBACK PREPARED will ever get. A record still pending at that moment would +-- be dropped from the prepared transaction's chain with no later chance to +-- notice -- so XactUndoFlushPending() is called before the capture, and +-- AtPrepare_XactUndo() asserts nothing is left pending. +-- +-- dead_after_rollback_prepared is 0: ROLLBACK PREPARED hands the chain to the +-- logical revert worker, which leaves index entries for VACUUM (after the +-- abort is visible their heap TIDs could hold committed rows; see +-- nbtree_undo_apply()). What this case proves is that PREPARE itself is safe: +-- it does not error, lose the chain, or trip the AtPrepare_XactUndo() +-- assertion, and the committed rows stay index-reachable. +CREATE TABLE iu_2pc (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_2pc_idx ON iu_2pc (id); +INSERT INTO iu_2pc SELECT g, 'committed' FROM generate_series(1, 100) g; +BEGIN; +INSERT INTO iu_2pc SELECT g, 'prepared' FROM generate_series(501, 560) g; +PREPARE TRANSACTION 'iu_p1'; +ROLLBACK PREPARED 'iu_p1'; +-- The 60 entries from the prepared-then-aborted transaction are left for VACUUM. +SELECT idxundo_count_dead_all('iu_2pc_idx') AS dead_after_rollback_prepared; + dead_after_rollback_prepared +------------------------------ + 0 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_2pc WHERE id BETWEEN 1 AND 100; + committed_via_index +--------------------- + 100 +(1 row) + +SELECT count(*) AS prepared_via_index FROM iu_2pc WHERE id BETWEEN 501 AND 560; + prepared_via_index +-------------------- + 0 +(1 row) + +RESET enable_seqscan; +SELECT bt_index_check('iu_2pc_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +DROP TABLE iu_def, iu_sub2, iu_sub3, iu_nest, iu_big, iu_2pc; +DROP EXTENSION amcheck; +DROP EXTENSION test_idxundo; diff --git a/src/test/modules/test_idxundo/meson.build b/src/test/modules/test_idxundo/meson.build new file mode 100644 index 0000000000000..aae1dc0af94be --- /dev/null +++ b/src/test/modules/test_idxundo/meson.build @@ -0,0 +1,52 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +test_idxundo_sources = files( + 'test_idxundo.c', +) + +if host_system == 'windows' + test_idxundo_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'test_idxundo', + '--FILEDESC', 'test_idxundo - exercise index UNDO',]) +endif + +test_idxundo = shared_module('test_idxundo', + test_idxundo_sources, + kwargs: pg_test_mod_args, +) +test_install_libs += test_idxundo + +test_install_data += files( + 'test_idxundo.control', + 'test_idxundo--1.0.sql', +) + +tests += { + 'name': 'test_idxundo', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'regress': { + 'sql': [ + 'test_idxundo', + ], + # The end-to-end tests read the index back and verify it with amcheck. + 'regress_args': ['--load-extension=amcheck'], + }, + 'tap': { + 'env': { + 'enable_injection_points': get_option('injection_points') ? 'yes' : 'no', + }, + 'tests': [ + 't/001_idxundo_crash.pl', + 't/002_idxundo_abort_threshold.pl', + 't/003_idxundo_apply_failure.pl', + 't/004_idxundo_apply_order.pl', + 't/005_idxundo_atm_retention.pl', + 't/006_idxundo_revert_retry.pl', + 't/007_idxundo_2pc_database.pl', + 't/008_idxundo_revert_liveness.pl', + 't/009_idxundo_recovery_first_batch.pl', + 't/011_idxundo_wal_consistency.pl', + ], + }, +} diff --git a/src/test/modules/test_idxundo/sql/test_idxundo.sql b/src/test/modules/test_idxundo/sql/test_idxundo.sql new file mode 100644 index 0000000000000..89aacdf9995ef --- /dev/null +++ b/src/test/modules/test_idxundo/sql/test_idxundo.sql @@ -0,0 +1,321 @@ +-- --------------------------------------------------------------------------- +-- Index UNDO, end to end through the real abort path. +-- +-- A table created WITH (index_undo = on) makes its btree indexes write UNDO +-- into the cluster-wide UNDO-in-WAL stream. On ROLLBACK, AtAbort_XactUndo() +-- walks the chain and the apply callback marks the provisionally-inserted +-- entries LP_DEAD -- no VACUUM required. +-- --------------------------------------------------------------------------- +CREATE EXTENSION test_idxundo; +CREATE EXTENSION amcheck; + +-- =========================================================================== +-- Reloption plumbing +-- =========================================================================== + +CREATE TABLE iu_opt (id int, val text) WITH (index_undo = on); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; + +-- round-trips through ALTER ... SET / RESET +ALTER TABLE iu_opt SET (index_undo = off); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; +ALTER TABLE iu_opt SET (index_undo = on); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; +ALTER TABLE iu_opt RESET (index_undo); +SELECT reloptions FROM pg_class WHERE relname = 'iu_opt'; + +-- rejected where it has no meaning +CREATE INDEX iu_opt_idx ON iu_opt (id); +ALTER INDEX iu_opt_idx SET (index_undo = on); +DROP TABLE iu_opt; + +-- =========================================================================== +-- The core promise: ROLLBACK reverses provisional entries, COMMIT keeps them +-- =========================================================================== + +CREATE TABLE iu_btree (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_btree_idx ON iu_btree (id); + +-- committed baseline +INSERT INTO iu_btree SELECT g, 'committed' FROM generate_series(1, 200) g; + +-- Nothing is dead yet. +SELECT idxundo_count_dead_all('iu_btree_idx') AS dead_before; + +-- An aborted insert. Inside the transaction the chain head must be published, +-- or AtAbort_XactUndo() would have nothing to walk (the defect that made this +-- feature a silent no-op). +BEGIN; +INSERT INTO iu_btree SELECT g, 'aborted' FROM generate_series(1001, 1100) g; +SELECT idxundo_undo_chain_published() AS chain_published; +ROLLBACK; + +-- The 100 aborted entries are now LP_DEAD, without any VACUUM. +SELECT idxundo_count_dead_all('iu_btree_idx') AS dead_after_rollback; + +-- Every committed row is still reachable THROUGH THE INDEX. This is the +-- assertion that fails if apply killed the wrong slot. +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_btree WHERE id BETWEEN 1 AND 200; +SELECT count(*) AS aborted_via_index FROM iu_btree WHERE id BETWEEN 1001 AND 1100; +RESET enable_seqscan; + +-- Index and heap agree, and the index is structurally sound. +SELECT count(*) AS heap_rows FROM iu_btree; +SELECT bt_index_check('iu_btree_idx', heapallindexed => true); + +-- A committed insert is untouched by the same machinery. +BEGIN; +INSERT INTO iu_btree SELECT g, 'committed2' FROM generate_series(2001, 2050) g; +COMMIT; +SELECT idxundo_count_dead_all('iu_btree_idx') AS dead_after_commit; +SET enable_seqscan = off; +SELECT count(*) AS committed2_via_index FROM iu_btree WHERE id BETWEEN 2001 AND 2050; +RESET enable_seqscan; +SELECT bt_index_check('iu_btree_idx', heapallindexed => true); + +-- =========================================================================== +-- Opting out must leave the old behaviour exactly as it was +-- +-- index_undo defaults to ON, so opting out takes an EXPLICIT off. (A table with +-- no reloptions at all is covered by the default-ON cases below; that is the +-- common configuration, not this one.) +-- =========================================================================== + +CREATE TABLE iu_off (id int, val text) WITH (index_undo = off); +CREATE INDEX iu_off_idx ON iu_off (id); +BEGIN; +INSERT INTO iu_off SELECT g, 'aborted' FROM generate_series(1, 100) g; +SELECT idxundo_undo_chain_published() AS chain_published_when_off; +ROLLBACK; +-- No index UNDO was written, so the entries are left for VACUUM: not dead. +SELECT idxundo_count_dead_all('iu_off_idx') AS dead_when_off; +SELECT bt_index_check('iu_off_idx', heapallindexed => true); + +-- =========================================================================== +-- Subtransactions: only the aborted subxact's entries are reversed +-- =========================================================================== + +CREATE TABLE iu_sub (id int) WITH (index_undo = on); +CREATE INDEX iu_sub_idx ON iu_sub (id); +BEGIN; + INSERT INTO iu_sub SELECT g FROM generate_series(1, 50) g; -- keep + SAVEPOINT s1; + INSERT INTO iu_sub SELECT g FROM generate_series(501, 550) g; -- discard + ROLLBACK TO SAVEPOINT s1; + INSERT INTO iu_sub SELECT g FROM generate_series(101, 150) g; -- keep +COMMIT; +SET enable_seqscan = off; +SELECT count(*) AS kept_via_index FROM iu_sub WHERE id BETWEEN 1 AND 150; +SELECT count(*) AS discarded_via_index FROM iu_sub WHERE id BETWEEN 501 AND 550; +RESET enable_seqscan; +SELECT count(*) AS heap_rows FROM iu_sub; +SELECT bt_index_check('iu_sub_idx', heapallindexed => true); + +-- =========================================================================== +-- Page splits during an aborted transaction: splits are not reversed, the +-- aborted leaf entries are killed wherever the split moved them, and the tree +-- must stay consistent. +-- =========================================================================== + +CREATE TABLE iu_split (id int, pad text) WITH (index_undo = on); +CREATE INDEX iu_split_idx ON iu_split (id); +INSERT INTO iu_split SELECT g, repeat('c', 50) FROM generate_series(1, 500) g; +BEGIN; +-- Roll back inline rather than handing the batch to the revert worker, so the +-- dead count below is deterministic. +SET LOCAL undo_instant_abort_threshold = 2147483647; +-- Enough rows, interleaved with the committed keys, to force many leaf splits. +INSERT INTO iu_split SELECT g, repeat('a', 50) FROM generate_series(501, 5000) g; +-- Keep any rollback diagnostics, whose detail depends on page geometry, out of +-- the expected output. +SET client_min_messages = error; +ROLLBACK; +RESET client_min_messages; +-- Every aborted entry is dead, including those whose insert split its leaf. +SELECT idxundo_count_dead_all('iu_split_idx') AS dead_after_split_rollback; +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_split WHERE id BETWEEN 1 AND 500; +RESET enable_seqscan; +SELECT count(*) AS heap_rows FROM iu_split; +SELECT bt_index_check('iu_split_idx', heapallindexed => true); + +-- =========================================================================== +-- Turning the option on AFTER rows already exist. +-- +-- The option only governs whether NEW inserts write UNDO; pre-existing entries +-- have no UNDO record and are therefore unaffected by a later rollback. That +-- is the documented behaviour, and it is safe: an entry with no UNDO record is +-- simply left to VACUUM, exactly as today. +-- =========================================================================== + +CREATE TABLE iu_late (id int); +CREATE INDEX iu_late_idx ON iu_late (id); +INSERT INTO iu_late SELECT g FROM generate_series(1, 100) g; -- no UNDO written +ALTER TABLE iu_late SET (index_undo = on); +BEGIN; +INSERT INTO iu_late SELECT g FROM generate_series(201, 250) g; -- UNDO written +ROLLBACK; +-- Only the 50 post-ALTER entries are reversed; the 100 earlier ones are intact. +SELECT idxundo_count_dead_all('iu_late_idx') AS dead_after_rollback; +SET enable_seqscan = off; +SELECT count(*) AS preexisting_via_index FROM iu_late WHERE id BETWEEN 1 AND 100; +RESET enable_seqscan; +SELECT bt_index_check('iu_late_idx', heapallindexed => true); + +DROP TABLE iu_btree, iu_off, iu_sub, iu_split, iu_late; + +-- =========================================================================== +-- DEFERRED BATCHING SAFETY +-- +-- Index UNDO records are DEFERRED, not written per record: they accumulate in +-- one UndoRecordSet and a single XLOG_UNDO_BATCH carries a whole statement's +-- entries. That is the whole performance win, and it is only sound because +-- every path that can consume the chain flushes the pending batch first. +-- +-- A missed flush does not corrupt anything -- it makes ROLLBACK a SILENT NO-OP, +-- which is far worse, because it looks like success. So each case below aborts +-- work and asserts the entries were actually reversed, i.e. that the flush +-- happened at that choke point. A regression here shows up as +-- dead_after_rollback = 0. +-- =========================================================================== + +CREATE TABLE iu_def (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_def_idx ON iu_def (id); +INSERT INTO iu_def SELECT g, 'committed' FROM generate_series(1, 200) g; + +-- --- 1. error mid-statement ------------------------------------------------- +-- The error unwinds into AbortTransaction() -> AtAbort_XactUndo(). A partial +-- statement's deferred records must be flushed and applied exactly like a +-- completed statement's. +BEGIN; +INSERT INTO iu_def SELECT g, 'x' FROM generate_series(1001, 1100) g; +-- now fail, mid-transaction, with records pending +SELECT 1 / 0; +ROLLBACK; +SELECT idxundo_count_dead_all('iu_def_idx') AS dead_after_error; + +-- --- 2. subxact abort whose parent COMMITS ---------------------------------- +-- The one case that loses data permanently rather than deferring it: an aborted +-- subtransaction under a committing parent produces no top-level ATM entry, so +-- if its records never reached WAL nothing would ever revert them and the +-- entries would persist as though committed. AtSubAbort_XactUndo() must flush. +CREATE TABLE iu_sub2 (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_sub2_idx ON iu_sub2 (id); +INSERT INTO iu_sub2 SELECT g, 'committed' FROM generate_series(1, 100) g; +BEGIN; + SAVEPOINT s1; + INSERT INTO iu_sub2 SELECT g, 'rolled back' FROM generate_series(501, 560) g; + ROLLBACK TO SAVEPOINT s1; + INSERT INTO iu_sub2 SELECT g, 'kept' FROM generate_series(601, 620) g; +COMMIT; +-- The 60 subxact entries are reversed even though the top-level txn committed. +SELECT idxundo_count_dead_all('iu_sub2_idx') AS dead_after_subabort; +SET enable_seqscan = off; +-- Committed rows, both pre-savepoint and post-rollback-to-savepoint, survive. +SELECT count(*) AS committed_via_index FROM iu_sub2 WHERE id BETWEEN 1 AND 100; +SELECT count(*) AS kept_via_index FROM iu_sub2 WHERE id BETWEEN 601 AND 620; +SELECT count(*) AS rolledback_via_index FROM iu_sub2 WHERE id BETWEEN 501 AND 560; +RESET enable_seqscan; +SELECT bt_index_check('iu_sub2_idx', heapallindexed => true); + +-- --- 3. ROLLBACK TO SAVEPOINT must not over-revert --------------------------- +-- A pending batch must never span a subtransaction boundary. If the flush +-- happened after the boundary was saved, the OUTER level's records would land +-- above it and be reverted along with the inner subtransaction's -- discarding +-- work that was never rolled back. Hence the flush at SUBXACT_EVENT_START_SUB. +CREATE TABLE iu_sub3 (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_sub3_idx ON iu_sub3 (id); +BEGIN; + -- outer-level work, deferred and still pending when the savepoint opens + INSERT INTO iu_sub3 SELECT g, 'outer' FROM generate_series(1, 40) g; + SAVEPOINT s2; + INSERT INTO iu_sub3 SELECT g, 'inner' FROM generate_series(101, 140) g; + ROLLBACK TO SAVEPOINT s2; +COMMIT; +-- Exactly the 40 inner entries died; the 40 outer ones committed. +SELECT idxundo_count_dead_all('iu_sub3_idx') AS dead_after_rollback_to_sp; +SET enable_seqscan = off; +SELECT count(*) AS outer_via_index FROM iu_sub3 WHERE id BETWEEN 1 AND 40; +SELECT count(*) AS inner_via_index FROM iu_sub3 WHERE id BETWEEN 101 AND 140; +RESET enable_seqscan; +SELECT bt_index_check('iu_sub3_idx', heapallindexed => true); + +-- --- 4. nested subxacts ----------------------------------------------------- +CREATE TABLE iu_nest (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_nest_idx ON iu_nest (id); +BEGIN; + INSERT INTO iu_nest SELECT g, 'L0' FROM generate_series(1, 20) g; + SAVEPOINT a; + INSERT INTO iu_nest SELECT g, 'L1' FROM generate_series(101, 120) g; + SAVEPOINT b; + INSERT INTO iu_nest SELECT g, 'L2' FROM generate_series(201, 220) g; + RELEASE SAVEPOINT b; -- L2 merges into L1, still live + SAVEPOINT c; + INSERT INTO iu_nest SELECT g, 'L2c' FROM generate_series(301, 320) g; + ROLLBACK TO SAVEPOINT c; -- only L2c dies + RELEASE SAVEPOINT a; +COMMIT; +-- 20 dead (L2c only); L0, L1 and the released L2 all committed. +SELECT idxundo_count_dead_all('iu_nest_idx') AS dead_after_nested; +SET enable_seqscan = off; +SELECT count(*) AS live_via_index FROM iu_nest WHERE id < 300; +SELECT count(*) AS dead_via_index FROM iu_nest WHERE id BETWEEN 301 AND 320; +RESET enable_seqscan; +SELECT bt_index_check('iu_nest_idx', heapallindexed => true); + +-- --- 5. the memory bound: an intermediate flush must still publish the chain - +-- A statement bigger than undo_batch_record_limit (1000) / undo_batch_size_kb +-- flushes mid-statement. Each intermediate flush is a complete batch chained +-- onto the previous one, so rollback must walk EVERY chained batch, not just the +-- final partial one. If chaining were broken the count would collapse to +-- roughly one batch worth (<=1000), which is what this guards. +CREATE TABLE iu_big (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_big_idx ON iu_big (id); +BEGIN; +-- This much UNDO is over undo_instant_abort_threshold, which would hand the +-- abort to the logical revert worker; keep it inline so the walk is observable. +SET LOCAL undo_instant_abort_threshold = 2147483647; +INSERT INTO iu_big SELECT g, 'big' FROM generate_series(1, 5000) g; +SELECT idxundo_undo_chain_published() AS chain_published; +ROLLBACK; +-- All 5000 reversed, i.e. every chained batch was walked. +SELECT idxundo_count_dead_all('iu_big_idx') AS dead_after_big_rollback; +SET enable_seqscan = off; +SELECT count(*) AS survivors_via_index FROM iu_big; +RESET enable_seqscan; +SELECT bt_index_check('iu_big_idx', heapallindexed => true); + +-- --- 6. 2PC: PREPARE must not lose the deferred batch ------------------------ +-- PrepareTransaction() captures GetCurrentXactLastBatchLSN() into the 2PC state +-- before AtPrepare_XactUndo() runs, and that snapshot is the only chain head +-- ROLLBACK PREPARED will ever get. A record still pending at that moment would +-- be dropped from the prepared transaction's chain with no later chance to +-- notice -- so XactUndoFlushPending() is called before the capture, and +-- AtPrepare_XactUndo() asserts nothing is left pending. +-- +-- dead_after_rollback_prepared is 0: ROLLBACK PREPARED hands the chain to the +-- logical revert worker, which leaves index entries for VACUUM (after the +-- abort is visible their heap TIDs could hold committed rows; see +-- nbtree_undo_apply()). What this case proves is that PREPARE itself is safe: +-- it does not error, lose the chain, or trip the AtPrepare_XactUndo() +-- assertion, and the committed rows stay index-reachable. +CREATE TABLE iu_2pc (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_2pc_idx ON iu_2pc (id); +INSERT INTO iu_2pc SELECT g, 'committed' FROM generate_series(1, 100) g; +BEGIN; +INSERT INTO iu_2pc SELECT g, 'prepared' FROM generate_series(501, 560) g; +PREPARE TRANSACTION 'iu_p1'; +ROLLBACK PREPARED 'iu_p1'; +-- The 60 entries from the prepared-then-aborted transaction are left for VACUUM. +SELECT idxundo_count_dead_all('iu_2pc_idx') AS dead_after_rollback_prepared; +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_2pc WHERE id BETWEEN 1 AND 100; +SELECT count(*) AS prepared_via_index FROM iu_2pc WHERE id BETWEEN 501 AND 560; +RESET enable_seqscan; +SELECT bt_index_check('iu_2pc_idx', heapallindexed => true); + +DROP TABLE iu_def, iu_sub2, iu_sub3, iu_nest, iu_big, iu_2pc; + +DROP EXTENSION amcheck; +DROP EXTENSION test_idxundo; diff --git a/src/test/modules/test_idxundo/t/001_idxundo_crash.pl b/src/test/modules/test_idxundo/t/001_idxundo_crash.pl new file mode 100644 index 0000000000000..b5b82b15102ea --- /dev/null +++ b/src/test/modules/test_idxundo/t/001_idxundo_crash.pl @@ -0,0 +1,214 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# Crash recovery for index UNDO. +# +# A transaction that inserts into a table WITH (index_undo = on) and is killed +# before it commits must, after recovery, leave no index entry pointing at a row +# that is not in the heap. The index UNDO records reach the recovery path the +# same way FILEOPS' do: the XLOG_UNDO_BATCH records are replayed, the +# transaction is found to have no commit record, and the chain is applied -- +# either during the recovery UNDO phase or, when the syscache is not yet +# available there (always, for index UNDO), deferred to the ATM and drained by +# the logical revert worker for the transaction's database. +# +# A transaction that committed while its index UNDO was still deferred must not +# be mistaken for a loser. +# +# The assertion is deliberately about index/heap AGREEMENT rather than about a +# dead-entry count: whether a given entry ends up LP_DEAD immediately or is left +# for VACUUM is an efficiency question, but an index scan returning a row the +# heap does not have would be corruption. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('idxundo_crash'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +max_logical_revert_workers = 2 +}); +$node->start; + +$node->safe_psql('postgres', 'CREATE EXTENSION amcheck'); +$node->safe_psql('postgres', 'CREATE EXTENSION test_idxundo'); +my $dboid = $node->safe_psql('postgres', + "SELECT oid FROM pg_database WHERE datname = 'postgres'"); + +# A table whose indexes write UNDO, plus a committed baseline to protect. +$node->safe_psql( + 'postgres', q{ +CREATE TABLE ct (id int primary key, val text) WITH (index_undo = on); +CREATE INDEX ct_val_idx ON ct (val); +INSERT INTO ct SELECT g, 'committed-' || g FROM generate_series(1, 500) g; +}); + +my $committed = $node->safe_psql('postgres', 'SELECT count(*) FROM ct'); +is($committed, '500', 'committed baseline present before crash'); + +# A committed transaction whose index UNDO is still sitting in the deferred +# batch when it commits: 300 rows x 1 index stays under undo_batch_record_limit, +# so nothing is written until the commit path flushes it. That batch must reach +# WAL before the commit record; if it follows the commit record, redo removes +# the xid at COMMIT and re-adds it at the batch, and recovery then treats this +# committed transaction as one to roll back. +$node->safe_psql('postgres', + 'CREATE TABLE cc (id int primary key) WITH (index_undo = on)'); +my $committed_xid = $node->safe_psql( + 'postgres', q{ +BEGIN; +INSERT INTO cc SELECT g FROM generate_series(1, 300) g; +SELECT pg_current_xact_id(); +COMMIT; +}); + +# The same, but the transaction's first index UNDO is written inside a +# subtransaction. Its batches must still be attributed to the top-level xid: +# the commit record clears only that xid from recovery tracking. +$node->safe_psql('postgres', + 'CREATE TABLE cs (id int primary key) WITH (index_undo = on)'); +$node->safe_psql( + 'postgres', q{ +BEGIN; +SAVEPOINT s; +INSERT INTO cs SELECT g FROM generate_series(1, 300) g; +RELEASE SAVEPOINT s; +INSERT INTO cs SELECT g FROM generate_series(301, 310) g; +COMMIT; +}); + +# Start a transaction, insert, and leave it uncommitted. A background psql +# keeps the session (and so the transaction) open while we kill the server. +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +$bg->query_safe( + q{INSERT INTO ct SELECT g, 'aborted-' || g FROM generate_series(10001, 10300) g}); + +# The rows are visible to their own transaction, so the insert really happened +# and its UNDO is in WAL. +my $in_txn = $bg->query_safe('SELECT count(*) FROM ct WHERE id >= 10001'); +is($in_txn, '300', 'uncommitted rows visible inside their own transaction'); +my $killed_xid = $bg->query_safe('SELECT pg_current_xact_id()'); + +# Index UNDO is deferred and batched; 600 records is below the batch limits, so +# they would still be in backend memory at the crash and never reach WAL (such +# entries are left for VACUUM by design). Starting a subtransaction is one of +# the points that writes the pending batch, so recovery sees a real loser chain. +$bg->query_safe('SAVEPOINT s'); + +# Force the WAL out, then crash before the transaction can commit. +$node->safe_psql('postgres', 'SELECT pg_switch_wal()'); +$node->stop('immediate'); + +$node->start; + +# The killed transaction never committed, so none of its rows may be visible. +my $after = $node->safe_psql('postgres', 'SELECT count(*) FROM ct WHERE id >= 10001'); +is($after, '0', 'rows from the killed transaction are not visible after recovery'); + +# The committed rows survived. +is($node->safe_psql('postgres', 'SELECT count(*) FROM ct'), + '500', 'committed rows survived the crash'); + +# Recovery cannot apply index UNDO itself (no syscache in the startup process), +# so it defers the killed transaction to the ATM. The entry must carry the +# database the transaction ran in: no revert worker serves any other database, +# so an entry with the wrong one would never drain and would pin WAL forever. +my $log = slurp_file($node->logfile); +like( + $log, + qr/UNDO recovery: rolling back transaction $killed_xid\b/, + 'recovery found the killed transaction as a loser'); +like( + $log, + qr/UNDO recovery: transaction $killed_xid deferred to logical revert worker/, + 'recovery deferred its index UNDO to the logical revert worker'); + +like( + $log, + qr/UNDO recovery: 1 incomplete transaction\(s\) to roll back/, + 'the killed transaction is the only recovery loser'); + +# The logical revert worker for that database must revert and forget it. +ok( $node->poll_query_until( + 'postgres', "SELECT count(*) = 0 FROM idxundo_atm_entries()"), + 'logical revert worker drained the deferred ATM entry'); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM idxundo_atm_entries() WHERE dboid <> $dboid"), + '0', + 'no ATM entry is left for a database without a revert worker'); + +# The worker leaves the killed transaction's index entries for VACUUM: after +# the abort is visible their heap TIDs could hold committed rows, so index UNDO +# is applied only by the aborting backend itself. +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('ct_pkey')"), + '0', 'killed transaction\'s primary key entries are left for VACUUM'); +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('ct_val_idx')"), + '0', 'killed transaction\'s secondary index entries are left for VACUUM'); + +# The property that matters: every index agrees with the heap. A forced +# index-only path must not produce a row the heap scan does not, and amcheck +# must find no entry without a matching heap tuple. +for my $idx ('ct_pkey', 'ct_val_idx') +{ + my ($rc, $stdout, $stderr) = $node->psql('postgres', + "SELECT bt_index_check('$idx', heapallindexed => true)"); + is($rc, 0, "amcheck clean on $idx after crash recovery"); + is($stderr, '', "amcheck reported nothing on $idx"); +} + +my $viaidx = $node->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; +SELECT count(*) FROM ct WHERE id BETWEEN 1 AND 500; +}); +is($viaidx, '500', 'all committed rows still reachable through the index'); + +my $ghosts = $node->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; +SELECT count(*) FROM ct WHERE id BETWEEN 10001 AND 10300; +}); +is($ghosts, '0', 'no index entry resolves to a killed transaction row'); + +# The same check for the secondary index, which is scanned by value. +my $ghosts_val = $node->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; +SELECT count(*) FROM ct WHERE val LIKE 'aborted-%'; +}); +is($ghosts_val, '0', 'no secondary-index entry resolves to a killed row'); + +# The committed transaction is not a recovery loser, and its index entries are +# all still live. +unlike( + $log, + qr/UNDO recovery: rolling back transaction $committed_xid\b/, + 'committed transaction with deferred index UNDO is not rolled back'); +is( $node->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; +SELECT count(*) FROM cc WHERE id BETWEEN 1 AND 300; +}), + '300', + 'committed rows with deferred index UNDO still reachable through the index'); +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('cc_pkey')"), + '0', 'no index entry of the committed transaction was marked dead'); +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('cs_pkey')"), + '0', + 'no index entry of the committed subtransaction-first writer was marked dead'); +is( $node->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; +SELECT count(*) FROM cs WHERE id BETWEEN 1 AND 310; +}), + '310', + 'committed rows first written in a subtransaction reachable through the index'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/005_idxundo_atm_retention.pl b/src/test/modules/test_idxundo/t/005_idxundo_atm_retention.pl index 3d93303031750..6cb1687d74785 100644 --- a/src/test/modules/test_idxundo/t/005_idxundo_atm_retention.pl +++ b/src/test/modules/test_idxundo/t/005_idxundo_atm_retention.pl @@ -45,9 +45,10 @@ sub recycle_wal } } -# Run the revert worker (it is off until now) and check that it reverted the -# whole chain of $table's index. Each phase has a single deferred chain, so no -# other entry's pin can cover for a missing one. +# Run the revert worker (it is off until now) and check that it walked the +# whole chain of $table's index: it forgets an entry only after reading every +# batch, and a removed batch is a PANIC. Each phase has a single deferred +# chain, so no other entry's pin can cover for a missing one. sub revert_and_check { my ($table, $what) = @_; @@ -58,13 +59,11 @@ sub revert_and_check 'postgres', 'SELECT count(*) = 0 FROM idxundo_atm_entries()'), "revert worker drained the ATM ($what)"); - # A few entries may be left for VACUUM by the bounded right-link walk; a - # recycled batch would lose up to a thousand at a time. - cmp_ok( - $node->safe_psql('postgres', + # The worker leaves index entries for VACUUM (see nbtree_undo_apply()). + is( $node->safe_psql('postgres', "SELECT idxundo_count_dead_all('${table}_pkey')"), - '>=', $nrows - 200, - "the whole $what chain was reverted"); + '0', + "the $what chain's index entries are left for VACUUM"); $node->append_conf('postgresql.conf', 'max_logical_revert_workers = 0'); $node->restart; diff --git a/src/test/modules/test_idxundo/t/006_idxundo_revert_retry.pl b/src/test/modules/test_idxundo/t/006_idxundo_revert_retry.pl index 27d7ae3d5de30..917c640bcd1da 100644 --- a/src/test/modules/test_idxundo/t/006_idxundo_revert_retry.pl +++ b/src/test/modules/test_idxundo/t/006_idxundo_revert_retry.pl @@ -75,8 +75,9 @@ 'postgres', "SELECT count(*) = 0 FROM idxundo_atm_entries() WHERE xid = '$xid'"), 'entry is reverted and forgotten on retry'); -cmp_ok($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), - '>=', 4900, 'the whole chain was reverted'); +# The worker leaves index entries for VACUUM (see nbtree_undo_apply()). +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '0', 'its index entries are left for VACUUM'); # ERROR must abort, not commit a transaction with outstanding resources. $node->safe_psql( diff --git a/src/test/modules/test_idxundo/t/007_idxundo_2pc_database.pl b/src/test/modules/test_idxundo/t/007_idxundo_2pc_database.pl index 1496f72807d16..a6200c283187d 100644 --- a/src/test/modules/test_idxundo/t/007_idxundo_2pc_database.pl +++ b/src/test/modules/test_idxundo/t/007_idxundo_2pc_database.pl @@ -59,8 +59,9 @@ ok( $node->poll_query_until( 'postgres', 'SELECT count(*) = 0 FROM idxundo_atm_entries()'), 'the revert worker for that database drained it'); -cmp_ok($node->safe_psql('db1', "SELECT idxundo_count_dead_all('t_pkey')"), - '>=', 4900, 'the prepared transaction\'s index entries were reverted'); +# The worker leaves index entries for VACUUM (see nbtree_undo_apply()). +is($node->safe_psql('db1', "SELECT idxundo_count_dead_all('t_pkey')"), + '0', 'the prepared transaction\'s index entries are left for VACUUM'); $node->stop; done_testing(); diff --git a/src/test/modules/test_idxundo/t/008_idxundo_revert_liveness.pl b/src/test/modules/test_idxundo/t/008_idxundo_revert_liveness.pl index 5c2b9f41f5c38..2bfdcaf76a891 100644 --- a/src/test/modules/test_idxundo/t/008_idxundo_revert_liveness.pl +++ b/src/test/modules/test_idxundo/t/008_idxundo_revert_liveness.pl @@ -63,8 +63,9 @@ 'postgres', "SELECT count(*) = 0 FROM idxundo_atm_entries() WHERE xid = '$good'"), 'the entry behind the malformed one is reverted'); -cmp_ok($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), - '>=', 4900, 'its whole chain was reverted'); +# The worker leaves index entries for VACUUM (see nbtree_undo_apply()). +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '0', 'its index entries are left for VACUUM'); $node->wait_for_log( qr/giving up on transaction $bad, whose UNDO chain is malformed/, diff --git a/src/test/modules/test_idxundo/t/011_idxundo_wal_consistency.pl b/src/test/modules/test_idxundo/t/011_idxundo_wal_consistency.pl new file mode 100644 index 0000000000000..b2aceae92379f --- /dev/null +++ b/src/test/modules/test_idxundo/t/011_idxundo_wal_consistency.pl @@ -0,0 +1,58 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Rolling back index inserts writes compensation records (CLRs) through the +# Undo resource manager that change btree and hash pages. wal_consistency_ +# checking must be able to verify the redo of those page changes, so it has +# to accept "undo", and a standby replaying the CLRs must find every page +# consistent with the primary's image. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf('postgresql.conf', + "wal_consistency_checking = 'undo,btree,hash'"); +$primary->start; + +$primary->safe_psql( + 'postgres', q{ +CREATE EXTENSION test_idxundo; +CREATE TABLE w (id int, val text) WITH (index_undo = on); +CREATE INDEX w_b ON w (id); +CREATE INDEX w_h ON w USING hash (id); +INSERT INTO w SELECT g, 'committed' FROM generate_series(1, 200) g; +}); + +$primary->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->start; + +$primary->safe_psql( + 'postgres', q{ +BEGIN; +SET LOCAL undo_instant_abort_threshold = 2147483647; +INSERT INTO w SELECT g, 'aborted' FROM generate_series(1001, 1300) g; +ROLLBACK; +}); +is($primary->safe_psql('postgres', "SELECT idxundo_count_dead_all('w_b')"), + '300', 'rollback killed the btree entries'); +cmp_ok( + $primary->safe_psql('postgres', "SELECT idxundo_count_dead_all('w_h')"), + '>', 0, 'rollback killed hash entries'); + +$primary->wait_for_catchup($standby, 'replay'); +my $q = "SET enable_seqscan = off; SET enable_bitmapscan = off; " + . "SELECT count(*) FROM w WHERE id BETWEEN 1 AND 200"; +is($standby->safe_psql('postgres', $q), + '200', 'standby replayed the CLRs and still finds the committed rows'); +ok( !$standby->log_contains('inconsistent page found'), + 'no page inconsistency in the standby log'); + +$standby->stop; +$primary->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/test_idxundo--1.0.sql b/src/test/modules/test_idxundo/test_idxundo--1.0.sql new file mode 100644 index 0000000000000..bc5f2e3993edf --- /dev/null +++ b/src/test/modules/test_idxundo/test_idxundo--1.0.sql @@ -0,0 +1,37 @@ +/* src/test/modules/test_idxundo/test_idxundo--1.0.sql */ +\echo Use "CREATE EXTENSION test_idxundo" to load this file. \quit + +-- Total LP_DEAD line pointers across every block of an index. Used by the +-- end-to-end tests, where the aborted entries' block is not known in advance. +CREATE FUNCTION idxundo_count_dead_all(index regclass) +RETURNS int AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Does the current transaction hold published index UNDO? Reports +-- XactUndo.has_undo together with whether a permanent chain-head batch LSN was +-- recorded -- the two pieces AtAbort_XactUndo() requires before it will walk a +-- chain. An index insert that published neither would make ROLLBACK a +-- silent no-op; this guards against that. +CREATE FUNCTION idxundo_undo_chain_published() +RETURNS bool AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Every Aborted Transaction Map entry: which aborts were handed to the logical +-- revert worker, for which database, whether the chain is bounded (a +-- subtransaction's; stop_lsn is NULL for a whole chain), and whether they were +-- reverted. +CREATE FUNCTION idxundo_atm_entries(OUT xid xid, OUT dboid oid, + OUT last_batch_lsn pg_lsn, OUT stop_lsn pg_lsn, OUT revert_complete bool) +RETURNS SETOF record AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Queue xid in the ATM with an UNDO chain at lsn (which need not be one). +CREATE FUNCTION idxundo_atm_add(xid xid, lsn pg_lsn) +RETURNS bool AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Write an UNDO record that fails to apply, in the current (sub)transaction, +-- into the chain of the given relpersistence ('p', 'u' or 't'). +CREATE FUNCTION idxundo_write_failing_undo(persistence "char" DEFAULT 'p') +RETURNS void AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Write UNDO records 1..n of an order-logging resource manager in one batch +-- (needs shared_preload_libraries = 'test_idxundo'). +CREATE FUNCTION idxundo_write_ordered_undo(n int) +RETURNS void AS 'MODULE_PATHNAME' LANGUAGE C STRICT; diff --git a/src/test/modules/test_idxundo/test_idxundo.c b/src/test/modules/test_idxundo/test_idxundo.c new file mode 100644 index 0000000000000..fd92c30979219 --- /dev/null +++ b/src/test/modules/test_idxundo/test_idxundo.c @@ -0,0 +1,340 @@ +/*------------------------------------------------------------------------- + * + * test_idxundo.c + * SQL-callable probes for the index UNDO tests. + * + * The tests drive index UNDO end to end through the real abort path: a table + * created WITH (index_undo = on) makes its indexes write UNDO, and ROLLBACK + * reverses the provisional entries. These functions only observe the result: + * how many index entries are LP_DEAD, and whether the current transaction has + * published an UNDO chain for AtAbort_XactUndo() to walk. + * + * This is a test-only module. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/atm.h" +#include "access/nbtree.h" +#include "access/relation.h" +#include "access/table.h" +#include "access/slog.h" +#include "access/xact.h" +#include "access/undormgr.h" +#include "access/xactundo.h" +#include "catalog/pg_am_d.h" +#include "catalog/pg_class.h" +#include "fmgr.h" +#include "miscadmin.h" +#include "storage/ipc.h" +#include "storage/lwlock.h" +#include "utils/injection_point.h" +#include "utils/guc.h" +#include "funcapi.h" +#include "storage/bufmgr.h" +#include "storage/bufpage.h" +#include "utils/pg_lsn.h" +#include "utils/rel.h" +#include "utils/tuplestore.h" + +PG_MODULE_MAGIC; + +void _PG_init(void); + +/* + * idxundo_count_dead_all(index regclass) -> total LP_DEAD items in the index + * + * The end-to-end tests do not know which block the aborted entries landed on, + * so they count over every leaf page of the index. + */ +PG_FUNCTION_INFO_V1(idxundo_count_dead_all); +Datum +idxundo_count_dead_all(PG_FUNCTION_ARGS) +{ + Oid indexoid = PG_GETARG_OID(0); + Relation indexrel; + BlockNumber blk, + nblocks; + Oid relam; + int ndead = 0; + + indexrel = relation_open(indexoid, AccessShareLock); + nblocks = RelationGetNumberOfBlocks(indexrel); + relam = indexrel->rd_rel->relam; + + if (relam != BTREE_AM_OID) + ereport(ERROR, + (errmsg("index \"%s\" is not a btree index", + RelationGetRelationName(indexrel)))); + + for (blk = 0; blk < nblocks; blk++) + { + Buffer buffer = ReadBuffer(indexrel, blk); + Page page; + OffsetNumber off, + maxoff; + + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page = BufferGetPage(buffer); + + /* + * Only leaf pages hold index entries; skip the metapage and + * internals. + */ + if (!PageIsNew(page) && !PageIsEmpty(page) && + PageGetSpecialSize(page) > 0 && + P_ISLEAF(BTPageGetOpaque(page))) + { + maxoff = PageGetMaxOffsetNumber(page); + for (off = FirstOffsetNumber; off <= maxoff; off++) + { + if (ItemIdIsDead(PageGetItemId(page, off))) + ndead++; + } + } + UnlockReleaseBuffer(buffer); + } + relation_close(indexrel, AccessShareLock); + + PG_RETURN_INT32(ndead); +} + +/* + * idxundo_undo_chain_published() -> bool + * + * True when the current transaction has UNDO that AtAbort_XactUndo() will act + * on: either a permanent-persistence batch LSN already recorded in + * XactUndo.last_batch_lsn[], or records still sitting in the deferred batch that + * the abort path flushes before it reads that LSN. + * + * An insert that produced a valid XLOG_UNDO_BATCH but published no chain head + * would make ROLLBACK a silent no-op; this returns false in that case. + * + * Both states count as published, because index UNDO DEFERS its records so a + * statement's entries share one batch (DeferXactUndoData). Mid-transaction + * the records are normally still pending, and that is correct -- what would be a + * bug is neither: no pending records AND no chain head means the insert produced + * no UNDO at all. + */ +PG_FUNCTION_INFO_V1(idxundo_undo_chain_published); +Datum +idxundo_undo_chain_published(PG_FUNCTION_ARGS) +{ + XLogRecPtr lsn = GetCurrentXactLastBatchLSN(UNDOPERSISTENCE_PERMANENT); + + PG_RETURN_BOOL(!XLogRecPtrIsInvalid(lsn) || XactUndoHasPendingData()); +} + +/* + * idxundo_atm_entries() -> setof (xid, dboid, last_batch_lsn, stop_lsn, + * revert_complete) + * + * Every entry in the Aborted Transaction Map. Lets a test see whether an abort + * was handed to the logical revert worker (and for which database) or applied + * inline, whether a crash-recovery deferral was ever drained, and whether an + * entry covers a bounded (subtransaction) chain. + */ +PG_FUNCTION_INFO_V1(idxundo_atm_entries); +Datum +idxundo_atm_entries(PG_FUNCTION_ARGS) +{ + ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; + SLogTxnEntry *entries; + int n; + + InitMaterializedSRF(fcinfo, 0); + + n = SLogTxnSnapshotForCheckpoint(&entries); + for (int i = 0; i < n; i++) + { + Datum values[5]; + bool nulls[5] = {0}; + + values[0] = TransactionIdGetDatum(entries[i].xid); + values[1] = ObjectIdGetDatum(entries[i].dboid); + values[2] = LSNGetDatum(entries[i].last_batch_lsn); + values[3] = LSNGetDatum(entries[i].stop_lsn); + nulls[3] = XLogRecPtrIsInvalid(entries[i].stop_lsn); + values[4] = BoolGetDatum(entries[i].revert_complete); + tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, + values, nulls); + } + + return (Datum) 0; +} + +/* + * idxundo_atm_add(xid xid, lsn pg_lsn) -> bool + * + * Queue xid in the ATM for this database with an UNDO chain at lsn, which + * need not be an UNDO batch: pointing it at some other WAL record makes a + * malformed chain. + */ +PG_FUNCTION_INFO_V1(idxundo_atm_add); +Datum +idxundo_atm_add(PG_FUNCTION_ARGS) +{ + TransactionId xid = DatumGetTransactionId(PG_GETARG_DATUM(0)); + XLogRecPtr lsn = PG_GETARG_LSN(1); + + PG_RETURN_BOOL(ATMAddAborted(xid, MyDatabaseId, lsn, lsn)); +} + +/* + * A test UNDO resource manager whose records always fail to apply. + * + * It is registered in the calling backend only, on first use, so only that + * backend's own (inline) rollback can dispatch to it; anywhere else -- the + * logical revert worker -- its records are of an unknown resource manager, + * which fails just the same. + */ +#define TEST_IDXUNDO_RMID 200 + +static UndoApplyResult +test_idxundo_failing_apply(uint8 rmid, uint16 info, TransactionId xid, + Oid reloid, const char *payload, Size payload_len, + UndoRecPtr urec_ptr) +{ + return UNDO_APPLY_ERROR; +} + +static const UndoRmgrData test_idxundo_failing_rmgr = { + .rm_name = "test_idxundo_failing", + .rm_undo = test_idxundo_failing_apply, +}; + +/* + * idxundo_write_failing_undo(persistence "char" DEFAULT 'p') -> void + * + * Write one UNDO record, in the current (sub)transaction, that cannot be + * applied, into the chain of the given relpersistence ('p', 'u' or 't'). + * Any index UNDO still deferred is written in the same batch. + */ +PG_FUNCTION_INFO_V1(idxundo_write_failing_undo); +Datum +idxundo_write_failing_undo(PG_FUNCTION_ARGS) +{ + XactUndoContext ctx; + + if (GetUndoRmgr(TEST_IDXUNDO_RMID) == NULL) + RegisterUndoRmgr(TEST_IDXUNDO_RMID, &test_idxundo_failing_rmgr); + + PrepareXactUndoData(&ctx, PG_GETARG_CHAR(0), TEST_IDXUNDO_RMID, 0, + InvalidOid, NULL, 0); + START_CRIT_SECTION(); + InsertXactUndoData(&ctx); + CleanupXactUndoInsertion(&ctx); + END_CRIT_SECTION(); + + PG_RETURN_VOID(); +} + +/* + * A test UNDO resource manager that logs the order its records are applied in. + * + * Registered only when the module is in shared_preload_libraries, from + * shmem_startup_hook: the core zeroes the dispatch table while it sets up + * shared memory, after _PG_init has run. Every process forked by the + * postmaster -- the startup process included -- inherits the registration. + */ +#define TEST_IDXUNDO_ORDER_RMID 201 + +static shmem_startup_hook_type prev_shmem_startup_hook = NULL; +static bool recovery_error = false; + +static UndoApplyResult +test_idxundo_order_apply(uint8 rmid, uint16 info, TransactionId xid, + Oid reloid, const char *payload, Size payload_len, + UndoRecPtr urec_ptr) +{ + int32 seq; + + if (payload_len != sizeof(int32)) + return UNDO_APPLY_ERROR; + memcpy(&seq, payload, sizeof(int32)); + elog(LOG, "test_idxundo: applying UNDO record %d of transaction %u", + seq, xid); + return UNDO_APPLY_SUCCESS; +} + +static const UndoRmgrData test_idxundo_order_rmgr = { + .rm_name = "test_idxundo_order", + .rm_undo = test_idxundo_order_apply, +}; + +static void +test_idxundo_shmem_startup(void) +{ + if (prev_shmem_startup_hook) + prev_shmem_startup_hook(); + RegisterUndoRmgr(TEST_IDXUNDO_ORDER_RMID, &test_idxundo_order_rmgr); +#ifdef USE_INJECTION_POINTS + if (recovery_error) + InjectionPointAttach("pbu-recovery-error", "test_idxundo", + "idxundo_recovery_error", NULL, 0); +#endif +} + +void +_PG_init(void) +{ + if (!process_shared_preload_libraries_in_progress) + return; + DefineCustomBoolVariable("test_idxundo.recovery_error", + "Inject an error during per-backend undo recovery.", + NULL, &recovery_error, false, PGC_POSTMASTER, 0, + NULL, NULL, NULL); + prev_shmem_startup_hook = shmem_startup_hook; + shmem_startup_hook = test_idxundo_shmem_startup; +} + +/* + * idxundo_write_ordered_undo(n int) -> void + * + * Write records 1..n of the order-logging resource manager, all in ONE batch. + * Requires the module in shared_preload_libraries. + */ +PG_FUNCTION_INFO_V1(idxundo_write_ordered_undo); +Datum +idxundo_write_ordered_undo(PG_FUNCTION_ARGS) +{ + int32 n = PG_GETARG_INT32(0); + XactUndoContext ctx; + + if (GetUndoRmgr(TEST_IDXUNDO_ORDER_RMID) == NULL) + elog(ERROR, "test_idxundo must be in shared_preload_libraries"); + + for (int32 seq = 1; seq <= n; seq++) + PrepareXactUndoData(&ctx, RELPERSISTENCE_PERMANENT, + TEST_IDXUNDO_ORDER_RMID, 0, InvalidOid, + (const char *) &seq, sizeof(int32)); + START_CRIT_SECTION(); + InsertXactUndoData(&ctx); + CleanupXactUndoInsertion(&ctx); + END_CRIT_SECTION(); + + PG_RETURN_VOID(); +} + +/* Throw with both a buffer pin and an LWLock outstanding. */ +extern PGDLLEXPORT void idxundo_error_with_resources(const char *name, + const void *private_data, + void *arg); +void +idxundo_error_with_resources(const char *name, const void *private_data, void *arg) +{ + Relation rel = table_open(RelationRelationId, AccessShareLock); + + (void) ReadBuffer(rel, 0); + LWLockAcquire(ProcArrayLock, LW_SHARED); + elog(ERROR, "test_idxundo: error with resources at %s", name); +} + +extern PGDLLEXPORT void idxundo_recovery_error(const char *name, + const void *private_data, void *arg); +void +idxundo_recovery_error(const char *name, const void *private_data, void *arg) +{ + elog(ERROR, "test_idxundo: recovery error"); +} diff --git a/src/test/modules/test_idxundo/test_idxundo.control b/src/test/modules/test_idxundo/test_idxundo.control new file mode 100644 index 0000000000000..f42cd8b163a8f --- /dev/null +++ b/src/test/modules/test_idxundo/test_idxundo.control @@ -0,0 +1,4 @@ +comment = 'test harness for nbtree/hash index UNDO apply callbacks' +default_version = '1.0' +module_pathname = '$libdir/test_idxundo' +relocatable = true diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index c5108f52b10ad..b00b327381abd 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -86,6 +86,7 @@ tests += { 't/114_undo_recovery_error.pl', 't/117_undo_rewind_snapshot.pl', 't/126_atm_full_abort.pl', + 't/124_index_undo_tid_reuse.pl', 't/134_undo_shmem_on_demand.pl', 't/152_undo_attach_order_after_checkpoint.pl', ], diff --git a/src/test/recovery/t/124_index_undo_tid_reuse.pl b/src/test/recovery/t/124_index_undo_tid_reuse.pl new file mode 100644 index 0000000000000..066268c9820e1 --- /dev/null +++ b/src/test/recovery/t/124_index_undo_tid_reuse.pl @@ -0,0 +1,206 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Index UNDO applied after the abort is visible must not kill a committed +# entry that reuses the aborted heap TID. +# +# nbtree and hash index UNDO find "their" entry by (heap TID, length, key +# digest). That identifies it only while the aborted heap tuple still holds +# the TID. When the apply is deferred -- ATM instant abort handed to the +# logical revert worker, or crash recovery deferring the chain to the worker +# -- VACUUM can remove the aborted heap tuples and their index entries in +# between, and a committed INSERT can reuse the TIDs with the same keys. The +# deferred apply must then leave those committed entries alone: index scans +# must still find every row, and the primary key must still reject a +# duplicate. +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 $nrows = 200; + +my $node = PostgreSQL::Test::Cluster->new('index_undo_tid_reuse'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +logical_revert_naptime = 100 +# Put the UNDO into the WAL as it is written, for the crash case. +undo_batch_record_limit = 100 +shared_preload_libraries = 'injection_points' +)); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('amcheck') + || !$node->check_extension('test_fileops')) +{ + plan skip_all => + 'injection_points, amcheck or test_fileops not installed'; +} +$node->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION amcheck; +CREATE EXTENSION test_fileops; +-- Number of rows with key 1..n found by index scans on column col. +CREATE FUNCTION idx_found(tab regclass, col text, n int) RETURNS int +LANGUAGE plpgsql AS $$ +DECLARE + c int := 0; + f int; +BEGIN + PERFORM set_config('enable_seqscan', 'off', true); + PERFORM set_config('enable_bitmapscan', 'off', true); + FOR i IN 1..n LOOP + EXECUTE format('SELECT count(*) FROM %s WHERE %I = $1', tab, col) + INTO f USING i; + c := c + f; + END LOOP; + RETURN c; +END $$; +)); + +sub create_table +{ + my ($t) = @_; + $node->safe_psql( + 'postgres', qq( +CREATE TABLE $t (id int PRIMARY KEY, v int) USING heap + WITH (index_undo = on, autovacuum_enabled = off); +CREATE INDEX ${t}_v_hash ON $t USING hash (v); +)); +} + +sub insert_sql +{ + my ($t) = @_; + return "INSERT INTO $t SELECT g, g FROM generate_series(1, $nrows) g " + . "RETURNING ctid;"; +} + +# VACUUM away the aborted rows, then commit the same keys again, which +# reuses the freed line pointers. Returns how many TIDs were reused. +sub vacuum_and_reinsert +{ + my ($t, $aborted_ctids) = @_; + my %aborted = map { $_ => 1 } split /\n/, $aborted_ctids; + + $node->safe_psql('postgres', "VACUUM (INDEX_CLEANUP on) $t"); + is($node->safe_psql('postgres', "SELECT count(*) FROM $t"), + '0', "$t: aborted rows gone after VACUUM"); + my $new = $node->safe_psql('postgres', insert_sql($t)); + return scalar grep { $aborted{$_} } split /\n/, $new; +} + +sub wait_atm_drained +{ + $node->poll_query_until('postgres', + 'SELECT test_fileops_atm_oldest_lsn() IS NULL') + or die 'timed out waiting for the ATM to drain'; +} + +sub check_table +{ + my ($t, $label) = @_; + + is($node->safe_psql('postgres', "SELECT count(*) FROM $t"), + $nrows, "$label: seqscan sees every row"); + like( + $node->safe_psql( + 'postgres', + "SET enable_seqscan = off; SET enable_bitmapscan = off; + EXPLAIN (COSTS OFF) SELECT * FROM $t WHERE v = 1"), + qr/Index Scan using ${t}_v_hash/, + "$label: hash lookups use the hash index"); + is( $node->safe_psql('postgres', "SELECT idx_found('$t', 'id', $nrows)"), + $nrows, + "$label: btree index scans find every row"); + is($node->safe_psql('postgres', "SELECT idx_found('$t', 'v', $nrows)"), + $nrows, "$label: hash index scans find every row"); + + my ($ret, $stdout, $stderr) = + $node->psql('postgres', "INSERT INTO $t VALUES (1, 1)"); + like( + $stderr, + qr/duplicate key value violates unique constraint/, + "$label: primary key rejects a duplicate of a committed key"); + + ($ret, $stdout, $stderr) = $node->psql('postgres', + "SELECT bt_index_check('${t}_pkey', true, true)"); + is($ret, 0, "$label: bt_index_check passes") + or diag($stderr); +} + +########################################################################### +# 1. ATM instant abort: the logical revert worker applies the chain later. +########################################################################### +create_table('t_atm'); +$node->safe_psql('postgres', + "SELECT injection_points_attach('logical-revert-before-process', 'wait')" +); + +my $aborted = $node->safe_psql( + 'postgres', qq( +SET undo_instant_abort_threshold = 0; +BEGIN; +@{[insert_sql('t_atm')]} +ROLLBACK; +)); +$aborted =~ s/^(SET|BEGIN|ROLLBACK)\n?//mg; +isnt(test_fileops_atm_pending(), 0, 'ATM: rollback went to the ATM'); + +$node->wait_for_event('logical revert worker', + 'logical-revert-before-process'); + +my $reused = vacuum_and_reinsert('t_atm', $aborted); +cmp_ok($reused, '>', 0, "ATM: committed rows reuse $reused aborted TIDs"); + +$node->safe_psql( + 'postgres', q( +SELECT injection_points_detach('logical-revert-before-process'); +SELECT injection_points_wakeup('logical-revert-before-process'); +)); +wait_atm_drained(); +check_table('t_atm', 'ATM'); + +sub test_fileops_atm_pending +{ + return $node->safe_psql('postgres', + 'SELECT (test_fileops_atm_oldest_lsn() IS NOT NULL)::int'); +} + +########################################################################### +# 2. Crash: recovery defers the chain to the worker, which runs only after +# VACUUM and the committed re-insert. +########################################################################### +create_table('t_crash'); +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 0'); + +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +my $crash_ctids = $bg->query_safe(insert_sql('t_crash')); +# Make the inserts and their UNDO durable before the crash. +$node->safe_psql('postgres', 'SELECT pg_switch_wal()'); +$node->stop('immediate'); +eval { $bg->quit; }; +$node->start; + +is(test_fileops_atm_pending(), + 1, 'crash: recovery deferred the chain to the ATM'); +$reused = vacuum_and_reinsert('t_crash', $crash_ctids); +cmp_ok($reused, '>', 0, "crash: committed rows reuse $reused aborted TIDs"); + +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->restart; +wait_atm_drained(); +check_table('t_crash', 'crash'); + +$node->stop; +done_testing(); diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index a09d3f381070c..c1639a1a72ecc 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -1806,6 +1806,7 @@ MultirangeIOData MultirangeParseState MultirangeType MyUndoLogState +NbtreeUndoInsertLeaf NDBOX NDistinctParseState NDistinctSemanticState @@ -3353,6 +3354,7 @@ UndoApplyWorker UndoBatchData UndoBuffers UndoBufferStat +UndoEngine UndoErrorQueue UndoFileMru UndoLogControl From 73911b217390e235e86ddd05538e7c7d40ccc2b1 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Sat, 19 Sep 2026 18:44:28 +0000 Subject: [PATCH 09/22] hash: mark aborted index inserts dead via UNDO Extend the index UNDO added for nbtree in the previous commit to hash indexes: on a table with index_undo on (the default), the entries a transaction inserted into a hash index are marked LP_DEAD during rollback rather than left for VACUUM. _hash_doinsert() writes one INSERT record per entry into the cluster-wide UNDO-in-WAL stream, tagged UNDO_RMID_HASH: the index OID, the (block, offset) of the insert, and the same discriminator nbtree uses -- heap TID, length, and a 4-byte digest of the stored hashkey. Records are deferred and batched exactly as for nbtree. On abort the chain walk dispatches each one to hash_undo_apply(), which marks the entry LP_DEAD and writes a CLR for crash-recovery idempotency. The recorded offset is not reliable by rollback time: _hash_pgaddtup() keeps a page in hashkey order, so a later insert with a smaller hashkey shifts the aborted entry up a slot, and a bucket split or overflow allocation can move it within the bucket. The apply path searches the recorded offset, then the page, then up to eight pages along the bucket's overflow chain, and kills a slot only when heap TID, length and digest all match. Killing a relocated slot would mark a committed entry dead, making its row unreachable by any hash scan -- so any mismatch leaves the entry for VACUUM instead. Gating is shared with nbtree through RelationUsesIndexUndo(), including the "index created in this transaction" exclusion. That one matters more for hash than for nbtree: hashbuildCallback() passes a live heapRel into _hash_doinsert() during a build, where nbtree passes NULL, so without it CREATE INDEX would write one UNDO record per indexed row. test_idxundo gains hash cases for plain rollback and for the deferred-batch path. Measured under a concurrent 80%-abort insert load, 266 of 6.37M aborted hash entries (0.004%) were left for VACUUM; the cost figures in the previous commit's message were measured with both a btree and a hash index on the table. Author: Greg Burd Changes since v2: - Restore UNDO abort ordering and persist FILEOPS ownership [c0202ed6c8] - Preserve FILEOPS ownership for deferred crash losers [e39358b589] - Checkpoint at promotion when startup undid FILEOPS losers [bfe3f394ca] - Apply subtransaction undo before logging storage removal [8d784c8bc0] - Nbtree: skip index UNDO outside the aborting backend [b17de80480] - Hash: skip index UNDO outside the aborting backend [50d7399bfe] - Test_idxundo: a hash rollback must set the page's dead-tuples hint [dd25eb77e3] - Hash: set the dead-tuples hint when UNDO kills an aborted entry [cbcd97c139] - Test_idxundo: make the 2PC case deterministic [422215b692] - ATM: never fail to record an aborted transaction while aborting [133259d44b] - PANIC when an inverse cannot be made durable during abort [9f1f9e60c6] - Hash: describe index UNDO's exclusions without naming later features [67dc0c6dcc] - Apply project formatting (1 file) [9e994d0e34] - Apply pgperltidy to its tests (12 files) [88d387b23a] Tests: - Drop the blank line left at the end of 010 [b287419f0a] --- src/backend/access/hash/Makefile | 1 + src/backend/access/hash/hash_undo.c | 483 ++++++++++++++++++ src/backend/access/hash/hashinsert.c | 17 + src/backend/access/hash/meson.build | 1 + src/backend/access/transam/twophase.c | 21 +- src/backend/access/transam/xact.c | 69 ++- src/backend/access/transam/xlog.c | 14 +- src/backend/access/transam/xlogrecovery.c | 6 +- src/backend/access/undo/atm.c | 61 ++- .../access/undo/logical_revert_worker.c | 8 + src/backend/access/undo/slog.c | 65 ++- src/backend/access/undo/undo_xlog.c | 36 +- src/backend/access/undo/undoapply.c | 13 +- src/backend/access/undo/xactundo.c | 13 +- src/backend/storage/file/README | 2 +- src/backend/storage/file/fileops.c | 126 ++--- src/backend/storage/file/fileops_undo.c | 106 ++-- src/include/access/atm.h | 6 +- src/include/access/atm_xlog.h | 3 +- src/include/access/hash.h | 6 + src/include/access/slog.h | 4 +- src/include/access/undo_xlog.h | 2 +- src/include/access/undorecord.h | 2 + src/include/access/undormgrlist.h | 1 + src/include/access/xlogrecovery.h | 3 + src/include/storage/fileops.h | 24 +- src/test/modules/test_fileops/meson.build | 7 +- .../t/001_fileops_apply_failure.pl | 12 +- .../t/008_fileops_standby_abort.pl | 20 + ...bort_redo.pl => 010_fileops_abort_redo.pl} | 69 ++- .../t/011_fileops_undo_ownership.pl | 110 ++++ .../test_fileops/t/012_fileops_contracts.pl | 20 + .../test_fileops/t/013_fileops_loser_reuse.pl | 74 +++ .../t/014_fileops_loser_ownership.pl | 68 +++ .../t/015_fileops_promote_loser.pl | 78 +++ src/test/modules/test_idxundo/Makefile | 7 +- .../test_idxundo/expected/test_idxundo.out | 121 ++++- src/test/modules/test_idxundo/meson.build | 4 +- .../modules/test_idxundo/sql/test_idxundo.sql | 69 ++- src/test/modules/test_idxundo/test_idxundo.c | 23 +- .../modules/test_idxundo/test_idxundo.conf | 5 + src/tools/pgindent/typedefs.list | 1 + 42 files changed, 1538 insertions(+), 243 deletions(-) create mode 100644 src/backend/access/hash/hash_undo.c rename src/test/modules/test_fileops/t/{007_fileops_abort_redo.pl => 010_fileops_abort_redo.pl} (61%) create mode 100644 src/test/modules/test_fileops/t/011_fileops_undo_ownership.pl create mode 100644 src/test/modules/test_fileops/t/012_fileops_contracts.pl create mode 100644 src/test/modules/test_fileops/t/013_fileops_loser_reuse.pl create mode 100644 src/test/modules/test_fileops/t/014_fileops_loser_ownership.pl create mode 100644 src/test/modules/test_fileops/t/015_fileops_promote_loser.pl create mode 100644 src/test/modules/test_idxundo/test_idxundo.conf diff --git a/src/backend/access/hash/Makefile b/src/backend/access/hash/Makefile index 75bf36598246b..590c06c0e9976 100644 --- a/src/backend/access/hash/Makefile +++ b/src/backend/access/hash/Makefile @@ -14,6 +14,7 @@ include $(top_builddir)/src/Makefile.global OBJS = \ hash.o \ + hash_undo.o \ hash_xlog.o \ hashfunc.o \ hashinsert.o \ diff --git a/src/backend/access/hash/hash_undo.c b/src/backend/access/hash/hash_undo.c new file mode 100644 index 0000000000000..39e0cf6f05d3a --- /dev/null +++ b/src/backend/access/hash/hash_undo.c @@ -0,0 +1,483 @@ +/*------------------------------------------------------------------------- + * + * hash_undo.c + * Hash index UNDO resource manager + * + * When a transaction aborts, the entries it inserted into a hash index are + * marked LP_DEAD during rollback, so they are not left for VACUUM. The heap + * tuples themselves are dead by xmin as usual; only the index work changes. + * This is done only by the aborting backend itself; a rollback applied later + * (logical revert worker, crash recovery) leaves the entries for VACUUM, see + * hash_undo_apply(). + * + * One record type is written: INSERT, for each bucket or overflow page entry. + * + * All hooks are gated by RelationUsesIndexUndo(): hash UNDO is enabled per + * relation by the parent table's index_undo reloption, exactly as for nbtree. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/hash/hash_undo.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/hash.h" +#include "access/relation.h" +#include "access/undo_xlog.h" +#include "access/undorecord.h" +#include "access/undormgr.h" +#include "access/xactundo.h" +#include "access/tableam.h" +#include "access/xact.h" +#include "access/xloginsert.h" +#include "access/xlogutils.h" +#include "common/hashfn.h" +#include "miscadmin.h" +#include "storage/bufmgr.h" +#include "storage/bufpage.h" +#include "storage/itemid.h" +#include "utils/rel.h" +#include "utils/relcache.h" + +/* + * Hash UNDO subtypes (stored in urec_info) + */ +#define HASH_UNDO_INSERT 0x0001 /* bucket/overflow page tuple insertion */ + +/* + * HashUndoInsert - Payload for hash insert undo + * + * Like the nbtree leaf record, this stores a DISCRIMINATOR rather than the + * inserted tuple: the apply path only ever uses it to prove a slot still holds + * this entry before marking it LP_DEAD, never to restore data. + * + * heap_tid -- the heap pointer (a hash entry's t_tid). The strong half. + * key_digest -- a hash over the stored hashkey bytes, which distinguishes two + * entries pointing at the same heap TID. + * itup_sz -- rejects a differently-shaped tuple outright. + * + * All three must match before a slot is marked; any mismatch means "leave it + * for VACUUM", which is safe. See hash_undo_verify_entry(). That proof holds + * only while the aborted heap tuple still holds the heap TID, which is why the + * record is applied only inline, by the aborting backend. + */ +typedef struct HashUndoInsert +{ + Oid index_oid; /* OID of the hash index relation */ + BlockNumber blkno; /* Page where tuple was inserted */ + OffsetNumber offset; /* Offset of the inserted tuple */ + uint16 itup_sz; /* Size of the index tuple */ + ItemPointerData heap_tid; /* Heap TID the entry points at */ + uint32 key_digest; /* Hash of the stored key bytes */ +} HashUndoInsert; + +#define SizeOfHashUndoInsert \ + (offsetof(HashUndoInsert, key_digest) + sizeof(uint32)) + +/* + * hash_undo_key_digest - hash the key bytes of a hash index entry + * + * Covers everything after the IndexTuple header -- for a hash index that is the + * stored hashkey -- and deliberately not t_tid, which is compared separately and + * exactly. + */ +static inline uint32 +hash_undo_key_digest(IndexTuple itup, Size itup_sz) +{ + if (itup_sz <= sizeof(IndexTupleData)) + return 0; + + return hash_bytes((const unsigned char *) itup + sizeof(IndexTupleData), + (int) (itup_sz - sizeof(IndexTupleData))); +} + +/* Forward declarations */ +static UndoApplyResult hash_undo_apply(uint8 rmid, uint16 info, + TransactionId xid, Oid reloid, + const char *payload, Size payload_len, + UndoRecPtr urec_ptr); +static void hash_undo_desc(StringInfo buf, uint8 rmid, uint16 info, + const char *payload, Size payload_len); + +/* The hash UNDO RM registration entry */ +static const UndoRmgrData hash_undo_rmgr = { + .rm_name = "hash", + .rm_undo = hash_undo_apply, + .rm_desc = hash_undo_desc, +}; + +/* + * HashUndoRmgrInit - Register the hash UNDO resource manager + */ +void +HashUndoRmgrInit(void) +{ + RegisterUndoRmgr(UNDO_RMID_HASH, &hash_undo_rmgr); +} + +/* + * HashUndoLogInsert - Write UNDO record for a hash index tuple insertion + * + * Called from _hash_doinsert() after the insertion has been WAL-logged. + * This records enough information to mark the inserted entry LP_DEAD on abort. + * + * Records go to the cluster-wide UNDO-in-WAL stream through the transaction's + * UndoRecordSet, the same way FILEOPS writes its records. The XactUndo* API is + * what publishes the chain head (XactUndo.has_undo and + * XactUndo.last_batch_lsn[]) that AtAbort_XactUndo() and + * ApplyUndoChainFromWAL() need. See nbtree_undo_write() in + * access/nbtree/nbtree_undo.c for the full note. + * + * The record carries a discriminator (see HashUndoInsert) so the apply path can + * prove the slot still holds this entry -- a bucket split or a later insert in + * hashkey order can move it and leave a COMMITTED entry at the recorded offset. + * See hash_undo_verify_entry(). + * + * The caller must not be in a critical section; PrepareXactUndoDataParts() can + * palloc. + * + * The record is DEFERRED (DeferXactUndoData) rather than written immediately, so + * all of a statement's index entries share one XLOG_UNDO_BATCH instead of taking + * one WAL record each. See the fuller note in nbtree_undo_write(). + */ +void +HashUndoLogInsert(Relation rel, Relation heapRel, Buffer buf, + OffsetNumber offset, IndexTuple itup, Size itemsz) +{ + HashUndoInsert hdr; + XactUndoContext undo_ctx; + + hdr.index_oid = RelationGetRelid(rel); + hdr.blkno = BufferGetBlockNumber(buf); + hdr.offset = offset; + hdr.itup_sz = (uint16) itemsz; + hdr.heap_tid = itup->t_tid; + hdr.key_digest = hash_undo_key_digest(itup, itemsz); + + PrepareXactUndoDataParts(&undo_ctx, RELPERSISTENCE_PERMANENT, + UNDO_RMID_HASH, HASH_UNDO_INSERT, + RelationGetRelid(heapRel), + (const char *) &hdr, SizeOfHashUndoInsert, + NULL, 0); + + DeferXactUndoData(&undo_ctx); +} + +/* + * hash_undo_verify_entry - is the entry at (page, offset) still the exact entry + * this UNDO record was written for? + * + * Killing by (block, offset) alone is unsafe for a hash index: a bucket split + * moves entries between the primary bucket page and its overflow pages, and + * _hash_pgaddtup places new entries in hashkey order, so both the page and the + * offset an aborted entry occupied can later belong to a different -- possibly + * COMMITTED -- entry. Marking that slot LP_DEAD would silently lose committed + * data: the row stays in the heap but no hash scan can find it. + * + * The record carries a discriminator -- the heap TID (t_tid, which for a hash + * index entry is the heap pointer), a digest of the stored hashkey bytes, and the + * entry length -- and all three must agree. It does not carry the tuple: the + * comparison is the only use, so a fixed-size digest serves it as well as the + * bytes would, and no two legitimate entries in one index share all three. + * + * Returns true only when the slot provably still holds the aborted entry. + */ +static bool +hash_undo_verify_entry(Page page, OffsetNumber offset, + const HashUndoInsert *rec) +{ + ItemId lp; + IndexTuple pagetup; + + if (offset < FirstOffsetNumber || offset > PageGetMaxOffsetNumber(page)) + return false; + + lp = PageGetItemId(page, offset); + if (!ItemIdIsNormal(lp)) + return false; + + /* A record with no recorded length cannot be verified; refuse to guess. */ + if (rec->itup_sz < sizeof(IndexTupleData)) + return false; + + if (ItemIdGetLength(lp) != rec->itup_sz) + return false; + + pagetup = (IndexTuple) PageGetItem(page, lp); + + if (IndexTupleSize(pagetup) != rec->itup_sz) + return false; + + /* Heap TID must match. */ + if (!ItemPointerEquals(&pagetup->t_tid, &rec->heap_tid)) + return false; + + /* Key digest (covering the stored hashkey) must match. */ + if (hash_undo_key_digest(pagetup, rec->itup_sz) != rec->key_digest) + return false; + + return true; +} + +/* + * How far along a bucket's overflow chain the apply path will look for an entry + * that moved off its original page. Bounded so a corrupt or circular + * hasho_nextblkno chain cannot spin during rollback; a far-moved entry is left + * to VACUUM instead. + */ +#define HASH_UNDO_MAX_CHAIN_HOPS 8 + +/* + * hash_undo_find_entry - locate the aborted entry on this page + * + * As in nbtree, the recorded offset is only a hint. _hash_pgaddtup() inserts + * in hashkey order, so any later insert with a smaller hashkey on the same page + * shifts the aborted entry up a slot, leaving a different -- possibly COMMITTED + * -- entry at the recorded offset. Verification then fails and the aborted + * entry survives the rollback, which is safe but useless: measured on a + * 500-row hash index with 200 aborted inserts, offset-only matching reversed 28 + * of 200 entries. + * + * Check the hint first, then scan the page. Verification requires agreement on + * the length, the heap TID and the digest of the stored hashkey, and no two + * legitimate entries share all three, so the scan cannot mis-identify an entry. + * + * Returns InvalidOffsetNumber if the entry is not on this page. + */ +static OffsetNumber +hash_undo_find_entry(Page page, OffsetNumber hint, + const HashUndoInsert *rec) +{ + OffsetNumber off, + maxoff; + + if (hash_undo_verify_entry(page, hint, rec)) + return hint; + + maxoff = PageGetMaxOffsetNumber(page); + for (off = FirstOffsetNumber; off <= maxoff; off++) + { + if (off != hint && + hash_undo_verify_entry(page, off, rec)) + return off; + } + + return InvalidOffsetNumber; +} + +/* + * hash_undo_apply - Apply a single hash UNDO record + * + * This is the rm_undo callback for the hash RM. On abort, marks the + * inserted index entry as LP_DEAD. + */ +static UndoApplyResult +hash_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, + const char *payload, Size payload_len, UndoRecPtr urec_ptr) +{ + Assert(rmid == UNDO_RMID_HASH); + + /* + * Apply only inline, while the aborting (sub)transaction is still + * running: after the abort is visible the heap TID can hold a committed + * row with the same key. See nbtree_undo_apply(). + */ + if (!UndoApplyInline) + return UNDO_APPLY_SKIPPED; + + switch (info) + { + case HASH_UNDO_INSERT: + { + HashUndoInsert hdr; + Relation indexrel; + Buffer buffer; + Page page; + + if (payload_len < SizeOfHashUndoInsert) + return UNDO_APPLY_ERROR; + + memcpy(&hdr, payload, SizeOfHashUndoInsert); + + /* + * Open the index directly using the OID stored in the UNDO + * payload. + */ + indexrel = try_relation_open(hdr.index_oid, RowExclusiveLock); + if (indexrel == NULL) + { + ereport(DEBUG2, + (errmsg("hash UNDO INSERT: index %u no longer exists", + hdr.index_oid))); + return UNDO_APPLY_SKIPPED; + } + + if (RelationGetNumberOfBlocks(indexrel) <= hdr.blkno) + { + ereport(DEBUG2, + (errmsg("hash UNDO INSERT: block %u beyond end of index %u", + hdr.blkno, hdr.index_oid))); + relation_close(indexrel, RowExclusiveLock); + return UNDO_APPLY_SKIPPED; + } + + /* + * Find the entry, starting at the recorded page and following + * the bucket's overflow chain while it is not found. A + * bucket split or an overflow allocation can move the aborted + * entry to another page in the same bucket; without walking + * the chain those entries are never reversed. + * + * Only a slot that provably still holds THIS entry is killed: + * a relocated slot may hold a COMMITTED entry, and marking + * that dead would make its heap row unreachable by any hash + * scan. An entry that is genuinely gone needs no action, so + * "not found" is a successful no-op. + */ + { + BlockNumber blkno = hdr.blkno; + OffsetNumber found = InvalidOffsetNumber; + int hops; + + buffer = InvalidBuffer; + + for (hops = 0; hops < HASH_UNDO_MAX_CHAIN_HOPS; hops++) + { + HashPageOpaque opaque; + BlockNumber next; + + buffer = ReadBuffer(indexrel, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + if (PageIsNew(page) || PageGetSpecialSize(page) == 0) + break; + + opaque = HashPageGetOpaque(page); + if ((opaque->hasho_flag & LH_PAGE_TYPE) != LH_BUCKET_PAGE && + (opaque->hasho_flag & LH_PAGE_TYPE) != LH_OVERFLOW_PAGE) + break; + + found = hash_undo_find_entry(page, hdr.offset, + &hdr); + if (found != InvalidOffsetNumber) + break; + + next = opaque->hasho_nextblkno; + if (!BlockNumberIsValid(next) || next == blkno || + next >= RelationGetNumberOfBlocks(indexrel)) + break; + + UnlockReleaseBuffer(buffer); + buffer = InvalidBuffer; + blkno = next; + } + + if (!BufferIsValid(buffer)) + { + relation_close(indexrel, RowExclusiveLock); + return UNDO_APPLY_SUCCESS; + } + + page = BufferGetPage(buffer); + + if (found == InvalidOffsetNumber) + { + UnlockReleaseBuffer(buffer); + relation_close(indexrel, RowExclusiveLock); + return UNDO_APPLY_SUCCESS; + } + + hdr.blkno = blkno; + hdr.offset = found; + } + + { + ItemId lp = PageGetItemId(page, hdr.offset); + + START_CRIT_SECTION(); + + ItemIdMarkDead(lp); + + /* + * Set the page's dead-tuples hint, as _hash_kill_items + * does, so that an insert that finds the page full + * reclaims the entry (_hash_vacuum_one_page) rather than + * leaving it for VACUUM. Like that hint, it is not + * replayed; hash_mask masks it. + */ + HashPageGetOpaque(page)->hasho_flag |= LH_PAGE_HAS_DEAD_TUPLES; + + MarkBufferDirty(buffer); + + /* Generate physiological CLR for crash recovery */ + if (RelationNeedsWAL(indexrel)) + { + XLogRecPtr clr_lsn; + xl_undo_apply xlrec; + + xlrec.urec_ptr = urec_ptr; + xlrec.xid = xid; + xlrec.target_locator = indexrel->rd_locator; + xlrec.target_block = hdr.blkno; + xlrec.target_offset = hdr.offset; + xlrec.operation_type = info; + xlrec.clr_flags = UNDO_CLR_LP_DEAD; + xlrec.tuple_len = 0; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, + SizeOfUndoApply); + XLogRegisterBuffer(0, buffer, + REGBUF_STANDARD); + clr_lsn = XLogInsert(RM_UNDO_ID, + XLOG_UNDO_APPLY_RECORD); + PageSetLSN(page, clr_lsn); + } + + END_CRIT_SECTION(); + } + + UnlockReleaseBuffer(buffer); + relation_close(indexrel, RowExclusiveLock); + return UNDO_APPLY_SUCCESS; + } + + default: + return UNDO_APPLY_SKIPPED; + } +} + +/* + * hash_undo_desc - Describe a hash UNDO record for debugging + */ +static void +hash_undo_desc(StringInfo buf, uint8 rmid, uint16 info, + const char *payload, Size payload_len) +{ + const char *opname; + + switch (info) + { + case HASH_UNDO_INSERT: + opname = "INSERT"; + break; + default: + opname = "UNKNOWN"; + break; + } + + appendStringInfo(buf, "hash %s", opname); + + if (payload_len >= sizeof(Oid) && info == HASH_UNDO_INSERT) + { + Oid index_oid; + + memcpy(&index_oid, payload, sizeof(Oid)); + appendStringInfo(buf, " index %u", index_oid); + } +} diff --git a/src/backend/access/hash/hashinsert.c b/src/backend/access/hash/hashinsert.c index 3395bbc13f825..8361316859da5 100644 --- a/src/backend/access/hash/hashinsert.c +++ b/src/backend/access/hash/hashinsert.c @@ -17,6 +17,7 @@ #include "access/hash.h" #include "access/hash_xlog.h" +#include "access/tableam.h" #include "access/xloginsert.h" #include "miscadmin.h" #include "storage/predicate.h" @@ -238,6 +239,22 @@ _hash_doinsert(Relation rel, IndexTuple itup, Relation heapRel, bool sorted) END_CRIT_SECTION(); + /* + * Write UNDO record for the insertion if the parent table AM supports + * UNDO. This must happen after WAL logging but while we still hold the + * buffer pin (needed for BufferGetBlockNumber). + */ + + /* + * Write index UNDO only when the parent table asks for it (the index_undo + * reloption). RelationUsesIndexUndo() applies every other condition too, + * among them non-WAL relations and indexes built in this transaction + * (which is how the hashbuildCallback() path is excluded, since unlike + * nbtree it passes a live heapRel during the build). + */ + if (RelationUsesIndexUndo(rel, heapRel)) + HashUndoLogInsert(rel, heapRel, buf, itup_off, itup, itemsz); + /* drop lock on metapage, but keep pin */ LockBuffer(metabuf, BUFFER_LOCK_UNLOCK); diff --git a/src/backend/access/hash/meson.build b/src/backend/access/hash/meson.build index ad011b8f99ab6..ca2012be87bf7 100644 --- a/src/backend/access/hash/meson.build +++ b/src/backend/access/hash/meson.build @@ -2,6 +2,7 @@ backend_sources += files( 'hash.c', + 'hash_undo.c', 'hash_xlog.c', 'hashfunc.c', 'hashinsert.c', diff --git a/src/backend/access/transam/twophase.c b/src/backend/access/transam/twophase.c index 2ece61233bec6..ad8f8da40209e 100644 --- a/src/backend/access/transam/twophase.c +++ b/src/backend/access/transam/twophase.c @@ -1720,9 +1720,9 @@ FinishPreparedTransaction(const char *gid, bool isCommit) } else { - char *fileops = NULL; - uint32 fileops_len = 0; - char *scan = bufptr; + char *fileops = NULL; + uint32 fileops_len = 0; + char *scan = bufptr; /* * ROLLBACK PREPARED: hand any cluster-wide UNDO chain to the same @@ -1753,7 +1753,7 @@ FinishPreparedTransaction(const char *gid, bool isCommit) if (XLogRecPtrIsValid(perm_lsn) && !ATMAddAborted(xid, hdr->database, perm_lsn, - hdr->undo_first_batch_lsn)) + hdr->undo_first_batch_lsn, true)) elog(WARNING, "ATM full: could not record rolled-back prepared " "transaction %u for UNDO", xid); @@ -2704,9 +2704,16 @@ RecordTransactionAbortPrepared(TransactionId xid, END_CRIT_SECTION(); if (fileops_len) { - INJECTION_POINT("fileops-abort-after-flush", NULL); - FileOpsApplyAbort(fileops, fileops_len); - FileOpsEndAbort(); + PG_TRY(); + { + INJECTION_POINT("fileops-abort-after-flush", NULL); + FileOpsApplyAbort(fileops, fileops_len); + } + PG_FINALLY(); + { + FileOpsEndAbort(); + } + PG_END_TRY(); } /* diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c index f0c3183e05ad0..e49a14fe76a84 100644 --- a/src/backend/access/transam/xact.c +++ b/src/backend/access/transam/xact.c @@ -224,6 +224,7 @@ typedef struct TransactionStateData 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 */ + /* * Per-backend UNDO: first and most recent record of this (sub)transaction * in each persistence level's undo log (0 = none). A transaction writing @@ -1265,8 +1266,8 @@ AtSubCommit_PbuUndo(void) * AtSubAbort_PbuUndo * Hand an aborted subtransaction's per-backend UNDO to its parent. * - * The access methods have applied the subtransaction's records and marked - * them applied (PbuApplySubxactUndo), but the records stay in the chain, and + * We have applied the subtransaction's records and marked + * them applied (PbuApplySubxactUndoAll), but the records stay in the chain, and * the parent takes them over exactly as for a commit. If they are the first * records of the transaction in an undo log, the first one carries the * transaction header, which a rollback of the whole chain needs as its 'to' @@ -1282,11 +1283,11 @@ AtSubAbort_PbuUndo(void) /* * AtSubAbort_PbuRetryUndo - * Apply the per-backend UNDO the SUBXACT_EVENT_ABORT_SUB callbacks could + * Apply the per-backend UNDO the pre-abort walk could * not, now that the subtransaction's catalog changes are rolled back. * * A subtransaction that updated a table and then truncated or dropped it - * still sees the table empty or gone when the callbacks run. Once its + * still sees the table empty or gone when the pre-abort walk runs. Once its * relcache and invalidations are rolled back, the parent sees the table as * it was, so run the remaining records as the parent, before the * subtransaction's locks are released. @@ -2056,8 +2057,8 @@ RecordTransactionAbort(bool isSubXact) TransactionId *children; TimestampTz xact_time; bool replorigin; - char *fileops; - uint32 fileops_len; + char *fileops; + uint32 fileops_len; /* * If we haven't been assigned an XID, nobody will care whether we aborted @@ -2076,9 +2077,9 @@ RecordTransactionAbort(bool isSubXact) /* * We have a valid XID, so we should write an ABORT record for it. * - * We do not flush XLOG to disk here, since the default assumption after a - * crash would be that we aborted, anyway. For the same reason, we don't - * need to worry about interlocking against checkpoint start. + * An ordinary abort need not flush WAL: a crash treats it as a loser. + * FILEOPS inverses, however, must follow a durable abort decision, with + * checkpoint start delayed until their filesystem effects are synced. */ /* @@ -2155,9 +2156,16 @@ RecordTransactionAbort(bool isSubXact) if (fileops_len) { - INJECTION_POINT("fileops-abort-after-flush", NULL); - FileOpsApplyAbort(fileops, fileops_len); - FileOpsEndAbort(); + PG_TRY(); + { + INJECTION_POINT("fileops-abort-after-flush", NULL); + FileOpsApplyAbort(fileops, fileops_len); + } + PG_FINALLY(); + { + FileOpsEndAbort(); + } + PG_END_TRY(); } if (fileops) pfree(fileops); @@ -3312,16 +3320,25 @@ AbortTransaction(void) s->parallelModeLevel = 0; s->parallelChildXact = false; /* should be false already */ - /* Flush pending UNDO before publishing the abort decision. */ - XactUndoFlushPending(); + /* Complete generic UNDO or register its ATM obligation before abort WAL. */ + FileOpsSkipUndo = true; + PG_TRY(); + { + AtAbort_XactUndo(); + } + PG_FINALLY(); + { + FileOpsSkipUndo = false; + } + PG_END_TRY(); /* * Roll back the per-backend UNDO engine's records for this transaction, * if any. This engine coexists with the per-relation engine handled by * AtAbort_XactUndo() above; the call is a no-op unless the per-backend - * engine produced UNDO for this xact (FLUX and RECNO tables do). - * UNDO appliers open relations, so present TRANS_INPROGRESS for the - * duration (all backing resources are still live at this point). + * engine produced UNDO for this xact (FLUX tables do). UNDO + * appliers open relations, so present TRANS_INPROGRESS for the duration + * (all backing resources are still live at this point). */ { int pbu_saved_state = EnterInlineUndoApplyState(); @@ -3368,8 +3385,6 @@ AbortTransaction(void) XLogSetAsyncXactLSN(XactLastRecEnd); } - AtAbort_XactUndo(); - TRACE_POSTGRESQL_TRANSACTION_ABORT(MyProc->vxid.lxid); /* @@ -5777,6 +5792,19 @@ AbortSubTransaction(void) s->parent->subTransactionId); AtSubAbort_Notify(); + /* Undo must precede the abort record that drops subtransaction files. */ + FileOpsSkipUndo = true; + PG_TRY(); + { + AtSubAbort_XactUndo(s->subTransactionId); + } + PG_FINALLY(); + { + FileOpsSkipUndo = false; + } + PG_END_TRY(); + PbuApplySubxactUndoAll(); + /* Advertise the fact that we aborted in pg_xact. */ (void) RecordTransactionAbort(true); @@ -5787,7 +5815,7 @@ AbortSubTransaction(void) CallSubXactCallbacks(SUBXACT_EVENT_ABORT_SUB, s->subTransactionId, s->parent->subTransactionId); - /* The callbacks above applied and marked its per-backend UNDO. */ + /* The apply above marked its per-backend UNDO. */ AtSubAbort_PbuUndo(); ResourceOwnerRelease(s->curTransactionOwner, @@ -6808,6 +6836,7 @@ xact_redo_abort(xl_xact_parsed_abort *parsed, TransactionId xid, if (parsed->fileops_len) { XLogFlush(lsn); + INJECTION_POINT("fileops-abort-after-flush", NULL); FileOpsApplyAbort(parsed->fileops, parsed->fileops_len); } diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c index 072271b975c2a..cdfbfe0c66964 100644 --- a/src/backend/access/transam/xlog.c +++ b/src/backend/access/transam/xlog.c @@ -789,7 +789,7 @@ static void UpdateLastRemovedPtr(char *filename); static void ValidateXLOGDirectoryStructure(void); static void CleanupBackupHistory(void); static void UpdateMinRecoveryPoint(XLogRecPtr lsn, bool force); -static bool PerformRecoveryXLogAction(void); +static bool PerformRecoveryXLogAction(bool fileopsUndoApplied); static void CheckReplayedDataChecksumState(uint32 replayed_state); static void AdoptReplayedDataChecksumState(uint32 new_version, XLogRecPtr lsn); static void InitControlFile(uint64 sysidentifier, uint32 data_checksum_version); @@ -6880,7 +6880,7 @@ StartupXLOG(void) * Emit checkpoint or end-of-recovery record in XLOG, if required. */ if (performedWalRecovery) - promoted = PerformRecoveryXLogAction(); + promoted = PerformRecoveryXLogAction(endOfRecoveryInfo->fileopsUndoApplied); /* * Finalize ATM state after recovery. WAL replay has reconstructed the @@ -7115,7 +7115,7 @@ ReachedEndOfBackup(XLogRecPtr EndRecPtr, TimeLineID tli) * allows some extra error checking in xlog_redo. */ static bool -PerformRecoveryXLogAction(void) +PerformRecoveryXLogAction(bool fileopsUndoApplied) { bool promoted = false; bool flushForChecksums; @@ -7147,10 +7147,14 @@ PerformRecoveryXLogAction(void) * * In promotion, only create a lightweight end-of-recovery record instead * of a full checkpoint. A checkpoint is requested later, after we're - * fully out of recovery mode and already accepting queries. + * fully out of recovery mode and already accepting queries. However, if + * startup applied FILEOPS loser inverses, checkpoint before admitting + * writers: otherwise another crash can replay the old loser and apply its + * inverses again, destroying a committed reuse of the same path. The + * checkpoint also persists FILEOPS ownership for deferred ATM entries. */ if (ArchiveRecoveryRequested && IsUnderPostmaster && - PromoteIsTriggered() && !flushForChecksums) + PromoteIsTriggered() && !flushForChecksums && !fileopsUndoApplied) { promoted = true; diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index 7938c796f3764..eceb49a30afca 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -216,6 +216,9 @@ typedef struct XLogPageReadPrivate /* flag to tell XLogPageRead that we have started replaying */ static bool InRedo = false; +/* Successful FILEOPS loser inverses require an end-of-recovery checkpoint. */ +static bool fileopsUndoApplied = false; + /* * Codes indicating where we got a WAL file from during recovery, or where * to attempt to get one. @@ -1579,6 +1582,7 @@ FinishWalRecovery(void) result->standby_signal_file_found = standby_signal_file_found; result->recovery_signal_file_found = recovery_signal_file_found; + result->fileopsUndoApplied = fileopsUndoApplied; return result; } @@ -1896,7 +1900,7 @@ PerformWalRecovery(void) { ereport(LOG, (errmsg("starting undo phase for incomplete transactions"))); - PerformUndoRecovery(); + fileopsUndoApplied = PerformUndoRecovery(); ereport(LOG, (errmsg("undo phase complete"))); } diff --git a/src/backend/access/undo/atm.c b/src/backend/access/undo/atm.c index 4c3d92a503a26..f084863da32b4 100644 --- a/src/backend/access/undo/atm.c +++ b/src/backend/access/undo/atm.c @@ -67,7 +67,7 @@ */ #define ATM_STATE_FILE "pg_undo_atm" #define ATM_STATE_TMP_FILE "pg_undo_atm.tmp" -#define ATM_STATE_MAGIC 0x41544D33 /* "ATM3" */ +#define ATM_STATE_MAGIC 0x41544D34 /* "ATM4" */ typedef struct AtmStateHeader { @@ -84,13 +84,14 @@ typedef struct AtmStateRecord XLogRecPtr stop_lsn; XLogRecPtr first_batch_lsn; bool revert_complete; + bool fileops_owned; } AtmStateRecord; /* Internal helpers that skip WAL emission (used during redo) */ static bool ATMAddAbortedInternal(TransactionId xid, Oid dboid, Oid reloid, XLogRecPtr last_batch_lsn, XLogRecPtr stop_lsn, - XLogRecPtr first_batch_lsn); + XLogRecPtr first_batch_lsn, bool fileops_owned); static void ATMForgetInternal(TransactionId xid); /* @@ -132,18 +133,17 @@ ATMGetLastBatchLSN(TransactionId xid, XLogRecPtr *lsn_out) * ATMAddAbortedInternal * Add an entry to the ATM without emitting WAL. * - * Used during both normal operation (after WAL has been written by the - * caller) and during redo replay. - * - * Returns false if the sLog is full. + * For redo, crash-recovery UNDO, and restoring the checkpointed state. An + * ATM too full for the entry raises ERROR, failing startup rather than + * dropping a rollback; it never returns false. */ static bool ATMAddAbortedInternal(TransactionId xid, Oid dboid, Oid reloid, XLogRecPtr last_batch_lsn, XLogRecPtr stop_lsn, - XLogRecPtr first_batch_lsn) + XLogRecPtr first_batch_lsn, bool fileops_owned) { return SLogTxnInsert(xid, reloid, dboid, last_batch_lsn, stop_lsn, - first_batch_lsn); + first_batch_lsn, fileops_owned, false); } /* @@ -156,15 +156,21 @@ ATMAddAbortedInternal(TransactionId xid, Oid dboid, Oid reloid, * own retention (the per-backend slot, or the 2PC state) may be released once * this returns true. * + * fileops_owned is true for online aborts: their abort WAL record carries + * the FILEOPS inverses, which a later generic walk must not apply again. + * Startup also passes true after completing a loser's FILEOPS inverses. + * Preserve this ownership in both WAL and checkpoint state, including subxacts + * whose records carry the top-level xid rather than the aborting subxid. + * * Called from the abort path. Returns false if the sLog is full, * signaling the caller to fall back to synchronous rollback. */ bool ATMAddAborted(TransactionId xid, Oid dboid, XLogRecPtr last_batch_lsn, - XLogRecPtr first_batch_lsn) + XLogRecPtr first_batch_lsn, bool fileops_owned) { return ATMAddAbortedBounded(xid, dboid, last_batch_lsn, InvalidXLogRecPtr, - first_batch_lsn); + first_batch_lsn, fileops_owned); } /* @@ -178,15 +184,28 @@ ATMAddAborted(TransactionId xid, Oid dboid, XLogRecPtr last_batch_lsn, */ bool ATMAddAbortedBounded(TransactionId xid, Oid dboid, XLogRecPtr last_batch_lsn, - XLogRecPtr stop_lsn, XLogRecPtr first_batch_lsn) + XLogRecPtr stop_lsn, XLogRecPtr first_batch_lsn, bool fileops_owned) { xl_atm_abort xlrec; - /* Write WAL first */ + /* + * Update shared memory first. This runs while aborting, so it must not + * fail: if the ATM is full, report that without writing WAL, and the + * caller keeps its own WAL retention. Writing the record first would + * leave redo with an entry the primary lacks. The entry is visible + * before its record, but the revert worker skips it until the transaction + * is no longer in progress, which comes after the abort record that + * follows this one. + */ + if (!SLogTxnInsert(xid, InvalidOid, dboid, last_batch_lsn, stop_lsn, + first_batch_lsn, fileops_owned, true)) + return false; + xlrec.xid = xid; xlrec.last_batch_lsn = last_batch_lsn; xlrec.stop_lsn = stop_lsn; xlrec.first_batch_lsn = first_batch_lsn; + xlrec.fileops_owned = fileops_owned; xlrec.dboid = dboid; xlrec.reloid = InvalidOid; @@ -194,9 +213,7 @@ ATMAddAbortedBounded(TransactionId xid, Oid dboid, XLogRecPtr last_batch_lsn, XLogRegisterData((char *) &xlrec, SizeOfXlAtmAbort); XLogInsert(RM_ATM_ID, XLOG_ATM_ABORT); - /* Now update shared memory */ - return ATMAddAbortedInternal(xid, dboid, InvalidOid, last_batch_lsn, - stop_lsn, first_batch_lsn); + return true; } /* @@ -208,14 +225,15 @@ ATMAddAbortedBounded(TransactionId xid, Oid dboid, XLogRecPtr last_batch_lsn, * end-of-recovery checkpoint's KeepLogSeg() sees it through * ATMGetOldestUnrevertedLSN(), and CheckPointATM() persists it. The caller * must WAL-log it with ATMAddAborted() once WAL is writable, so standbys get - * it too; that call finds the entry already present and leaves it alone. + * it too. A recovery insert replaces any ownership marker from an online + * abort that crashed before writing its abort record. */ void ATMAddAbortedNoWAL(TransactionId xid, Oid dboid, XLogRecPtr last_batch_lsn, - XLogRecPtr first_batch_lsn) + XLogRecPtr first_batch_lsn, bool fileops_owned) { (void) ATMAddAbortedInternal(xid, dboid, InvalidOid, last_batch_lsn, - InvalidXLogRecPtr, first_batch_lsn); + InvalidXLogRecPtr, first_batch_lsn, fileops_owned); } /* @@ -357,7 +375,7 @@ atm_redo(XLogReaderState *record) ATMAddAbortedInternal(xlrec->xid, xlrec->dboid, xlrec->reloid, xlrec->last_batch_lsn, xlrec->stop_lsn, - xlrec->first_batch_lsn); + xlrec->first_batch_lsn, xlrec->fileops_owned); } break; @@ -390,7 +408,7 @@ atm_redo(XLogReaderState *record) * We snapshot ALL entries, not only those with an XLOG_ATM_ABORT preceding * the redo point: an entry whose abort record follows the redo point will be * re-added by redo, and ATMAddAbortedInternal is idempotent on (xid, reloid) - * (SLogTxnInsert leaves an existing entry untouched), so double-add across + * (SLogTxnInsert preserves its chain and progress), so double-add across * the checkpoint boundary converges to a single entry. Persisting the whole * map is simpler than partitioning it by redo point and is cheap because the * ATM only holds un-forgotten aborts. @@ -449,6 +467,7 @@ CheckPointATM(void) rec.last_batch_lsn = entries[i].last_batch_lsn; rec.stop_lsn = entries[i].stop_lsn; rec.first_batch_lsn = entries[i].first_batch_lsn; + rec.fileops_owned = entries[i].fileops_owned; rec.revert_complete = entries[i].revert_complete; COMP_CRC32C(crc, &rec, sizeof(rec)); @@ -594,7 +613,7 @@ ATMReloadFromCheckpoint(void) ATMAddAbortedInternal(records[i].xid, records[i].dboid, records[i].reloid, records[i].last_batch_lsn, records[i].stop_lsn, - records[i].first_batch_lsn); + records[i].first_batch_lsn, records[i].fileops_owned); if (records[i].revert_complete) ATMMarkReverted(records[i].xid); } diff --git a/src/backend/access/undo/logical_revert_worker.c b/src/backend/access/undo/logical_revert_worker.c index 1822e33379260..78b1a13bbc091 100644 --- a/src/backend/access/undo/logical_revert_worker.c +++ b/src/backend/access/undo/logical_revert_worker.c @@ -39,6 +39,7 @@ #include "access/atm.h" #include "access/heapam.h" #include "access/logical_revert_worker.h" +#include "access/slog.h" #include "access/table.h" #include "access/undo_xlog.h" #include "access/undorecord.h" @@ -51,6 +52,7 @@ #include "nodes/pg_list.h" #include "pgstat.h" #include "postmaster/bgworker.h" +#include "storage/fileops.h" #include "storage/ipc.h" #include "storage/latch.h" #include "storage/procarray.h" @@ -391,6 +393,7 @@ LogicalRevertWorkerMain(Datum main_arg) TransactionId xid; XLogRecPtr last_batch_lsn; XLogRecPtr stop_lsn; + SLogTxnEntry entry; volatile bool done = false; bool error = false; int rc; @@ -451,7 +454,11 @@ LogicalRevertWorkerMain(Datum main_arg) PG_TRY(); { + if (!SLogTxnLookup(xid, InvalidOid, &entry)) + elog(ERROR, "missing ATM entry for transaction %u", xid); + FileOpsSkipUndo = entry.fileops_owned; done = process_revert_entry(xid, last_batch_lsn, stop_lsn); + FileOpsSkipUndo = false; /* * Mark the ATM entry as reverted, then emit XLOG_ATM_FORGET and @@ -465,6 +472,7 @@ LogicalRevertWorkerMain(Datum main_arg) } PG_CATCH(); { + FileOpsSkipUndo = false; error = true; HOLD_INTERRUPTS(); EmitErrorReport(); diff --git a/src/backend/access/undo/slog.c b/src/backend/access/undo/slog.c index a3f03746d6819..9cf5923c6f644 100644 --- a/src/backend/access/undo/slog.c +++ b/src/backend/access/undo/slog.c @@ -53,6 +53,7 @@ #include "storage/spin.h" #include "storage/subsystems.h" #include "utils/dsa.h" +#include "utils/freepage.h" #include "utils/memutils.h" #include "utils/snapmgr.h" #include "utils/timestamp.h" @@ -91,6 +92,7 @@ typedef struct slog_atm_value_t Oid dboid; TimestampTz abort_time; bool revert_complete; + bool fileops_owned; } slog_atm_value_t; #define RT_PREFIX slog_atm @@ -336,27 +338,44 @@ SLogEnsureDsaAttached(void) * ================================================================ */ +/* + * Free DSA space one new ATM entry may need: a fresh superblock (16 pages, + * DSA_PAGES_PER_SUPERBLOCK in dsa.c) for each size class the insert allocates + * from -- the node4s of a new path, one grown node, the leaf, and the span + * descriptors -- plus one for slack. + */ +#define SLOG_ATM_INSERT_RESERVE (5 * 16 * FPM_PAGE_SIZE) + /* * SLogTxnInsert * Insert an aborted transaction entry into the sLog. * - * Creates an entry in the ATM radix tree. The tree grows on demand from - * the DSA area, so unlike the old fixed pool this never fails for lack of - * space; the return type is retained for API compatibility and is always - * true. + * Creates an entry in the ATM radix tree, which grows on demand from the DSA + * area up to slog_dsa_max_size_mb. The tree's allocations raise ERROR when + * the area is full, and an ERROR midway through an insert leaves the tree + * corrupt. With check_space, the caller is aborting a transaction and must + * not fail: first make sure the area has room for the insert, and return + * false if it does not. Only this tree allocates from the area, and txn_lock + * serializes it, so the room cannot be taken in between. Without + * check_space (redo, and restoring entries at startup) the insert raises + * ERROR when the area is full, failing startup rather than dropping a + * rollback. */ bool SLogTxnInsert(TransactionId xid, Oid reloid, Oid dboid, XLogRecPtr last_batch_lsn, XLogRecPtr stop_lsn, - XLogRecPtr first_batch_lsn) + XLogRecPtr first_batch_lsn, bool fileops_owned, + bool check_space) { slog_atm_radix_tree *tree = slog_atm_tree(); uint64 key = slog_atm_key(xid, reloid); slog_atm_value_t value; + slog_atm_value_t *found; value.last_batch_lsn = last_batch_lsn; value.stop_lsn = stop_lsn; value.first_batch_lsn = first_batch_lsn; + value.fileops_owned = fileops_owned; value.dboid = dboid; value.abort_time = GetCurrentTimestamp(); value.revert_complete = false; @@ -364,13 +383,37 @@ SLogTxnInsert(TransactionId xid, Oid reloid, Oid dboid, LWLockAcquire(&SLogState->txn_lock.lock, LW_EXCLUSIVE); /* - * RT_SET returns whether the key already existed. A duplicate insert is - * a no-op on the (xid, reloid) identity; the original entry's data fields - * would be overwritten, so preserve the old behavior of leaving an - * existing entry untouched by finding first. + * Keep the original chain and progress on duplicate insertion, but take + * the latest FILEOPS ownership. Recovery may supersede an online insert + * made before an abort record that never reached WAL, or mark the + * inverses complete after applying them itself. */ - if (slog_atm_find(tree, key) == NULL) + found = slog_atm_find(tree, key); + if (found != NULL) + found->fileops_owned = fileops_owned; + else + { + if (check_space) + { + dsa_pointer reserve; + + /* + * Freeing a large allocation returns its pages, contiguous, to + * the free page manager, where the insert's new superblocks find + * them. + */ + reserve = dsa_allocate_extended(slog_dsa_handle, + SLOG_ATM_INSERT_RESERVE, + DSA_ALLOC_NO_OOM); + if (!DsaPointerIsValid(reserve)) + { + LWLockRelease(&SLogState->txn_lock.lock); + return false; + } + dsa_free(slog_dsa_handle, reserve); + } (void) slog_atm_set(tree, key, &value); + } LWLockRelease(&SLogState->txn_lock.lock); return true; @@ -400,6 +443,7 @@ SLogTxnLookup(TransactionId xid, Oid reloid, SLogTxnEntry *entry_out) entry_out->last_batch_lsn = found->last_batch_lsn; entry_out->stop_lsn = found->stop_lsn; entry_out->first_batch_lsn = found->first_batch_lsn; + entry_out->fileops_owned = found->fileops_owned; entry_out->dboid = found->dboid; entry_out->abort_time = found->abort_time; entry_out->revert_complete = found->revert_complete; @@ -798,6 +842,7 @@ SLogTxnSnapshotForCheckpoint(SLogTxnEntry **entries_out) arr[count].last_batch_lsn = val->last_batch_lsn; arr[count].stop_lsn = val->stop_lsn; arr[count].first_batch_lsn = val->first_batch_lsn; + arr[count].fileops_owned = val->fileops_owned; arr[count].dboid = val->dboid; arr[count].abort_time = val->abort_time; arr[count].revert_complete = val->revert_complete; diff --git a/src/backend/access/undo/undo_xlog.c b/src/backend/access/undo/undo_xlog.c index 4e2c4206da034..26d36393016ad 100644 --- a/src/backend/access/undo/undo_xlog.c +++ b/src/backend/access/undo/undo_xlog.c @@ -439,9 +439,10 @@ undo_recovery_publish_retention(void) * 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. + * Committed transactions don't need UNDO rollback. Before an abort record + * is written, generic UNDO is either applied or registered in the ATM. + * FILEOPS inverses are embedded in the abort record and applied by redo. + * The ATM retains any outstanding generic rollback independently. */ void UndoRecoveryRemoveXid(TransactionId xid) @@ -493,6 +494,7 @@ typedef struct DeferredUndoXact Oid dboid; XLogRecPtr last_batch_lsn; XLogRecPtr first_batch_lsn; /* oldest batch: WAL to retain */ + bool fileops_owned; /* startup completed the FILEOPS inverses */ struct DeferredUndoXact *next; } DeferredUndoXact; @@ -510,8 +512,12 @@ static DeferredUndoXact *deferred_undo_xacts = NULL; * * If UNDO application is skipped (e.g., due to syscache not being available), * the transaction is tracked for deferred processing after recovery completes. + * + * Return true if any FILEOPS inverse succeeded. Before admitting writers, + * recovery must checkpoint those effects and any deferred ownership, so another + * crash cannot re-track the loser and undo a committed reuse of its paths. */ -void +bool PerformUndoRecovery(void) { int i, @@ -520,6 +526,7 @@ PerformUndoRecovery(void) int total_records = 0; int pending_xacts = 0; int deferred_xacts = 0; + bool fileops_applied = false; /* Count pending transactions for the opening log message. */ for (i = 0; i < undo_recovery_nentries; i++) @@ -537,6 +544,7 @@ PerformUndoRecovery(void) { UndoRecoveryEntry *entry = &undo_recovery_entries[i]; bool any_skipped = false; + bool fileops_owned = true; XLogRecPtr perm_first = InvalidXLogRecPtr; if (!TransactionIdIsValid(entry->xid)) @@ -596,14 +604,19 @@ PerformUndoRecovery(void) */ (void) UndoWalkChain(batch_lsn, InvalidXLogRecPtr, &res); total_records += res.applied + res.skipped + res.failed; + fileops_owned &= res.complete && !res.fileops_failed; + fileops_applied |= res.fileops_applied; /* * A record skipped here (an index AM needs the syscache, * which the startup process does not have) or one that failed * is handed to the logical revert worker, which walks the - * whole chain again; the rm_undo callbacks are idempotent. + * whole chain again. FILEOPS cannot distinguish a new file + * at the same path, so the ATM must remember when startup + * completed all its inverses. Index callbacks skip their + * records there too (see UndoApplyInline). */ - if (res.skipped > 0 || res.failed > 0) + if (!res.complete || res.skipped > 0 || res.failed > 0) any_skipped = true; /* @@ -661,11 +674,12 @@ PerformUndoRecovery(void) deferred->dboid = entry->dboid; deferred->last_batch_lsn = perm_lsn; deferred->first_batch_lsn = perm_first; + deferred->fileops_owned = fileops_owned; deferred->next = deferred_undo_xacts; deferred_undo_xacts = deferred; ATMAddAbortedNoWAL(deferred->xid, deferred->dboid, perm_lsn, - perm_first); + perm_first, fileops_owned); deferred_xacts++; ereport(LOG, @@ -699,6 +713,8 @@ PerformUndoRecovery(void) } undo_recovery_nentries = 0; undo_recovery_capacity = 0; + + return fileops_applied; } /* @@ -706,8 +722,8 @@ PerformUndoRecovery(void) * * Called after recovery completes (when InRedo is false). PerformUndoRecovery() * already put each deferred transaction into the Aborted Transaction Map - * without WAL; this writes the XLOG_ATM_ABORT records for them (ATMAddAborted() - * leaves the existing entries untouched), so standbys learn about them too. + * without WAL; this writes the XLOG_ATM_ABORT records, including ownership of + * the completed FILEOPS inverses, so standbys learn about them too. * The logical revert worker of each entry's database processes them * asynchronously. * @@ -733,7 +749,7 @@ FlushDeferredUndoXacts(void) deferred_undo_xacts = deferred->next; ATMAddAborted(deferred->xid, deferred->dboid, deferred->last_batch_lsn, - deferred->first_batch_lsn); + deferred->first_batch_lsn, deferred->fileops_owned); count++; pfree(deferred); diff --git a/src/backend/access/undo/undoapply.c b/src/backend/access/undo/undoapply.c index 8121ca262f765..c4991861d6fa9 100644 --- a/src/backend/access/undo/undoapply.c +++ b/src/backend/access/undo/undoapply.c @@ -31,6 +31,7 @@ #include "access/undormgr.h" #include "access/undo_xlog.h" #include "miscadmin.h" +#include "storage/fileops.h" #include "utils/injection_point.h" #include "utils/memutils.h" @@ -357,6 +358,7 @@ UndoWalkChain(XLogRecPtr last_batch_lsn, XLogRecPtr stop_at_lsn, for (i = nrecords_in_batch - 1; i >= 0; i--) { UndoRecordHeader header; + UndoApplyResult result; char *payload = NULL; memcpy(&header, record_starts[i], SizeOfUndoRecordHeader); @@ -364,7 +366,16 @@ UndoWalkChain(XLogRecPtr last_batch_lsn, XLogRecPtr stop_at_lsn, if (header.urec_payload_len > 0) payload = record_starts[i] + SizeOfUndoRecordHeader; - switch (ApplyOneUndoRecord(&header, payload, InvalidUndoRecPtr)) + result = ApplyOneUndoRecord(&header, payload, InvalidUndoRecPtr); + if (header.urec_rmid == UNDO_RMID_FILEOPS) + { + if (result == UNDO_APPLY_SUCCESS) + res->fileops_applied = true; + else + res->fileops_failed = true; + } + + switch (result) { case UNDO_APPLY_SUCCESS: res->applied++; diff --git a/src/backend/access/undo/xactundo.c b/src/backend/access/undo/xactundo.c index 8147b2119205d..4834375cd1e81 100644 --- a/src/backend/access/undo/xactundo.c +++ b/src/backend/access/undo/xactundo.c @@ -76,6 +76,7 @@ #include "miscadmin.h" #include "storage/aio_subsys.h" #include "storage/condition_variable.h" +#include "storage/fileops.h" #include "storage/lwlock.h" #include "storage/bufmgr.h" #include "storage/ipc.h" @@ -1179,7 +1180,7 @@ AtAbort_XactUndo(void) if (ATMAddAborted(GetCurrentTransactionId(), MyDatabaseId, perm_lsn, - XactUndo.first_batch_lsn[UNDOPERSISTENCE_PERMANENT])) + XactUndo.first_batch_lsn[UNDOPERSISTENCE_PERMANENT], true)) lsn_safely_held = true; /* ATM holds the LSN */ else elog(WARNING, "ATM full: could not record aborted transaction %u", GetCurrentTransactionId()); @@ -1208,7 +1209,7 @@ AtAbort_XactUndo(void) */ if (ATMAddAborted(GetCurrentTransactionId(), MyDatabaseId, perm_lsn, - XactUndo.first_batch_lsn[UNDOPERSISTENCE_PERMANENT])) + XactUndo.first_batch_lsn[UNDOPERSISTENCE_PERMANENT], true)) lsn_safely_held = true; else elog(WARNING, @@ -1518,7 +1519,7 @@ AtSubAbort_XactUndo(int level) Assert(TransactionIdIsValid(subxid)); if (!ATMAddAbortedBounded(subxid, MyDatabaseId, sub_head, - parent_head, first)) + parent_head, first, true)) elog(WARNING, "ATM full: could not record rolled-back subtransaction %u", subxid); @@ -1652,11 +1653,7 @@ XactUndo_SubXactCallback(SubXactEvent event, SubTransactionId mySubid, break; case SUBXACT_EVENT_ABORT_SUB: - - /* - * Subtransaction is aborting. Apply UNDO and clean up. - */ - AtSubAbort_XactUndo(mySubid); + /* AbortSubTransaction applies undo before logging the abort. */ break; case SUBXACT_EVENT_PRE_COMMIT_SUB: diff --git a/src/backend/storage/file/README b/src/backend/storage/file/README index ef400330c661e..109c7c116dc23 100644 --- a/src/backend/storage/file/README +++ b/src/backend/storage/file/README @@ -37,7 +37,7 @@ abort-time undo action that reverses it if the transaction rolls back: 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) + FileOpsWrite -- pwrite() now (WAL-logged, bounded before-image UNDO) FileOpsSetXattr / FileOpsRemoveXattr -- now; UNDO restore old value on abort DESTRUCTIVE operations are DEFERRED: the API only records a PendingFileOp, diff --git a/src/backend/storage/file/fileops.c b/src/backend/storage/file/fileops.c index 9dd21e17d8a59..b5972c44a5985 100644 --- a/src/backend/storage/file/fileops.c +++ b/src/backend/storage/file/fileops.c @@ -274,7 +274,7 @@ AddPendingFileOpWithData(PendingFileOpType type, const char *path, /* Keep the same inverse for online abort, abort redo, and crash losers. */ static void fileops_prepare_undo(XactUndoContext *ctx, char persistence, uint8 rmid, - uint16 info, Oid reloid, const char *payload, Size len) + uint16 info, Oid reloid, const char *payload, Size len) { AddPendingFileOpWithData(PENDING_FILEOP_INVERSE, "", NULL, info, payload, len, false); @@ -288,7 +288,7 @@ FileOpsGetAbortData(char **data, uint32 *len) { PendingFileOp *p; StringInfoData buf; - int level = GetCurrentTransactionNestLevel(); + int level = GetCurrentTransactionNestLevel(); *data = NULL; *len = 0; @@ -297,8 +297,8 @@ FileOpsGetAbortData(char **data, uint32 *len) initStringInfo(&buf); for (p = pendingFileOps; p; p = p->next) { - uint32 info = p->length; - uint32 size = p->data_len; + uint32 info = p->length; + uint32 size = p->data_len; if (p->type != PENDING_FILEOP_INVERSE || p->nestLevel < level) continue; @@ -332,11 +332,10 @@ FileOpsEndAbort(void) void FileOpsApplyAbort(const char *data, uint32 len) { - FileOpsResetAbortFailure(); while (len > 0) { - uint32 info; - uint32 size; + uint32 info; + uint32 size; if (len < 2 * sizeof(uint32)) elog(PANIC, "invalid FILEOPS abort payload"); @@ -474,9 +473,9 @@ FileOpsCreate(const char *path, int flags, mode_t mode, bool register_delete) hdr->path_len = (uint16) pathlen; memcpy(undo_payload + sizeof(FileopsUndoCreate), path, pathlen); fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT, - UNDO_RMID_FILEOPS, FILEOPS_UNDO_CREATE, - InvalidOid, undo_payload, - sizeof(FileopsUndoCreate) + pathlen); + UNDO_RMID_FILEOPS, FILEOPS_UNDO_CREATE, + InvalidOid, undo_payload, + sizeof(FileopsUndoCreate) + pathlen); XLogBeginInsert(); XLogRegisterData(&xlrec, SizeOfFileOpsCreate); @@ -540,7 +539,7 @@ FileOpsRename(const char *oldpath, const char *newpath) static char * fileops_before_image(int fd, const char *path, off_t offset, Size len) { - char *data; + char *data; if (len > FILEOPS_MAX_DATA_BYTES) ereport(ERROR, @@ -572,10 +571,10 @@ FileOpsWrite(const char *path, off_t offset, const void *data, uint32 len) ssize_t nbytes; int pathlen = fileops_pathlen(path); struct stat st; - char *before; - uint32 before_len; - char *payload; - Size payload_len; + char *before; + uint32 before_len; + char *payload; + Size payload_len; FileopsUndoWrite *hdr; XactUndoContext undo_ctx; @@ -611,11 +610,32 @@ FileOpsWrite(const char *path, off_t offset, const void *data, uint32 len) memcpy(payload + sizeof(*hdr) + pathlen, before, before_len); memcpy(payload + sizeof(*hdr) + pathlen + before_len, data, len); - nbytes = pg_pwrite(fd, data, len, offset); + /* Keep an inverse even if pwrite changes only part of the range. */ + if (XLogIsNeeded()) + { + (void) GetCurrentTransactionId(); + AddPendingFileOpWithData(PENDING_FILEOP_INVERSE, "", NULL, + FILEOPS_UNDO_WRITE, payload, payload_len, false); + inverseBytes += payload_len + 2 * sizeof(uint32); + } + + nbytes = pg_pwrite(fd, data, + IS_INJECTION_POINT_ATTACHED("fileops-write-short") ? len / 2 : len, + offset); if (nbytes != (ssize_t) len) { int save_errno = errno; + /* Match the actual after-image, not the requested complete write. */ + if (XLogIsNeeded()) + { + FileopsUndoWrite *inverse = (FileopsUndoWrite *) pendingFileOps->data; + + inverse->after_len = nbytes > 0 ? nbytes : 0; + inverse->new_length = nbytes > 0 ? + Max(st.st_size, offset + nbytes) : st.st_size; + pendingFileOps->data_len -= len - inverse->after_len; + } CloseTransientFile(fd); errno = save_errno; ereport(ERROR, @@ -644,11 +664,9 @@ FileOpsWrite(const char *path, off_t offset, const void *data, uint32 len) { xl_fileops_write xlrec; - fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT, - UNDO_RMID_FILEOPS, FILEOPS_UNDO_WRITE, - InvalidOid, payload, payload_len); - xlrec.orig_length = st.st_size; - xlrec.before_len = before_len; + PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT, + UNDO_RMID_FILEOPS, FILEOPS_UNDO_WRITE, + InvalidOid, payload, payload_len); xlrec.offset = offset; xlrec.len = len; xlrec.path_len = (uint16) pathlen; @@ -657,8 +675,6 @@ FileOpsWrite(const char *path, off_t offset, const void *data, uint32 len) XLogRegisterData(&xlrec, SizeOfFileOpsWrite); XLogRegisterData(path, pathlen); XLogRegisterData(data, len); - if (before_len) - XLogRegisterData(before, before_len); fileops_log(XLOG_FILEOPS_WRITE, &undo_ctx); } @@ -681,8 +697,8 @@ FileOpsTruncate(const char *path, off_t length) struct stat st; off_t orig_length = 0; int pathlen = fileops_pathlen(path); - char *before; - uint32 before_len; + char *before; + uint32 before_len; Assert(!IsInParallelMode()); @@ -743,12 +759,10 @@ FileOpsTruncate(const char *path, off_t length) { xl_fileops_truncate xlrec; XactUndoContext undo_ctx; - char *payload = palloc0(sizeof(FileopsUndoTruncate) + pathlen + before_len); + char *payload = palloc0(sizeof(FileopsUndoTruncate) + pathlen + before_len); FileopsUndoTruncate *hdr = (FileopsUndoTruncate *) payload; - xlrec.before_len = before_len; xlrec.length = length; - xlrec.orig_length = orig_length; hdr->before_len = before_len; hdr->orig_length = orig_length; @@ -757,15 +771,13 @@ FileOpsTruncate(const char *path, off_t length) memcpy(payload + sizeof(FileopsUndoTruncate), path, pathlen); memcpy(payload + sizeof(FileopsUndoTruncate) + pathlen, before, before_len); fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT, - UNDO_RMID_FILEOPS, FILEOPS_UNDO_TRUNCATE, - InvalidOid, payload, - sizeof(FileopsUndoTruncate) + pathlen + before_len); + UNDO_RMID_FILEOPS, FILEOPS_UNDO_TRUNCATE, + InvalidOid, payload, + sizeof(FileopsUndoTruncate) + pathlen + before_len); XLogBeginInsert(); XLogRegisterData(&xlrec, SizeOfFileOpsTruncate); XLogRegisterData(path, pathlen); - if (before_len) - XLogRegisterData(before, before_len); fileops_log(XLOG_FILEOPS_TRUNCATE, &undo_ctx); pfree(payload); } @@ -824,9 +836,9 @@ FileOpsChmod(const char *path, mode_t mode) hdr->path_len = (uint16) pathlen; memcpy(payload + sizeof(FileopsUndoChmod), path, pathlen); fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT, - UNDO_RMID_FILEOPS, FILEOPS_UNDO_CHMOD, - InvalidOid, payload, - sizeof(FileopsUndoChmod) + pathlen); + UNDO_RMID_FILEOPS, FILEOPS_UNDO_CHMOD, + InvalidOid, payload, + sizeof(FileopsUndoChmod) + pathlen); XLogBeginInsert(); XLogRegisterData(&xlrec, SizeOfFileOpsChmod); @@ -898,9 +910,9 @@ FileOpsChown(const char *path, uid_t uid, gid_t gid) hdr->path_len = (uint16) pathlen; memcpy(payload + sizeof(FileopsUndoChown), path, pathlen); fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT, - UNDO_RMID_FILEOPS, FILEOPS_UNDO_CHOWN, - InvalidOid, payload, - sizeof(FileopsUndoChown) + pathlen); + UNDO_RMID_FILEOPS, FILEOPS_UNDO_CHOWN, + InvalidOid, payload, + sizeof(FileopsUndoChown) + pathlen); XLogBeginInsert(); XLogRegisterData(&xlrec, SizeOfFileOpsChown); @@ -937,8 +949,8 @@ fileops_mkdir(const char *path, mode_t mode, uint16 flags, Oid dboid) /* Remove the directory (or tree) if the transaction aborts */ if (!XLogIsNeeded()) AddPendingFileOp((flags & FILEOPS_MKDIR_TREE) ? - PENDING_FILEOP_RMTREE : PENDING_FILEOP_RMDIR, - path, NULL, 0, false); + PENDING_FILEOP_RMTREE : PENDING_FILEOP_RMDIR, + path, NULL, 0, false); if (enableFsync) fileops_fsync_parent(path, data_sync_elevel(ERROR)); @@ -965,9 +977,9 @@ fileops_mkdir(const char *path, mode_t mode, uint16 flags, Oid dboid) hdr->path_len = (uint16) pathlen; memcpy(payload + sizeof(FileopsUndoMkdir), path, pathlen); fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT, - UNDO_RMID_FILEOPS, FILEOPS_UNDO_MKDIR, - InvalidOid, payload, - sizeof(FileopsUndoMkdir) + pathlen); + UNDO_RMID_FILEOPS, FILEOPS_UNDO_MKDIR, + InvalidOid, payload, + sizeof(FileopsUndoMkdir) + pathlen); XLogBeginInsert(); XLogRegisterData(&xlrec, SizeOfFileOpsMkdir); @@ -1124,10 +1136,10 @@ FileOpsSymlink(const char *target, const char *linkpath) memcpy(payload + sizeof(FileopsUndoSymlink) + linkpathlen, target, targetlen); fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT, - UNDO_RMID_FILEOPS, FILEOPS_UNDO_SYMLINK, - InvalidOid, payload, - sizeof(FileopsUndoSymlink) + linkpathlen + - targetlen); + UNDO_RMID_FILEOPS, FILEOPS_UNDO_SYMLINK, + InvalidOid, payload, + sizeof(FileopsUndoSymlink) + linkpathlen + + targetlen); XLogBeginInsert(); XLogRegisterData(&xlrec, SizeOfFileOpsSymlink); @@ -1192,9 +1204,9 @@ FileOpsLink(const char *oldpath, const char *newpath) hdr->newpath_len = (uint16) newpathlen; memcpy(payload + sizeof(FileopsUndoLink), newpath, newpathlen); fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT, - UNDO_RMID_FILEOPS, FILEOPS_UNDO_LINK, - InvalidOid, payload, - sizeof(FileopsUndoLink) + newpathlen); + UNDO_RMID_FILEOPS, FILEOPS_UNDO_LINK, + InvalidOid, payload, + sizeof(FileopsUndoLink) + newpathlen); XLogBeginInsert(); XLogRegisterData(&xlrec, SizeOfFileOpsLink); @@ -1321,8 +1333,8 @@ FileOpsSetXattr(const char *path, const char *name, hdr->orig_value_len, value, len); fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT, - UNDO_RMID_FILEOPS, FILEOPS_UNDO_SETXATTR, - InvalidOid, payload, payload_len); + UNDO_RMID_FILEOPS, FILEOPS_UNDO_SETXATTR, + InvalidOid, payload, payload_len); XLogBeginInsert(); XLogRegisterData(&xlrec, SizeOfFileOpsSetxattr); @@ -1444,8 +1456,8 @@ FileOpsRemoveXattr(const char *path, const char *name) saved_value, saved_value_len); fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT, - UNDO_RMID_FILEOPS, FILEOPS_UNDO_REMOVEXATTR, - InvalidOid, payload, payload_len); + UNDO_RMID_FILEOPS, FILEOPS_UNDO_REMOVEXATTR, + InvalidOid, payload, payload_len); } XLogBeginInsert(); @@ -1946,8 +1958,8 @@ void FileOpsAtPrepare(void) { PendingFileOp *pending; - char *data; - uint32 len; + char *data; + uint32 len; FileOpsGetAbortData(&data, &len); if (len) diff --git a/src/backend/storage/file/fileops_undo.c b/src/backend/storage/file/fileops_undo.c index 6ed41d18ad2fc..6d95fbb0dbe07 100644 --- a/src/backend/storage/file/fileops_undo.c +++ b/src/backend/storage/file/fileops_undo.c @@ -57,9 +57,11 @@ */ static bool applyingAbort = false; -static bool abortApplyFailed = false; +bool FileOpsSkipUndo = false; /* Forward declarations */ +static void fileops_sync_inverse(uint16 info, const char *data, uint32 len, + int elevel); static UndoApplyResult fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, const char *payload, Size payload_len, @@ -194,8 +196,8 @@ fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, Assert(rmid == UNDO_RMID_FILEOPS); /* Abort WAL owns completed aborts; generic UNDO is for crash losers. */ - if (!applyingAbort && !InRecovery && TransactionIdDidAbort(xid)) - return abortApplyFailed ? UNDO_APPLY_ERROR : UNDO_APPLY_SUCCESS; + if (!applyingAbort && FileOpsSkipUndo) + return UNDO_APPLY_SUCCESS; INJECTION_POINT("fileops-undo-apply-begin", NULL); @@ -240,8 +242,8 @@ fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, const char *path; const char *before; const char *after; - char *current; - int fd; + char *current; + int fd; if (payload_len < sizeof(hdr)) return UNDO_APPLY_ERROR; @@ -653,14 +655,12 @@ fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, INJECTION_POINT("fileops-undo-apply-end", NULL); - return result; -} + /* Startup can persist FILEOPS completion in the ATM after this returns. */ + if (InRecovery && !applyingAbort && result == UNDO_APPLY_SUCCESS) + fileops_sync_inverse(info, payload, payload_len, + data_sync_elevel(ERROR)); -/* Failure status belongs to this abort, not the lifetime of the backend. */ -void -FileOpsResetAbortFailure(void) -{ - abortApplyFailed = false; + return result; } /* @@ -668,51 +668,91 @@ FileOpsResetAbortFailure(void) * Ordinary syscall failures remain warnings. A failed inverse can therefore * leave an online effect until recovery replays the abort record; callers must * not infer successful filesystem rollback merely from transaction abort. + * + * Failing to make the inverse durable is a PANIC regardless of + * data_sync_retry. Online, we run after the abort record is flushed and the + * transaction is marked aborted in clog, so an ERROR would re-enter + * AbortTransaction and log a second abort record. The caller holds off + * checkpoints until the inverses are applied, so crash recovery replays the + * abort record and applies the inverse again. */ void FileOpsApplyInverse(uint16 info, const char *data, uint32 len) +{ + applyingAbort = true; + PG_TRY(); + { + (void) fileops_undo_apply(UNDO_RMID_FILEOPS, info, InvalidTransactionId, + InvalidOid, data, len, InvalidUndoRecPtr); + } + PG_FINALLY(); + { + applyingAbort = false; + } + PG_END_TRY(); + fileops_sync_inverse(info, data, len, PANIC); +} + +/* Make an inverse durable before its completion can outlive recovery. */ +static void +fileops_sync_inverse(uint16 info, const char *data, uint32 len, int elevel) { const char *path = NULL; struct stat st; - Size hdrlen = 0; + Size hdrlen = 0; - applyingAbort = true; - if (fileops_undo_apply(UNDO_RMID_FILEOPS, info, InvalidTransactionId, - InvalidOid, data, len, InvalidUndoRecPtr) != UNDO_APPLY_SUCCESS) - abortApplyFailed = true; - applyingAbort = false; switch (info) { - case FILEOPS_UNDO_CREATE: hdrlen = sizeof(FileopsUndoCreate); break; - case FILEOPS_UNDO_WRITE: hdrlen = sizeof(FileopsUndoWrite); break; - case FILEOPS_UNDO_TRUNCATE: hdrlen = sizeof(FileopsUndoTruncate); break; - case FILEOPS_UNDO_CHMOD: hdrlen = sizeof(FileopsUndoChmod); break; - case FILEOPS_UNDO_CHOWN: hdrlen = sizeof(FileopsUndoChown); break; - case FILEOPS_UNDO_MKDIR: hdrlen = sizeof(FileopsUndoMkdir); break; - case FILEOPS_UNDO_SYMLINK: hdrlen = sizeof(FileopsUndoSymlink); break; - case FILEOPS_UNDO_LINK: hdrlen = sizeof(FileopsUndoLink); break; - case FILEOPS_UNDO_SETXATTR: hdrlen = sizeof(FileopsUndoSetxattr); break; - case FILEOPS_UNDO_REMOVEXATTR: hdrlen = sizeof(FileopsUndoRemovexattr); break; + case FILEOPS_UNDO_CREATE: + hdrlen = sizeof(FileopsUndoCreate); + break; + case FILEOPS_UNDO_WRITE: + hdrlen = sizeof(FileopsUndoWrite); + break; + case FILEOPS_UNDO_TRUNCATE: + hdrlen = sizeof(FileopsUndoTruncate); + break; + case FILEOPS_UNDO_CHMOD: + hdrlen = sizeof(FileopsUndoChmod); + break; + case FILEOPS_UNDO_CHOWN: + hdrlen = sizeof(FileopsUndoChown); + break; + case FILEOPS_UNDO_MKDIR: + hdrlen = sizeof(FileopsUndoMkdir); + break; + case FILEOPS_UNDO_SYMLINK: + hdrlen = sizeof(FileopsUndoSymlink); + break; + case FILEOPS_UNDO_LINK: + hdrlen = sizeof(FileopsUndoLink); + break; + case FILEOPS_UNDO_SETXATTR: + hdrlen = sizeof(FileopsUndoSetxattr); + break; + case FILEOPS_UNDO_REMOVEXATTR: + hdrlen = sizeof(FileopsUndoRemovexattr); + break; } if (hdrlen == 0 || len <= hdrlen) elog(PANIC, "invalid FILEOPS inverse payload"); path = data + hdrlen; if (enableFsync) { - char parent[MAXPGPATH]; - char *sep; + char parent[MAXPGPATH]; + char *sep; #ifdef USE_INJECTION_POINTS if (IS_INJECTION_POINT_ATTACHED("fileops-inverse-fsync-fail")) { errno = EIO; - ereport(data_sync_elevel(ERROR), + ereport(elevel, (errcode_for_file_access(), errmsg("could not fsync file \"%s\": %m", path))); } #endif if (lstat(path, &st) == 0 && !S_ISLNK(st.st_mode)) - fsync_fname_ext(path, S_ISDIR(st.st_mode), true, WARNING); + fsync_fname_ext(path, S_ISDIR(st.st_mode), true, elevel); strlcpy(parent, path, sizeof(parent)); sep = strrchr(parent, '/'); if (sep == NULL) @@ -721,7 +761,7 @@ FileOpsApplyInverse(uint16 info, const char *data, uint32 len) parent[1] = '\0'; else *sep = '\0'; - fsync_fname_ext(parent, true, true, WARNING); + fsync_fname_ext(parent, true, true, elevel); } } diff --git a/src/include/access/atm.h b/src/include/access/atm.h index 7eeb4a6cb2311..1fafbef036bcf 100644 --- a/src/include/access/atm.h +++ b/src/include/access/atm.h @@ -34,14 +34,14 @@ extern void ATMShmemInit(void); extern bool ATMGetLastBatchLSN(TransactionId xid, XLogRecPtr *lsn_out); extern bool ATMAddAborted(TransactionId xid, Oid dboid, XLogRecPtr last_batch_lsn, - XLogRecPtr first_batch_lsn); + XLogRecPtr first_batch_lsn, bool fileops_owned); extern bool ATMAddAbortedBounded(TransactionId xid, Oid dboid, XLogRecPtr last_batch_lsn, XLogRecPtr stop_lsn, - XLogRecPtr first_batch_lsn); + XLogRecPtr first_batch_lsn, bool fileops_owned); extern void ATMAddAbortedNoWAL(TransactionId xid, Oid dboid, XLogRecPtr last_batch_lsn, - XLogRecPtr first_batch_lsn); + XLogRecPtr first_batch_lsn, bool fileops_owned); extern void ATMForget(TransactionId xid); extern void ATMMarkReverted(TransactionId xid); diff --git a/src/include/access/atm_xlog.h b/src/include/access/atm_xlog.h index cfbf22e743a15..3bcfca914241b 100644 --- a/src/include/access/atm_xlog.h +++ b/src/include/access/atm_xlog.h @@ -35,9 +35,10 @@ typedef struct xl_atm_abort * forgotten */ Oid dboid; Oid reloid; /* InvalidOid (kept for struct layout) */ + bool fileops_owned; /* FILEOPS completed or owned by abort WAL */ } xl_atm_abort; -#define SizeOfXlAtmAbort (offsetof(xl_atm_abort, reloid) + sizeof(Oid)) +#define SizeOfXlAtmAbort (offsetof(xl_atm_abort, fileops_owned) + sizeof(bool)) typedef struct xl_atm_forget { diff --git a/src/include/access/hash.h b/src/include/access/hash.h index a8702f0e5ea13..6de3a6601423c 100644 --- a/src/include/access/hash.h +++ b/src/include/access/hash.h @@ -487,4 +487,10 @@ extern void hashbucketcleanup(Relation rel, Bucket cur_bucket, bool split_cleanup, IndexBulkDeleteCallback callback, void *callback_state); +/* hash_undo.c -- UNDO support */ +extern void HashUndoRmgrInit(void); +extern void HashUndoLogInsert(Relation rel, Relation heapRel, Buffer buf, + OffsetNumber offset, IndexTuple itup, + Size itemsz); + #endif /* HASH_H */ diff --git a/src/include/access/slog.h b/src/include/access/slog.h index 9232a7610e794..14f46048618c4 100644 --- a/src/include/access/slog.h +++ b/src/include/access/slog.h @@ -119,6 +119,7 @@ typedef struct SLogTxnEntry Oid dboid; /* database OID */ TimestampTz abort_time; /* when transaction aborted */ bool revert_complete; /* has Logical Revert finished? */ + bool fileops_owned; /* FILEOPS completed or owned by abort WAL */ } SLogTxnEntry; /* ---------------------------------------------------------------- @@ -202,7 +203,8 @@ extern void SLogShmemInit(void); */ extern bool SLogTxnInsert(TransactionId xid, Oid reloid, Oid dboid, XLogRecPtr last_batch_lsn, XLogRecPtr stop_lsn, - XLogRecPtr first_batch_lsn); + XLogRecPtr first_batch_lsn, bool fileops_owned, + bool check_space); extern bool SLogTxnLookup(TransactionId xid, Oid reloid, SLogTxnEntry *entry_out); extern bool SLogTxnLookupByXid(TransactionId xid, XLogRecPtr *lsn_out); diff --git a/src/include/access/undo_xlog.h b/src/include/access/undo_xlog.h index 1754a00026abe..ef573f6924e8c 100644 --- a/src/include/access/undo_xlog.h +++ b/src/include/access/undo_xlog.h @@ -213,7 +213,7 @@ extern void UndoRecoveryTrackBatch(TransactionId xid, Oid dboid, extern void UndoRecoveryRemoveXid(TransactionId xid); extern void UndoRecoveryTrackInflight(const xl_undo_inflight_xact *x); extern bool UndoRecoveryNeeded(void); -extern void PerformUndoRecovery(void); +extern bool PerformUndoRecovery(void); extern void FlushDeferredUndoXacts(void); #endif /* UNDO_XLOG_H */ diff --git a/src/include/access/undorecord.h b/src/include/access/undorecord.h index b8d5b547dfbbc..c0d9d6171d7c5 100644 --- a/src/include/access/undorecord.h +++ b/src/include/access/undorecord.h @@ -245,6 +245,8 @@ typedef struct UndoChainWalkResult int applied; /* records returning UNDO_APPLY_SUCCESS */ int skipped; /* records returning UNDO_APPLY_SKIPPED */ int failed; /* UNDO_APPLY_ERROR, or unknown rmgr */ + bool fileops_failed; /* a FILEOPS inverse was not applied */ + bool fileops_applied; /* at least one FILEOPS inverse succeeded */ bool complete; /* walked to the end (or stop) of the chain */ bool malformed; /* if !complete, because of a malformed batch */ XLogRecPtr oldest_batch; /* LSN of the oldest batch read */ diff --git a/src/include/access/undormgrlist.h b/src/include/access/undormgrlist.h index 4c76c465f69ff..31a47d9cf850e 100644 --- a/src/include/access/undormgrlist.h +++ b/src/include/access/undormgrlist.h @@ -34,6 +34,7 @@ /* built-in index AM UNDO resource managers */ UNDO_RMGR_INIT(NbtreeUndoRmgrInit) +UNDO_RMGR_INIT(HashUndoRmgrInit) /* FILEOPS: transactional filesystem operations UNDO rmgr (UNDO_RMID_FILEOPS=2) */ UNDO_RMGR_INIT(FileopsUndoRmgrInit) diff --git a/src/include/access/xlogrecovery.h b/src/include/access/xlogrecovery.h index 8786b6d3a8e70..8c71971d4f615 100644 --- a/src/include/access/xlogrecovery.h +++ b/src/include/access/xlogrecovery.h @@ -209,6 +209,9 @@ typedef struct */ bool standby_signal_file_found; bool recovery_signal_file_found; + + /* Startup successfully applied at least one FILEOPS loser inverse. */ + bool fileopsUndoApplied; } EndOfWalRecoveryInfo; extern EndOfWalRecoveryInfo *FinishWalRecovery(void); diff --git a/src/include/storage/fileops.h b/src/include/storage/fileops.h index e91a2ad41b404..41f757a9ce960 100644 --- a/src/include/storage/fileops.h +++ b/src/include/storage/fileops.h @@ -118,7 +118,9 @@ extern void FileOpsSync(const char *path); #define FILEOPS_TWOPHASE_INVERSES 1 extern void FileOpsGetAbortData(char **data, uint32 *len); extern void FileOpsApplyAbort(const char *data, uint32 len); -extern void FileOpsResetAbortFailure(void); + +/* Scoped to a generic walk whose FILEOPS inverses belong to abort WAL. */ +extern PGDLLIMPORT bool FileOpsSkipUndo; extern void FileOpsApplyInverse(uint16 info, const char *data, uint32 len); extern void FileOpsBeginAbort(void); extern void FileOpsEndAbort(void); @@ -198,8 +200,6 @@ typedef struct xl_fileops_write { off_t offset; /* write offset in file */ uint32 len; /* data length */ - off_t orig_length; - uint32 before_len; uint16 path_len; /* length of path (including NUL) */ /* variable-length path follows, then data */ } xl_fileops_write; @@ -216,12 +216,10 @@ extern int FileOpsWrite(const char *path, off_t offset, typedef struct xl_fileops_truncate { off_t length; /* new file length */ - off_t orig_length; /* pre-truncate length */ - uint32 before_len; /* variable-length path follows */ } xl_fileops_truncate; -#define SizeOfFileOpsTruncate (offsetof(xl_fileops_truncate, before_len) + sizeof(uint32)) +#define SizeOfFileOpsTruncate (offsetof(xl_fileops_truncate, length) + sizeof(off_t)) /* File truncation API */ extern void FileOpsTruncate(const char *path, off_t length); @@ -406,13 +404,13 @@ typedef struct FileopsUndoCreate /* WRITE undo: path, before-image, then after-image. */ typedef struct FileopsUndoWrite { - off_t orig_length; - off_t new_length; - off_t offset; - uint32 before_len; - uint32 after_len; - uint16 path_len; -} FileopsUndoWrite; + off_t orig_length; + off_t new_length; + off_t offset; + uint32 before_len; + uint32 after_len; + uint16 path_len; +} FileopsUndoWrite; /* TRUNCATE undo payload: path, followed by the bytes removed by a shrink. */ typedef struct FileopsUndoTruncate diff --git a/src/test/modules/test_fileops/meson.build b/src/test/modules/test_fileops/meson.build index 4a3d776aa4f4a..bdfa55a3afb0c 100644 --- a/src/test/modules/test_fileops/meson.build +++ b/src/test/modules/test_fileops/meson.build @@ -42,9 +42,14 @@ tests += { 't/005_fileops_checkpoint_loser.pl', 't/006_fileops_rollback_prepared_crash.pl', 't/007_fileops_commit_crash.pl', - 't/007_fileops_abort_redo.pl', 't/008_fileops_standby_abort.pl', 't/009_fileops_reuse.pl', + 't/010_fileops_abort_redo.pl', + 't/011_fileops_undo_ownership.pl', + 't/012_fileops_contracts.pl', + 't/013_fileops_loser_reuse.pl', + 't/014_fileops_loser_ownership.pl', + 't/015_fileops_promote_loser.pl', 't/016_fileops_inverse_fsync.pl', ], }, 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 index 4299e67fdb051..ff3b04d87eb99 100644 --- a/src/test/modules/test_fileops/t/001_fileops_apply_failure.pl +++ b/src/test/modules/test_fileops/t/001_fileops_apply_failure.pl @@ -1,11 +1,7 @@ # Copyright (c) 2021-2026, PostgreSQL Global Development Group -# A FILEOPS UNDO record that cannot be applied must be reported as a failure, -# so the abort hands the chain to the logical revert worker instead of -# treating it as rolled back. -# -# The transaction truncates a file, which is then made read-only behind its -# back, so the truncation's UNDO cannot reopen it for writing. +# FILEOPS inverses belong to the abort record, not the generic ATM. A +# syscall failure is reported; crash replay retries the embedded inverse. use strict; use warnings FATAL => 'all'; @@ -36,9 +32,9 @@ $bg->quit; is( $node->safe_psql( - 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), 't', - 'the rollback whose FILEOPS UNDO failed was handed to the ATM'); + 'FILEOPS abort inverses are not handed to the generic ATM'); like( slurp_file($node->logfile), qr/FILEOPS UNDO TRUNCATE: could not open/, diff --git a/src/test/modules/test_fileops/t/008_fileops_standby_abort.pl b/src/test/modules/test_fileops/t/008_fileops_standby_abort.pl index 64a20d3812655..0c2718aff73c9 100644 --- a/src/test/modules/test_fileops/t/008_fileops_standby_abort.pl +++ b/src/test/modules/test_fileops/t/008_fileops_standby_abort.pl @@ -10,6 +10,8 @@ $primary->append_conf('postgresql.conf', "max_prepared_transactions = 5\n"); $primary->start; $primary->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); +$primary->safe_psql('postgres', 'CREATE EXTENSION injection_points') + if $ENV{enable_injection_points} eq 'yes'; $primary->backup('backup'); my $standby = PostgreSQL::Test::Cluster->new('standby'); $standby->init_from_backup($primary, 'backup', has_streaming => 1); @@ -41,6 +43,24 @@ COMMIT; }); $primary->wait_for_catchup($standby); +if ($ENV{enable_injection_points} eq 'yes') +{ + $standby->safe_psql('postgres', + "SELECT injection_points_attach('fileops-abort-after-flush', 'wait')"); + $primary->safe_psql('postgres', q{ +BEGIN; +SELECT test_fileops_create('standby-window.dat', 384); +ROLLBACK; +}); + $standby->wait_for_event('startup', 'fileops-abort-after-flush'); + ok(-e $standby->data_dir . '/standby-window.dat', + 'standby paused before inverse'); + $standby->stop('immediate'); + $standby->start; + $primary->wait_for_catchup($standby); + ok(!-e $standby->data_dir . '/standby-window.dat', + 'standby crash replays inverse'); +} for my $stage ('streaming', 'promoted') { $standby->promote if $stage eq 'promoted'; diff --git a/src/test/modules/test_fileops/t/007_fileops_abort_redo.pl b/src/test/modules/test_fileops/t/010_fileops_abort_redo.pl similarity index 61% rename from src/test/modules/test_fileops/t/007_fileops_abort_redo.pl rename to src/test/modules/test_fileops/t/010_fileops_abort_redo.pl index f423dc79ca1d3..2a4cd2ef81941 100644 --- a/src/test/modules/test_fileops/t/007_fileops_abort_redo.pl +++ b/src/test/modules/test_fileops/t/010_fileops_abort_redo.pl @@ -7,7 +7,7 @@ my $node = PostgreSQL::Test::Cluster->new('abort_redo'); $node->init; -$node->append_conf('postgresql.conf', "wal_level = replica\nautovacuum = off\ncheckpoint_timeout = '1h'\nmax_logical_revert_workers = 0\n"); +$node->append_conf('postgresql.conf', "wal_level = replica\nautovacuum = off\ncheckpoint_timeout = '1h'\nmax_logical_revert_workers = 0\nmax_prepared_transactions = 5\n"); $node->start; $node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); my $dir = $node->data_dir; @@ -25,7 +25,34 @@ BEGIN; SELECT test_fileops_chmod('base.dat', 448); ROLLBACK; BEGIN; SELECT test_fileops_write('base.dat', 0, 'OVERWRITTEN!!!'); ROLLBACK; BEGIN; SELECT test_fileops_truncate('base.dat', 3); ROLLBACK; +BEGIN; +SAVEPOINT s; +SELECT test_fileops_create('sub.dat', 384); +ROLLBACK TO s; +SELECT test_fileops_write('base.dat', 0, 'parent'); +ROLLBACK; +BEGIN; SELECT test_fileops_symlink('base.dat', 'abort.sym'); ROLLBACK; +BEGIN; SELECT test_fileops_link('base.dat', 'abort.link'); ROLLBACK; + +}); +my $xattrs = $node->safe_psql('postgres', + "SELECT test_fileops_setxattr('base.dat', 'user.abort_test', 'original')") eq 't'; +if ($xattrs) +{ + $node->safe_psql('postgres', q{ +BEGIN; +SELECT test_fileops_setxattr('base.dat', 'user.abort_test', 'changed'); +ROLLBACK; +BEGIN; +SELECT test_fileops_removexattr('base.dat', 'user.abort_test'); +ROLLBACK; }); +} +my ($uid, $gid) = (stat("$dir/base.dat"))[4, 5]; +# An unprivileged test can chown to its own identity; a different uid needs +# a privileged harness. This still exercises CHOWN inverse serialization. +$node->safe_psql('postgres', + "BEGIN; SELECT test_fileops_chown('base.dat', $uid, $gid); ROLLBACK;"); for my $restart (0 .. 2) { if ($restart) @@ -33,9 +60,21 @@ $node->stop('immediate'); $node->start; } + ok(!-e "$dir/sub.dat", "$restart: subabort then parent abort"); + ok(!-l "$dir/abort.sym", "$restart: symlink inverse"); + ok(!-e "$dir/abort.link", "$restart: link inverse"); ok(!-e "$dir/abort.dat", "$restart: aborted create absent"); ok(!-e "$dir/abortdir", "$restart: aborted mkdir absent"); is((stat("$dir/base.dat"))[2] & 07777, 0600, "$restart: original mode"); + is((stat("$dir/base.dat"))[4], $uid, "$restart: owner after chown inverse"); + is((stat("$dir/base.dat"))[5], $gid, "$restart: group after chown inverse"); + SKIP: + { + skip 'extended attributes not supported', 1 unless $xattrs; + is($node->safe_psql('postgres', + "SELECT test_fileops_getxattr('base.dat', 'user.abort_test')"), + 'original', "$restart: xattr inverses restore value"); + } is(-s "$dir/base.dat", 14, "$restart: original size"); is(slurp_file("$dir/base.dat"), 'committed-base', "$restart: original bytes"); } @@ -45,8 +84,9 @@ if ($ENV{enable_injection_points} eq 'yes') { $node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); - for my $checkpoint (0, 1) + for my $checkpoint (0, 1, 2) { + my $finish = $checkpoint == 2 ? "PREPARE TRANSACTION 'window'; ROLLBACK PREPARED 'window';" : 'ROLLBACK;'; $node->safe_psql('postgres', 'CHECKPOINT'); $node->safe_psql('postgres', "SELECT injection_points_attach('fileops-abort-after-flush', 'wait')"); @@ -56,11 +96,11 @@ SELECT test_fileops_create('mid.dat', 384); SELECT test_fileops_write('mid.dat', 0, 'uncommitted'); SELECT test_fileops_truncate('base.dat', 3); -ROLLBACK; +$finish )); $node->wait_for_event('client backend', 'fileops-abort-after-flush'); my $ckpt; - if ($checkpoint) + if ($checkpoint == 1) { $ckpt = $node->background_psql('postgres', on_error_stop => 0); $ckpt->query_until(qr/checkpoint/, "\\echo checkpoint\nCHECKPOINT;\n"); @@ -79,6 +119,26 @@ } } +if ($ENV{enable_injection_points} eq 'yes') +{ + $node->safe_psql('postgres', 'CHECKPOINT'); + $node->safe_psql('postgres', q{ +SELECT injection_points_attach('createdb-wal-log-after-copy-relation', 'error'); +SELECT injection_points_attach('fileops-abort-after-flush', 'wait'); +}); + my $db = $node->background_psql('postgres', on_error_stop => 0); + $db->query_until(qr/start/, "\\echo start\nCREATE DATABASE aborted_tree STRATEGY WAL_LOG OID 90000;\n"); + $node->wait_for_event('client backend', 'fileops-abort-after-flush'); + # createdb's error cleanup removes the online directory already. Redo + # recreates it from forward WAL, so the embedded tree inverse must remove + # it again even though the original inode no longer exists. + ok(!-e "$dir/base/90000", 'createdb error cleanup removed online tree'); + $node->stop('immediate'); + $db->{run}->finish; + $node->start; + ok(!-e "$dir/base/90000", 'abort redo removes database tree'); +} + # A WARNING is not a durable retry obligation: fixing permissions does not # retry this inverse online. A crash before the next checkpoint replays it. $node->safe_psql('postgres', 'CHECKPOINT'); @@ -107,4 +167,3 @@ is(slurp_file("$dir/base.dat"), 'committed-base', 'refused write changes nothing'); $node->stop; done_testing(); - diff --git a/src/test/modules/test_fileops/t/011_fileops_undo_ownership.pl b/src/test/modules/test_fileops/t/011_fileops_undo_ownership.pl new file mode 100644 index 0000000000000..d7539f70cf454 --- /dev/null +++ b/src/test/modules/test_fileops/t/011_fileops_undo_ownership.pl @@ -0,0 +1,110 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +plan skip_all => 'Injection points not supported by this build' + unless $ENV{enable_injection_points} eq 'yes'; + +my $node = PostgreSQL::Test::Cluster->new('ownership'); +$node->init; +$node->append_conf('postgresql.conf', q{ +wal_level = replica +autovacuum = off +max_logical_revert_workers = 0 +logical_revert_naptime = 100ms +}); +$node->start; +$node->safe_psql('postgres', q{ +CREATE EXTENSION injection_points; +CREATE EXTENSION test_fileops; +CREATE EXTENSION test_idxundo; +CREATE TABLE t (id int primary key) WITH (index_undo = on); +CHECKPOINT; +}); + +# This point must precede the abort decision. Otherwise recovery forgets +# the non-FILEOPS chain before it was applied or registered in the ATM. +$node->safe_psql('postgres', + "SELECT injection_points_attach('undo-xact-abort-before-apply', 'wait')"); +my $bg = $node->background_psql('postgres', on_error_stop => 0); +$bg->query_until(qr/start/, qq(\\echo start +BEGIN; +INSERT INTO t SELECT generate_series(1, 10); +SELECT test_fileops_create('first.dat', 384); +ROLLBACK; +)); +$node->wait_for_event('client backend', 'undo-xact-abort-before-apply'); +$node->stop('immediate'); +$bg->{run}->finish; +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->start; +ok($node->poll_query_until('postgres', + 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), 'crash rollback drained'); +# The worker walks the index chain but leaves its entries for VACUUM (see +# nbtree_undo_apply()). +is($node->safe_psql('postgres', + "SELECT idxundo_count_dead_all('t_pkey')"), '0', + 'crash before generic apply leaves the index entries for VACUUM'); +ok(!-e $node->data_dir . '/first.dat', 'crash rollback removes file'); + +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 0'); +$node->restart; +$bg = $node->background_psql('postgres'); +$bg->query_safe('SELECT injection_points_set_local()'); +$bg->query_safe("SELECT injection_points_attach('undo-apply-after-batch', 'error')"); +$bg->query_safe('BEGIN; SAVEPOINT s'); +$bg->query_safe("SELECT test_fileops_create('reuse.dat', 384)"); +$bg->query_safe('INSERT INTO t VALUES (11)'); +$bg->query('ROLLBACK TO s'); +$bg->query_safe('COMMIT'); +$bg->quit; +is($node->safe_psql('postgres', + 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), 't', + 'failed bounded walk is queued after parent commits'); +ok(!-e $node->data_dir . '/reuse.dat', 'subabort removed file'); +$node->safe_psql('postgres', q{ +BEGIN; +SELECT test_fileops_create('reuse.dat', 384); +SELECT test_fileops_write('reuse.dat', 0, 'T2-committed'); +COMMIT; +CHECKPOINT; +}); +# The checkpoint/restart also checks that ownership survives ATM persistence. +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->restart; +ok($node->poll_query_until('postgres', + 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), 'bounded ATM entry drained'); +my $path = $node->data_dir . '/reuse.dat'; +is(-e $path ? slurp_file($path) : '', 'T2-committed', + 'bounded worker leaves committed same-path reuse intact'); +# The inline walk killed entry 11 before the injected error; the worker +# leaves index entries alone. +is($node->safe_psql('postgres', + "SELECT idxundo_count_dead_all('t_pkey')"), '1', + 'only the inline subtransaction walk marked an index entry dead'); +$node->safe_psql('postgres', + "SELECT injection_points_attach('fileops-write-short', 'error')"); +my ($ret, $out, $err) = $node->psql('postgres', + "SELECT test_fileops_write('reuse.dat', 0, 'partialwrite')"); +like($err, qr/could not write/, 'short write reports error'); +$node->safe_psql('postgres', + "SELECT injection_points_detach('fileops-write-short')"); +is(slurp_file($path), 'T2-committed', 'short write restores before-image'); +# A first write inside a savepoint must assign the subxid before failing. +$bg = $node->background_psql('postgres', on_error_stop => 0); +$bg->query_safe('BEGIN; SAVEPOINT short_write'); +$node->safe_psql('postgres', + "SELECT injection_points_attach('fileops-write-short', 'error')"); +$bg->query("SELECT test_fileops_write('reuse.dat', 20, 'extension')"); +like($bg->{stderr}, qr/could not write/, 'short extending write reports error'); +$bg->{stderr} = ''; +$bg->query_safe('ROLLBACK TO short_write; COMMIT'); +$bg->quit; +$node->safe_psql('postgres', + "SELECT injection_points_detach('fileops-write-short')"); +is(slurp_file($path), 'T2-committed', 'short extending subxact write restores length'); +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/012_fileops_contracts.pl b/src/test/modules/test_fileops/t/012_fileops_contracts.pl new file mode 100644 index 0000000000000..bdcebd5d70ac5 --- /dev/null +++ b/src/test/modules/test_fileops/t/012_fileops_contracts.pl @@ -0,0 +1,20 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# These are source-contract checks, not an fsync fault-injection test. +use strict; +use warnings FATAL => 'all'; +use FindBin; +use PostgreSQL::Test::Utils; +use Test::More; + +my $src = "$FindBin::RealBin/../../../.."; +my $undo = slurp_file("$src/backend/storage/file/fileops_undo.c"); +$undo =~ /void\s+FileOpsApplyInverse\(.*?(?=fileops_undo_desc -)/s + or die 'cannot find inverse applier'; +my $apply = $&; +my @syncs = $apply =~ /(fsync_fname_ext\([^;]+;)/g; +is(scalar @syncs, 2, 'inverse syncs file and parent'); +# 016_fileops_inverse_fsync.pl tests what a failed inverse sync does. +my $header = slurp_file("$src/include/access/xlog_internal.h"); +unlike($header, qr/#define XLOG_PAGE_MAGIC 0xD125\b/, + 'incompatible FILEOPS and ATM records change WAL magic'); +done_testing(); diff --git a/src/test/modules/test_fileops/t/013_fileops_loser_reuse.pl b/src/test/modules/test_fileops/t/013_fileops_loser_reuse.pl new file mode 100644 index 0000000000000..83fcb5023eb7f --- /dev/null +++ b/src/test/modules/test_fileops/t/013_fileops_loser_reuse.pl @@ -0,0 +1,74 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Startup can revert FILEOPS without the syscache needed for index UNDO. +# A deferred worker must not repeat those inverses after same-path reuse. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +for my $crash_again (0, 1) +{ + my $node = PostgreSQL::Test::Cluster->new("loser_$crash_again"); + $node->init; + $node->append_conf('postgresql.conf', q{ +wal_level = replica +autovacuum = off +max_logical_revert_workers = 0 +logical_revert_naptime = 100ms +}); + $node->start; + $node->safe_psql('postgres', q{ +CREATE EXTENSION test_fileops; +CREATE EXTENSION test_idxundo; +CREATE TABLE t (id int primary key) WITH (index_undo = on); +CHECKPOINT; +}); + my $bg = $node->background_psql('postgres', on_error_stop => 0); + $bg->query_safe('BEGIN'); + $bg->query_safe('INSERT INTO t SELECT generate_series(1, 10)'); + $bg->query_safe("SELECT test_fileops_create('loser.dat', 384)"); + $node->safe_psql('postgres', 'SELECT pg_switch_wal()'); + $node->stop('immediate'); + $bg->{run}->finish; + $node->start; + my $path = $node->data_dir . '/loser.dat'; + ok(!-e $path, "$crash_again: startup removed loser file"); + is($node->safe_psql('postgres', + 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), 't', + "$crash_again: index chain deferred"); + $node->safe_psql('postgres', q{ +BEGIN; +SELECT test_fileops_create('loser.dat', 384); +SELECT test_fileops_write('loser.dat', 0, 'T2-committed'); +COMMIT; +}); + if ($crash_again) + { + # No user checkpoint: recover the end-of-recovery ATM snapshot and + # its subsequent WAL while the worker is still disabled. + $node->stop('immediate'); + $node->start; + is(-e $path ? slurp_file($path) : '', 'T2-committed', + 'second crash preserves committed reuse before worker runs'); + is($node->safe_psql('postgres', + 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), 't', + 'second crash retains deferred index chain'); + } + $node->safe_psql('postgres', 'CHECKPOINT'); + $node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); + $node->restart; + ok($node->poll_query_until('postgres', + 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + "$crash_again: worker drained ATM"); + # The worker leaves index entries for VACUUM (see nbtree_undo_apply()). + is($node->safe_psql('postgres', + "SELECT idxundo_count_dead_all('t_pkey')"), '0', + "$crash_again: worker left index entries for VACUUM"); + is(-e $path ? slurp_file($path) : '', 'T2-committed', + "$crash_again: committed reuse survives deferred loser revert"); + $node->stop; +} + +done_testing(); diff --git a/src/test/modules/test_fileops/t/014_fileops_loser_ownership.pl b/src/test/modules/test_fileops/t/014_fileops_loser_ownership.pl new file mode 100644 index 0000000000000..2dd82ca8a6e36 --- /dev/null +++ b/src/test/modules/test_fileops/t/014_fileops_loser_ownership.pl @@ -0,0 +1,68 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# An online ATM entry does not own FILEOPS if the server crashes before +# writing the abort record. A failed startup inverse must remain retryable. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +plan skip_all => 'Injection points not supported by this build' + unless $ENV{enable_injection_points} eq 'yes'; +plan skip_all => 'POSIX permission semantics required' if $windows_os; + +my $node = PostgreSQL::Test::Cluster->new('loser_ownership'); +$node->init; +$node->append_conf( + 'postgresql.conf', q{ +wal_level = replica +max_logical_revert_workers = 0 +logical_revert_naptime = 100ms +undo_instant_abort_threshold = 0 +}); +$node->start; +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION injection_points; +CREATE EXTENSION test_fileops; +CHECKPOINT; +SELECT injection_points_attach('undo-apply-before-batch', 'error'); +SELECT injection_points_attach('undo-xact-abort-after-atm', 'wait'); +}); +my $dir = $node->data_dir . '/blocked'; +mkdir($dir) or die "mkdir $dir: $!"; +my $path = "$dir/loser.dat"; +my $bg = $node->background_psql('postgres', on_error_stop => 0); +$bg->query_until( + qr/start/, qq(\\echo start +BEGIN; +SELECT test_fileops_create('blocked/loser.dat', 384); +ROLLBACK; +)); +$node->wait_for_event('client backend', 'undo-xact-abort-after-atm'); +is( $node->safe_psql( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), + 't', + 'online abort registered in ATM before abort WAL'); +$node->safe_psql('postgres', 'SELECT pg_switch_wal()'); +$node->stop('immediate'); +$bg->{run}->finish; +chmod(0500, $dir) or die "chmod $dir: $!"; +$node->start; +ok(-e $path, 'failed startup inverse leaves loser file'); +like( + slurp_file($node->logfile), + qr/FILEOPS UNDO CREATE: could not unlink/, + 'startup attempted the inverse'); +chmod(0700, $dir) or die "chmod $dir: $!"; +# Persist the replacement marker before allowing the worker to run. +$node->safe_psql('postgres', 'CHECKPOINT'); +$node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); +$node->restart; +ok( $node->poll_query_until( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + 'worker drained ATM'); +ok(!-e $path, 'worker retries FILEOPS without a durable abort record'); +$node->stop; +done_testing(); diff --git a/src/test/modules/test_fileops/t/015_fileops_promote_loser.pl b/src/test/modules/test_fileops/t/015_fileops_promote_loser.pl new file mode 100644 index 0000000000000..3ef248c0ed873 --- /dev/null +++ b/src/test/modules/test_fileops/t/015_fileops_promote_loser.pl @@ -0,0 +1,78 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Promotion must not leave a FILEOPS loser eligible for another startup walk +# after a committed transaction has reused its path. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Time::HiRes qw(time); +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', q{ +checkpoint_timeout = '1h' +checkpoint_completion_target = 0.9 +max_wal_size = '2GB' +autovacuum = off +max_logical_revert_workers = 0 +log_checkpoints = on +}); +$primary->start; +$primary->safe_psql( + 'postgres', q{ +CREATE EXTENSION test_fileops; +CREATE TABLE dirty (data text); +}); +$primary->backup('backup'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'backup', has_streaming => 1); +$standby->start; + +# Dirty enough buffers that the asynchronous, time-smoothed checkpoint +# requested after fast promotion cannot finish before the immediate stop. +$primary->safe_psql('postgres', + "INSERT INTO dirty SELECT repeat('x', 1000) FROM generate_series(1, 10000)" +); +my $bg = $primary->background_psql('postgres', on_error_stop => 0); +$bg->query_safe('BEGIN'); +$bg->query_safe("SELECT test_fileops_create('loser.dat', 384)"); +$primary->safe_psql('postgres', 'SELECT pg_switch_wal()'); +$primary->wait_for_catchup($standby); +my $path = $standby->data_dir . '/loser.dat'; +ok(-e $path, 'standby replayed the loser create'); +$primary->stop('immediate'); +$bg->{run}->finish; + +my $started = time(); +$standby->promote; +diag( + sprintf( + 'promotion with 10MB dirty data: %.3f seconds', time() - $started)); +ok(!-e $path, 'promotion removed loser file'); +my $checkpoint = $standby->safe_psql('postgres', + 'SELECT checkpoint_lsn FROM pg_control_checkpoint()'); +$standby->safe_psql( + 'postgres', q{ +BEGIN; +SELECT test_fileops_create('loser.dat', 384); +SELECT test_fileops_write('loser.dat', 0, 'committed-after-promotion'); +COMMIT; +}); +is( $standby->safe_psql( + 'postgres', 'SELECT checkpoint_lsn FROM pg_control_checkpoint()'), + $checkpoint, + 'no checkpoint completed after promotion'); + +for my $restart (1, 2) +{ + $standby->stop('immediate'); + $standby->start; + is( -e $path ? slurp_file($path) : '', + 'committed-after-promotion', + "restart $restart preserves committed reuse"); +} +$standby->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/Makefile b/src/test/modules/test_idxundo/Makefile index 570fe565dd338..6141a25f15475 100644 --- a/src/test/modules/test_idxundo/Makefile +++ b/src/test/modules/test_idxundo/Makefile @@ -12,9 +12,12 @@ DATA = test_idxundo--1.0.sql REGRESS = test_idxundo # The end-to-end tests verify that ROLLBACK reversed the index entries by # reading them back through the index, and check the result with amcheck. -EXTRA_INSTALL = contrib/amcheck src/test/modules/injection_points +EXTRA_INSTALL = contrib/amcheck contrib/pageinspect src/test/modules/injection_points export enable_injection_points -REGRESS_OPTS = --load-extension=amcheck +REGRESS_OPTS = --load-extension=amcheck --load-extension=pageinspect +REGRESS_OPTS += --temp-config $(top_srcdir)/src/test/modules/test_idxundo/test_idxundo.conf +# Disables the logical revert worker, which needs a server of its own. +NO_INSTALLCHECK = 1 TAP_TESTS = 1 diff --git a/src/test/modules/test_idxundo/expected/test_idxundo.out b/src/test/modules/test_idxundo/expected/test_idxundo.out index 683ffd3a375b8..d64392e55317b 100644 --- a/src/test/modules/test_idxundo/expected/test_idxundo.out +++ b/src/test/modules/test_idxundo/expected/test_idxundo.out @@ -1,10 +1,10 @@ -- --------------------------------------------------------------------------- -- Index UNDO, end to end through the real abort path. -- --- A table created WITH (index_undo = on) makes its btree indexes write UNDO --- into the cluster-wide UNDO-in-WAL stream. On ROLLBACK, AtAbort_XactUndo() --- walks the chain and the apply callback marks the provisionally-inserted --- entries LP_DEAD -- no VACUUM required. +-- A table created WITH (index_undo = on) makes its btree and hash indexes +-- write UNDO into the cluster-wide UNDO-in-WAL stream. On ROLLBACK, +-- AtAbort_XactUndo() walks the chain and the apply callbacks mark the +-- provisionally-inserted entries LP_DEAD -- no VACUUM required. -- --------------------------------------------------------------------------- CREATE EXTENSION test_idxundo; CREATE EXTENSION amcheck; @@ -247,6 +247,66 @@ SELECT bt_index_check('iu_split_idx', heapallindexed => true); (1 row) +-- =========================================================================== +-- hash indexes +-- =========================================================================== +CREATE TABLE iu_hash (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_hash_idx ON iu_hash USING hash (id); +INSERT INTO iu_hash SELECT g, 'committed' FROM generate_series(1, 200) g; +SELECT idxundo_count_dead_all('iu_hash_idx') AS dead_before; + dead_before +------------- + 0 +(1 row) + +BEGIN; +INSERT INTO iu_hash SELECT g, 'aborted' FROM generate_series(1001, 1100) g; +SELECT idxundo_undo_chain_published() AS chain_published; + chain_published +----------------- + t +(1 row) + +ROLLBACK; +SELECT idxundo_count_dead_all('iu_hash_idx') AS dead_after_rollback; + dead_after_rollback +--------------------- + 100 +(1 row) + +-- The pages holding them carry the dead-tuples hint, so an insert that finds +-- the page full reclaims them (_hash_vacuum_one_page) instead of waiting for +-- VACUUM. +SELECT bool_or((s.hasho_flag & 128) <> 0) AS has_dead_hint + FROM generate_series(1, pg_relation_size('iu_hash_idx') / + current_setting('block_size')::int - 1) b, + LATERAL hash_page_stats(get_raw_page('iu_hash_idx', b::int)) s + WHERE hash_page_type(get_raw_page('iu_hash_idx', b::int)) IN ('bucket', 'overflow'); + has_dead_hint +--------------- + t +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_hash WHERE id BETWEEN 1 AND 200; + committed_via_index +--------------------- + 200 +(1 row) + +SELECT count(*) AS aborted_via_index FROM iu_hash WHERE id BETWEEN 1001 AND 1100; + aborted_via_index +------------------- + 0 +(1 row) + +RESET enable_seqscan; +SELECT count(*) AS heap_rows FROM iu_hash; + heap_rows +----------- + 200 +(1 row) + -- =========================================================================== -- Turning the option on AFTER rows already exist. -- @@ -283,7 +343,7 @@ SELECT bt_index_check('iu_late_idx', heapallindexed => true); (1 row) -DROP TABLE iu_btree, iu_off, iu_sub, iu_split, iu_late; +DROP TABLE iu_btree, iu_off, iu_sub, iu_split, iu_hash, iu_late; -- =========================================================================== -- DEFERRED BATCHING SAFETY -- @@ -497,20 +557,28 @@ SELECT bt_index_check('iu_big_idx', heapallindexed => true); -- notice -- so XactUndoFlushPending() is called before the capture, and -- AtPrepare_XactUndo() asserts nothing is left pending. -- --- dead_after_rollback_prepared is 0: ROLLBACK PREPARED hands the chain to the --- logical revert worker, which leaves index entries for VACUUM (after the --- abort is visible their heap TIDs could hold committed rows; see --- nbtree_undo_apply()). What this case proves is that PREPARE itself is safe: --- it does not error, lose the chain, or trip the AtPrepare_XactUndo() --- assertion, and the committed rows stay index-reachable. +-- ROLLBACK PREPARED hands the chain to the logical revert worker through the +-- ATM rather than applying it inline. The worker is off for this test (see +-- test_idxundo.conf), so the entries are still live afterwards and the ATM +-- holds the transaction; t/007 shows the worker reverting it. This case also +-- proves PREPARE itself is safe: it does not error, lose the chain, or trip +-- the AtPrepare_XactUndo() assertion, and the committed rows stay +-- index-reachable. CREATE TABLE iu_2pc (id int, val text) WITH (index_undo = on); CREATE INDEX iu_2pc_idx ON iu_2pc (id); INSERT INTO iu_2pc SELECT g, 'committed' FROM generate_series(1, 100) g; BEGIN; INSERT INTO iu_2pc SELECT g, 'prepared' FROM generate_series(501, 560) g; +SELECT pg_current_xact_id()::xid AS iu_p1_xid \gset PREPARE TRANSACTION 'iu_p1'; ROLLBACK PREPARED 'iu_p1'; --- The 60 entries from the prepared-then-aborted transaction are left for VACUUM. +SELECT count(*) AS queued_for_revert FROM idxundo_atm_entries() + WHERE xid = :'iu_p1_xid' AND NOT revert_complete; + queued_for_revert +------------------- + 1 +(1 row) + SELECT idxundo_count_dead_all('iu_2pc_idx') AS dead_after_rollback_prepared; dead_after_rollback_prepared ------------------------------ @@ -537,6 +605,33 @@ SELECT bt_index_check('iu_2pc_idx', heapallindexed => true); (1 row) -DROP TABLE iu_def, iu_sub2, iu_sub3, iu_nest, iu_big, iu_2pc; +-- --- 7. hash AM, same deferral path ----------------------------------------- +CREATE TABLE iu_hash2 (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_hash2_idx ON iu_hash2 USING hash (id); +INSERT INTO iu_hash2 SELECT g, 'committed' FROM generate_series(1, 200) g; +BEGIN; +INSERT INTO iu_hash2 SELECT g, 'aborted' FROM generate_series(1001, 1100) g; +ROLLBACK; +SELECT idxundo_count_dead_all('iu_hash2_idx') AS dead_after_rollback; + dead_after_rollback +--------------------- + 100 +(1 row) + +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_hash2 WHERE id BETWEEN 1 AND 200; + committed_via_index +--------------------- + 200 +(1 row) + +SELECT count(*) AS aborted_via_index FROM iu_hash2 WHERE id BETWEEN 1001 AND 1100; + aborted_via_index +------------------- + 0 +(1 row) + +RESET enable_seqscan; +DROP TABLE iu_def, iu_sub2, iu_sub3, iu_nest, iu_big, iu_2pc, iu_hash2; DROP EXTENSION amcheck; DROP EXTENSION test_idxundo; diff --git a/src/test/modules/test_idxundo/meson.build b/src/test/modules/test_idxundo/meson.build index aae1dc0af94be..f1661d03afae2 100644 --- a/src/test/modules/test_idxundo/meson.build +++ b/src/test/modules/test_idxundo/meson.build @@ -30,7 +30,9 @@ tests += { 'test_idxundo', ], # The end-to-end tests read the index back and verify it with amcheck. - 'regress_args': ['--load-extension=amcheck'], + 'regress_args': ['--load-extension=amcheck', '--load-extension=pageinspect', + '--temp-config', files('test_idxundo.conf')], + 'runningcheck': false, }, 'tap': { 'env': { diff --git a/src/test/modules/test_idxundo/sql/test_idxundo.sql b/src/test/modules/test_idxundo/sql/test_idxundo.sql index 89aacdf9995ef..d5b00c1491de0 100644 --- a/src/test/modules/test_idxundo/sql/test_idxundo.sql +++ b/src/test/modules/test_idxundo/sql/test_idxundo.sql @@ -1,10 +1,10 @@ -- --------------------------------------------------------------------------- -- Index UNDO, end to end through the real abort path. -- --- A table created WITH (index_undo = on) makes its btree indexes write UNDO --- into the cluster-wide UNDO-in-WAL stream. On ROLLBACK, AtAbort_XactUndo() --- walks the chain and the apply callback marks the provisionally-inserted --- entries LP_DEAD -- no VACUUM required. +-- A table created WITH (index_undo = on) makes its btree and hash indexes +-- write UNDO into the cluster-wide UNDO-in-WAL stream. On ROLLBACK, +-- AtAbort_XactUndo() walks the chain and the apply callbacks mark the +-- provisionally-inserted entries LP_DEAD -- no VACUUM required. -- --------------------------------------------------------------------------- CREATE EXTENSION test_idxundo; CREATE EXTENSION amcheck; @@ -140,6 +140,33 @@ RESET enable_seqscan; SELECT count(*) AS heap_rows FROM iu_split; SELECT bt_index_check('iu_split_idx', heapallindexed => true); +-- =========================================================================== +-- hash indexes +-- =========================================================================== + +CREATE TABLE iu_hash (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_hash_idx ON iu_hash USING hash (id); +INSERT INTO iu_hash SELECT g, 'committed' FROM generate_series(1, 200) g; +SELECT idxundo_count_dead_all('iu_hash_idx') AS dead_before; +BEGIN; +INSERT INTO iu_hash SELECT g, 'aborted' FROM generate_series(1001, 1100) g; +SELECT idxundo_undo_chain_published() AS chain_published; +ROLLBACK; +SELECT idxundo_count_dead_all('iu_hash_idx') AS dead_after_rollback; +-- The pages holding them carry the dead-tuples hint, so an insert that finds +-- the page full reclaims them (_hash_vacuum_one_page) instead of waiting for +-- VACUUM. +SELECT bool_or((s.hasho_flag & 128) <> 0) AS has_dead_hint + FROM generate_series(1, pg_relation_size('iu_hash_idx') / + current_setting('block_size')::int - 1) b, + LATERAL hash_page_stats(get_raw_page('iu_hash_idx', b::int)) s + WHERE hash_page_type(get_raw_page('iu_hash_idx', b::int)) IN ('bucket', 'overflow'); +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_hash WHERE id BETWEEN 1 AND 200; +SELECT count(*) AS aborted_via_index FROM iu_hash WHERE id BETWEEN 1001 AND 1100; +RESET enable_seqscan; +SELECT count(*) AS heap_rows FROM iu_hash; + -- =========================================================================== -- Turning the option on AFTER rows already exist. -- @@ -163,7 +190,7 @@ SELECT count(*) AS preexisting_via_index FROM iu_late WHERE id BETWEEN 1 AND 100 RESET enable_seqscan; SELECT bt_index_check('iu_late_idx', heapallindexed => true); -DROP TABLE iu_btree, iu_off, iu_sub, iu_split, iu_late; +DROP TABLE iu_btree, iu_off, iu_sub, iu_split, iu_hash, iu_late; -- =========================================================================== -- DEFERRED BATCHING SAFETY @@ -294,20 +321,23 @@ SELECT bt_index_check('iu_big_idx', heapallindexed => true); -- notice -- so XactUndoFlushPending() is called before the capture, and -- AtPrepare_XactUndo() asserts nothing is left pending. -- --- dead_after_rollback_prepared is 0: ROLLBACK PREPARED hands the chain to the --- logical revert worker, which leaves index entries for VACUUM (after the --- abort is visible their heap TIDs could hold committed rows; see --- nbtree_undo_apply()). What this case proves is that PREPARE itself is safe: --- it does not error, lose the chain, or trip the AtPrepare_XactUndo() --- assertion, and the committed rows stay index-reachable. +-- ROLLBACK PREPARED hands the chain to the logical revert worker through the +-- ATM rather than applying it inline. The worker is off for this test (see +-- test_idxundo.conf), so the entries are still live afterwards and the ATM +-- holds the transaction; t/007 shows the worker reverting it. This case also +-- proves PREPARE itself is safe: it does not error, lose the chain, or trip +-- the AtPrepare_XactUndo() assertion, and the committed rows stay +-- index-reachable. CREATE TABLE iu_2pc (id int, val text) WITH (index_undo = on); CREATE INDEX iu_2pc_idx ON iu_2pc (id); INSERT INTO iu_2pc SELECT g, 'committed' FROM generate_series(1, 100) g; BEGIN; INSERT INTO iu_2pc SELECT g, 'prepared' FROM generate_series(501, 560) g; +SELECT pg_current_xact_id()::xid AS iu_p1_xid \gset PREPARE TRANSACTION 'iu_p1'; ROLLBACK PREPARED 'iu_p1'; --- The 60 entries from the prepared-then-aborted transaction are left for VACUUM. +SELECT count(*) AS queued_for_revert FROM idxundo_atm_entries() + WHERE xid = :'iu_p1_xid' AND NOT revert_complete; SELECT idxundo_count_dead_all('iu_2pc_idx') AS dead_after_rollback_prepared; SET enable_seqscan = off; SELECT count(*) AS committed_via_index FROM iu_2pc WHERE id BETWEEN 1 AND 100; @@ -315,7 +345,20 @@ SELECT count(*) AS prepared_via_index FROM iu_2pc WHERE id BETWEEN 501 AND 560; RESET enable_seqscan; SELECT bt_index_check('iu_2pc_idx', heapallindexed => true); -DROP TABLE iu_def, iu_sub2, iu_sub3, iu_nest, iu_big, iu_2pc; +-- --- 7. hash AM, same deferral path ----------------------------------------- +CREATE TABLE iu_hash2 (id int, val text) WITH (index_undo = on); +CREATE INDEX iu_hash2_idx ON iu_hash2 USING hash (id); +INSERT INTO iu_hash2 SELECT g, 'committed' FROM generate_series(1, 200) g; +BEGIN; +INSERT INTO iu_hash2 SELECT g, 'aborted' FROM generate_series(1001, 1100) g; +ROLLBACK; +SELECT idxundo_count_dead_all('iu_hash2_idx') AS dead_after_rollback; +SET enable_seqscan = off; +SELECT count(*) AS committed_via_index FROM iu_hash2 WHERE id BETWEEN 1 AND 200; +SELECT count(*) AS aborted_via_index FROM iu_hash2 WHERE id BETWEEN 1001 AND 1100; +RESET enable_seqscan; + +DROP TABLE iu_def, iu_sub2, iu_sub3, iu_nest, iu_big, iu_2pc, iu_hash2; DROP EXTENSION amcheck; DROP EXTENSION test_idxundo; diff --git a/src/test/modules/test_idxundo/test_idxundo.c b/src/test/modules/test_idxundo/test_idxundo.c index fd92c30979219..21851c26f43fb 100644 --- a/src/test/modules/test_idxundo/test_idxundo.c +++ b/src/test/modules/test_idxundo/test_idxundo.c @@ -16,6 +16,7 @@ #include "postgres.h" #include "access/atm.h" +#include "access/hash.h" #include "access/nbtree.h" #include "access/relation.h" #include "access/table.h" @@ -46,7 +47,8 @@ void _PG_init(void); * idxundo_count_dead_all(index regclass) -> total LP_DEAD items in the index * * The end-to-end tests do not know which block the aborted entries landed on, - * so they count over every leaf page of the index. + * so they count over every entry-bearing page of the index: btree leaves, hash + * bucket and overflow pages. */ PG_FUNCTION_INFO_V1(idxundo_count_dead_all); Datum @@ -63,9 +65,9 @@ idxundo_count_dead_all(PG_FUNCTION_ARGS) nblocks = RelationGetNumberOfBlocks(indexrel); relam = indexrel->rd_rel->relam; - if (relam != BTREE_AM_OID) + if (relam != BTREE_AM_OID && relam != HASH_AM_OID) ereport(ERROR, - (errmsg("index \"%s\" is not a btree index", + (errmsg("index \"%s\" is neither a btree nor a hash index", RelationGetRelationName(indexrel)))); for (blk = 0; blk < nblocks; blk++) @@ -79,12 +81,19 @@ idxundo_count_dead_all(PG_FUNCTION_ARGS) page = BufferGetPage(buffer); /* - * Only leaf pages hold index entries; skip the metapage and - * internals. + * Only count line pointers on pages that actually hold index entries. + * Every other page kind must be skipped, not merely tested for + * emptiness: a hash bitmap page is a dense run of set bits, which + * read as line pointers whose lp_flags happen to be LP_DEAD. Counting + * those reports over a thousand phantom dead entries in a freshly + * built hash index and makes the before/after comparison meaningless. */ if (!PageIsNew(page) && !PageIsEmpty(page) && PageGetSpecialSize(page) > 0 && - P_ISLEAF(BTPageGetOpaque(page))) + (relam == BTREE_AM_OID + ? P_ISLEAF(BTPageGetOpaque(page)) + : ((HashPageGetOpaque(page)->hasho_flag & LH_PAGE_TYPE) == LH_BUCKET_PAGE || + (HashPageGetOpaque(page)->hasho_flag & LH_PAGE_TYPE) == LH_OVERFLOW_PAGE))) { maxoff = PageGetMaxOffsetNumber(page); for (off = FirstOffsetNumber; off <= maxoff; off++) @@ -178,7 +187,7 @@ idxundo_atm_add(PG_FUNCTION_ARGS) TransactionId xid = DatumGetTransactionId(PG_GETARG_DATUM(0)); XLogRecPtr lsn = PG_GETARG_LSN(1); - PG_RETURN_BOOL(ATMAddAborted(xid, MyDatabaseId, lsn, lsn)); + PG_RETURN_BOOL(ATMAddAborted(xid, MyDatabaseId, lsn, lsn, false)); } /* diff --git a/src/test/modules/test_idxundo/test_idxundo.conf b/src/test/modules/test_idxundo/test_idxundo.conf new file mode 100644 index 0000000000000..98ccc52b5db93 --- /dev/null +++ b/src/test/modules/test_idxundo/test_idxundo.conf @@ -0,0 +1,5 @@ +# ROLLBACK PREPARED and large rollbacks hand their UNDO to the logical revert +# worker, which applies it asynchronously. Keep the worker off so the dead +# counts in the regression test do not race it; t/007 covers the worker. +max_prepared_transactions = 2 +max_logical_revert_workers = 0 diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index c1639a1a72ecc..11d5d345abe9f 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -1204,6 +1204,7 @@ HASHELEMENT HASHHDR HASHSEGMENT HASH_SEQ_STATUS +HashUndoInsert HE HEntry HIST_ENTRY From bf6ca106b8d83a65e58bcca2caa9f3cc17367296 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Sat, 19 Sep 2026 18:47:52 +0000 Subject: [PATCH 10/22] nbtree: support in-place delete-marking for stable-TID UPDATE Add the nbtree delete-marking machinery an in-place-UPDATE table AM requires, so an indexed-column UPDATE can keep the row's TID instead of moving it. - nbtree delete-marking: an index entry can be marked deleted in place and later reactivated, so an in-place UPDATE of an indexed column inserts the new key and delete-marks the old one at the same TID instead of moving the row. amcheck and index scans agree with a sequential scan across such UPDATEs, including the A->B->A case. The in-place UPDATE index reversal is idempotent and durable so a rollback or crash-recovery re-drive is safe. - Table-AM plumbing the feature keys off: the am_index_delete_marking routine field and the RelationSupportsDeleteMarking() accessor. Index-only scans are suppressed for indexes on a delete-marking table (a delete-marked entry's key may be stale -- Phase 5 invariant I4), enforced in the planner. - Includes the test_deletemark module and its recovery TAP test. The nbtree/hash index UNDO apply handlers that previously shared this commit are now separate commits earlier in the series: they are a different feature (revert provisionally-inserted index entries when a transaction aborts) from delete-marking (mark a stale index entry in place for a stable-TID UPDATE), and separating them keeps their defects reviewable on their own. This commit narrows the index UNDO write hooks to exclude a delete-marking parent table: such an AM drives its own index cleanup on rollback through its table UNDO (remove the new entry, clear the old delete-mark), so the index AM must not also write index UNDO or the two would fight. Because every in-tree AM that supports UNDO also sets am_index_delete_marking, this is what makes the index UNDO write hooks unreachable in-tree; see those commits' messages. No table AM sets am_index_delete_marking yet; the first consumer (FLUX) arrives in a later commit. Author: Greg Burd Changes since v2: - Nbtree: a uniqueness check asks the table AM about a delete-marked entry [094bcd255b] - Nbtree: delete-mark a full leaf's entry atomically, with a replace split [9049cd3056] - Nbtree: keep LP_DEAD off delete-marked and demoted entries [d0e28e1310] - Amcheck: handle nbtree delete-marked entries [215a5ea9b0] - Nbtree: leave room for the tombstone trailer in delete-marking indexes [14ad7d28e4] - Nbtree: describe delete-marking without development phase labels [1bade0b4df] - Nbtree: document delete-marking in the README [873d5a9fd5] - Nbtree: describe XLOG_BTREE_DELETE_MARK's deleted flag as it is used [d1c043661e] - Nbtree: read a tombstone's heap TID from its trailer when splitting [8fb67e167a] - Nbtree: keep the index UNDO comment in _bt_insertonpg as first written [19dfab47b2] - Nbtree: mark btundomark's search stack as used only by assertions [96a9fc1b30] Tests: - Old snapshot loses a row while nbtree delete-marks it [6de97aaa51] --- contrib/amcheck/verify_nbtree.c | 42 +- src/backend/access/index/genam.c | 1 + src/backend/access/index/indexam.c | 63 ++ src/backend/access/nbtree/README | 107 ++++ src/backend/access/nbtree/nbtdedup.c | 35 +- src/backend/access/nbtree/nbtinsert.c | 175 +++++- src/backend/access/nbtree/nbtpage.c | 10 +- src/backend/access/nbtree/nbtreadpage.c | 21 +- src/backend/access/nbtree/nbtree.c | 558 +++++++++++++++++- src/backend/access/nbtree/nbtsearch.c | 22 + src/backend/access/nbtree/nbtsort.c | 5 +- src/backend/access/nbtree/nbtsplitloc.c | 3 +- src/backend/access/nbtree/nbtutils.c | 58 +- src/backend/access/nbtree/nbtxlog.c | 67 +++ src/backend/access/rmgrdesc/nbtdesc.c | 18 +- src/backend/access/table/tableam.c | 31 +- src/backend/access/table/tableamapi.c | 2 + src/backend/optimizer/util/plancat.c | 25 +- src/include/access/amapi.h | 36 ++ src/include/access/genam.h | 9 + src/include/access/nbtree.h | 135 +++++ src/include/access/nbtxlog.h | 39 +- src/include/access/relscan.h | 11 + src/include/access/tableam.h | 118 ++++ src/test/modules/Makefile | 3 +- src/test/modules/meson.build | 1 + src/test/modules/test_deletemark/.gitignore | 4 + src/test/modules/test_deletemark/Makefile | 29 + .../expected/test_deletemark.out | 101 ++++ src/test/modules/test_deletemark/meson.build | 38 ++ .../test_deletemark/sql/test_deletemark.sql | 58 ++ .../t/001_deletemark_recovery.pl | 63 ++ .../test_deletemark/test_deletemark--1.0.sql | 20 + .../modules/test_deletemark/test_deletemark.c | 249 ++++++++ .../test_deletemark/test_deletemark.control | 4 + src/test/recovery/meson.build | 2 + .../recovery/t/094_flux_tombstone_prune.pl | 197 +++++++ .../recovery/t/121_flux_deletemark_race.pl | 255 ++++++++ src/tools/pgindent/typedefs.list | 3 + 39 files changed, 2559 insertions(+), 59 deletions(-) create mode 100644 src/test/modules/test_deletemark/.gitignore create mode 100644 src/test/modules/test_deletemark/Makefile create mode 100644 src/test/modules/test_deletemark/expected/test_deletemark.out create mode 100644 src/test/modules/test_deletemark/meson.build create mode 100644 src/test/modules/test_deletemark/sql/test_deletemark.sql create mode 100644 src/test/modules/test_deletemark/t/001_deletemark_recovery.pl create mode 100644 src/test/modules/test_deletemark/test_deletemark--1.0.sql create mode 100644 src/test/modules/test_deletemark/test_deletemark.c create mode 100644 src/test/modules/test_deletemark/test_deletemark.control create mode 100644 src/test/recovery/t/094_flux_tombstone_prune.pl create mode 100644 src/test/recovery/t/121_flux_deletemark_race.pl diff --git a/contrib/amcheck/verify_nbtree.c b/contrib/amcheck/verify_nbtree.c index 97c519b47f53b..41e8394cbc919 100644 --- a/contrib/amcheck/verify_nbtree.c +++ b/contrib/amcheck/verify_nbtree.c @@ -904,6 +904,16 @@ bt_entry_unique_check(BtreeCheckState *state, IndexTuple itup, Assert(targetblock != P_NONE); + /* + * A delete-marked tombstone is not a live entry for its key: its row has + * moved to another key, or is moving there. Its heap TID is visible + * under the new key, so checking it here would report a live entry with + * the same key for another row as a duplicate. Skip it without touching + * lVis, so that live entries on either side are still compared. + */ + if (BTreeTupleIsDeleteMarked(itup)) + return; + /* * Current tuple has posting list. Report duplicate if TID of any posting * list entry is visible and lVis->tid is valid. @@ -1475,7 +1485,36 @@ bt_target_page_check(BtreeCheckState *state) { IndexTuple norm; - if (BTreeTupleIsPosting(itup)) + if (BTreeTupleIsDeleteMarked(itup)) + { + /* + * A delete-marked tombstone keeps a row reachable under a key + * it no longer has, for snapshots that still see that key. + * Such a snapshot -- possibly ours, since writers run + * concurrently -- derives exactly this (key, heap TID) from + * the heap, and the tombstone is the only entry that holds + * it. Fingerprint it in plain form: key, with the heap TID + * from the trailer in t_tid, as _bt_truncate demotes it. An + * extra fingerprint cannot cause a false report, since the + * heap is only probed against the index. + */ + TupleDesc itupdesc = RelationGetDescr(state->rel); + Datum vals[INDEX_MAX_KEYS]; + bool nulls[INDEX_MAX_KEYS]; + IndexTuple plain; + + index_deform_tuple(itup, itupdesc, vals, nulls); + plain = index_form_tuple(itupdesc, vals, nulls); + plain->t_tid = *BTreeTupleGetHeapTID(itup); + norm = bt_normalize_tuple(state, plain); + bloom_add_element(state->filter, (unsigned char *) norm, + IndexTupleSize(norm)); + /* Be tidy */ + if (norm != plain) + pfree(norm); + pfree(plain); + } + else if (BTreeTupleIsPosting(itup)) { /* Fingerprint all elements as distinct "plain" tuples */ for (int i = 0; i < BTreeTupleGetNPosting(itup); i++) @@ -3026,6 +3065,7 @@ bt_rootdescend(BtreeCheckState *state, IndexTuple itup) insertstate.itemsz = MAXALIGN(IndexTupleSize(itup)); insertstate.itup_key = key; insertstate.postingoff = 0; + insertstate.nopostingsplit = false; insertstate.bounds_valid = false; insertstate.buf = lbuf; diff --git a/src/backend/access/index/genam.c b/src/backend/access/index/genam.c index 98ac5bc79c99b..cc266e0de4b26 100644 --- a/src/backend/access/index/genam.c +++ b/src/backend/access/index/genam.c @@ -103,6 +103,7 @@ RelationGetIndexScan(Relation indexRelation, int nkeys, int norderbys) scan->orderByData = NULL; scan->xs_want_itup = false; /* may be set later */ + scan->xs_want_itup_recheck = false; /* may be set later */ /* * During recovery we ignore killed tuples and don't bother to kill them diff --git a/src/backend/access/index/indexam.c b/src/backend/access/index/indexam.c index db811056991b2..24052bc1cfa31 100644 --- a/src/backend/access/index/indexam.c +++ b/src/backend/access/index/indexam.c @@ -238,6 +238,69 @@ index_insert(Relation indexRelation, indexInfo); } +/* ---------------- + * index_delete_mark - delete-mark an existing (key, TID) index entry + * + * Called by a delete-marking-capable table AM during an in-place + * UPDATE of an indexed column: the old (values, heap_t_ctid) entry is promoted + * to a delete-marked tombstone rather than removed, so old-snapshot readers can + * still reach the before-image through this index. + * + * The AM must support delete-marking (amdeletemark != NULL) AND the parent + * table must use UNDO (RelationUndoEngine(heapRelation) != NONE). + * + * Returns true if a live entry was found and marked; false otherwise. + * ---------------- + */ +bool +index_delete_mark(Relation indexRelation, + Datum *values, + bool *isnull, + ItemPointer heap_t_ctid, + Relation heapRelation) +{ + RELATION_CHECKS; + + /* Capability gate: AM method present AND table AM enables delete-marking */ + if (indexRelation->rd_indam->amdeletemark == NULL) + return false; + if (RelationUndoEngine(heapRelation) == UNDO_ENGINE_NONE) + return false; + + return indexRelation->rd_indam->amdeletemark(indexRelation, heapRelation, + values, isnull, heap_t_ctid); +} + +/* ---------------- + * index_undo_mark - reverse an in-place key UPDATE's index change + * + * Called by a delete-marking table AM's UNDO during ROLLBACK: clear_mark=true + * demotes the OLD key's delete-marked tombstone back to a live entry; + * clear_mark=false kills the NEW key's live entry the aborted update inserted. + * See amundomark_function in access/amapi.h. Returns true if the entry was + * found and reversed. + * ---------------- + */ +bool +index_undo_mark(Relation indexRelation, + Datum *values, + bool *isnull, + ItemPointer heap_t_ctid, + Relation heapRelation, + bool clear_mark) +{ + RELATION_CHECKS; + + if (indexRelation->rd_indam->amundomark == NULL) + return false; + if (RelationUndoEngine(heapRelation) == UNDO_ENGINE_NONE) + return false; + + return indexRelation->rd_indam->amundomark(indexRelation, heapRelation, + values, isnull, heap_t_ctid, + clear_mark); +} + /* ------------------------- * index_insert_cleanup - clean up after all index inserts are done * ------------------------- diff --git a/src/backend/access/nbtree/README b/src/backend/access/nbtree/README index f3867547691d0..aa8c91948db92 100644 --- a/src/backend/access/nbtree/README +++ b/src/backend/access/nbtree/README @@ -1041,6 +1041,113 @@ that need a page split anyway. Besides, supporting variable "split points" while splitting posting lists won't actually improve overall space utilization. +Delete-marking +-------------- + +A table AM that updates rows in place keeps a row's TID across an +UPDATE, including one that changes an indexed column. The index then +needs two entries for one TID: (k_new, TID) for snapshots that see the +new version, and (k_old, TID) for snapshots that still see the old one. +The old entry cannot simply be deleted, and it cannot stay a plain +entry either, because a scan has no way to tell it from a live one. +nbtree supports this by delete-marking: the old entry is rewritten in +place as a "tombstone" that stays in the index until no snapshot can +need it. Only AMs that set am_index_delete_marking (and +am_supports_undo) use it; RelationSupportsDeleteMarking() tests the +table, and every rule below applies only to indexes on such tables. +Heap tables never produce a delete-marked entry. + +A delete-marked entry is a third kind of tuple with INDEX_ALT_TID_MASK +set, beside pivot tuples and posting list tuples. Its t_tid offset +number has BT_IS_DELETE_MARKED (0x4000) set and BT_IS_POSTING clear, +and its block number is zero; the heap TID moves to a trailer in the +last sizeof(ItemPointerData) bytes of the tuple, where a pivot tuple +keeps its heap TID. BTreeTupleIsPivot() and BTreeTupleIsPosting() both +return false for it, and BTreeTupleGetHeapTID() and +BTreeTupleGetMaxHeapTID() return the trailer. The tombstone sorts +exactly where the plain entry did, since its key and heap TID are +unchanged. It is bigger than the plain entry (the size is +BTreeTupleGetDeleteMarkedSize()), so plain leaf entries of such an +index are limited to BTMaxItemSizeDeleteMarking, which leaves room for +the trailer within BTMaxItemSize. When a pivot tuple is formed from a +tombstone, _bt_truncate() first rebuilds it as a plain tuple, so pivots +never carry the delete-mark bit. + +The index AM interface is amdeletemark (btdeletemark) and amundomark +(btundomark), reached through index_delete_mark(), index_undo_mark() +and index_probe_delete_mark(). btdeletemark descends to the leaf by +(k_old, TID), using the heap TID as scantid, with a write lock on the +leaf page, and rewrites the matching plain entry as a tombstone. An +entry that is already delete-marked, missing, or LP_DEAD is left alone +(an LP_DEAD entry is dead to every snapshot, so nobody needs a +tombstone for it). If the bigger tuple does not fit on the page, +_bt_replace_split() splits the page with the tombstone taking the place +of the plain entry; the leaf stays locked from the search to the split, +so this is one atomic action and a scan always finds one of the two +tuples. btundomark reverses an in-place key UPDATE: with clear_mark it +turns the old key's tombstone back into a plain entry (if a plain entry +for that key and TID already exists, it only clears an LP_DEAD hint on +it), and without clear_mark it physically deletes the new key's entry +that the aborted UPDATE inserted. With probe it only reports whether +the entry is a tombstone, and if it is calls +CheckForSerializableConflictIn() for the leaf page as an insertion +would, because the caller is about to revive the tombstone instead of +inserting a new entry. Locking is that of an insertion: a write lock +on the one leaf page, plus what a page split takes. + +Delete-marked entries are never merged into posting lists. +Deduplication is disabled for an index on a delete-marking table, both +in _bt_delete_or_dedup_one_page() and in _bt_load(), and an insertion +into such an index never splits a posting list (insertstate's +nopostingsplit). Revival and removal by btundomark rely on every entry +being a single (key, TID) tuple. + +Scans return a tombstone's heap TID with recheck set, both in +btgettuple and in btgetbitmap, because its key may not be the key of +the version the scan's snapshot sees. The table AM rechecks the key +against the version it fetches; for that, it asks for the index tuple +with xs_want_itup_recheck, which keeps xs_itup filled during an +ordinary index scan (and keeps the scan from dropping its leaf pin). +Index-only scans would return a stale key without visiting the table, +so the planner disables them for indexes on a delete-marking table +(get_relation_info() clears canreturn). btcanreturn cannot make that +decision itself: it is called while relcache entries are being built, +before the table AM can be asked. + +A uniqueness check that meets a tombstone with an equal key asks the +table AM about it through table_index_fetch_tid(), passing the heap TID +from the trailer. The key is still taken while the UPDATE that moved +the row off it is running, or after that UPDATE aborted and before its +rollback has been applied, and free once it committed; only the table +AM knows which. If the AM has no index_fetch_tid callback, the +tombstone is skipped. + +A tombstone is never marked LP_DEAD (_bt_killitems skips it) and is +never removed by simple or bottom-up deletion: its TID is live, since +the row lives on under its new key, so the usual "TID is dead" test +says nothing about it. VACUUM removes a tombstone when the table AM's +bulk-delete callback reports its heap TID dead, like any entry, or when +table_index_tombstone_removable() says no snapshot can need the row +through the tombstone's key any more. The second kind of removal has +no table-side WAL record carrying a recovery conflict, so btvacuumpage +logs it with _bt_delitems_delete(), with a snapshotConflictHorizon the +table AM supplies. + +Delete-marking and its reversal are WAL-logged as +XLOG_BTREE_DELETE_MARK. The record names the leaf item; for a mark or +an unmark it carries the new tuple image, which redo writes over the +item, and with deleted set it carries no image and redo deletes the +item. Redo also clears an LP_DEAD hint on the item, as the primary +does when it makes an entry live again. A delete-mark that needs a +page split is logged as an ordinary XLOG_BTREE_SPLIT_L/R record with +xl_btree_split.replace set: redo removes the replaced item from the +original page before it rebuilds the left page. + +UNDO: an index on a delete-marking table writes no index UNDO of its +own (RelationUsesIndexUndo() is false for it). The table AM's own UNDO +for the in-place UPDATE calls amundomark to put the index back the way +it was on rollback. + Notes About Data Representation ------------------------------- diff --git a/src/backend/access/nbtree/nbtdedup.c b/src/backend/access/nbtree/nbtdedup.c index af7affdf409d3..f1b4aa045c006 100644 --- a/src/backend/access/nbtree/nbtdedup.c +++ b/src/backend/access/nbtree/nbtdedup.c @@ -149,12 +149,22 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz, _bt_dedup_start_pending(state, itup, offnum); } else if (state->deduplicate && + !BTreeTupleIsDeleteMarked(state->base) && + !BTreeTupleIsDeleteMarked(itup) && _bt_keep_natts_fast(rel, state->base, itup) > nkeyatts && _bt_dedup_save_htid(state, itup)) { /* * Tuple is equal to base tuple of pending posting list. Heap * TID(s) for itup have been saved in state. + * + * A delete-marked tombstone is never merged in either + * direction: it carries alt-TID metadata in t_tid instead of a + * heap TID, and its key no longer describes the live version. The + * guards above force it through the "not equal" path, where + * _bt_dedup_finish_pending() emits it verbatim as a single-item + * pending list (nitems stays 1, so the bogus t_tid is never + * used). */ } else @@ -371,12 +381,29 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel, Assert(!ItemIdIsDead(itemid)); + if (BTreeTupleIsDeleteMarked(itup)) + { + /* + * A delete-marked tombstone is never a bottom-up delete + * candidate: its t_tid holds alt-TID metadata, not a heap TID, + * and it must linger until VACUUM decides it is removable. + * Finalize any pending interval and skip it, starting the next + * interval fresh from the following tuple. + */ + if (state->nitems > 0) + _bt_bottomupdel_finish_pending(page, state, &delstate); + state->base = NULL; + state->baseoff = InvalidOffsetNumber; + continue; + } + if (offnum == minoff) { /* itup starts first pending interval */ _bt_dedup_start_pending(state, itup, offnum); } - else if (_bt_keep_natts_fast(rel, state->base, itup) > nkeyatts && + else if (state->base != NULL && + _bt_keep_natts_fast(rel, state->base, itup) > nkeyatts && _bt_dedup_save_htid(state, itup)) { /* Tuple is equal; just added its TIDs to pending interval */ @@ -384,14 +411,16 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel, else { /* Finalize interval -- move its TIDs to delete state */ - _bt_bottomupdel_finish_pending(page, state, &delstate); + if (state->nitems > 0) + _bt_bottomupdel_finish_pending(page, state, &delstate); /* itup starts new pending interval */ _bt_dedup_start_pending(state, itup, offnum); } } /* Finalize final interval -- move its TIDs to delete state */ - _bt_bottomupdel_finish_pending(page, state, &delstate); + if (state->nitems > 0) + _bt_bottomupdel_finish_pending(page, state, &delstate); /* * We don't give up now in the event of having few (or even zero) diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c index 87baf77cd4331..5aa1597927fc4 100644 --- a/src/backend/access/nbtree/nbtinsert.c +++ b/src/backend/access/nbtree/nbtinsert.c @@ -54,14 +54,14 @@ static void _bt_insertonpg(Relation rel, Relation heaprel, BTScanInsert itup_key Size itemsz, OffsetNumber newitemoff, int postingoff, - bool split_only_page); + bool split_only_page, + bool replace); static Buffer _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, Buffer cbuf, OffsetNumber newitemoff, Size newitemsz, IndexTuple newitem, IndexTuple orignewitem, - IndexTuple nposting, uint16 postingoff); + IndexTuple nposting, uint16 postingoff, bool replace); static void _bt_insert_parent(Relation rel, Relation heaprel, Buffer buf, Buffer rbuf, BTStack stack, bool isroot, bool isonly); -static void _bt_freestack(BTStack stack); static Buffer _bt_newlevel(Relation rel, Relation heaprel, Buffer lbuf, Buffer rbuf); static inline bool _bt_pgaddtup(Page page, Size itemsize, const IndexTupleData *itup, OffsetNumber itup_off, bool newfirstdataitem); @@ -159,6 +159,8 @@ _bt_doinsert(Relation rel, IndexTuple itup, insertstate.bounds_valid = false; insertstate.buf = InvalidBuffer; insertstate.postingoff = 0; + /* delete-marking indexes never posting-split on insert */ + insertstate.nopostingsplit = (heapRel != NULL && RelationSupportsDeleteMarking(heapRel)); search: @@ -260,9 +262,10 @@ _bt_doinsert(Relation rel, IndexTuple itup, */ newitemoff = _bt_findinsertloc(rel, &insertstate, checkingunique, indexUnchanged, stack, heapRel); + _bt_insertonpg(rel, heapRel, itup_key, insertstate.buf, InvalidBuffer, stack, itup, insertstate.itemsz, newitemoff, - insertstate.postingoff, false); + insertstate.postingoff, false, false); } else { @@ -519,10 +522,31 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, /* Advanced curitup */ curitup = (IndexTuple) PageGetItem(page, curitemid); Assert(!BTreeTupleIsPivot(curitup)); + + /* + * A delete-marked entry is a tombstone: an + * in-place UPDATE moved the row at its TID off this key. + * The key is still taken while that UPDATE is running + * (wait for it) or after it aborted (its rollback may not + * have been applied yet), and free once it committed. + * Only the table AM knows which: table_index_fetch_tid() + * below. The tombstone is removed by VACUUM + * (table_index_tombstone_removable), never here. An AM + * that cannot judge it has the tombstone skipped, the + * live (k_new, T) entry being the authority. + */ + if (BTreeTupleIsDeleteMarked(curitup) && + heapRel->rd_tableam->index_fetch_tid == NULL) + goto next_uniqueness_offset; } /* okay, we gotta fetch the heap tuple using htid ... */ - if (!BTreeTupleIsPosting(curitup)) + if (BTreeTupleIsDeleteMarked(curitup)) + { + Assert(!inposting); + htid = *BTreeTupleGetHeapTID(curitup); + } + else if (!BTreeTupleIsPosting(curitup)) { /* ... htid is from simple non-pivot tuple */ Assert(!inposting); @@ -558,10 +582,11 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, * Check if there's any table tuples for this index entry * satisfying SnapshotDirty. This is necessary because for AMs * with optimizations like heap's HOT, we have just a single - * index entry for the entire chain. + * index entry for the entire chain. A delete-marking AM + * judges the version that carries this entry's key. */ - else if (table_fetch_tid(heapRel, &htid, &SnapshotDirty, - &all_dead)) + else if (table_index_fetch_tid(heapRel, rel, curitup, &htid, + &SnapshotDirty, &all_dead)) { TransactionId xwait; @@ -617,7 +642,8 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, * entry. */ htid = itup->t_tid; - if (table_fetch_tid(heapRel, &htid, SnapshotSelf, NULL)) + if (table_index_fetch_tid(heapRel, rel, itup, &htid, + SnapshotSelf, NULL)) { /* Normal case --- it's still live */ } @@ -674,9 +700,10 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, RelationGetRelationName(rel)))); } } - else if (all_dead && (!inposting || - (prevalldead && - curposti == BTreeTupleGetNPosting(curitup) - 1))) + else if (all_dead && !BTreeTupleIsDeleteMarked(curitup) && + (!inposting || + (prevalldead && + curposti == BTreeTupleGetNPosting(curitup) - 1))) { /* * The conflicting tuple (or all HOT chains pointed to by @@ -716,6 +743,7 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, } } +next_uniqueness_offset: if (inposting && curposti < BTreeTupleGetNPosting(curitup) - 1) { /* Advance to next TID in same posting list */ @@ -839,7 +867,7 @@ _bt_findinsertloc(Relation rel, opaque = BTPageGetOpaque(page); /* Check 1/3 of a page restriction */ - if (unlikely(insertstate->itemsz > BTMaxItemSize)) + if (unlikely(insertstate->itemsz > BTMaxItemSizeDeleteMarking)) _bt_check_third_page(rel, heapRel, itup_key->heapkeyspace, page, insertstate->itup); @@ -1083,6 +1111,43 @@ _bt_stepright(Relation rel, Relation heaprel, BTInsertState insertstate, insertstate->bounds_valid = false; } +/* + * _bt_replace_split() -- replace a leaf item by a larger tuple that does not + * fit on the page, splitting the page. + * + * btdeletemark grows a plain leaf entry by a heap TID trailer to delete-mark + * it. When the page has no room for that, the delete-marked tuple replaces + * the plain entry as part of a page split: one atomic action and one WAL + * record, so a concurrent scan never finds the page with neither tuple, and + * an error leaves the page as it was. + * + * The caller holds a write lock on the leaf page buf, which it reached with + * _bt_search and the returned stack, and has found that itup does not fit in + * place of the item at offnum. itup must sort exactly where that item does. + * buf is released. + */ +void +_bt_replace_split(Relation rel, Relation heaprel, BTScanInsert itup_key, + Buffer buf, BTStack stack, IndexTuple itup, + OffsetNumber offnum) +{ + Size itemsz = MAXALIGN(IndexTupleSize(itup)); + + /* Check 1/3 of a page restriction, as _bt_findinsertloc does */ + if (unlikely(itemsz > BTMaxItemSize)) + _bt_check_third_page(rel, heaprel, itup_key->heapkeyspace, + BufferGetPage(buf), itup); + + /* + * This insert never takes the fastpath, so forget the cached target + * block, as _bt_search_insert does when that page has no room + */ + RelationSetTargetBlock(rel, InvalidBlockNumber); + + _bt_insertonpg(rel, heaprel, itup_key, buf, InvalidBuffer, stack, itup, + itemsz, offnum, 0, false, true); +} + /*---------- * _bt_insertonpg() -- Insert a tuple on a particular page in the index. * @@ -1111,6 +1176,10 @@ _bt_stepright(Relation rel, Relation heaprel, BTInsertState insertstate, * inserting to a non-leaf page, 'cbuf' is the left-sibling of the page * we're inserting the downlink for. This function will clear the * INCOMPLETE_SPLIT flag on it, and release the buffer. + * + * With replace, 'itup' takes the place of the leaf item at newitemoff + * instead of going in front of it, and the page is always split; see + * _bt_replace_split(). *---------- */ static void @@ -1124,7 +1193,8 @@ _bt_insertonpg(Relation rel, Size itemsz, OffsetNumber newitemoff, int postingoff, - bool split_only_page) + bool split_only_page, + bool replace) { Page page; BTPageOpaque opaque; @@ -1157,6 +1227,9 @@ _bt_insertonpg(Relation rel, Assert(MAXALIGN(IndexTupleSize(itup)) == itemsz); /* Caller must always finish incomplete split for us */ Assert(!P_INCOMPLETE_SPLIT(opaque)); + /* Only a plain leaf item is ever replaced */ + Assert(!replace || (isleaf && postingoff == 0 && + newitemoff <= PageGetMaxOffsetNumber(page))); /* * Every internal page should have exactly one negative infinity item at @@ -1219,8 +1292,11 @@ _bt_insertonpg(Relation rel, * Note: PageGetFreeSpace() subtracts sizeof(ItemIdData) from its result, * so this comparison is correct even though we appear to be accounting * only for the item and not for its line pointer. + * + * A replacing item is only passed here when it does not fit in place of + * the item it replaces, so it always splits the page. */ - if (PageGetFreeSpace(page) < itemsz) + if (replace || PageGetFreeSpace(page) < itemsz) { Buffer rbuf; @@ -1228,7 +1304,7 @@ _bt_insertonpg(Relation rel, /* split the buffer into left and right halves */ rbuf = _bt_split(rel, heaprel, itup_key, buf, cbuf, newitemoff, itemsz, - itup, origitup, nposting, postingoff); + itup, origitup, nposting, postingoff, replace); PredicateLockPageSplit(rel, BufferGetBlockNumber(buf), BufferGetBlockNumber(rbuf)); @@ -1508,20 +1584,28 @@ _bt_insertonpg(Relation rel, * into parent page will still be a separate operation). See * nbtree/README for details on the design of posting list splits. * + * With replace, newitem takes the place of the leaf item at newitemoff + * rather than going in front of it. The split is then worked out on an + * image of the page without that item, which is what REDO rebuilds the + * left page from, too. + * * Returns the new right sibling of buf, pinned and write-locked. * The pin and lock on buf are maintained. */ static Buffer _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, Buffer cbuf, OffsetNumber newitemoff, Size newitemsz, IndexTuple newitem, - IndexTuple orignewitem, IndexTuple nposting, uint16 postingoff) + IndexTuple orignewitem, IndexTuple nposting, uint16 postingoff, + bool replace) { Buffer rbuf; + Page bufpage; Page origpage; Page leftpage, rightpage; PGAlignedBlock leftpage_buf, - rightpage_buf; + rightpage_buf, + origpage_buf; BlockNumber origpagenumber, rightpagenumber; BTPageOpaque ropaque, @@ -1558,8 +1642,20 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, * corruption). Failing before acquiring new buffer won't have lasting * consequences, since origpage won't have been modified and leftpage is * only workspace. + * + * With replace, origpage is a copy of the page without the item that + * newitem replaces, so that everything below sees newitem as an ordinary + * insertion at newitemoff. bufpage is the page in buf. */ - origpage = BufferGetPage(buf); + bufpage = BufferGetPage(buf); + origpage = bufpage; + if (replace) + { + Assert(postingoff == 0); + memcpy(origpage_buf.data, bufpage, BLCKSZ); + origpage = origpage_buf.data; + PageIndexTupleDelete(origpage, newitemoff); + } oopaque = BTPageGetOpaque(origpage); isleaf = P_ISLEAF(oopaque); isrightmost = P_RIGHTMOST(oopaque); @@ -1984,7 +2080,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, * original. We need to do this before writing the WAL record, so that * XLogInsert can WAL log an image of the page if necessary. */ - memcpy(origpage, leftpage, BLCKSZ); + memcpy(bufpage, leftpage, BLCKSZ); /* leftpage, lopaque must not be used below here */ /* @@ -2032,6 +2128,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, xlrec.postingoff = 0; if (postingoff != 0 && origpagepostingoff < firstrightoff) xlrec.postingoff = postingoff; + xlrec.replace = replace; XLogBeginInsert(); XLogRegisterData(&xlrec, SizeOfBtreeSplit); @@ -2085,8 +2182,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, if (!isleaf) { /* lefthighkey isn't local copy, get current pointer */ - itemid = PageGetItemId(origpage, P_HIKEY); - lefthighkey = (IndexTuple) PageGetItem(origpage, itemid); + itemid = PageGetItemId(bufpage, P_HIKEY); + lefthighkey = (IndexTuple) PageGetItem(bufpage, itemid); } XLogRegisterBufData(0, lefthighkey, MAXALIGN(IndexTupleSize(lefthighkey))); @@ -2111,7 +2208,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, else recptr = XLogGetFakeLSN(rel); - PageSetLSN(origpage, recptr); + PageSetLSN(bufpage, recptr); PageSetLSN(rightpage, recptr); if (!isrightmost) PageSetLSN(spage, recptr); @@ -2274,7 +2371,7 @@ _bt_insert_parent(Relation rel, /* Recursively insert into the parent */ _bt_insertonpg(rel, heaprel, NULL, pbuf, buf, stack->bts_parent, new_item, MAXALIGN(IndexTupleSize(new_item)), - stack->bts_offset + 1, 0, isonly); + stack->bts_offset + 1, 0, isonly, false); /* be tidy */ pfree(new_item); @@ -2483,7 +2580,7 @@ _bt_getstackbuf(Relation rel, Relation heaprel, BTStack stack, BlockNumber child /* * _bt_freestack() -- free a retracement stack made by _bt_search_insert. */ -static void +void _bt_freestack(BTStack stack) { BTStack ostack; @@ -2848,8 +2945,21 @@ _bt_delete_or_dedup_one_page(Relation rel, Relation heapRel, _bt_bottomupdel_pass(rel, buffer, heapRel, insertstate->itemsz)) return; - /* Perform deduplication pass (when enabled and index-is-allequalimage) */ - if (BTGetDeduplicateItems(rel) && itup_key->allequalimage) + /* + * Perform deduplication pass (when enabled and index-is-allequalimage). + * + * Deduplication is disabled for a delete-marking table's index. + * A delete-marked tombstone is an alt-TID leaf tuple whose t_tid holds + * status metadata (not a heap TID) and which must linger until VACUUM; + * folding equal-key entries into posting lists around such tombstones + * (and around the revive/re-insert an in-place key UPDATE produces) + * breaks nbtree's posting-list TID-ordering invariants. Dedup is only a + * space optimization, so we simply skip it here. Restoring dedup on + * these indexes would require teaching _bt_dedup to fully respect + * tombstones: never use one as a posting base and never merge across one. + */ + if (BTGetDeduplicateItems(rel) && itup_key->allequalimage && + !(heapRel != NULL && RelationSupportsDeleteMarking(heapRel))) _bt_dedup_pass(rel, buffer, insertstate->itup, insertstate->itemsz, (indexUnchanged || uniquedup)); } @@ -2916,6 +3026,17 @@ _bt_simpledel_pass(Relation rel, Buffer buffer, Relation heapRel, BlockNumber tidblock; void *match; + /* + * A delete-marked tombstone is never LP_DEAD and its t_tid + * holds alt-TID metadata rather than a heap TID; skip it so we never + * feed a bogus TID into the table AM's index-deletion call. + */ + if (BTreeTupleIsDeleteMarked(itup)) + { + Assert(!ItemIdIsDead(itemid)); + continue; + } + if (!BTreeTupleIsPosting(itup)) { tidblock = ItemPointerGetBlockNumber(&itup->t_tid); diff --git a/src/backend/access/nbtree/nbtpage.c b/src/backend/access/nbtree/nbtpage.c index 410514d8e1013..ac028311a6f27 100644 --- a/src/backend/access/nbtree/nbtpage.c +++ b/src/backend/access/nbtree/nbtpage.c @@ -39,11 +39,6 @@ #include "utils/snapmgr.h" static BTMetaPageData *_bt_getmeta(Relation rel, Buffer metabuf); -static void _bt_delitems_delete(Relation rel, Buffer buf, - TransactionId snapshotConflictHorizon, - bool isCatalogRel, - OffsetNumber *deletable, int ndeletable, - BTVacuumPosting *updatable, int nupdatable); static char *_bt_delitems_update(BTVacuumPosting *updatable, int nupdatable, OffsetNumber *updatedoffsets, Size *updatedbuflen, bool needswal); @@ -1288,8 +1283,11 @@ _bt_delitems_vacuum(Relation rel, Buffer buf, * (from the tableam). This is used by the REDO routine to generate recovery * conflicts. The other difference is that only _bt_delitems_vacuum will * clear page's VACUUM cycle ID. + * + * btvacuumpage also uses it for delete-marked entries of live table TIDs, + * whose removal needs a recovery conflict that a VACUUM record lacks. */ -static void +void _bt_delitems_delete(Relation rel, Buffer buf, TransactionId snapshotConflictHorizon, bool isCatalogRel, OffsetNumber *deletable, int ndeletable, diff --git a/src/backend/access/nbtree/nbtreadpage.c b/src/backend/access/nbtree/nbtreadpage.c index 448a51412445f..dbc2f37b167e1 100644 --- a/src/backend/access/nbtree/nbtreadpage.c +++ b/src/backend/access/nbtree/nbtreadpage.c @@ -1036,7 +1036,24 @@ _bt_saveitem(BTScanOpaque so, int itemIndex, Assert(!BTreeTupleIsPivot(itup) && !BTreeTupleIsPosting(itup)); - currItem->heapTid = itup->t_tid; + /* + * A delete-marked leaf tuple stores its heap TID in a trailer, + * not in t_tid (which holds alt-TID metadata). Use + * BTreeTupleGetHeapTID() to fetch the real TID, and flag the item so the + * caller rechecks the qual against the visible heap version: the + * delete-marked entry's key may describe an older version of the + * row. + */ + if (BTreeTupleIsDeleteMarked(itup)) + { + currItem->heapTid = *BTreeTupleGetHeapTID(itup); + currItem->recheck = true; + } + else + { + currItem->heapTid = itup->t_tid; + currItem->recheck = false; + } currItem->indexOffset = offnum; if (so->currTuples) { @@ -1068,6 +1085,7 @@ _bt_setuppostingitems(BTScanOpaque so, int itemIndex, OffsetNumber offnum, currItem->heapTid = *heapTid; currItem->indexOffset = offnum; + currItem->recheck = false; /* posting tuples are never delete-marked */ if (so->currTuples) { /* Save base IndexTuple (truncate posting list) */ @@ -1104,6 +1122,7 @@ _bt_savepostingitem(BTScanOpaque so, int itemIndex, OffsetNumber offnum, currItem->heapTid = *heapTid; currItem->indexOffset = offnum; + currItem->recheck = false; /* posting tuples are never delete-marked */ /* * Have index-only scans return the same base IndexTuple for every TID diff --git a/src/backend/access/nbtree/nbtree.c b/src/backend/access/nbtree/nbtree.c index 0abdd7b49f5b1..4eef07dcdad62 100644 --- a/src/backend/access/nbtree/nbtree.c +++ b/src/backend/access/nbtree/nbtree.c @@ -19,8 +19,11 @@ #include "postgres.h" #include "access/nbtree.h" +#include "access/nbtxlog.h" #include "access/relscan.h" #include "access/stratnum.h" +#include "access/tableam.h" +#include "access/xloginsert.h" #include "commands/progress.h" #include "commands/vacuum.h" #include "nodes/execnodes.h" @@ -35,6 +38,7 @@ #include "utils/datum.h" #include "utils/fmgrprotos.h" #include "utils/index_selfuncs.h" +#include "utils/injection_point.h" #include "utils/memutils.h" #include "utils/wait_event.h" @@ -163,6 +167,8 @@ bthandler(PG_FUNCTION_ARGS) .amrescan = btrescan, .amgettuple = btgettuple, .amgetbitmap = btgetbitmap, + .amdeletemark = btdeletemark, + .amundomark = btundomark, .amendscan = btendscan, .ammarkpos = btmarkpos, .amrestrpos = btrestrpos, @@ -304,7 +310,8 @@ btgetbitmap(IndexScanDesc scan, TIDBitmap *tbm) { /* Save tuple ID, and continue scanning */ heapTid = &scan->xs_heaptid; - tbm_add_tuples(tbm, heapTid, 1, false); + tbm_add_tuples(tbm, heapTid, 1, + so->currPos.items[so->currPos.itemIndex].recheck); ntids++; for (;;) @@ -320,9 +327,14 @@ btgetbitmap(IndexScanDesc scan, TIDBitmap *tbm) break; } - /* Save tuple ID, and continue scanning */ + /* + * Save tuple ID, and continue scanning. A delete-marked + * entry is added with recheck=true so the bitmap-heap-scan + * consumer re-evaluates the qual against the visible version. + */ heapTid = &so->currPos.items[so->currPos.itemIndex].heapTid; - tbm_add_tuples(tbm, heapTid, 1, false); + tbm_add_tuples(tbm, heapTid, 1, + so->currPos.items[so->currPos.itemIndex].recheck); ntids++; } } @@ -416,7 +428,7 @@ btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys, * * Note: so->dropPin should never change across rescans. */ - so->dropPin = (!scan->xs_want_itup && + so->dropPin = (!scan->xs_want_itup && !scan->xs_want_itup_recheck && IsMVCCLikeSnapshot(scan->xs_snapshot) && scan->heapRelation != NULL); @@ -433,7 +445,8 @@ btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys, * overhead, both workspaces are allocated as one palloc block; only this * function and btendscan know that. */ - if (scan->xs_want_itup && so->currTuples == NULL) + if ((scan->xs_want_itup || scan->xs_want_itup_recheck) && + so->currTuples == NULL) { so->currTuples = (char *) palloc(BLCKSZ * 2); so->markTuples = so->currTuples + BLCKSZ; @@ -1524,6 +1537,9 @@ btvacuumpage(BTVacState *vstate, Buffer buf) int ndeletable; BTVacuumPosting updatable[MaxIndexTuplesPerPage]; int nupdatable; + OffsetNumber tombstones[MaxIndexTuplesPerPage]; + int ntombstones = 0; + TransactionId tombstone_horizon = InvalidTransactionId; OffsetNumber offnum, minoff, maxoff; @@ -1573,7 +1589,46 @@ btvacuumpage(BTVacState *vstate, Buffer buf) PageGetItemId(page, offnum)); Assert(!BTreeTupleIsPivot(itup)); - if (!BTreeTupleIsPosting(itup)) + if (BTreeTupleIsDeleteMarked(itup)) + { + /* + * Delete-marked tombstone: its heap TID lives + * in the trailer, not in t_tid. Ask the callback about + * that TID. The callback (driven by undo-informed index + * pruning) is the sole authority on removability: we + * remove the tombstone only when it says so -- i.e. when + * no snapshot can still need the before-image at this TID. + * We never remove a tombstone merely + * because it is marked. + */ + ItemPointer htid = BTreeTupleGetHeapTID(itup); + TransactionId conflict_xid = InvalidTransactionId; + + if (callback(htid, callback_state)) + { + deletable[ndeletable++] = offnum; + nhtidsdead++; + } + else if (heaprel != NULL && + table_index_tombstone_removable(heaprel, rel, itup, + htid, + &conflict_xid)) + { + /* + * The TID is live but no snapshot can need the row + * through this entry's key any more. + */ + tombstones[ntombstones++] = offnum; + if (TransactionIdIsNormal(conflict_xid) && + (!TransactionIdIsValid(tombstone_horizon) || + TransactionIdFollows(conflict_xid, + tombstone_horizon))) + tombstone_horizon = conflict_xid; + } + else + nhtidslive++; + } + else if (!BTreeTupleIsPosting(itup)) { /* Regular tuple, standard table TID representation */ if (callback(&itup->t_tid, callback_state)) @@ -1670,6 +1725,29 @@ btvacuumpage(BTVacState *vstate, Buffer buf) } } + /* + * Remove the prunable tombstones of live TIDs. Unlike the entries + * above, nothing table-side generated a recovery conflict for them, + * so log them as a deletion carrying its own conflict horizon. The + * deletions above shifted the page's later items down. + */ + if (ntombstones > 0) + { + for (int i = 0, j = 0; i < ntombstones; i++) + { + while (j < ndeletable && deletable[j] < tombstones[i]) + j++; + tombstones[i] -= j; + } + if (!XLogStandbyInfoActive()) + tombstone_horizon = InvalidTransactionId; + _bt_delitems_delete(rel, buf, tombstone_horizon, + RelationIsAccessibleInLogicalDecoding(heaprel), + tombstones, ntombstones, NULL, 0); + stats->tuples_removed += ntombstones; + maxoff = PageGetMaxOffsetNumber(page); + } + /* * If the leaf page is now empty, try to delete it; else count the * live tuples (live table TIDs in posting lists are counted as @@ -1796,7 +1874,22 @@ btreevacuumposting(BTVacState *vstate, IndexTuple posting, /* * btcanreturn() -- Check whether btree indexes support index-only scans. * - * btrees always do, so this is trivial. + * btrees always do. + * + * On a delete-marking table an index-only scan could return a delete-marked + * entry's stale key without visiting the heap (when the VM says + * all-visible), which would be wrong, so index-only scans must be disabled + * for such indexes. That gate cannot live here: + * btcanreturn() is reachable during catalog/relcache bootstrap and planning, + * where determining the parent table's AM (opening the relation, or + * instantiating its table-AM routine) is unsafe and crashes + * RelationUndoEngine() on half-built relcache entries. + * + * We therefore return true (the upstream behaviour) here. A delete-marked + * entry may carry a stale key, so index-only scans must be disabled for such + * indexes in the planner / index-open path that already holds the heap + * relation (forcing amcanreturn=false there) rather than by reaching for the + * table AM from inside this callback. */ bool btcanreturn(Relation index, int attno) @@ -1813,6 +1906,457 @@ btgettreeheight(Relation rel) return _bt_getrootheight(rel); } +/* + * btdeletemark() -- delete-mark an existing (key, TID) leaf entry in place. + * + * Called by the table AM during an in-place UPDATE of an + * indexed column: after inserting the new (k_new, T) entry, the AM asks each + * affected index to delete-mark the OLD (k_old, T) entry so that it lingers as + * a tombstone (reachable by old-snapshot readers via the before-image) instead + * of being deleted. See "Delete-marking" in src/backend/access/nbtree/README. + * + * 'values'/'isnull' describe k_old; heap_t_ctid is the stable heap TID T. + * + * Concurrency safety: we re-descend the tree by (k_old, T) using an insertion + * scankey with scantid = T (exactly as _bt_doinsert() does), so a concurrent + * page split cannot cause us to mark the wrong entry -- the heapkeyspace TID + * tiebreaker pins us to the one leaf tuple that has this exact TID. We hold + * the leaf buffer's exclusive lock across the search result, the in-place + * promotion, and the WAL insert. + * + * Returns true if the entry was found and marked, false otherwise (already + * marked, or not present -- e.g. a concurrent VACUUM removed it). A missing + * entry is not an error: the caller's new entry already covers new readers, + * and if the old entry is gone no old reader can still need it. + */ +bool +btdeletemark(Relation rel, Relation heapRel, + Datum *values, bool *isnull, ItemPointer heap_t_ctid) +{ + IndexTuple searchitup; + BTScanInsert itup_key; + BTInsertStateData insertstate; + BTStack stack; + Buffer buf; + Page page; + BTPageOpaque opaque; + OffsetNumber offnum; + ItemId itemid; + IndexTuple olditup; + IndexTuple newitup; + Size newsize; + ItemPointerData htid; + bool marked = false; + + /* Build the (k_old, T) search tuple and its insertion scankey. */ + searchitup = index_form_tuple(RelationGetDescr(rel), values, isnull); + searchitup->t_tid = *heap_t_ctid; + + itup_key = _bt_mkscankey(rel, searchitup); + + /* + * heapkeyspace is required: delete-marking relies on the heap-TID + * tiebreaker to locate the exact entry. Non-heapkeyspace (pre-v11) + * indexes are not supported. + */ + if (!itup_key->heapkeyspace) + { + pfree(itup_key); + pfree(searchitup); + elog(ERROR, "btdeletemark requires a heapkeyspace (version 4) index"); + } + + /* scantid pins the search to the exact TID (concurrency-safe re-descend) */ + itup_key->scantid = heap_t_ctid; + + insertstate.itup = searchitup; + insertstate.itemsz = MAXALIGN(IndexTupleSize(searchitup)); + insertstate.itup_key = itup_key; + insertstate.bounds_valid = false; + insertstate.postingoff = 0; + insertstate.nopostingsplit = (heapRel != NULL && RelationSupportsDeleteMarking(heapRel)); + + /* + * Descend to the leaf level with an exclusive lock on the target page. + * Keep the stack: growing the entry may have to split the leaf. + */ + stack = _bt_search(rel, heapRel, itup_key, &buf, BT_WRITE, true); + + insertstate.buf = buf; + page = BufferGetPage(buf); + opaque = BTPageGetOpaque(page); + Assert(P_ISLEAF(opaque)); + + /* + * _bt_binsrch_insert() returns the offset of the first entry >= the + * scankey. With scantid set, that is the exact (k_old, T) entry if it is + * present on this page. + */ + offnum = _bt_binsrch_insert(rel, &insertstate); + + if (offnum >= P_FIRSTDATAKEY(opaque) && + offnum <= PageGetMaxOffsetNumber(page) && + _bt_compare(rel, itup_key, page, offnum) == 0) + { + itemid = PageGetItemId(page, offnum); + olditup = (IndexTuple) PageGetItem(page, itemid); + + /* + * The scankey (with scantid) matched, so this must be the exact + * single-TID entry we mean to mark: not a pivot (leaf), not a posting + * list (posting tuples compare equal only across a TID range, and an + * in-place UPDATE never targets a merged posting list -- dedup only + * merges fully-live equal-key entries). If it is already + * delete-marked there is nothing to do. + */ + if (BTreeTupleIsDeleteMarked(olditup)) + { + /* already a tombstone -- idempotent success */ + marked = false; + } + else if (BTreeTupleIsPosting(olditup) || BTreeTupleIsPivot(olditup)) + { + /* not a plain single-TID entry -- refuse (should not happen) */ + marked = false; + } + else if (ItemIdIsDead(itemid)) + { + /* + * The LP_DEAD hint says the entry is dead to every snapshot, so + * no old-snapshot reader needs it and there is nothing to mark. + * Report "not present". Marking it anyway would produce an + * LP_DEAD tombstone (PageIndexTupleOverwrite keeps lp_flags), + * which the scans it exists for would skip and which + * _bt_simpledel_pass does not expect. + */ + marked = false; + } + else + { + /* + * Build the delete-marked replacement. Copy the existing tuple, + * grow it by an ItemPointerData trailer, and promote it. Its + * heap TID (currently in t_tid) moves into the trailer. + */ + Size oldsz = IndexTupleSize(olditup); + + ItemPointerCopy(&olditup->t_tid, &htid); + newsize = BTreeTupleGetDeleteMarkedSize(olditup); + newitup = (IndexTuple) palloc0(newsize); + memcpy(newitup, olditup, oldsz); + BTreeTupleSetDeleteMarked(newitup, &htid, newsize); + + START_CRIT_SECTION(); + + if (!PageIndexTupleOverwrite(page, offnum, newitup, newsize)) + { + /* + * No room to grow the entry in place by the delete-marked + * trailer. We must NOT leave it plain (that would keep a + * stale live entry under the old key -- the source of + * duplicate (key,TID) entries after key churn on full pages). + * Replace it with the delete-marked tuple as part of a split + * of this leaf, under the lock we found it with: one atomic + * action, so an old-snapshot scan always finds one of the two + * tuples, and an error leaves the plain entry in place. The + * page is unchanged so far. + */ + END_CRIT_SECTION(); + _bt_replace_split(rel, heapRel, itup_key, buf, stack, + newitup, offnum); + + INJECTION_POINT("nbtree-deletemark-no-room", NULL); + + _bt_freestack(stack); + pfree(newitup); + pfree(itup_key); + pfree(searchitup); + return true; + } + + MarkBufferDirty(buf); + + if (RelationNeedsWAL(rel)) + { + xl_btree_delete_mark xlrec; + XLogRecPtr recptr; + + xlrec.offnum = offnum; + xlrec.setmark = true; + xlrec.deleted = false; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfBtreeDeleteMark); + XLogRegisterBuffer(0, buf, REGBUF_STANDARD); + /* Log the exact final tuple image for deterministic redo */ + XLogRegisterBufData(0, (char *) newitup, newsize); + + recptr = XLogInsert(RM_BTREE_ID, XLOG_BTREE_DELETE_MARK); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + + pfree(newitup); + marked = true; + } + } + + _bt_relbuf(rel, buf); + _bt_freestack(stack); + pfree(itup_key); + pfree(searchitup); + + return marked; +} + +/* + * btundomark() -- reverse an in-place key UPDATE's index change on ROLLBACK. + * + * Called by a delete-marking table AM's UNDO (e.g. FLUX + * flux_undo_apply) to restore an index to its pre-update state after an + * in-place indexed-column UPDATE is rolled back. We re-descend by + * (values, heap_t_ctid) -- exactly as btdeletemark does, so a concurrent split + * cannot move us to the wrong entry -- and: + * + * clear_mark == true : make a live entry for (values, heap_t_ctid) exist. + * Normally the located entry is the OLD key's + * delete-marked tombstone, which is DEMOTED back to a + * plain live entry (rebuilt from values/isnull with + * t_tid = the heap TID) and WAL-logged + * (XLOG_BTREE_DELETE_MARK, setmark=false). If a plain + * entry for that exact (key, heap TID) is already + * present the request is already satisfied and we report + * success, so the caller does not insert a duplicate. + * + * clear_mark == false: the located entry is the NEW key's live entry the + * aborted update inserted; DELETE it physically and + * WAL-log the deletion (XLOG_BTREE_DELETE_MARK, + * deleted=true). + * + * The kill must be a physical, WAL-logged delete rather than an LP_DEAD hint. + * An aborted update leaves no trace in the heap, so nothing will ever come back + * to reclaim a merely-hinted entry: LP_DEAD garbage for (k_new, TID) would + * accumulate without bound, one entry per abort of the same row. That breaks + * the heapkeyspace ordering invariant, because a subsequent UPDATE of the same + * row to the same k_new finds the leftover entry not delete-marked (it is a + * plain entry that merely has its LP_DEAD bit set), declines to revive it, and + * inserts a second entry with an identical (key, heap TID). Two entries that + * compare exactly equal are unordered with respect to each other, which amcheck + * reports as "item order invariant violated". VACUUM cannot be relied on to + * clean up first, and LP_DEAD is only a hint, so it can also be lost. + * + * Returns true if the entry was found and reversed, false otherwise (already + * gone -- not an error). + */ +bool +btundomark(Relation rel, Relation heapRel, + Datum *values, bool *isnull, ItemPointer heap_t_ctid, + bool clear_mark) +{ + IndexTuple searchitup; + BTScanInsert itup_key; + BTInsertStateData insertstate; + BTStack stack PG_USED_FOR_ASSERTS_ONLY; + Buffer buf; + Page page; + BTPageOpaque opaque; + OffsetNumber offnum; + ItemId itemid; + IndexTuple olditup; + bool done = false; + + searchitup = index_form_tuple(RelationGetDescr(rel), values, isnull); + searchitup->t_tid = *heap_t_ctid; + + itup_key = _bt_mkscankey(rel, searchitup); + if (!itup_key->heapkeyspace) + { + pfree(itup_key); + pfree(searchitup); + elog(ERROR, "btundomark requires a heapkeyspace (version 4) index"); + } + itup_key->scantid = heap_t_ctid; + + insertstate.itup = searchitup; + insertstate.itemsz = MAXALIGN(IndexTupleSize(searchitup)); + insertstate.itup_key = itup_key; + insertstate.bounds_valid = false; + insertstate.postingoff = 0; + insertstate.nopostingsplit = (heapRel != NULL && RelationSupportsDeleteMarking(heapRel)); + + stack = _bt_search(rel, heapRel, itup_key, &buf, BT_WRITE, false); + Assert(stack == NULL); + + insertstate.buf = buf; + page = BufferGetPage(buf); + opaque = BTPageGetOpaque(page); + Assert(P_ISLEAF(opaque)); + + offnum = _bt_binsrch_insert(rel, &insertstate); + + if (offnum >= P_FIRSTDATAKEY(opaque) && + offnum <= PageGetMaxOffsetNumber(page) && + _bt_compare(rel, itup_key, page, offnum) == 0) + { + itemid = PageGetItemId(page, offnum); + olditup = (IndexTuple) PageGetItem(page, itemid); + + if (clear_mark) + { + /* Demote the OLD key's tombstone back to a plain live entry. */ + if (BTreeTupleIsDeleteMarked(olditup)) + { + IndexTuple plainitup; + Size plainsize; + + plainitup = index_form_tuple(RelationGetDescr(rel), + values, isnull); + plainitup->t_tid = *heap_t_ctid; + plainsize = IndexTupleSize(plainitup); + + START_CRIT_SECTION(); + + if (!PageIndexTupleOverwrite(page, offnum, plainitup, plainsize)) + { + END_CRIT_SECTION(); + pfree(plainitup); + _bt_relbuf(rel, buf); + pfree(itup_key); + pfree(searchitup); + return false; + } + + /* + * The entry is live again, so an LP_DEAD hint must not + * survive the overwrite (which keeps lp_flags): scans would + * keep skipping it. Redo does the same. + */ + if (ItemIdIsDead(itemid)) + ItemIdSetNormal(itemid, ItemIdGetOffset(itemid), + ItemIdGetLength(itemid)); + + MarkBufferDirty(buf); + + if (RelationNeedsWAL(rel)) + { + xl_btree_delete_mark xlrec; + XLogRecPtr recptr; + + xlrec.offnum = offnum; + xlrec.setmark = false; + xlrec.deleted = false; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfBtreeDeleteMark); + XLogRegisterBuffer(0, buf, REGBUF_STANDARD); + XLogRegisterBufData(0, (char *) plainitup, plainsize); + recptr = XLogInsert(RM_BTREE_ID, XLOG_BTREE_DELETE_MARK); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + pfree(plainitup); + done = true; + } + else if (!BTreeTupleIsPosting(olditup) && + !BTreeTupleIsPivot(olditup) && + ItemPointerEquals(&olditup->t_tid, heap_t_ctid)) + { + /* + * A plain entry for this exact (key, heap TID) is already + * present, so the live entry the caller wants already exists. + * Report success: an insert here would add a second entry + * with an identical key and heap TID, and two entries that + * compare exactly equal have no defined order relative to + * each other. + * + * The caller reaches this state when another backend's + * forward in-place UPDATE of the same row to the same key, or + * the reversal of one, has already established the entry. + * Only the LP_DEAD hint may need clearing, which makes the + * entry visible to scans again; that is a page change, so it + * is WAL-logged with the tuple image (redo clears LP_DEAD for + * setmark=false). + */ + if (ItemIdIsDead(itemid)) + { + Size olditupsz = IndexTupleSize(olditup); + + START_CRIT_SECTION(); + + ItemIdSetNormal(itemid, ItemIdGetOffset(itemid), + ItemIdGetLength(itemid)); + MarkBufferDirty(buf); + + if (RelationNeedsWAL(rel)) + { + xl_btree_delete_mark xlrec; + XLogRecPtr recptr; + + xlrec.offnum = offnum; + xlrec.setmark = false; + xlrec.deleted = false; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfBtreeDeleteMark); + XLogRegisterBuffer(0, buf, REGBUF_STANDARD); + XLogRegisterBufData(0, (char *) olditup, olditupsz); + recptr = XLogInsert(RM_BTREE_ID, XLOG_BTREE_DELETE_MARK); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + } + + done = true; + } + } + else + { + /* + * Remove the NEW key's entry that the aborted update inserted. A + * posting list is never the target: an in-place indexed-column + * UPDATE inserts with nopostingsplit, so its entry is always a + * plain single-TID tuple. + */ + if (!BTreeTupleIsPosting(olditup) && !BTreeTupleIsPivot(olditup)) + { + START_CRIT_SECTION(); + + PageIndexTupleDelete(page, offnum); + MarkBufferDirty(buf); + + if (RelationNeedsWAL(rel)) + { + xl_btree_delete_mark xlrec; + XLogRecPtr recptr; + + /* deleted=true => redo removes the entry (no tuple image) */ + xlrec.offnum = offnum; + xlrec.setmark = false; + xlrec.deleted = true; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfBtreeDeleteMark); + XLogRegisterBuffer(0, buf, REGBUF_STANDARD); + recptr = XLogInsert(RM_BTREE_ID, XLOG_BTREE_DELETE_MARK); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + done = true; + } + } + } + + _bt_relbuf(rel, buf); + pfree(itup_key); + pfree(searchitup); + + return done; +} + CompareType bttranslatestrategy(StrategyNumber strategy, Oid opfamily) { diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c index 5964bc9195e1d..64d232c98cab1 100644 --- a/src/backend/access/nbtree/nbtsearch.c +++ b/src/backend/access/nbtree/nbtsearch.c @@ -554,9 +554,28 @@ _bt_binsrch_insert(Relation rel, BTInsertState insertstate) * binary search to set postingoff for caller. Caller must split the * posting list when postingoff is set. This should happen * infrequently. + * + * An index on a delete-marking table has no posting lists (see + * "Delete-marking" in the README), so finding one there means the + * index is corrupt; inserting next to it would break TID order. */ if (unlikely(result == 0 && key->scantid != NULL)) { + if (unlikely(insertstate->nopostingsplit)) + { + IndexTuple itup; + + itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, mid)); + if (BTreeTupleIsPosting(itup)) + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg_internal("posting list tuple at offset %u of block %u in index \"%s\" on a delete-marking table", + mid, + BufferGetBlockNumber(insertstate->buf), + RelationGetRelationName(rel)))); + continue; + } + /* * postingoff should never be set more than once per leaf page * binary search. That would mean that there are duplicate table @@ -1637,6 +1656,9 @@ _bt_returnitem(IndexScanDesc scan, BTScanOpaque so) /* Return next item, per amgettuple contract */ scan->xs_heaptid = currItem->heapTid; + /* Delete-marked entries force a recheck of the qual */ + if (currItem->recheck) + scan->xs_recheck = true; if (so->currTuples) scan->xs_itup = (IndexTuple) (so->currTuples + currItem->tupleOffset); } diff --git a/src/backend/access/nbtree/nbtsort.c b/src/backend/access/nbtree/nbtsort.c index 756dfa3dcf47e..1afebc360a915 100644 --- a/src/backend/access/nbtree/nbtsort.c +++ b/src/backend/access/nbtree/nbtsort.c @@ -832,7 +832,7 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup, * make use of the reserved space. This should never fail on internal * pages. */ - if (unlikely(itupsz > BTMaxItemSize)) + if (unlikely(itupsz > BTMaxItemSizeDeleteMarking)) _bt_check_third_page(wstate->index, wstate->heap, isleaf, npage, itup); @@ -1153,7 +1153,8 @@ _bt_load(BTWriteState *wstate, BTSpool *btspool, BTSpool *btspool2) wstate->bulkstate = smgr_bulk_start_rel(wstate->index, MAIN_FORKNUM); deduplicate = wstate->inskey->allequalimage && !btspool->isunique && - BTGetDeduplicateItems(wstate->index); + BTGetDeduplicateItems(wstate->index) && + !(wstate->heap != NULL && RelationSupportsDeleteMarking(wstate->heap)); if (merge) { diff --git a/src/backend/access/nbtree/nbtsplitloc.c b/src/backend/access/nbtree/nbtsplitloc.c index de9eca3c8b2ea..d84467366c792 100644 --- a/src/backend/access/nbtree/nbtsplitloc.c +++ b/src/backend/access/nbtree/nbtsplitloc.c @@ -716,7 +716,8 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff, tup = (IndexTuple) PageGetItem(state->origpage, itemid); /* Do cheaper test first */ if (BTreeTupleIsPosting(tup) || - !_bt_adjacenthtid(&tup->t_tid, &state->newitem->t_tid)) + !_bt_adjacenthtid(BTreeTupleGetHeapTID(tup), + BTreeTupleGetHeapTID(state->newitem))) return false; /* Check same conditions as rightmost item case, too */ keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem); diff --git a/src/backend/access/nbtree/nbtutils.c b/src/backend/access/nbtree/nbtutils.c index 014faa1622fb5..728f09feba9a1 100644 --- a/src/backend/access/nbtree/nbtutils.c +++ b/src/backend/access/nbtree/nbtutils.c @@ -20,6 +20,7 @@ #include "access/nbtree.h" #include "access/reloptions.h" #include "access/relscan.h" +#include "access/tableam.h" #include "commands/progress.h" #include "common/int.h" #include "lib/qunique.h" @@ -333,6 +334,17 @@ _bt_killitems(IndexScanDesc scan) if (j == nposting) killtuple = true; } + else if (BTreeTupleIsDeleteMarked(ituple)) + { + /* + * A delete-marked tombstone is never LP_DEAD-killed via + * kill_prior_tuple: it must linger for old-snapshot readers + * until VACUUM decides it is removable. Also, its t_tid holds + * alt-TID metadata rather than the heap TID, so comparing it + * against kitem->heapTid would be meaningless. Leave killtuple + * false. + */ + } else if (ItemPointerEquals(&ituple->t_tid, &kitem->heapTid)) killtuple = true; @@ -699,6 +711,7 @@ _bt_truncate(Relation rel, IndexTuple lastleft, IndexTuple firstright, IndexTuple tidpivot; ItemPointer pivotheaptid; Size newsize; + IndexTuple firstright_plain = NULL; /* * We should only ever truncate non-pivot tuples from leaf pages. It's @@ -706,6 +719,30 @@ _bt_truncate(Relation rel, IndexTuple lastleft, IndexTuple firstright, */ Assert(!BTreeTupleIsPivot(lastleft) && !BTreeTupleIsPivot(firstright)); + /* + * A delete-marked leaf tuple carries alt-TID status metadata + * in t_tid and its heap TID in a trailer. index_truncate_tuple() would + * copy that alt-TID metadata / oversized trailer into the new pivot, + * producing a malformed separator whose heap-TID tiebreaker is wrong (a + * later scantid descent then routes to the wrong leaf -- the delete-mark + * revive miss that leaves a duplicate (key,TID)). Demote firstright to a + * plain leaf tuple (same key attrs, heap TID back in t_tid, no trailer, + * no status bit) for forming the pivot; the pivot only needs key + heap + * TID. + */ + if (BTreeTupleIsDeleteMarked(firstright)) + { + Datum vals[INDEX_MAX_KEYS]; + bool nulls[INDEX_MAX_KEYS]; + ItemPointerData htid; + + ItemPointerCopy(BTreeTupleGetHeapTID(firstright), &htid); + index_deform_tuple(firstright, itupdesc, vals, nulls); + firstright_plain = index_form_tuple(itupdesc, vals, nulls); + firstright_plain->t_tid = htid; + firstright = firstright_plain; + } + /* Determine how many attributes must be kept in truncated tuple */ keepnatts = _bt_keep_natts(rel, lastleft, firstright, itup_key); @@ -737,6 +774,8 @@ _bt_truncate(Relation rel, IndexTuple lastleft, IndexTuple firstright, if (keepnatts <= nkeyatts) { BTreeTupleSetNAtts(pivot, keepnatts, false); + if (firstright_plain != NULL) + pfree(firstright_plain); return pivot; } @@ -819,6 +858,8 @@ _bt_truncate(Relation rel, IndexTuple lastleft, IndexTuple firstright, BTreeTupleGetHeapTID(firstright)) < 0); #endif + if (firstright_plain != NULL) + pfree(firstright_plain); return tidpivot; } @@ -1110,6 +1151,11 @@ _bt_check_natts(Relation rel, bool heapkeyspace, Page page, OffsetNumber offnum) * any one item to 1/3 the per-page available space. Note that itemsz should * not include the ItemId overhead. * + * If the table supports delete-marking, a plain leaf tuple must leave room + * for the trailer it gains when it is delete-marked, so the lower + * BTMaxItemSizeDeleteMarking limit applies to it. Callers may skip calling + * here only for tuples within that lower limit. + * * It might be useful to apply TOAST methods rather than throw an error here. * Using out of line storage would break assumptions made by suffix truncation * and by contrib/amcheck, though. @@ -1119,12 +1165,19 @@ _bt_check_third_page(Relation rel, Relation heap, bool needheaptidspace, Page page, IndexTuple newtup) { Size itemsz; + Size maxitemsz = BTMaxItemSize; BTPageOpaque opaque; itemsz = MAXALIGN(IndexTupleSize(newtup)); + opaque = BTPageGetOpaque(page); + + if (needheaptidspace && P_ISLEAF(opaque) && + !BTreeTupleIsDeleteMarked(newtup) && !BTreeTupleIsPosting(newtup) && + heap != NULL && RelationSupportsDeleteMarking(heap)) + maxitemsz = BTMaxItemSizeDeleteMarking; /* Double check item size against limit */ - if (itemsz <= BTMaxItemSize) + if (itemsz <= maxitemsz) return; /* @@ -1139,7 +1192,6 @@ _bt_check_third_page(Relation rel, Relation heap, bool needheaptidspace, * Internal page insertions cannot fail here, because that would mean that * an earlier leaf level insertion that should have failed didn't */ - opaque = BTPageGetOpaque(page); if (!P_ISLEAF(opaque)) elog(ERROR, "cannot insert oversized tuple of size %zu on internal page of index \"%s\"", itemsz, RelationGetRelationName(rel)); @@ -1149,7 +1201,7 @@ _bt_check_third_page(Relation rel, Relation heap, bool needheaptidspace, errmsg("index row size %zu exceeds btree version %u maximum %zu for index \"%s\"", itemsz, needheaptidspace ? BTREE_VERSION : BTREE_NOVAC_VERSION, - needheaptidspace ? BTMaxItemSize : BTMaxItemSizeNoHeapTid, + needheaptidspace ? maxitemsz : BTMaxItemSizeNoHeapTid, RelationGetRelationName(rel)), errdetail("Index row references tuple (%u,%u) in relation \"%s\".", ItemPointerGetBlockNumber(BTreeTupleGetHeapTID(newtup)), diff --git a/src/backend/access/nbtree/nbtxlog.c b/src/backend/access/nbtree/nbtxlog.c index dff7d286fc834..31949d2c86b91 100644 --- a/src/backend/access/nbtree/nbtxlog.c +++ b/src/backend/access/nbtree/nbtxlog.c @@ -360,6 +360,13 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record) Assert(datalen == 0); + /* newitem took the place of the item at newitemoff */ + if (xlrec->replace) + { + Assert(xlrec->postingoff == 0); + PageIndexTupleDelete(origpage, xlrec->newitemoff); + } + leftpage = PageGetTempPageCopySpecial(origpage); /* Add high key tuple from WAL record to temp page */ @@ -1000,6 +1007,63 @@ btree_xlog_reuse_page(XLogReaderState *record) xlrec->locator); } +/* + * btree_xlog_delete_mark - redo an in-place delete-mark/unmark of a leaf tuple + * + * The record carries the final tuple image (already in delete-marked or plain + * form) as block data; we overwrite the item at offnum with it. Logging the + * exact bytes keeps redo byte-for-byte deterministic for + * wal_consistency_checking. + */ +static void +btree_xlog_delete_mark(XLogReaderState *record) +{ + XLogRecPtr lsn = record->EndRecPtr; + xl_btree_delete_mark *xlrec = (xl_btree_delete_mark *) XLogRecGetData(record); + Buffer buf; + + if (XLogReadBufferForRedo(record, 0, &buf) == BLK_NEEDS_REDO) + { + Page page = BufferGetPage(buf); + + if (xlrec->deleted) + { + /* btundomark() removed an aborted UPDATE's new-key entry */ + PageIndexTupleDelete(page, xlrec->offnum); + } + else + { + Size newsize = 0; + char *newtup = XLogRecGetBlockData(record, 0, &newsize); + + if (!PageIndexTupleOverwrite(page, xlrec->offnum, + (IndexTuple) newtup, newsize)) + elog(ERROR, "btree_xlog_delete_mark: failed to overwrite item at offset %u", + xlrec->offnum); + + /* + * The primary never leaves the entry LP_DEAD, whichever way it + * went: it does not delete-mark an LP_DEAD entry, and it clears + * the hint when it makes an entry live again. Do the same here; + * the overwrite keeps lp_flags, and the standby's copy of the + * page may carry an LP_DEAD hint from a full-page image. + */ + { + ItemId itemid = PageGetItemId(page, xlrec->offnum); + + if (ItemIdIsDead(itemid)) + ItemIdSetNormal(itemid, ItemIdGetOffset(itemid), + ItemIdGetLength(itemid)); + } + } + + PageSetLSN(page, lsn); + MarkBufferDirty(buf); + } + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); +} + void btree_redo(XLogReaderState *record) { @@ -1052,6 +1116,9 @@ btree_redo(XLogReaderState *record) case XLOG_BTREE_META_CLEANUP: _bt_restore_meta(record, 0); break; + case XLOG_BTREE_DELETE_MARK: + btree_xlog_delete_mark(record); + break; default: elog(PANIC, "btree_redo: unknown op code %u", info); } diff --git a/src/backend/access/rmgrdesc/nbtdesc.c b/src/backend/access/rmgrdesc/nbtdesc.c index 1d08f9957bdca..75f6bf807b2da 100644 --- a/src/backend/access/rmgrdesc/nbtdesc.c +++ b/src/backend/access/rmgrdesc/nbtdesc.c @@ -43,9 +43,10 @@ btree_desc(StringInfo buf, XLogReaderState *record) { xl_btree_split *xlrec = (xl_btree_split *) rec; - appendStringInfo(buf, "level: %u, firstrightoff: %d, newitemoff: %d, postingoff: %d", + appendStringInfo(buf, "level: %u, firstrightoff: %d, newitemoff: %d, postingoff: %d, replace: %c", xlrec->level, xlrec->firstrightoff, - xlrec->newitemoff, xlrec->postingoff); + xlrec->newitemoff, xlrec->postingoff, + xlrec->replace ? 'T' : 'F'); break; } case XLOG_BTREE_DEDUP: @@ -132,6 +133,16 @@ btree_desc(StringInfo buf, XLogReaderState *record) xlrec->last_cleanup_num_delpages); break; } + case XLOG_BTREE_DELETE_MARK: + { + xl_btree_delete_mark *xlrec = (xl_btree_delete_mark *) rec; + + appendStringInfo(buf, "offnum: %u, setmark: %c, deleted: %c", + xlrec->offnum, + xlrec->setmark ? 'T' : 'F', + xlrec->deleted ? 'T' : 'F'); + break; + } } } @@ -187,6 +198,9 @@ btree_identify(uint8 info) case XLOG_BTREE_META_CLEANUP: id = "META_CLEANUP"; break; + case XLOG_BTREE_DELETE_MARK: + id = "DELETE_MARK"; + break; } return id; diff --git a/src/backend/access/table/tableam.c b/src/backend/access/table/tableam.c index 53527c6727c0a..d82993ab7d656 100644 --- a/src/backend/access/table/tableam.c +++ b/src/backend/access/table/tableam.c @@ -831,9 +831,8 @@ table_block_relation_estimate_size(Relation rel, int32 *attr_widths, * rd_firstRelfilelocatorSubid covers REINDEX, which keeps the index's OID but * gives it a new relfilelocator in this transaction. * - * A later commit adds one more condition here: a table AM that does its own - * index cleanup via delete-marking must be refused, since a second independent - * record for the same entry would double-apply. + * - A delete-marking table AM (FLUX) is refused; see the comment in the + * body. */ bool RelationUsesIndexUndo(Relation indexrel, Relation heaprel) @@ -857,6 +856,16 @@ RelationUsesIndexUndo(Relation indexrel, Relation heaprel) IsToastRelation(heaprel)) return false; + /* + * A delete-marking table AM drives index cleanup from its own table UNDO + * -- it clears the old delete-mark and removes the new entry -- so an + * independent index-UNDO record for the same entry would double-apply. + * Refusing here, rather than at each call site, makes that unreachable + * whatever the reloption says. + */ + if (RelationSupportsDeleteMarking(heaprel)) + return false; + /* No WAL, no recoverable UNDO chain. Excludes TEMP and UNLOGGED. */ if (!RelationNeedsWAL(heaprel) || !RelationNeedsWAL(indexrel)) return false; @@ -887,3 +896,19 @@ RelationUndoEngine(Relation rel) Assert(rel->rd_tableam->am_undo_engine == UNDO_ENGINE_PERBACKEND); return UNDO_ENGINE_PERBACKEND; } + +/* + * RelationSupportsDeleteMarking + * Returns true if the relation's table AM does in-place indexed-column + * UPDATE via nbtree delete-marking. Index AMs use this to + * suppress their own index UNDO (the table AM's UNDO drives index cleanup + * on rollback) and the planner uses it to disable index-only scans + * (a delete-marked entry's key may not match the live tuple). + */ +bool +RelationSupportsDeleteMarking(Relation rel) +{ + if (!rel->rd_tableam || !rel->rd_tableam->am_supports_undo) + return false; + return rel->rd_tableam->am_index_delete_marking; +} diff --git a/src/backend/access/table/tableamapi.c b/src/backend/access/table/tableamapi.c index a3357ba7a893d..57ff7e6a3c3bc 100644 --- a/src/backend/access/table/tableamapi.c +++ b/src/backend/access/table/tableamapi.c @@ -64,6 +64,8 @@ GetTableAmRoutine(Oid amhandler) Assert(routine->index_scan_end != NULL); Assert(routine->fetch_tid != NULL); + Assert(routine->index_fetch_tid == NULL || + routine->am_index_delete_marking); Assert(routine->tuple_fetch_row_version != NULL); Assert(routine->tuple_tid_valid != NULL); Assert(routine->tuple_get_latest_tid != NULL); diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c index 22e0e8b11f8d5..aab6010ab0d88 100644 --- a/src/backend/optimizer/util/plancat.c +++ b/src/backend/optimizer/util/plancat.c @@ -134,6 +134,16 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent, */ relation = table_open(relationObjectId, NoLock); + /* + * Delete-marking entries can carry stale keys, so these tables must visit + * and recheck the visible table tuple. Clearing canreturn alone is not + * enough: a zero-column scan (for example count(*)) needs no attributes + * and therefore passes check_index_only's subset test even then. + */ + if (RelationSupportsDeleteMarking(relation)) + rel->pgs_mask &= ~PGS_CONSIDER_INDEXONLY; + + /* * Relations without a table AM can be used in a query only if they are of * special-cased relkinds. This check prevents us from crashing later if, @@ -284,7 +294,20 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent, for (i = 0; i < ncolumns; i++) { info->indexkeys[i] = index->indkey.values[i]; - info->canreturn[i] = index_can_return(indexRelation, i + 1); + + /* + * Suppress index-only scans for indexes on a delete-marking + * table: a delete-marked entry can carry a stale key that no + * longer matches the live heap tuple, so an IOS that trusted + * the index key without visiting the heap could return a wrong + * result. We do this here (not in btcanreturn) because we + * hold the fully-built heap relcache entry, so + * RelationSupportsDeleteMarking() is safe -- reaching for the + * table AM from inside btcanreturn crashes on half-built + * relcache entries during bootstrap. + */ + info->canreturn[i] = index_can_return(indexRelation, i + 1) && + !RelationSupportsDeleteMarking(relation); } for (i = 0; i < nkeycolumns; i++) diff --git a/src/include/access/amapi.h b/src/include/access/amapi.h index 7924033353031..f404fe99d3718 100644 --- a/src/include/access/amapi.h +++ b/src/include/access/amapi.h @@ -203,6 +203,40 @@ typedef bool (*amgettuple_function) (IndexScanDesc scan, typedef int64 (*amgetbitmap_function) (IndexScanDesc scan, TIDBitmap *tbm); +/* + * delete-mark an existing (key, TID) entry in place + * + * Optional: NULL if the AM does not support delete-marking. Called by the + * table AM during an in-place UPDATE of an indexed column to mark the old + * (key, TID) entry as a tombstone instead of deleting it. Returns true if a + * live entry was found and marked, false otherwise. + */ +typedef bool (*amdeletemark_function) (Relation indexRelation, + Relation heapRelation, + Datum *values, + bool *isnull, + ItemPointer heap_t_ctid); + +/* + * undo an in-place indexed-column UPDATE's index change + * + * Optional: NULL if the AM does not support delete-marking. Called by a + * delete-marking table AM's UNDO during ROLLBACK of an in-place key UPDATE, to + * restore the index to its pre-update state: + * clear_mark == true : the (key, TID) entry is a delete-marked tombstone of + * the OLD key; demote it back to a plain live entry. + * clear_mark == false: the (key, TID) entry is the NEW key's live entry that + * the aborted update inserted; kill it (LP_DEAD). + * Returns true if the entry was found and reversed, false otherwise (already + * gone -- not an error). + */ +typedef bool (*amundomark_function) (Relation indexRelation, + Relation heapRelation, + Datum *values, + bool *isnull, + ItemPointer heap_t_ctid, + bool clear_mark); + /* end index scan */ typedef void (*amendscan_function) (IndexScanDesc scan); @@ -311,6 +345,8 @@ typedef struct IndexAmRoutine amrescan_function amrescan; amgettuple_function amgettuple; /* can be NULL */ amgetbitmap_function amgetbitmap; /* can be NULL */ + amdeletemark_function amdeletemark; /* can be NULL */ + amundomark_function amundomark; /* can be NULL */ amendscan_function amendscan; ammarkpos_function ammarkpos; /* can be NULL */ amrestrpos_function amrestrpos; /* can be NULL */ diff --git a/src/include/access/genam.h b/src/include/access/genam.h index 1bfdc1ef6b1eb..0e64e49d522dd 100644 --- a/src/include/access/genam.h +++ b/src/include/access/genam.h @@ -154,6 +154,15 @@ extern bool index_insert(Relation indexRelation, IndexInfo *indexInfo); extern void index_insert_cleanup(Relation indexRelation, IndexInfo *indexInfo); +extern bool index_delete_mark(Relation indexRelation, + Datum *values, bool *isnull, + ItemPointer heap_t_ctid, + Relation heapRelation); +extern bool index_undo_mark(Relation indexRelation, + Datum *values, bool *isnull, + ItemPointer heap_t_ctid, + Relation heapRelation, + bool clear_mark); extern IndexScanDesc index_beginscan(Relation heapRelation, Relation indexRelation, diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h index 9011d71bcfa8c..772eaae40c964 100644 --- a/src/include/access/nbtree.h +++ b/src/include/access/nbtree.h @@ -167,6 +167,14 @@ typedef struct BTMetaPageData MAXALIGN(SizeOfPageHeaderData + 3*sizeof(ItemIdData)) - \ MAXALIGN(sizeof(BTPageOpaqueData))) / 3) - \ MAXALIGN(sizeof(ItemPointerData))) +/* + * A plain leaf entry in an index on a table that supports delete-marking may + * later become a tombstone, which is up to MAXALIGN(sizeof(ItemPointerData)) + * bytes bigger (see BTreeTupleGetDeleteMarkedSize). Such entries are limited + * to this size so that the tombstone still fits within BTMaxItemSize. + */ +#define BTMaxItemSizeDeleteMarking \ + (BTMaxItemSize - MAXALIGN(sizeof(ItemPointerData))) #define BTMaxItemSizeNoHeapTid \ MAXALIGN_DOWN((BLCKSZ - \ MAXALIGN(SizeOfPageHeaderData + 3*sizeof(ItemIdData)) - \ @@ -466,6 +474,20 @@ typedef struct BTVacState #define BT_PIVOT_HEAP_TID_ATTR 0x1000 #define BT_IS_POSTING 0x2000 +/* + * Delete-marking: a delete-marked leaf tuple is a THIRD alt-TID + * non-pivot, non-posting subtype. It has INDEX_ALT_TID_MASK set in t_info and + * BT_IS_DELETE_MARKED set (with BT_IS_POSTING clear) in t_tid's offset number. + * Its single heap TID is stored in a trailer at the end of the tuple, exactly + * like a pivot tuple's BT_PIVOT_HEAP_TID_ATTR heap TID, and is retrieved via + * BTreeTupleGetHeapTID(). Semantically it is an ordinary (non-pivot, + * non-posting) leaf entry whose index key no longer necessarily describes the + * live version of the heap tuple it points at; it lingers as a tombstone so + * old-snapshot readers can still reach the before-image. See + * "Delete-marking" in src/backend/access/nbtree/README. + */ +#define BT_IS_DELETE_MARKED 0x4000 /* 0x8000 remains free */ + /* * Mask allocated for number of keys in index tuple must be able to fit * maximum possible number of index attributes @@ -485,6 +507,9 @@ BTreeTupleIsPivot(IndexTuple itup) /* absence of BT_IS_POSTING in offset number indicates pivot tuple */ if ((ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) & BT_IS_POSTING) != 0) return false; + /* a delete-marked leaf tuple is a non-pivot alt-TID subtype */ + if ((ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) & BT_IS_DELETE_MARKED) != 0) + return false; return true; } @@ -501,6 +526,27 @@ BTreeTupleIsPosting(IndexTuple itup) return true; } +/* + * BTreeTupleIsDeleteMarked() -- is itup a delete-marked leaf tuple? + * + * A delete-marked tuple carries INDEX_ALT_TID_MASK (like a pivot or posting + * tuple) but is classified as neither pivot nor posting: it has + * BT_IS_DELETE_MARKED set and BT_IS_POSTING clear in t_tid's offset number. + * BTreeTupleIsPivot()/BTreeTupleIsPosting() both return false for it. + */ +static inline bool +BTreeTupleIsDeleteMarked(IndexTuple itup) +{ + if ((itup->t_info & INDEX_ALT_TID_MASK) == 0) + return false; + if ((ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) & BT_IS_POSTING) != 0) + return false; + if ((ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) & BT_IS_DELETE_MARKED) == 0) + return false; + + return true; +} + static inline void BTreeTupleSetPosting(IndexTuple itup, uint16 nhtids, int postingoffset) { @@ -651,6 +697,16 @@ BTreeTupleGetHeapTID(IndexTuple itup) } else if (BTreeTupleIsPosting(itup)) return BTreeTupleGetPosting(itup); + else if (BTreeTupleIsDeleteMarked(itup)) + { + /* + * Delete-marked leaf tuple: the single heap TID lives in a + * trailer at the tuple end, exactly like a pivot's heap TID. t_tid + * itself holds only alt-TID status metadata here. + */ + return (ItemPointer) ((char *) itup + IndexTupleSize(itup) - + sizeof(ItemPointerData)); + } return &itup->t_tid; } @@ -673,9 +729,71 @@ BTreeTupleGetMaxHeapTID(IndexTuple itup) return BTreeTupleGetPostingN(itup, nposting - 1); } + /* Delete-marked tuples carry their single TID in the trailer */ + if (BTreeTupleIsDeleteMarked(itup)) + return (ItemPointer) ((char *) itup + IndexTupleSize(itup) - + sizeof(ItemPointerData)); + return &itup->t_tid; } +/* + * Delete-marking accessors + * + * BTreeTupleSetDeleteMarked() promotes a plain non-pivot, non-posting leaf + * tuple into the delete-marked alt-TID form. The caller is responsible for + * having reserved sizeof(ItemPointerData) of trailer space at the end of the + * tuple and for having set INDEX_SIZE_MASK in t_info to include that trailer + * (i.e. the tuple passed here must already be sized to hold the trailer). The + * original heap TID (previously in t_tid) is written to the trailer. + * + * BTreeTupleGetDeleteMarkedSize() reports the tuple size (including the + * trailer) that a delete-marked form of a given plain leaf tuple requires. + */ +static inline Size +BTreeTupleGetDeleteMarkedSize(IndexTuple plain) +{ + Assert(!BTreeTupleIsPivot(plain) && !BTreeTupleIsPosting(plain) && + !BTreeTupleIsDeleteMarked(plain)); + + /* + * MAXALIGN the WHOLE tuple (not just the plain part): nbtree asserts + * itemsz == MAXALIGN(IndexTupleSize(itup)) throughout (page splits, high + * keys, dedup). A bare +sizeof(ItemPointerData) trailer would leave the + * size unaligned and trip those asserts, so pad to the next MAXALIGN + * boundary. BTreeTupleGetHeapTID reads the trailer at (end - 6), which + * is exactly where BTreeTupleSetDeleteMarked writes it below, so the + * padding (if any) sits between the plain body and the trailer + * harmlessly. + */ + return MAXALIGN(MAXALIGN(IndexTupleSize(plain)) + sizeof(ItemPointerData)); +} + +static inline void +BTreeTupleSetDeleteMarked(IndexTuple itup, ItemPointer heaptid, Size newsize) +{ + ItemPointer trailer; + + Assert(!BTreeTupleIsPivot(itup) && !BTreeTupleIsPosting(itup) && + !BTreeTupleIsDeleteMarked(itup)); + Assert(newsize >= IndexTupleSize(itup) + sizeof(ItemPointerData)); + Assert(ItemPointerIsValid(heaptid)); + + /* Grow the tuple to include the trailer, then write the heap TID there */ + itup->t_info &= ~INDEX_SIZE_MASK; + itup->t_info |= newsize; + trailer = (ItemPointer) ((char *) itup + newsize - sizeof(ItemPointerData)); + ItemPointerCopy(heaptid, trailer); + + /* Redefine t_tid as alt-TID delete-marked metadata */ + itup->t_info |= INDEX_ALT_TID_MASK; + ItemPointerSetBlockNumber(&itup->t_tid, 0); + ItemPointerSetOffsetNumber(&itup->t_tid, BT_IS_DELETE_MARKED); + + Assert(BTreeTupleIsDeleteMarked(itup)); + Assert(!BTreeTupleIsPivot(itup) && !BTreeTupleIsPosting(itup)); +} + /* * Operator strategy numbers for B-tree have been moved to access/stratnum.h, * because many places need to use them in ScanKeyInit() calls. @@ -841,6 +959,8 @@ typedef struct BTInsertStateData * with an existing posting list tuple that has its LP_DEAD bit set. */ int postingoff; + bool nopostingsplit; /* delete-marking index: never + * posting-split on insert */ } BTInsertStateData; typedef BTInsertStateData *BTInsertState; @@ -957,6 +1077,7 @@ typedef struct BTScanPosItem /* what we remember about each match */ ItemPointerData heapTid; /* TID of referenced heap item */ OffsetNumber indexOffset; /* index item's location within page */ LocationIndex tupleOffset; /* IndexTuple's offset in workspace, if any */ + bool recheck; /* delete-marked entry: caller must recheck */ } BTScanPosItem; typedef struct BTScanPosData @@ -1162,6 +1283,11 @@ extern Size btestimateparallelscan(Relation rel, int nkeys, int norderbys); extern void btinitparallelscan(void *target); extern bool btgettuple(IndexScanDesc scan, ScanDirection dir); extern int64 btgetbitmap(IndexScanDesc scan, TIDBitmap *tbm); +extern bool btdeletemark(Relation rel, Relation heapRel, + Datum *values, bool *isnull, ItemPointer heap_t_ctid); +extern bool btundomark(Relation rel, Relation heapRel, + Datum *values, bool *isnull, ItemPointer heap_t_ctid, + bool clear_mark); extern void btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys, ScanKey orderbys, int norderbys); extern void btparallelrescan(IndexScanDesc scan); @@ -1217,6 +1343,10 @@ extern bool _bt_doinsert(Relation rel, IndexTuple itup, Relation heapRel); extern void _bt_finish_split(Relation rel, Relation heaprel, Buffer lbuf, BTStack stack); +extern void _bt_replace_split(Relation rel, Relation heaprel, + BTScanInsert itup_key, Buffer buf, BTStack stack, + IndexTuple itup, OffsetNumber offnum); +extern void _bt_freestack(BTStack stack); extern Buffer _bt_getstackbuf(Relation rel, Relation heaprel, BTStack stack, BlockNumber child); @@ -1254,6 +1384,11 @@ extern void _bt_pageinit(Page page, Size size); extern void _bt_delitems_vacuum(Relation rel, Buffer buf, OffsetNumber *deletable, int ndeletable, BTVacuumPosting *updatable, int nupdatable); +extern void _bt_delitems_delete(Relation rel, Buffer buf, + TransactionId snapshotConflictHorizon, + bool isCatalogRel, + OffsetNumber *deletable, int ndeletable, + BTVacuumPosting *updatable, int nupdatable); struct TM_IndexDeleteOp; /* avoid including tableam.h here */ extern void _bt_delitems_delete_check(Relation rel, Buffer buf, Relation heapRel, diff --git a/src/include/access/nbtxlog.h b/src/include/access/nbtxlog.h index 3a78ec27fe8ce..1c437ba72993a 100644 --- a/src/include/access/nbtxlog.h +++ b/src/include/access/nbtxlog.h @@ -42,6 +42,8 @@ * FSM */ #define XLOG_BTREE_META_CLEANUP 0xE0 /* update cleanup-related data in the * metapage */ +#define XLOG_BTREE_DELETE_MARK 0xF0 /* delete-mark/unmark a leaf tuple in + * place */ /* * All that we need to regenerate the meta-data page @@ -138,6 +140,14 @@ typedef struct xl_btree_insert * here, the concept and goals are exactly the same. See _bt_swap_posting() * for details on posting list splits. * + * replace is set when newitem takes the place of the original page's item at + * newitemoff instead of going in front of it (btdeletemark grows a leaf entry + * this way when the bigger entry does not fit in place). The split was + * computed on an image of the original page without that item, so REDO + * removes it from the original page before rebuilding the left page; the + * right page's tuples never include it. replace is never combined with a + * posting list split. + * * Backup Blk 1: new right page * * The right page's data portion contains the right page's tuples in the form @@ -156,9 +166,10 @@ typedef struct xl_btree_split OffsetNumber firstrightoff; /* first origpage item on rightpage */ OffsetNumber newitemoff; /* new item's offset */ uint16 postingoff; /* offset inside orig posting tuple */ + bool replace; /* newitem replaces item at newitemoff */ } xl_btree_split; -#define SizeOfBtreeSplit (offsetof(xl_btree_split, postingoff) + sizeof(uint16)) +#define SizeOfBtreeSplit (offsetof(xl_btree_split, replace) + sizeof(bool)) /* * When page is deduplicated, consecutive groups of tuples with equal keys are @@ -194,6 +205,32 @@ typedef struct xl_btree_reuse_page #define SizeOfBtreeReusePage (offsetof(xl_btree_reuse_page, isCatalogRel) + sizeof(bool)) +/* + * This is what we need to redo an in-place delete-mark (or unmark) of a single + * leaf tuple, or the removal of one (XLOG_BTREE_DELETE_MARK). + * + * Backup Blk 0: the leaf page. Unless 'deleted' is set, the full replacement + * tuple image (already in its final delete-marked or plain form) follows the + * fixed portion of the record; redo overwrites the item at 'offnum' with it. + * Logging the exact final bytes keeps the redo deterministic for + * wal_consistency_checking. + * + * A delete-mark that does not fit on the page is not logged here: it is done + * by a page split, logged as XLOG_BTREE_SPLIT_L/R with 'replace' set. + */ +typedef struct xl_btree_delete_mark +{ + OffsetNumber offnum; /* offset of the leaf tuple being rewritten */ + bool setmark; /* true = set mark, false = clear mark */ + bool deleted; /* true = remove the entry instead of + * rewriting it; written only by btundomark() + * when it removes the entry an aborted + * in-place UPDATE inserted for its new key */ + /* NEW TUPLE IMAGE FOLLOWS AT END OF STRUCT (absent when deleted) */ +} xl_btree_delete_mark; + +#define SizeOfBtreeDeleteMark (offsetof(xl_btree_delete_mark, deleted) + sizeof(bool)) + /* * xl_btree_vacuum and xl_btree_delete records describe deletion of index * tuples on a leaf page. The former variant is used by VACUUM, while the diff --git a/src/include/access/relscan.h b/src/include/access/relscan.h index d7614104730fb..f53f99ecd72f6 100644 --- a/src/include/access/relscan.h +++ b/src/include/access/relscan.h @@ -138,6 +138,17 @@ typedef struct IndexScanDescData struct ScanKeyData *keyData; /* array of index qualifier descriptors */ struct ScanKeyData *orderByData; /* array of ordering op descriptors */ bool xs_want_itup; /* caller requests index tuples */ + + /* + * A plain index scan on a delete-marking table AM needs the index tuple + * (xs_itup) to recheck a delete-marked entry's key against the visible + * heap version, but + * must NOT set xs_want_itup: that flag is now reserved for genuine + * index-only scans (table_index_getnext_slot asserts an IOS uses a + * virtual slot). This separate flag asks the index AM to keep xs_itup + * populated for the recheck during an ordinary (buffer-slot) index scan. + */ + bool xs_want_itup_recheck; bool xs_temp_snap; /* unregister snapshot at scan end? */ /* signaling to index AM about killing index tuples */ diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h index 480dbdb2b9d6d..e2baa816e0274 100644 --- a/src/include/access/tableam.h +++ b/src/include/access/tableam.h @@ -366,6 +366,24 @@ typedef struct TableAmRoutine */ UndoEngine am_undo_engine; + /* + * am_index_delete_marking: true if this AM performs in-place UPDATE of an + * indexed column via nbtree delete-marking rather than moving the row to + * a new TID. When true: - the row keeps a STABLE TID across an + * indexed-column UPDATE; - the AM itself drives index maintenance for the + * changed indexes (insert the new (k_new,TID) entry + delete-mark the old + * (k_old,TID) entry via index_delete_mark) and reports TU_None so the + * executor does NOT re-insert; - the AM's own UNDO drives index cleanup + * on rollback (remove the new entry + clear the old delete-mark), so + * index AMs must NOT write their own index UNDO for such a parent table + * (see nbtinsert.c/hashinsert.c); - index-only scans are suppressed for + * indexes on the table (a delete-marked entry's key may not match the + * live tuple), which the planner enforces via + * RelationSupportsDeleteMarking() in get_relation_info(). Only meaningful + * when am_supports_undo is true. + */ + bool am_index_delete_marking; + /* ------------------------------------------------------------------------ * Slot related callbacks. @@ -543,6 +561,14 @@ typedef struct TableAmRoutine Snapshot snapshot, bool *all_dead); + /* + * Optional, for am_index_delete_marking AMs; see + * table_index_fetch_tid(). + */ + bool (*index_fetch_tid) (Relation rel, Relation irel, + IndexTuple itup, ItemPointer tid, + Snapshot snapshot, bool *all_dead); + /* * Fetch tuple at `tid` into `slot`, after doing a visibility test * according to `snapshot`. If a tuple was found and passed the visibility @@ -578,6 +604,14 @@ typedef struct TableAmRoutine TransactionId (*index_delete_tuples) (Relation rel, TM_IndexDeleteOp *delstate); + /* + * Optional, for am_index_delete_marking AMs; see + * table_index_tombstone_removable(). + */ + bool (*index_tombstone_removable) (Relation rel, Relation irel, + IndexTuple itup, ItemPointer tid, + TransactionId *conflict_xid); + /* ------------------------------------------------------------------------ * Manipulations of physical tuples. @@ -1392,6 +1426,70 @@ table_fetch_tid(Relation rel, return rel->rd_tableam->fetch_tid(rel, tid, snapshot, all_dead); } +/* + * table_fetch_tid() for a uniqueness check that knows the index entry it + * found `tid` through: is there a version of the row that passes `snapshot` + * AND carries the key of the entry `itup` of unique index `irel`? + * + * A delete-marking AM (am_index_delete_marking) updates an indexed column in + * place, so one TID carries several keys over time: the UPDATE inserts a new + * (k_new, TID) entry and delete-marks the old (k_old, TID) one, which stays + * for snapshots that still see k_old. The TID alone no longer says which + * version a uniqueness check is about; only the table AM can tell whether + * the version that carries the entry's key is live, being changed, or dead. + * This asks it. It answers exactly what heap's table_fetch_tid() answers + * for the version that carries the key, as if that version had its own TID: + * + * - `itup` is the entry as the index stores it. It may be delete-marked + * (a tombstone); its key is the entry's key either way, and the caller has + * already matched it against the key being inserted. `tid` is the heap + * TID it points to (for a tombstone, BTreeTupleGetHeapTID(), not t_tid), + * and is not modified. + * + * - `snapshot` is SnapshotDirty or SnapshotSelf. Returns true if a version + * carrying the key passes it, false otherwise. + * + * - For SnapshotDirty, a version whose inserter, updater or deleter is still + * in progress passes, and its xid is reported as heap does + * (HeapTupleSatisfiesDirty): snapshot->xmin for a running writer that made + * the version (and snapshot->speculativeToken for a speculative insertion + * that is not yet confirmed), snapshot->xmax for a running writer that is + * replacing it -- for an in-place key UPDATE in progress, the updater, on + * both its old-key and its new-key entry. The caller waits for that xid + * and checks again. Both are InvalidTransactionId and the token 0 when + * there is nothing to wait for. + * + * - A version whose writer aborted is not the row's, even when the AM's + * rollback has not been applied yet: the version before it is, so the old + * key of an aborted key UPDATE passes and the new one does not. A version + * superseded by a committed key UPDATE (or deleted by a committed DELETE) + * does not pass. + * + * - If all_dead is not NULL, *all_dead is set as by table_fetch_tid(): true + * iff no snapshot, now or in the future, can need any version at `tid`, so + * that a plain entry may be marked LP_DEAD. The caller never marks a + * tombstone LP_DEAD, whatever *all_dead says: tombstones are removed by + * table_index_tombstone_removable() only. + * + * AMs without delete-marking leave the callback NULL; the call is then + * table_fetch_tid(rel, tid, snapshot, all_dead), which is right because + * their TIDs carry a single key, and which an AM with delete-marking may + * also be given for a plain entry. + */ +static inline bool +table_index_fetch_tid(Relation rel, Relation irel, IndexTuple itup, + ItemPointer tid, Snapshot snapshot, bool *all_dead) +{ + if (rel->rd_tableam->index_fetch_tid == NULL) + return table_fetch_tid(rel, tid, snapshot, all_dead); + + if (unlikely(TransactionIdIsValid(CheckXidAlive) && !bsysscan)) + elog(ERROR, "unexpected table_index_fetch_tid call during logical decoding"); + + return rel->rd_tableam->index_fetch_tid(rel, irel, itup, tid, snapshot, + all_dead); +} + /* * Fetch tuple at `tid` into `slot`, after doing a visibility test according to * `snapshot`. If a tuple was found and passed the visibility test, returns @@ -1474,6 +1572,25 @@ table_index_delete_tuples(Relation rel, TM_IndexDeleteOp *delstate) return rel->rd_tableam->index_delete_tuples(rel, delstate); } +/* + * May the delete-marked index entry itup of index irel be removed? + * + * A delete-marking AM's in-place UPDATE of an indexed column leaves the old + * entry delete-marked for older snapshots, at a TID that stays live, so the + * index AM's dead-TID test never removes it. Returns true once no snapshot + * can need the row through that entry, setting *conflict_xid to the XID whose + * snapshots (on a standby) must have ended before it is removed. AMs without + * the callback keep their delete-marked entries. + */ +static inline bool +table_index_tombstone_removable(Relation rel, Relation irel, IndexTuple itup, + ItemPointer tid, TransactionId *conflict_xid) +{ + return rel->rd_tableam->index_tombstone_removable != NULL && + rel->rd_tableam->index_tombstone_removable(rel, irel, itup, tid, + conflict_xid); +} + /* ---------------------------------------------------------------------------- * Functions for manipulations of physical tuples. @@ -2211,5 +2328,6 @@ extern const TableAmRoutine *GetHeapamTableAmRoutine(void); extern bool RelationUsesIndexUndo(Relation indexrel, Relation heaprel); extern UndoEngine RelationUndoEngine(Relation rel); +extern bool RelationSupportsDeleteMarking(Relation rel); #endif /* TABLEAM_H */ diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile index 415e24c35ecda..2d1425722fc7b 100644 --- a/src/test/modules/Makefile +++ b/src/test/modules/Makefile @@ -4,7 +4,8 @@ subdir = src/test/modules top_builddir = ../../.. include $(top_builddir)/src/Makefile.global -SUBDIRS = test_idxundo \ +SUBDIRS = test_deletemark \ + test_idxundo \ \ brin \ commit_ts \ diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build index 0729f987b7349..a73f3cbf67e04 100644 --- a/src/test/modules/meson.build +++ b/src/test/modules/meson.build @@ -28,6 +28,7 @@ subdir('test_cplusplusext') subdir('test_custom_rmgrs') subdir('test_custom_stats') subdir('test_custom_types') +subdir('test_deletemark') subdir('test_idxundo') subdir('test_ddl_deparse') subdir('test_dsa') diff --git a/src/test/modules/test_deletemark/.gitignore b/src/test/modules/test_deletemark/.gitignore new file mode 100644 index 0000000000000..536e465d407f7 --- /dev/null +++ b/src/test/modules/test_deletemark/.gitignore @@ -0,0 +1,4 @@ +/log/ +/results/ +/tmp_check/ +/output_iso/ diff --git a/src/test/modules/test_deletemark/Makefile b/src/test/modules/test_deletemark/Makefile new file mode 100644 index 0000000000000..53248dee6e087 --- /dev/null +++ b/src/test/modules/test_deletemark/Makefile @@ -0,0 +1,29 @@ +# src/test/modules/test_deletemark/Makefile + +MODULE_big = test_deletemark +OBJS = \ + $(WIN32RES) \ + test_deletemark.o +PGFILEDESC = "test_deletemark - synthetic exerciser for nbtree delete-marking" + +EXTENSION = test_deletemark +DATA = test_deletemark--1.0.sql + +REGRESS = test_deletemark +TAP_TESTS = 1 + +# The SQL test uses amcheck to verify structural soundness of an index that +# contains a delete-marked entry, so amcheck must be present in the temporary +# install used by "make check". +EXTRA_INSTALL = contrib/amcheck + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/test_deletemark +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/src/test/modules/test_deletemark/expected/test_deletemark.out b/src/test/modules/test_deletemark/expected/test_deletemark.out new file mode 100644 index 0000000000000..a25060960dc1d --- /dev/null +++ b/src/test/modules/test_deletemark/expected/test_deletemark.out @@ -0,0 +1,101 @@ +-- Synthetic exerciser for nbtree delete-marking. +CREATE EXTENSION test_deletemark; +-- Basic table + non-unique btree index over an ordinary heap. +-- (No table AM enables delete-marking yet; we drive btdeletemark directly.) +CREATE TABLE dm_t (k int, v int); +INSERT INTO dm_t SELECT g, g FROM generate_series(1, 20) g; +CREATE INDEX dm_idx ON dm_t (k); +-- Pick a concrete heap TID to operate on (k = 10). +SELECT ctid AS tid10 FROM dm_t WHERE k = 10 \gset +-- (a) Before marking: entry classifies as a plain leaf tuple, heap TID matches. +SELECT dm_classify('dm_idx', :'tid10') AS before_class; + before_class +-------------- + plain +(1 row) + +SELECT dm_heaptid('dm_idx', :'tid10') = :'tid10' AS before_heaptid_ok; + before_heaptid_ok +------------------- + t +(1 row) + +-- Mark it delete-marked in place (re-descend by key+TID, WAL-logged). +SELECT dm_mark('dm_idx', :'tid10') AS marked; + marked +-------- + t +(1 row) + +-- (a) After marking: classifies as delete-marked (NOT pivot, NOT posting), +-- and the heap TID is still retrievable from the trailer == original TID. +SELECT dm_classify('dm_idx', :'tid10') AS after_class; + after_class +--------------- + delete-marked +(1 row) + +SELECT dm_heaptid('dm_idx', :'tid10') = :'tid10' AS after_heaptid_ok; + after_heaptid_ok +------------------ + t +(1 row) + +-- Marking an already-marked entry is idempotent (returns false: nothing to do). +SELECT dm_mark('dm_idx', :'tid10') AS remark; + remark +-------- + f +(1 row) + +-- (b) A scan returns the delete-marked entry WITH recheck set. +SELECT dm_scan_recheck('dm_idx') AS scan_sets_recheck; + scan_sets_recheck +------------------- + t +(1 row) + +-- (d) amcheck: structural checks must still pass on an index with a +-- delete-marked entry (TID validity, key ordering, alt-TID subtype). +CREATE EXTENSION amcheck; +SELECT bt_index_check('dm_idx'); + bt_index_check +---------------- + +(1 row) + +SELECT bt_index_parent_check('dm_idx'); + bt_index_parent_check +----------------------- + +(1 row) + +-- (c) Uniqueness: delete-marked entry must be skipped as a duplicate. +CREATE TABLE dm_u (k int); +INSERT INTO dm_u SELECT g FROM generate_series(1, 20) g; +CREATE UNIQUE INDEX dm_uidx ON dm_u (k); +SELECT ctid AS utid7 FROM dm_u WHERE k = 7 \gset +-- Delete-mark the (k=7) entry, then a NEW insert of k=7 must SUCCEED +-- (the tombstone is not a live duplicate). +SELECT dm_mark('dm_uidx', :'utid7') AS u_marked; + u_marked +---------- + t +(1 row) + +INSERT INTO dm_u VALUES (7); -- accepted: delete-marked dup skipped +-- A true duplicate of a still-live key must still be REJECTED. +INSERT INTO dm_u VALUES (8); -- should fail: k=8 is live +ERROR: duplicate key value violates unique constraint "dm_uidx" +DETAIL: Key (k)=(8) already exists. +-- amcheck the unique index too (structural). +SELECT bt_index_check('dm_uidx'); + bt_index_check +---------------- + +(1 row) + +DROP EXTENSION test_deletemark CASCADE; +DROP EXTENSION amcheck CASCADE; +DROP TABLE dm_t; +DROP TABLE dm_u; diff --git a/src/test/modules/test_deletemark/meson.build b/src/test/modules/test_deletemark/meson.build new file mode 100644 index 0000000000000..f0ec193c45335 --- /dev/null +++ b/src/test/modules/test_deletemark/meson.build @@ -0,0 +1,38 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +test_deletemark_sources = files( + 'test_deletemark.c', +) + +if host_system == 'windows' + test_deletemark_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'test_deletemark', + '--FILEDESC', 'test_deletemark - synthetic exerciser for nbtree delete-marking',]) +endif + +test_deletemark = shared_module('test_deletemark', + test_deletemark_sources, + kwargs: pg_test_mod_args, +) +test_install_libs += test_deletemark + +test_install_data += files( + 'test_deletemark.control', + 'test_deletemark--1.0.sql', +) + +tests += { + 'name': 'test_deletemark', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'regress': { + 'sql': [ + 'test_deletemark', + ], + }, + 'tap': { + 'tests': [ + 't/001_deletemark_recovery.pl', + ], + }, +} diff --git a/src/test/modules/test_deletemark/sql/test_deletemark.sql b/src/test/modules/test_deletemark/sql/test_deletemark.sql new file mode 100644 index 0000000000000..de581bec3c405 --- /dev/null +++ b/src/test/modules/test_deletemark/sql/test_deletemark.sql @@ -0,0 +1,58 @@ +-- Synthetic exerciser for nbtree delete-marking. +CREATE EXTENSION test_deletemark; + +-- Basic table + non-unique btree index over an ordinary heap. +-- (No table AM enables delete-marking yet; we drive btdeletemark directly.) +CREATE TABLE dm_t (k int, v int); +INSERT INTO dm_t SELECT g, g FROM generate_series(1, 20) g; +CREATE INDEX dm_idx ON dm_t (k); + +-- Pick a concrete heap TID to operate on (k = 10). +SELECT ctid AS tid10 FROM dm_t WHERE k = 10 \gset + +-- (a) Before marking: entry classifies as a plain leaf tuple, heap TID matches. +SELECT dm_classify('dm_idx', :'tid10') AS before_class; +SELECT dm_heaptid('dm_idx', :'tid10') = :'tid10' AS before_heaptid_ok; + +-- Mark it delete-marked in place (re-descend by key+TID, WAL-logged). +SELECT dm_mark('dm_idx', :'tid10') AS marked; + +-- (a) After marking: classifies as delete-marked (NOT pivot, NOT posting), +-- and the heap TID is still retrievable from the trailer == original TID. +SELECT dm_classify('dm_idx', :'tid10') AS after_class; +SELECT dm_heaptid('dm_idx', :'tid10') = :'tid10' AS after_heaptid_ok; + +-- Marking an already-marked entry is idempotent (returns false: nothing to do). +SELECT dm_mark('dm_idx', :'tid10') AS remark; + +-- (b) A scan returns the delete-marked entry WITH recheck set. +SELECT dm_scan_recheck('dm_idx') AS scan_sets_recheck; + +-- (d) amcheck: structural checks must still pass on an index with a +-- delete-marked entry (TID validity, key ordering, alt-TID subtype). +CREATE EXTENSION amcheck; +SELECT bt_index_check('dm_idx'); +SELECT bt_index_parent_check('dm_idx'); + +-- (c) Uniqueness: delete-marked entry must be skipped as a duplicate. +CREATE TABLE dm_u (k int); +INSERT INTO dm_u SELECT g FROM generate_series(1, 20) g; +CREATE UNIQUE INDEX dm_uidx ON dm_u (k); + +SELECT ctid AS utid7 FROM dm_u WHERE k = 7 \gset + +-- Delete-mark the (k=7) entry, then a NEW insert of k=7 must SUCCEED +-- (the tombstone is not a live duplicate). +SELECT dm_mark('dm_uidx', :'utid7') AS u_marked; +INSERT INTO dm_u VALUES (7); -- accepted: delete-marked dup skipped + +-- A true duplicate of a still-live key must still be REJECTED. +INSERT INTO dm_u VALUES (8); -- should fail: k=8 is live + +-- amcheck the unique index too (structural). +SELECT bt_index_check('dm_uidx'); + +DROP EXTENSION test_deletemark CASCADE; +DROP EXTENSION amcheck CASCADE; +DROP TABLE dm_t; +DROP TABLE dm_u; diff --git a/src/test/modules/test_deletemark/t/001_deletemark_recovery.pl b/src/test/modules/test_deletemark/t/001_deletemark_recovery.pl new file mode 100644 index 0000000000000..4aa2fcd94c9ab --- /dev/null +++ b/src/test/modules/test_deletemark/t/001_deletemark_recovery.pl @@ -0,0 +1,63 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +# Crash-recovery test for nbtree delete-marking: +# delete-mark a leaf entry, crash (immediate stop, no clean shutdown), +# restart so WAL redo replays XLOG_BTREE_DELETE_MARK, and verify the entry +# is still delete-marked with an intact heap TID and passes amcheck. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('dm_recovery'); +$node->init; +# Deterministic redo verification for the new WAL record type. +$node->append_conf('postgresql.conf', 'wal_consistency_checking = btree'); +$node->start; + +$node->safe_psql('postgres', 'CREATE EXTENSION test_deletemark'); +$node->safe_psql('postgres', 'CREATE EXTENSION amcheck'); +$node->safe_psql('postgres', <<'SQL'); +CREATE TABLE dm_r (k int, v int); +INSERT INTO dm_r SELECT g, g FROM generate_series(1, 30) g; +CREATE INDEX dm_ridx ON dm_r (k); +SQL + +# Locate a concrete heap TID and delete-mark its index entry. +my $tid = $node->safe_psql('postgres', "SELECT ctid FROM dm_r WHERE k = 15"); +is( $node->safe_psql( + 'postgres', "SELECT dm_mark('dm_ridx'::regclass, '$tid'::tid)"), + 't', + 'entry delete-marked before crash'); +is( $node->safe_psql( + 'postgres', "SELECT dm_classify('dm_ridx'::regclass, '$tid'::tid)"), + 'delete-marked', + 'classified delete-marked before crash'); + +# Make sure the mark's WAL is flushed, then crash without a clean shutdown so +# recovery must replay it. +$node->safe_psql('postgres', "SELECT pg_switch_wal()"); +$node->stop('immediate'); +$node->start; + +# After crash recovery the bit must be re-set (WAL redo), TID intact. +is( $node->safe_psql( + 'postgres', "SELECT dm_classify('dm_ridx'::regclass, '$tid'::tid)"), + 'delete-marked', + 'entry still delete-marked after crash recovery'); +is( $node->safe_psql( + 'postgres', + "SELECT dm_heaptid('dm_ridx'::regclass, '$tid'::tid) = '$tid'::tid"), + 't', + 'heap TID intact after crash recovery'); + +# amcheck must be clean on the recovered index. +is($node->safe_psql('postgres', "SELECT bt_index_check('dm_ridx')"), + '', 'bt_index_check clean after crash recovery'); +is($node->safe_psql('postgres', "SELECT bt_index_parent_check('dm_ridx')"), + '', 'bt_index_parent_check clean after crash recovery'); + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_deletemark/test_deletemark--1.0.sql b/src/test/modules/test_deletemark/test_deletemark--1.0.sql new file mode 100644 index 0000000000000..ada7272c8354b --- /dev/null +++ b/src/test/modules/test_deletemark/test_deletemark--1.0.sql @@ -0,0 +1,20 @@ +/* src/test/modules/test_deletemark/test_deletemark--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_deletemark" to load this file. \quit + +CREATE FUNCTION dm_classify(idx regclass, t tid) +RETURNS text STRICT +AS 'MODULE_PATHNAME' LANGUAGE C; + +CREATE FUNCTION dm_heaptid(idx regclass, t tid) +RETURNS tid STRICT +AS 'MODULE_PATHNAME' LANGUAGE C; + +CREATE FUNCTION dm_mark(idx regclass, t tid) +RETURNS boolean STRICT +AS 'MODULE_PATHNAME' LANGUAGE C; + +CREATE FUNCTION dm_scan_recheck(idx regclass) +RETURNS boolean STRICT +AS 'MODULE_PATHNAME' LANGUAGE C; diff --git a/src/test/modules/test_deletemark/test_deletemark.c b/src/test/modules/test_deletemark/test_deletemark.c new file mode 100644 index 0000000000000..26903ade49b50 --- /dev/null +++ b/src/test/modules/test_deletemark/test_deletemark.c @@ -0,0 +1,249 @@ +/*------------------------------------------------------------------------- + * + * test_deletemark.c + * Synthetic exerciser for nbtree delete-marking. + * + * No in-tree table AM enables delete-marking yet (it is dormant until a + * later phase flips it on for FLUX/ZHEAP). This module drives the + * nbtree delete-marking mechanism directly against an ordinary btree index + * so the mechanism itself (classification, heap-TID retrieval, scan recheck, + * uniqueness handling, WAL/redo) can be tested end to end. + * + * SQL-callable primitives: + * dm_mark(regclass idx, tid t) -> mark the (single) entry with TID t + * dm_classify(regclass idx, tid t) -> text: classification of entry at t + * dm_heaptid(regclass idx, tid t) -> tid: heap TID recovered from entry + * dm_scan_recheck(regclass idx) -> bool: any returned tuple set recheck + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/test/modules/test_deletemark/test_deletemark.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/genam.h" +#include "access/nbtree.h" +#include "access/relation.h" +#include "access/relscan.h" +#include "access/tableam.h" +#include "catalog/index.h" +#include "storage/bufmgr.h" +#include "storage/itemptr.h" +#include "utils/rel.h" +#include "utils/snapmgr.h" +#include "utils/builtins.h" +#include "fmgr.h" + +PG_MODULE_MAGIC; + +/* + * Locate the single leaf entry whose heap TID equals 'target' by a linear + * scan over all leaf pages. Returns the buffer (pinned+share-locked) and the + * offset, or InvalidBuffer if not found. Intended for tiny test indexes. + */ +static Buffer +find_leaf_entry(Relation irel, ItemPointer target, OffsetNumber *offout) +{ + BlockNumber nblocks = RelationGetNumberOfBlocks(irel); + BlockNumber blk; + + for (blk = 0; blk < nblocks; blk++) + { + Buffer buf = ReadBuffer(irel, blk); + Page page; + BTPageOpaque opaque; + OffsetNumber off, + minoff, + maxoff; + + LockBuffer(buf, BUFFER_LOCK_SHARE); + page = BufferGetPage(buf); + opaque = BTPageGetOpaque(page); + + if (!P_ISLEAF(opaque) || P_IGNORE(opaque)) + { + UnlockReleaseBuffer(buf); + continue; + } + + minoff = P_FIRSTDATAKEY(opaque); + maxoff = PageGetMaxOffsetNumber(page); + for (off = minoff; off <= maxoff; off = OffsetNumberNext(off)) + { + IndexTuple itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, off)); + ItemPointer htid = BTreeTupleGetHeapTID(itup); + + if (htid != NULL && ItemPointerIsValid(htid) && + ItemPointerEquals(htid, target)) + { + *offout = off; + return buf; /* caller unlocks/releases */ + } + } + UnlockReleaseBuffer(buf); + } + return InvalidBuffer; +} + +PG_FUNCTION_INFO_V1(dm_classify); +Datum +dm_classify(PG_FUNCTION_ARGS) +{ + Oid idxoid = PG_GETARG_OID(0); + ItemPointer target = (ItemPointer) PG_GETARG_POINTER(1); + Relation irel = index_open(idxoid, AccessShareLock); + OffsetNumber off; + Buffer buf; + const char *res; + + buf = find_leaf_entry(irel, target, &off); + if (buf == InvalidBuffer) + { + index_close(irel, AccessShareLock); + PG_RETURN_TEXT_P(cstring_to_text("not found")); + } + { + Page page = BufferGetPage(buf); + IndexTuple itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, off)); + + if (BTreeTupleIsDeleteMarked(itup)) + { + /* Must NOT also classify as pivot or posting */ + if (BTreeTupleIsPivot(itup) || BTreeTupleIsPosting(itup)) + res = "BROKEN: delete-marked also pivot/posting"; + else + res = "delete-marked"; + } + else if (BTreeTupleIsPivot(itup)) + res = "pivot"; + else if (BTreeTupleIsPosting(itup)) + res = "posting"; + else + res = "plain"; + + UnlockReleaseBuffer(buf); + } + index_close(irel, AccessShareLock); + PG_RETURN_TEXT_P(cstring_to_text(res)); +} + +PG_FUNCTION_INFO_V1(dm_heaptid); +Datum +dm_heaptid(PG_FUNCTION_ARGS) +{ + Oid idxoid = PG_GETARG_OID(0); + ItemPointer target = (ItemPointer) PG_GETARG_POINTER(1); + Relation irel = index_open(idxoid, AccessShareLock); + OffsetNumber off; + Buffer buf; + ItemPointer result; + + buf = find_leaf_entry(irel, target, &off); + if (buf == InvalidBuffer) + { + index_close(irel, AccessShareLock); + PG_RETURN_NULL(); + } + result = palloc(sizeof(ItemPointerData)); + { + Page page = BufferGetPage(buf); + IndexTuple itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, off)); + + ItemPointerCopy(BTreeTupleGetHeapTID(itup), result); + UnlockReleaseBuffer(buf); + } + index_close(irel, AccessShareLock); + PG_RETURN_POINTER(result); +} + +/* + * dm_mark(idx, heaptid, values...) is awkward to pass key datums through SQL, + * so instead we mark the entry located purely by heap TID: read the existing + * plain entry's key from the leaf, then call btdeletemark with those datums. + * This exercises the full re-descend-by-(key,TID) path in btdeletemark. + */ +PG_FUNCTION_INFO_V1(dm_mark); +Datum +dm_mark(PG_FUNCTION_ARGS) +{ + Oid idxoid = PG_GETARG_OID(0); + ItemPointer target = (ItemPointer) PG_GETARG_POINTER(1); + Relation irel = index_open(idxoid, RowExclusiveLock); + Relation heapRel; + Oid heapoid; + OffsetNumber off; + Buffer buf; + Datum values[INDEX_MAX_KEYS]; + bool isnull[INDEX_MAX_KEYS]; + bool ok; + + heapoid = irel->rd_index->indrelid; + heapRel = relation_open(heapoid, RowExclusiveLock); + + buf = find_leaf_entry(irel, target, &off); + if (buf == InvalidBuffer) + { + relation_close(heapRel, RowExclusiveLock); + index_close(irel, RowExclusiveLock); + PG_RETURN_BOOL(false); + } + { + Page page = BufferGetPage(buf); + IndexTuple itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, off)); + IndexTuple copy; + + /* copy the tuple out before unlocking to deform its key */ + copy = CopyIndexTuple(itup); + UnlockReleaseBuffer(buf); + + index_deform_tuple(copy, RelationGetDescr(irel), values, isnull); + pfree(copy); + } + + /* Direct call to the AM method (bypasses the dormant capability gate) */ + ok = irel->rd_indam->amdeletemark(irel, heapRel, values, isnull, target); + + relation_close(heapRel, RowExclusiveLock); + index_close(irel, RowExclusiveLock); + PG_RETURN_BOOL(ok); +} + +/* + * dm_scan_recheck(idx) -- run a full index scan and report whether ANY + * returned tuple carried scan->xs_recheck (which delete-marked entries set). + */ +PG_FUNCTION_INFO_V1(dm_scan_recheck); +Datum +dm_scan_recheck(PG_FUNCTION_ARGS) +{ + Oid idxoid = PG_GETARG_OID(0); + Relation irel = index_open(idxoid, AccessShareLock); + Relation heapRel = relation_open(irel->rd_index->indrelid, AccessShareLock); + IndexScanDesc scan; + bool any_recheck = false; + + /* + * Post-ddce1da5b1b (slot-based table-AM index scan): index_getnext_tid is + * gone and index_beginscan takes an index_only_scan flag. We only want + * to observe scan->xs_recheck (set by delete-marked entries in + * nbtsearch.c), not fetch heap tuples, so drive the index AM's amgettuple + * directly -- exactly what tableam_index_getnext_tid does internally. + */ + scan = index_beginscan(heapRel, irel, false, GetActiveSnapshot(), + NULL, 0, 0, SO_NONE); + index_rescan(scan, NULL, 0, NULL, 0); + while (scan->indexRelation->rd_indam->amgettuple(scan, ForwardScanDirection)) + { + scan->kill_prior_tuple = false; + if (scan->xs_recheck) + any_recheck = true; + } + index_endscan(scan); + + relation_close(heapRel, AccessShareLock); + index_close(irel, AccessShareLock); + PG_RETURN_BOOL(any_recheck); +} diff --git a/src/test/modules/test_deletemark/test_deletemark.control b/src/test/modules/test_deletemark/test_deletemark.control new file mode 100644 index 0000000000000..febd27144e11d --- /dev/null +++ b/src/test/modules/test_deletemark/test_deletemark.control @@ -0,0 +1,4 @@ +comment = 'Synthetic exerciser for nbtree delete-marking' +default_version = '1.0' +module_pathname = '$libdir/test_deletemark' +relocatable = true diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index b00b327381abd..4a1eacf9be726 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -82,9 +82,11 @@ tests += { 't/085_perbackend_worker_done.pl', 't/089_perbackend_rewrite_rollback.pl', 't/090_fileops_createdb_crash.pl', + 't/094_flux_tombstone_prune.pl', 't/096_perbackend_redo_header_image.pl', 't/114_undo_recovery_error.pl', 't/117_undo_rewind_snapshot.pl', + 't/121_flux_deletemark_race.pl', 't/126_atm_full_abort.pl', 't/124_index_undo_tid_reuse.pl', 't/134_undo_shmem_on_demand.pl', diff --git a/src/test/recovery/t/094_flux_tombstone_prune.pl b/src/test/recovery/t/094_flux_tombstone_prune.pl new file mode 100644 index 0000000000000..3229b3699b963 --- /dev/null +++ b/src/test/recovery/t/094_flux_tombstone_prune.pl @@ -0,0 +1,197 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# VACUUM prunes the delete-marked index tombstones a FLUX in-place key UPDATE +# leaves behind for a row that is still live. +# +# An UPDATE of an indexed column keeps the row at its TID, inserts the new +# (key, TID) entry and delete-marks the old one so older snapshots still reach +# the row by its old key. The TID stays live, so the ordinary dead-TID test +# never lets VACUUM remove the tombstone. It must go once no snapshot can need +# the old key, and not before. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_tomb'); +$node->init(allows_streaming => 1); +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +if ( !$node->check_extension('pageinspect') + || !$node->check_extension('amcheck') + || !$node->check_extension('pg_walinspect')) +{ + plan skip_all => 'pageinspect, amcheck or pg_walinspect not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE EXTENSION amcheck; +CREATE EXTENSION pg_walinspect; +CREATE TABLE t (id int PRIMARY KEY, k int, v text) USING flux; +CREATE INDEX t_k ON t (k); +INSERT INTO t SELECT g, g, 'x' FROM generate_series(1, 10) g; +}); + +# The index fits on its root leaf page. +my $entries = q{SELECT live_items FROM bt_page_stats('t_k', 1)}; +my $q = 'SET enable_seqscan = off; SET enable_bitmapscan = off; ' + . 'SELECT count(*) FROM t WHERE k = '; +my $by_key = sub { + my ($s, $k) = @_; + return $s->isa('PostgreSQL::Test::Cluster') + ? $s->safe_psql('postgres', $q . $k) + : $s->query_safe($q . $k); +}; + +# A snapshot taken before the key UPDATEs. +my $old = $node->background_psql('postgres'); +$old->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); +$old->query_safe('SELECT count(*) FROM t'); + +$node->safe_psql('postgres', + join('', map { "UPDATE t SET k = k + 1000 WHERE id = 1;\n" } 1 .. 100)); +is($node->safe_psql('postgres', $entries), + '110', 'one tombstone per key UPDATE'); + +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $entries), + '110', 'tombstones kept while an older snapshot is open'); +is($by_key->($old, 1), + '1', 'older snapshot still finds the row by its old key'); +is($by_key->($old, 100001), '0', 'older snapshot does not see the new key'); +$old->query_safe('COMMIT'); +$old->quit; + +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $entries), + '10', 'VACUUM pruned the tombstones'); +is($node->safe_psql('postgres', q{SELECT bt_index_check('t_k', true)}), + '', 'index is consistent'); +is($by_key->($node, 100001), '1', 'row found by its current key'); +is($by_key->($node, 1001), '0', 'row not found by a superseded key'); + +# Back to a key whose tombstone was pruned: a fresh entry, ordering intact. +$node->safe_psql('postgres', 'UPDATE t SET k = 1 WHERE id = 1'); +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $entries), '10', 'return to a pruned key'); +is($by_key->($node, 1), '1', 'row found by the key it returned to'); +is($node->safe_psql('postgres', q{SELECT bt_index_check('t_k', true)}), + '', 'index is consistent after the return'); + +# Expression and partial indexes keep their tombstones (not evaluated under +# the index page lock). +$node->safe_psql( + 'postgres', q{ +CREATE INDEX t_e ON t ((k + 0)); +CREATE INDEX t_p ON t (k) WHERE id > 0; +UPDATE t SET k = 2000 WHERE id = 2; +VACUUM t; +}); +is( $node->safe_psql( + 'postgres', q{SELECT string_agg(live_items::text, ',' ORDER BY i) + FROM (VALUES (1, 't_e'), (2, 't_p'), (3, 't_k')) v(i, n), + LATERAL bt_page_stats(n, 1)}), + '11,11,10', + 'expression and partial index tombstones kept'); + +# An in-progress key UPDATE keeps its tombstone, and its rollback still +# restores the index. +my $w = $node->background_psql('postgres'); +$w->query_safe('BEGIN'); +$w->query_safe('UPDATE t SET k = -1 WHERE id = 1'); +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $entries), + '11', 'tombstone of an in-progress UPDATE kept'); +$w->query_safe('ROLLBACK'); +$w->quit; +is($by_key->($node, 1), '1', 'rolled-back key UPDATE: old key found'); +is($by_key->($node, -1), '0', 'rolled-back key UPDATE: new key gone'); +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $entries), + '10', 'index back to one entry per row'); +is($node->safe_psql('postgres', q{SELECT bt_index_check('t_k', true)}), + '', 'index is consistent after the rollback'); + +# The newest version's writer can hold an older XID than an earlier writer. +# A snapshot taken between them reads the earlier version by its key; the +# tombstone stays while it is open, and on a standby removing it cancels it. +$node->backup('b'); +my $standby = PostgreSQL::Test::Cluster->new('flux_tomb_standby'); +$standby->init_from_backup($node, 'b', has_streaming => 1); +$standby->append_conf('postgresql.conf', + "max_standby_streaming_delay = 0\nhot_standby_feedback = off"); +$standby->start; + +$node->safe_psql('postgres', 'CREATE TABLE other (i int)'); +my $a = $node->background_psql('postgres'); +$a->query_safe('BEGIN'); +$a->query_safe('INSERT INTO other VALUES (1)'); # A takes its XID first +my $axid = $a->query_safe('SELECT pg_current_xact_id()'); +$node->safe_psql('postgres', 'UPDATE t SET k = 7000 WHERE id = 3'); # B +$node->wait_for_replay_catchup($standby); + +my $p = $node->background_psql('postgres'); +my $s = $standby->background_psql('postgres', on_error_stop => 0); +foreach my $c ($p, $s) +{ + $c->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + is($by_key->($c, 7000), '1', + 'snapshot between the writers sees B\'s key'); +} +$a->query_safe('UPDATE t SET k = 8000 WHERE id = 3'); +$a->query_safe('COMMIT'); +$a->quit; + +$node->safe_psql('postgres', 'VACUUM t'); +is($by_key->($p, 7000), '1', 'tombstone kept for the primary snapshot'); +$p->query_safe('COMMIT'); +$p->quit; + +my $lsn = $node->safe_psql('postgres', 'SELECT pg_current_wal_lsn()'); +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $entries), '10', 'tombstones pruned'); +# VACUUM does not flush its WAL; a commit does. +$node->safe_psql('postgres', 'INSERT INTO other VALUES (2)'); +is( $node->safe_psql( + 'postgres', qq{SELECT string_agg(substring(description + from 'snapshotConflictHorizon: (\\d+)'), ',') + FROM pg_get_wal_records_info('$lsn', pg_current_wal_lsn()) + WHERE resource_manager = 'Btree' AND record_type = 'DELETE'}), + $axid, + 'removal conflicts with snapshots not seeing the older XID'); +ok( $standby->wait_for_log( + qr/terminating connection due to conflict with recovery/), + 'standby snapshot needing the tombstone was cancelled'); +$s->{run}->kill_kill; +is($by_key->($standby, 8000), '1', + 'standby finds the row by its current key'); + +# A key UPDATE committed through a released savepoint, and one replayed on +# the standby, leave page images with a stale UNCOMMITTED hint (it is cleared +# by the committing backend, by top-level XID, and never by redo). CLOG, not +# that hint, decides. +$node->safe_psql( + 'postgres', q{ +BEGIN; SAVEPOINT s; UPDATE t SET k = 9000 WHERE id = 4; RELEASE s; COMMIT; +UPDATE t SET k = 9001 WHERE id = 5; +}); +$node->wait_for_replay_catchup($standby); +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $entries), + '10', 'tombstone of a committed subtransaction pruned'); +$standby->promote; +$standby->safe_psql('postgres', 'VACUUM t'); +is($standby->safe_psql('postgres', $entries), + '10', 'promoted standby prunes tombstones of replayed UPDATEs'); +is($standby->safe_psql('postgres', q{SELECT bt_index_check('t_k', true)}), + '', 'promoted standby: index is consistent'); + +$standby->stop; +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/121_flux_deletemark_race.pl b/src/test/recovery/t/121_flux_deletemark_race.pl new file mode 100644 index 0000000000000..ed03372140113 --- /dev/null +++ b/src/test/recovery/t/121_flux_deletemark_race.pl @@ -0,0 +1,255 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Delete-marking an nbtree entry that has no room to grow on its leaf must be +# atomic. A FLUX in-place UPDATE that changes an indexed key delete-marks the +# old (k_old, TID) entry, which grows it by a heap TID trailer. When the leaf +# is full the entry cannot grow in place. A reader whose snapshot predates +# the UPDATE still has to reach the row under k_old the whole time; it must +# never find the entry missing. +# +# The injection point nbtree-deletemark-no-room fires in btdeletemark's +# no-room path once the leaf lock has been released. The UPDATE waits there +# while the old-snapshot reader looks the row up under k_old. +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('flux_deletemark_race'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} + +# fill_leaf() fills leaf block blk of an index on (k text) until fewer than +# 8 bytes (one heap TID trailer) remain free. A key of L bytes takes +# MAXALIGN(9 + L) + 4 bytes including its line pointer, so the entry sizes +# are 8m + 4, and the last one is sized to leave 0 or 4 bytes. The keys +# start with 'c', so they land on the leftmost leaf. +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE EXTENSION amcheck; +CREATE EXTENSION pg_walinspect; +CREATE FUNCTION fill_leaf(t regclass, idx regclass, blk int) RETURNS int +LANGUAGE plpgsql AS $f$ +DECLARE gap int; s int; n int := 100000; +BEGIN + LOOP + SELECT upper - lower INTO gap FROM page_header(get_raw_page(idx::text, blk)); + EXIT WHEN gap < 8; + IF gap >= 40 THEN s := 20; + ELSIF gap % 8 = 4 THEN s := gap; + ELSE s := gap - 4; + END IF; + n := n + 1; + EXECUTE format('INSERT INTO %s VALUES ($1, $2)', t) + USING n, 'c' || lpad(n::text, s - 14, '0'); + END LOOP; + RETURN gap; +END $f$; +CREATE FUNCTION leftmost_leaf(idx regclass) RETURNS int +LANGUAGE sql AS $f$ + SELECT s.blkno::int FROM generate_series(1, + pg_relation_size(idx) / 8192 - 1) AS g(b), + LATERAL bt_page_stats(idx::text, g.b::int) s + WHERE s.type = 'l' AND s.btpo_prev = 0 +$f$; + +}); + +my $idx = 'SET enable_seqscan = off; SET enable_bitmapscan = off; ' + . 'SET enable_indexonlyscan = off; '; +my @lsns; + +# Fill the leftmost leaf of $t's index and delete-mark one of its entries: +# the first one ('first'), which the split leaves on the left half, or the +# last one ('last'), which goes to the right half. +sub run_case +{ + my ($t, $which) = @_; + + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, k text) USING flux; +CREATE INDEX ${t}_k ON $t (k) WITH (deduplicate_items = off); +INSERT INTO $t VALUES (1, 'a'); +INSERT INTO $t SELECT i, 'm' || lpad(i::text, 30, '0') + FROM generate_series(2, 1000) i; +}); + + my $leaf = $node->safe_psql('postgres', "SELECT leftmost_leaf('${t}_k')"); + cmp_ok( + $node->safe_psql( + 'postgres', "SELECT fill_leaf('$t', '${t}_k', $leaf)"), + '<', 8, + "$t: leftmost leaf is full"); + is($node->safe_psql('postgres', "SELECT leftmost_leaf('${t}_k')"), + $leaf, "$t: filling did not split the leaf"); + + my $id = 1; + $id = $node->safe_psql( + 'postgres', qq{ +SELECT id FROM $t WHERE ctid = (SELECT htid FROM bt_page_items('${t}_k', $leaf) + ORDER BY itemoffset DESC LIMIT 1)}) if $which eq 'last'; + my $kold = + $node->safe_psql('postgres', "SELECT k FROM $t WHERE id = $id"); + + my $q = "SELECT count(id) FROM $t WHERE k = '$kold'"; + + # The reader takes its snapshot before the UPDATE. + my $reader = $node->background_psql('postgres'); + $reader->query_safe($idx); + $reader->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + is($reader->query_safe($q), '1', "$t: reader sees the row under k_old"); + + push @lsns, + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + + # The UPDATE moves the row to the rightmost leaf, then delete-marks + # (k_old, TID) on the full leftmost leaf and waits in the no-room path. + my $updater = $node->background_psql('postgres'); + $updater->query_safe( + q{SELECT injection_points_set_local(); + SELECT injection_points_attach('nbtree-deletemark-no-room', 'wait')}); + $updater->query_until( + qr/start/, qq{ +\\echo start +UPDATE $t SET k = 'zzz' WHERE id = $id; +}); + $node->wait_for_event('client backend', 'nbtree-deletemark-no-room'); + + is($reader->query_safe($q), + '1', + "$t: reader still sees the row under k_old during the delete-mark"); + + $node->safe_psql('postgres', + "SELECT injection_points_wakeup('nbtree-deletemark-no-room')"); + $updater->query_safe( + "SELECT injection_points_detach('nbtree-deletemark-no-room')"); + $updater->quit; + + push @lsns, + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + + is($reader->query_safe($q), + '1', "$t: reader sees the row under k_old after the UPDATE"); + $reader->query_safe('COMMIT'); + $reader->quit; + + check_index($t, $kold, 'after the UPDATE'); + return $kold; +} + +sub check_index +{ + my ($t, $kold, $what) = @_; + + is( $node->safe_psql( + 'postgres', $idx . "SELECT count(id) FROM $t WHERE k = '$kold'"), + '0', + "$t $what: new snapshot does not see the row under k_old"); + is( $node->safe_psql( + 'postgres', $idx . "SELECT count(id) FROM $t WHERE k = 'zzz'"), + '1', + "$t $what: new snapshot sees the row under k_new"); + my ($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_parent_check('${t}_k', true)"); + is($rc . $stderr, '0', "$t $what: amcheck heapallindexed"); +} + +my $kleft = run_case('t_left', 'first'); +my $kright = run_case('t_right', 'last'); + +# Each delete-mark was logged as one split record that replaces the entry. +my $wal = $node->data_dir . '/pg_wal'; +my ($out, $err) = run_command( + [ + 'pg_waldump', '-p', $wal, '-s', $lsns[0], '-e', + $lsns[1], '-r', 'Btree' + ]); +like( + $out, + qr/SPLIT_L .*replace: T/, + 't_left: replace split logged as SPLIT_L'); +($out, $err) = run_command( + [ + 'pg_waldump', '-p', $wal, '-s', $lsns[2], '-e', + $lsns[3], '-r', 'Btree' + ]); +like( + $out, + qr/SPLIT_R .*replace: T/, + 't_right: replace split logged as SPLIT_R'); + +# Many key UPDATEs against an index built with full leaves. The new keys go +# to the right end of the index, so the old leaves stay full and the +# delete-marks there need replace splits. The parent level splits as well. A reader +# whose snapshot predates them must still find every row by its old key. +$node->safe_psql( + 'postgres', q{ +CREATE TABLE s (id int, k text) USING flux; +INSERT INTO s SELECT i, md5(i::text) FROM generate_series(1, 20000) i; +CREATE INDEX s_k ON s (k) WITH (deduplicate_items = off, fillfactor = 100); +}); +my $reader = $node->background_psql('postgres'); +$reader->query_safe($idx); +$reader->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); +my $allold = q{SELECT count(id) FROM s WHERE k = md5(id::text)}; +is($reader->query_safe($allold), '20000', 's: reader sees every row'); +my $start = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); +$node->safe_psql('postgres', "UPDATE s SET k = 'z' || k WHERE id % 4 = $_") + for (0 .. 2); +cmp_ok( + $node->safe_psql( + 'postgres', qq{ +SELECT count(*) FROM pg_get_wal_records_info('$start', pg_current_wal_insert_lsn()) + WHERE resource_manager = 'Btree' AND description LIKE '%replace: T%'}), + '>', + 50, + 's: delete-marks were done with replace splits'); +cmp_ok( + $node->safe_psql( + 'postgres', qq{ +SELECT count(*) FROM pg_get_wal_records_info('$start', pg_current_wal_insert_lsn()) + WHERE resource_manager = 'Btree' AND record_type LIKE 'SPLIT%' + AND description LIKE 'level: 1,%'}), + '>', + 0, + 's: the parent level split, too'); +is($reader->query_safe($allold), + '20000', 's: reader still sees every row under its old key'); +$reader->query_safe('COMMIT'); +$reader->quit; +my ($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_parent_check('s_k', true)"); +is($rc . $stderr, '0', 's: amcheck heapallindexed'); + +# Crash recovery replays the splits (with wal_consistency_checking, it also +# compares the result against the logged page images). +$node->stop('immediate'); +$node->start; +check_index('t_left', $kleft, 'after crash recovery'); +check_index('t_right', $kright, 'after crash recovery'); +is( $node->safe_psql( + 'postgres', $idx . 'SELECT count(id) FROM s WHERE k = md5(id::text)'), + '5000', + 's after crash recovery: rows not updated keep their key'); +($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_parent_check('s_k', true)"); +is($rc . $stderr, '0', 's after crash recovery: amcheck heapallindexed'); + +$node->stop; +done_testing(); diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 11d5d345abe9f..7a03411bfb72f 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -2,6 +2,8 @@ ACCESS_ALLOWED_ACE ACL ACL_SIZE_INFORMATION AFFIX +amdeletemark_function +amundomark_function ASN1_INTEGER ASN1_OBJECT ASN1_OCTET_STRING @@ -3577,6 +3579,7 @@ XactUndoRecordInfo XactUndoSubTransactionState xl_atm_abort xl_atm_forget +xl_btree_delete_mark xl_fileops_chmod xl_fileops_chown xl_fileops_create From d27538d2c39182bccca812e88e42a513f28e36be Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 12 Oct 2026 01:10:44 +0000 Subject: [PATCH 11/22] tableam: let an AM declare that UPDATE keeps a row's TID Heap writes a new tuple version at a new TID on every UPDATE. The executor asserts as much in ExecUpdate and ExecDelete: when table_tuple_lock with TUPLE_LOCK_FLAG_FIND_LAST_VERSION returns TM_Ok for a row that the first attempt found concurrently updated, it must have followed the update chain to a different TID, so TM_FailureData.traversed must be set. An AM that updates rows in place reaches the latest version without moving, correctly reports traversed = false, and trips the assertion although nothing is wrong: EvalPlanQual is then run on the latest version exactly as for heap. Add a TableAmRoutine flag, am_inplace_update_keeps_tid, for an AM whose rows keep their TID for their whole lifetime (so the TID names only the newest version), and RelationUpdatesInPlace() to test it, false for relations without a table AM. Accept traversed = false in the two assertions for such an AM. Later commits use the same test where the core relies on the TID naming a particular version. Heap leaves the flag false; the change only weakens two assertions for AMs that set it. No in-core AM sets it yet. The FLUX table AM does, and its isolation test flux_lock_recheck fails this assertion without the change. --- src/backend/executor/nodeModifyTable.c | 24 ++++++++++++++++++++-- src/include/access/tableam.h | 28 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c index 558ceece9bb87..d366354e1365e 100644 --- a/src/backend/executor/nodeModifyTable.c +++ b/src/backend/executor/nodeModifyTable.c @@ -1755,7 +1755,17 @@ ExecDelete(ModifyTableContext *context, switch (result) { case TM_Ok: - Assert(context->tmfd.traversed); + + /* + * We asked for the latest version of a row we + * found outdated. An AM that moves a row on + * update must have followed the chain to a + * different TID to get there. An AM that updates + * in place reaches the latest version without + * moving, and correctly reports no traversal. + */ + Assert(context->tmfd.traversed || + RelationUpdatesInPlace(resultRelationDesc)); epqslot = EvalPlanQual(context->epqstate, resultRelationDesc, resultRelInfo->ri_RangeTableIndex, @@ -2660,7 +2670,17 @@ ExecUpdate(ModifyTableContext *context, ResultRelInfo *resultRelInfo, switch (result) { case TM_Ok: - Assert(context->tmfd.traversed); + + /* + * We asked for the latest version of a row we + * found outdated. An AM that moves a row on + * update must have followed the chain to a + * different TID to get there. An AM that updates + * in place reaches the latest version without + * moving, and correctly reports no traversal. + */ + Assert(context->tmfd.traversed || + RelationUpdatesInPlace(resultRelationDesc)); epqslot = EvalPlanQual(context->epqstate, resultRelationDesc, diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h index e2baa816e0274..abfb1b87b3e29 100644 --- a/src/include/access/tableam.h +++ b/src/include/access/tableam.h @@ -353,6 +353,24 @@ typedef struct TableAmRoutine */ bool am_supports_undo; + /* + * am_inplace_update_keeps_tid: this AM updates a row in place, so the row + * keeps its TID for the whole of its lifetime and the TID names only the + * row's newest version. Heap writes a new tuple version at a new TID on + * every UPDATE, and the core relies on that in the places below; each + * tests RelationUpdatesInPlace() and keeps heap's behaviour otherwise. + * + * A TUPLE_LOCK_FLAG_FIND_LAST_VERSION lock that reaches the latest + * version of a row it had found outdated must, for heap, have followed + * the update chain to a different TID, and reports + * TM_FailureData.traversed. An AM that sets this flag reaches the latest + * version without moving, so it correctly reports traversed = false, and + * ExecUpdate and ExecDelete accept that. + * + * The heap AM leaves this false. + */ + bool am_inplace_update_keeps_tid; + /* * am_undo_engine: which UNDO engine this AM writes UNDO to. Only * consulted when am_supports_undo is true. @@ -2321,6 +2339,16 @@ extern const TableAmRoutine *GetTableAmRoutine(Oid amhandler); extern const TableAmRoutine *GetHeapamTableAmRoutine(void); +/* + * Does this relation's AM overwrite a row's storage on UPDATE? False for + * relations without a table AM (foreign tables, views, partitioned tables). + */ +static inline bool +RelationUpdatesInPlace(Relation rel) +{ + return rel->rd_tableam != NULL && rel->rd_tableam->am_inplace_update_keeps_tid; +} + /* ---------------------------------------------------------------------------- * Functions in tableam.c * ---------------------------------------------------------------------------- From b8625dd5e60ff218e06343882b36e704b97a16f6 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 12 Oct 2026 01:10:44 +0000 Subject: [PATCH 12/22] executor: keep the pre-update row of an in-place UPDATE ExecUpdate reads the old row into oldSlot, calls table_tuple_update, and afterwards still uses the old row: RETURNING OLD projects from oldSlot, and ExecARUpdateTriggers re-fetches the old row by TID for the AFTER ROW UPDATE triggers. Both rely on heap writing the new version elsewhere. For an AM whose UPDATE overwrites the row in place (RelationUpdatesInPlace), oldSlot may point into the storage that was just overwritten, and the TID yields the new version, so an AFTER trigger would be handed NEW as OLD. Before updating such a row, materialise oldSlot, in ExecUpdate, in MERGE's UPDATE action and in logical replication apply (ExecSimpleRelationUpdate's searchslot). Pass the copy down: ExecUpdateEpilogue takes oldSlot, and ExecARUpdateTriggers takes a new old_image argument, which it uses instead of the TID re-fetch when the relation updates in place (setting tableoid and the TID on the trigger slot as the fetch would). Every other caller passes NULL. Heap is unaffected: both the materialisation and the use of old_image are gated by RelationUpdatesInPlace(), and heap keeps the TID re-fetch. This changes the signature of ExecARUpdateTriggers (one new argument); out-of-tree callers pass NULL. Exercised by FLUX's regress tests flux_deferred_trigger, flux_dml_semantics and flux_fk. --- src/backend/commands/trigger.c | 17 +++++++++++++- src/backend/executor/execReplication.c | 9 +++++++- src/backend/executor/nodeModifyTable.c | 31 +++++++++++++++++++++----- src/include/access/tableam.h | 6 +++++ src/include/commands/trigger.h | 1 + 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/backend/commands/trigger.c b/src/backend/commands/trigger.c index 3f0fb68eaeea4..8f7b06a8723e9 100644 --- a/src/backend/commands/trigger.c +++ b/src/backend/commands/trigger.c @@ -3187,6 +3187,7 @@ ExecARUpdateTriggers(EState *estate, ResultRelInfo *relinfo, ResultRelInfo *dst_partinfo, ItemPointer tupleid, HeapTuple fdw_trigtuple, + TupleTableSlot *old_image, TupleTableSlot *newslot, List *recheckIndexes, TransitionCaptureState *transition_capture, @@ -3224,7 +3225,21 @@ ExecARUpdateTriggers(EState *estate, ResultRelInfo *relinfo, tupsrc = src_partinfo ? src_partinfo : relinfo; oldslot = ExecGetTriggerOldSlot(estate, tupsrc); - if (fdw_trigtuple == NULL && ItemPointerIsValid(tupleid)) + /* + * The caller may already hold the pre-update image. It must when the + * AM updated the row in place: by now the row's TID yields the new + * version, so re-fetching it would hand the trigger NEW as OLD. + */ + if (old_image != NULL && !TupIsNull(old_image) && + RelationUpdatesInPlace(tupsrc->ri_RelationDesc)) + { + if (old_image != oldslot) + ExecCopySlot(oldslot, old_image); + oldslot->tts_tableOid = RelationGetRelid(tupsrc->ri_RelationDesc); + if (tupleid != NULL && ItemPointerIsValid(tupleid)) + oldslot->tts_tid = *tupleid; + } + else if (fdw_trigtuple == NULL && ItemPointerIsValid(tupleid)) GetTupleForTrigger(estate, NULL, tupsrc, diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c index 18da4fe08f633..d74e58e3b5eda 100644 --- a/src/backend/executor/execReplication.c +++ b/src/backend/executor/execReplication.c @@ -969,6 +969,13 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo, if (rel->rd_rel->relispartition) ExecPartitionCheck(resultRelInfo, slot, estate, true); + /* + * searchslot is read again below (conflict report, AFTER ROW + * triggers); an in-place AM is about to overwrite what it points at. + */ + if (RelationUpdatesInPlace(rel)) + ExecMaterializeSlot(searchslot); + simple_table_tuple_update(rel, tid, slot, estate->es_snapshot, &update_indexes); @@ -1000,7 +1007,7 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo, /* AFTER ROW UPDATE Triggers */ ExecARUpdateTriggers(estate, resultRelInfo, NULL, NULL, - tid, NULL, slot, + tid, NULL, searchslot, slot, recheckIndexes, NULL, false); list_free(recheckIndexes); diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c index d366354e1365e..d4190dbcdffa7 100644 --- a/src/backend/executor/nodeModifyTable.c +++ b/src/backend/executor/nodeModifyTable.c @@ -1292,6 +1292,7 @@ ExecInsert(ModifyTableContext *context, NULL, NULL, NULL, NULL, + NULL, slot, NULL, mtstate->mt_transition_capture, @@ -1566,7 +1567,7 @@ ExecDeleteEpilogue(ModifyTableContext *context, ResultRelInfo *resultRelInfo, ExecARUpdateTriggers(estate, resultRelInfo, NULL, NULL, tupleid, oldtuple, - NULL, NULL, mtstate->mt_transition_capture, + NULL, NULL, NULL, mtstate->mt_transition_capture, false); /* @@ -2366,7 +2367,8 @@ ExecUpdateAct(ModifyTableContext *context, ResultRelInfo *resultRelInfo, static void ExecUpdateEpilogue(ModifyTableContext *context, UpdateContext *updateCxt, ResultRelInfo *resultRelInfo, ItemPointer tupleid, - HeapTuple oldtuple, TupleTableSlot *slot) + HeapTuple oldtuple, TupleTableSlot *oldSlot, + TupleTableSlot *slot) { ModifyTableState *mtstate = context->mtstate; List *recheckIndexes = NIL; @@ -2386,7 +2388,7 @@ ExecUpdateEpilogue(ModifyTableContext *context, UpdateContext *updateCxt, /* AFTER ROW UPDATE Triggers */ ExecARUpdateTriggers(context->estate, resultRelInfo, NULL, NULL, - tupleid, oldtuple, slot, + tupleid, oldtuple, oldSlot, slot, recheckIndexes, mtstate->operation == CMD_INSERT ? mtstate->mt_oc_transition_capture : @@ -2475,7 +2477,7 @@ ExecCrossPartitionUpdateForeignKey(ModifyTableContext *context, /* Perform the root table's triggers. */ ExecARUpdateTriggers(context->estate, rootRelInfo, sourcePartInfo, destPartInfo, - tupleid, NULL, newslot, NIL, NULL, true); + tupleid, NULL, NULL, newslot, NIL, NULL, true); } /* ---------------------------------------------------------------- @@ -2593,6 +2595,16 @@ ExecUpdate(ModifyTableContext *context, ResultRelInfo *resultRelInfo, */ redo_act: lockedtid = *tupleid; + + /* + * An AM that updates in place overwrites the old row's storage, and + * oldSlot may still point into it. Copy the pre-update image out + * while it is intact, for RETURNING OLD and the AFTER ROW triggers. + */ + if (!TupIsNull(oldSlot) && + RelationUpdatesInPlace(resultRelInfo->ri_RelationDesc)) + ExecMaterializeSlot(oldSlot); + result = ExecUpdateAct(context, resultRelInfo, tupleid, oldtuple, slot, canSetTag, &updateCxt); @@ -2767,7 +2779,7 @@ ExecUpdate(ModifyTableContext *context, ResultRelInfo *resultRelInfo, (estate->es_processed)++; ExecUpdateEpilogue(context, &updateCxt, resultRelInfo, tupleid, oldtuple, - slot); + oldSlot, slot); /* Process RETURNING if present */ if (resultRelInfo->ri_projectReturning) @@ -3454,6 +3466,11 @@ ExecMergeMatched(ModifyTableContext *context, ResultRelInfo *resultRelInfo, /* checked ri_needLockTagTuple above */ Assert(oldtuple == NULL); + /* as in ExecUpdate: keep the pre-update image */ + if (!TupIsNull(resultRelInfo->ri_oldTupleSlot) && + RelationUpdatesInPlace(resultRelInfo->ri_RelationDesc)) + ExecMaterializeSlot(resultRelInfo->ri_oldTupleSlot); + result = ExecUpdateAct(context, resultRelInfo, tupleid, NULL, newslot, canSetTag, &updateCxt); @@ -3478,7 +3495,9 @@ ExecMergeMatched(ModifyTableContext *context, ResultRelInfo *resultRelInfo, if (result == TM_Ok) { ExecUpdateEpilogue(context, &updateCxt, resultRelInfo, - tupleid, NULL, newslot); + tupleid, NULL, + resultRelInfo->ri_oldTupleSlot, + newslot); mtstate->mt_merge_updated += 1; } break; diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h index abfb1b87b3e29..5c7c98c342f85 100644 --- a/src/include/access/tableam.h +++ b/src/include/access/tableam.h @@ -367,6 +367,12 @@ typedef struct TableAmRoutine * version without moving, so it correctly reports traversed = false, and * ExecUpdate and ExecDelete accept that. * + * The update overwrites the storage the pre-update row was read from, and + * the row's TID yields the new version afterwards. So ExecUpdate, MERGE + * and logical replication apply copy the old row out of its slot before + * updating, and hand that copy to RETURNING OLD and the AFTER ROW UPDATE + * triggers instead of re-fetching it by TID. + * * The heap AM leaves this false. */ bool am_inplace_update_keeps_tid; diff --git a/src/include/commands/trigger.h b/src/include/commands/trigger.h index d5cb16597f4fe..c7d6d2e9e043e 100644 --- a/src/include/commands/trigger.h +++ b/src/include/commands/trigger.h @@ -246,6 +246,7 @@ extern void ExecARUpdateTriggers(EState *estate, ResultRelInfo *dst_partinfo, ItemPointer tupleid, HeapTuple fdw_trigtuple, + TupleTableSlot *old_image, TupleTableSlot *newslot, List *recheckIndexes, TransitionCaptureState *transition_capture, From 3509f06fe04821fb80c411bdf3d65a6e88aaa433 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 12 Oct 2026 01:10:44 +0000 Subject: [PATCH 13/22] EvalPlanQual: fetch an in-place AM's reference rows with the query snapshot EvalPlanQualFetchRowMark refetches the row of a non-locked relation (ROW_MARK_REFERENCE) by TID with SnapshotAny. That is right for heap, whose TID names the version the query saw. An AM that updates rows in place keeps the row's newest version at that TID, so the recheck could join the locked row with values written by a transaction that committed after the statement's snapshot: SELECT ... FOR UPDATE OF a1 over a self-join returned a2 with the concurrent update's values where heap returns the ones the query saw. For such an AM fetch with the recheck estate's snapshot, the statement's, which yields the version the scan returned. If it sees none, fail as for heap. Heap's path is unchanged. Heap-neutral: gated by RelationUpdatesInPlace(). Tested by the isolation specs flux_tid_fetch_snapshot and its heap control heap_tid_fetch_snapshot (identical expected output), added with FLUX. --- src/backend/executor/execMain.c | 16 ++++++++++++++-- src/include/access/tableam.h | 4 ++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c index 2519af9e778e4..7ed41a5758c35 100644 --- a/src/backend/executor/execMain.c +++ b/src/backend/executor/execMain.c @@ -2940,10 +2940,22 @@ EvalPlanQualFetchRowMark(EPQState *epqstate, Index rti, TupleTableSlot *slot) } else { - /* ordinary table, fetch the tuple */ + /* + * An AM that updates rows in place keeps a row's newest version + * at its TID, so unlike heap's, the TID does not name the version + * the query saw: SnapshotAny could return one written by a + * transaction that committed after the statement's snapshot, and + * the recheck would join the locked row with values the query + * never saw. Ask for the version that snapshot sees, as the scan + * did. The row came from a scan under that snapshot, so it must + * see one; failing to is a bug, as for heap. + */ if (!table_tuple_fetch_row_version(erm->relation, (ItemPointer) DatumGetPointer(datum), - SnapshotAny, slot)) + RelationUpdatesInPlace(erm->relation) ? + epqstate->recheckestate->es_snapshot : + SnapshotAny, + slot)) elog(ERROR, "failed to fetch tuple for EvalPlanQual recheck"); return true; } diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h index 5c7c98c342f85..bbe6a9ba1e5c1 100644 --- a/src/include/access/tableam.h +++ b/src/include/access/tableam.h @@ -373,6 +373,10 @@ typedef struct TableAmRoutine * updating, and hand that copy to RETURNING OLD and the AFTER ROW UPDATE * triggers instead of re-fetching it by TID. * + * An EvalPlanQual recheck fetches a ROW_MARK_REFERENCE row by TID. With + * SnapshotAny that would be the newest version, possibly one the query's + * snapshot does not see, so the row is fetched with that snapshot. + * * The heap AM leaves this false. */ bool am_inplace_update_keeps_tid; From 68c55bc6d30e51a3cf066b7ae97c059f4fc34e1c Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 12 Oct 2026 01:10:44 +0000 Subject: [PATCH 14/22] AFTER triggers: queue an in-place AM's row images with the event An AFTER row trigger event records the row's TIDs, and the trigger re-fetches the OLD and NEW rows by TID when it fires, possibly at commit for a deferred trigger. That relies on the TID naming the version the event was queued for. For an AM that updates rows in place (RelationUpdatesInPlace) the TID yields the newest version by then, so a trigger would see a later version, and the RI triggers' "is this row version still live?" test (RI_FKey_check's table_tuple_satisfies_snapshot with SnapshotSelf) would be asked about the wrong version. For such a relation AfterTriggerSaveEvent stores the event's rows with the event, as heap-format copies made by the AM slot's copy_heap_tuple callback. When the event fires, the images are loaded with ExecCopySlot into the relation's trigger slots, which are of the AM's own slot type, as on the TID path; the AM's copyslot callback decides what of the version it keeps. The table AM contract is unchanged: tuple_satisfies_snapshot is still handed a slot of the AM's type. An AM that sets the flag must make copy_heap_tuple and copyslot carry what identifies the row version, so that tuple_satisfies_snapshot can judge that version rather than the row's newest; am_inplace_update_keeps_tid documents this. Heap never takes this path (the flag is false), so its events and memory use are unchanged. Tested by FLUX's regress tests flux_deferred_trigger and flux_fk (deferred FK checks across in-place updates) and flux_dml_semantics (tableoid on OLD/NEW). --- src/backend/commands/trigger.c | 303 ++++++++++++++++++++++++++++++++- src/include/access/tableam.h | 9 + 2 files changed, 304 insertions(+), 8 deletions(-) diff --git a/src/backend/commands/trigger.c b/src/backend/commands/trigger.c index 8f7b06a8723e9..f84bd385724b9 100644 --- a/src/backend/commands/trigger.c +++ b/src/backend/commands/trigger.c @@ -3743,6 +3743,15 @@ typedef uint32 TriggerFlags; #define AFTER_TRIGGER_1CTID 0x10000000 #define AFTER_TRIGGER_2CTID 0x30000000 #define AFTER_TRIGGER_CP_UPDATE 0x08000000 +/* + * A row event on a table whose AM updates rows in place carries its tuple + * images: by the time the trigger fires, the row's TID may yield a later + * version. An UPDATE carries two, an INSERT or DELETE one. (These and the + * one unused TUP_BITS value include the CP_UPDATE bit; that bit is only + * tested on the ctid-fetch path, which these kinds never take.) + */ +#define AFTER_TRIGGER_IMAGES 0x18000000 +#define AFTER_TRIGGER_1IMAGE 0x28000000 #define AFTER_TRIGGER_TUP_BITS 0x38000000 typedef struct AfterTriggerSharedData *AfterTriggerShared; @@ -3789,6 +3798,28 @@ typedef struct AfterTriggerEventDataOneCtid ItemPointerData ate_ctid1; /* inserted, deleted, or old updated tuple */ } AfterTriggerEventDataOneCtid; +/* + * AFTER_TRIGGER_IMAGES: the ctids plus pointers to the OLD and NEW images. + * Events are packed, so the pointers are only 4-byte aligned: access them + * with afterTriggerGetImage/afterTriggerSetImage. See + * AfterTriggersQueryData.image_cxt for who owns the images. + */ +typedef struct AfterTriggerEventDataImages +{ + TriggerFlags ate_flags; + ItemPointerData ate_ctid1; + ItemPointerData ate_ctid2; + char ate_image[2 * sizeof(HeapTuple)]; +} AfterTriggerEventDataImages; + +/* AFTER_TRIGGER_1IMAGE: likewise, for an INSERT or DELETE */ +typedef struct AfterTriggerEventDataOneImage +{ + TriggerFlags ate_flags; + ItemPointerData ate_ctid1; + char ate_image[sizeof(HeapTuple)]; +} AfterTriggerEventDataOneImage; + /* AfterTriggerEventData, minus ate_*_part, ate_ctid1 and ate_ctid2 */ typedef struct AfterTriggerEventDataZeroCtids { @@ -3798,6 +3829,10 @@ typedef struct AfterTriggerEventDataZeroCtids #define SizeofTriggerEvent(evt) \ (((evt)->ate_flags & AFTER_TRIGGER_TUP_BITS) == AFTER_TRIGGER_CP_UPDATE ? \ sizeof(AfterTriggerEventData) : \ + ((evt)->ate_flags & AFTER_TRIGGER_TUP_BITS) == AFTER_TRIGGER_IMAGES ? \ + sizeof(AfterTriggerEventDataImages) : \ + ((evt)->ate_flags & AFTER_TRIGGER_TUP_BITS) == AFTER_TRIGGER_1IMAGE ? \ + sizeof(AfterTriggerEventDataOneImage) : \ (((evt)->ate_flags & AFTER_TRIGGER_TUP_BITS) == AFTER_TRIGGER_2CTID ? \ sizeof(AfterTriggerEventDataNoOids) : \ (((evt)->ate_flags & AFTER_TRIGGER_TUP_BITS) == AFTER_TRIGGER_1CTID ? \ @@ -3807,6 +3842,46 @@ typedef struct AfterTriggerEventDataZeroCtids #define GetTriggerSharedData(evt) \ ((AfterTriggerShared) ((char *) (evt) + ((evt)->ate_flags & AFTER_TRIGGER_OFFSET))) +/* The image pointers of an AFTER_TRIGGER_[1]IMAGE[S] event, and their number */ +static inline char * +afterTriggerImageArea(AfterTriggerEvent event, int *nimages) +{ + switch (event->ate_flags & AFTER_TRIGGER_TUP_BITS) + { + case AFTER_TRIGGER_IMAGES: + *nimages = 2; + return ((AfterTriggerEventDataImages *) event)->ate_image; + case AFTER_TRIGGER_1IMAGE: + *nimages = 1; + return ((AfterTriggerEventDataOneImage *) event)->ate_image; + default: + *nimages = 0; + return NULL; + } +} + +static inline HeapTuple +afterTriggerGetImage(AfterTriggerEvent event, int n) +{ + int nimages; + char *area = afterTriggerImageArea(event, &nimages); + HeapTuple tuple; + + Assert(n < nimages); + memcpy(&tuple, area + n * sizeof(HeapTuple), sizeof(HeapTuple)); + return tuple; +} + +static inline void +afterTriggerSetImage(AfterTriggerEvent event, int n, HeapTuple tuple) +{ + int nimages; + char *area = afterTriggerImageArea(event, &nimages); + + Assert(n < nimages); + memcpy(area + n * sizeof(HeapTuple), &tuple, sizeof(HeapTuple)); +} + /* * To avoid palloc overhead, we keep trigger events in arrays in successively- * larger chunks (a slightly more sophisticated version of an expansible @@ -3820,6 +3895,7 @@ typedef struct AfterTriggerEventChunk char *freeptr; /* start of free space in chunk */ char *endfree; /* end of free space in chunk */ char *endptr; /* end of chunk */ + bool has_images; /* holds AFTER_TRIGGER_[1]IMAGE[S] events? */ /* event data follows here */ } AfterTriggerEventChunk; @@ -3949,9 +4025,31 @@ typedef struct AfterTriggersData int maxtransdepth; /* allocated len of above array */ } AfterTriggersData; +/* + * Row events of a table whose AM updates rows in place carry pointers to + * copies of their tuple images (heap re-fetches its versions by TID instead; + * an in-place AM's TID names only the newest version). The images are + * heap-format copies made by the AM's slot (ExecCopySlotHeapTuple), not + * minimal tuples: a heap tuple header can carry the identity of the row + * version the image was taken of (xmin, cmin, ctid), as a heap tuple's own + * header does. Heap's events are judged by that identity, through the + * version the TID still names; an in-place AM must find the identity in the + * image (see am_inplace_update_keeps_tid). + * + * The images live as long as their events. Those queued by a query are + * allocated in its level's image_cxt, shared by all the events of a row, and + * freed with the level's events in AfterTriggerFreeQuery. An event deferred + * to afterTriggers.events gets its own copies in event_cxt + * (afterTriggerMarkEvents), which it owns: they are freed when the event is + * discarded with an aborted subtransaction (afterTriggerRestoreEventList) or + * deleted after firing (afterTriggerInvokeEvents), and at the latest with + * event_cxt at transaction end. So, as for heap, only deferred events hold + * memory beyond their statement. + */ struct AfterTriggersQueryData { AfterTriggerEventList events; /* events pending from this query */ + MemoryContext image_cxt; /* tuple images for said events, if any */ Tuplestorestate *fdw_tuplestore; /* foreign tuples for said events */ List *tables; /* list of AfterTriggersTableData, see below */ }; @@ -4126,10 +4224,10 @@ afterTriggerCopyBitmap(Bitmapset *src) * afterTriggerAddEvent() * * Add a new trigger event to the specified queue. - * The passed-in event data is copied. + * The passed-in event data is copied; the copy is returned. * ---------- */ -static void +static AfterTriggerEvent afterTriggerAddEvent(AfterTriggerEventList *events, AfterTriggerEvent event, AfterTriggerShared evtshared) { @@ -4196,6 +4294,7 @@ afterTriggerAddEvent(AfterTriggerEventList *events, chunk->next = NULL; chunk->freeptr = CHUNK_DATA_START(chunk); chunk->endptr = chunk->endfree = (char *) chunk + chunksize; + chunk->has_images = false; Assert(chunk->endfree - chunk->freeptr >= needed); if (events->tail == NULL) @@ -4246,8 +4345,40 @@ afterTriggerAddEvent(AfterTriggerEventList *events, newevent->ate_flags &= ~AFTER_TRIGGER_OFFSET; newevent->ate_flags |= (char *) newshared - (char *) newevent; + if ((event->ate_flags & AFTER_TRIGGER_TUP_BITS) == AFTER_TRIGGER_IMAGES || + (event->ate_flags & AFTER_TRIGGER_TUP_BITS) == AFTER_TRIGGER_1IMAGE) + chunk->has_images = true; + chunk->freeptr += eventsize; events->tailfree = chunk->freeptr; + + return newevent; +} + +/* ---------- + * afterTriggerFreeImages() + * + * Free the images owned by the events of "chunk" from "from" (or from its + * first event, if NULL) on. Only the events of afterTriggers.events own + * their images; see AfterTriggersQueryData. + * ---------- + */ +static void +afterTriggerFreeImages(AfterTriggerEventChunk *chunk, char *from) +{ + AfterTriggerEvent event; + + if (!chunk->has_images) + return; + event = (AfterTriggerEvent) (from ? from : CHUNK_DATA_START(chunk)); + for_each_event_from(event, chunk) + { + int nimages; + + afterTriggerImageArea(event, &nimages); + for (int i = 0; i < nimages; i++) + heap_freetuple(afterTriggerGetImage(event, i)); + } } /* ---------- @@ -4284,6 +4415,21 @@ afterTriggerRestoreEventList(AfterTriggerEventList *events, AfterTriggerEventChunk *chunk; AfterTriggerEventChunk *next_chunk; + /* + * Free the images of the events being removed. Only the deferred-event + * list is restored, and its events own their images. + */ + Assert(events == &afterTriggers.events); + if (old_events->tail == NULL) + chunk = events->head; + else + { + afterTriggerFreeImages(old_events->tail, old_events->tailfree); + chunk = old_events->tail->next; + } + for (; chunk != NULL; chunk = chunk->next) + afterTriggerFreeImages(chunk, NULL); + if (old_events->tail == NULL) { /* restoring to a completely empty state, so free everything */ @@ -4450,6 +4596,51 @@ AfterTriggerExecute(EState *estate, } pg_fallthrough; case AFTER_TRIGGER_FDW_REUSE: + case AFTER_TRIGGER_IMAGES: + case AFTER_TRIGGER_1IMAGE: + if ((event->ate_flags & AFTER_TRIGGER_TUP_BITS) == AFTER_TRIGGER_IMAGES || + (event->ate_flags & AFTER_TRIGGER_TUP_BITS) == AFTER_TRIGGER_1IMAGE) + { + ExecStoreHeapTuple(afterTriggerGetImage(event, 0), + trig_tuple_slot1, false); + trig_tuple_slot1->tts_tid = event->ate_ctid1; + if ((evtshared->ats_event & TRIGGER_EVENT_OPMASK) == + TRIGGER_EVENT_UPDATE) + { + ExecStoreHeapTuple(afterTriggerGetImage(event, 1), + trig_tuple_slot2, false); + trig_tuple_slot2->tts_tid = event->ate_ctid2; + } + + /* + * Fire the trigger with slots of the AM's own type, as the + * ctid path does. The AM's copyslot callback loads them from + * the images, which its copy_heap_tuple made, so what the AM + * records of each row version survives the trip. + */ + LocTriggerData.tg_trigslot = ExecGetTriggerOldSlot(estate, relInfo); + ExecCopySlot(LocTriggerData.tg_trigslot, trig_tuple_slot1); + LocTriggerData.tg_trigslot->tts_tid = trig_tuple_slot1->tts_tid; + LocTriggerData.tg_trigslot->tts_tableOid = RelationGetRelid(rel); + ExecClearTuple(trig_tuple_slot1); + LocTriggerData.tg_trigtuple = + ExecFetchSlotHeapTuple(LocTriggerData.tg_trigslot, false, + &should_free_trig); + LocTriggerData.tg_newtuple = NULL; + if ((evtshared->ats_event & TRIGGER_EVENT_OPMASK) == + TRIGGER_EVENT_UPDATE) + { + LocTriggerData.tg_newslot = ExecGetTriggerNewSlot(estate, relInfo); + ExecCopySlot(LocTriggerData.tg_newslot, trig_tuple_slot2); + LocTriggerData.tg_newslot->tts_tid = trig_tuple_slot2->tts_tid; + LocTriggerData.tg_newslot->tts_tableOid = RelationGetRelid(rel); + ExecClearTuple(trig_tuple_slot2); + LocTriggerData.tg_newtuple = + ExecFetchSlotHeapTuple(LocTriggerData.tg_newslot, false, + &should_free_new); + } + break; + } /* * Store tuple in the slot so that tg_trigtuple does not reference @@ -4634,7 +4825,8 @@ AfterTriggerExecute(EState *estate, heap_freetuple(LocTriggerData.tg_newtuple); /* don't clear slots' contents if foreign table */ - if (trig_tuple_slot1 == NULL) + if (trig_tuple_slot1 == NULL || + LocTriggerData.tg_trigslot != trig_tuple_slot1) { if (LocTriggerData.tg_trigslot) ExecClearTuple(LocTriggerData.tg_trigslot); @@ -4707,11 +4899,33 @@ afterTriggerMarkEvents(AfterTriggerEventList *events, */ if (defer_it && move_list != NULL) { + AfterTriggerEvent moved; + int nimages; + deferred_found = true; /* add it to move_list */ - afterTriggerAddEvent(move_list, event, evtshared); + moved = afterTriggerAddEvent(move_list, event, evtshared); /* mark original copy "done" so we don't do it again */ event->ate_flags |= AFTER_TRIGGER_DONE; + + /* + * The images of the original copy go away with its query level; + * the moved copy gets its own (see AfterTriggersQueryData). + * + * ponytail: one copy per deferred event, so several deferred + * triggers on a row each copy its images; share them (refcount) + * if that ever matters. + */ + afterTriggerImageArea(moved, &nimages); + if (nimages > 0) + { + MemoryContext oldcxt = MemoryContextSwitchTo(afterTriggers.event_cxt); + + for (int i = 0; i < nimages; i++) + afterTriggerSetImage(moved, i, + heap_copytuple(afterTriggerGetImage(moved, i))); + MemoryContextSwitchTo(oldcxt); + } } } @@ -4826,6 +5040,14 @@ afterTriggerInvokeEvents(AfterTriggerEventList *events, slot2 = MakeSingleTupleTableSlot(rel->rd_att, &TTSOpsMinimalTuple); } + else if (RelationUpdatesInPlace(rel)) + { + /* AFTER_TRIGGER_[1]IMAGE[S]: to hold the images */ + slot1 = MakeSingleTupleTableSlot(rel->rd_att, + &TTSOpsHeapTuple); + slot2 = MakeSingleTupleTableSlot(rel->rd_att, + &TTSOpsHeapTuple); + } } /* @@ -4873,6 +5095,10 @@ afterTriggerInvokeEvents(AfterTriggerEventList *events, /* Clear the chunk if delete_ok and nothing left of interest */ if (delete_ok && all_fired_in_chunk) { + /* delete_ok is only passed for the deferred list; see above */ + Assert(!chunk->has_images || events == &afterTriggers.events); + afterTriggerFreeImages(chunk, NULL); + chunk->has_images = false; chunk->freeptr = CHUNK_DATA_START(chunk); chunk->endfree = chunk->endptr; @@ -5285,8 +5511,15 @@ AfterTriggerFreeQuery(AfterTriggersQueryData *qs) List *tables; ListCell *lc; - /* Drop the trigger events */ + /* Drop the trigger events, and their images */ afterTriggerFreeEventList(&qs->events); + if (qs->image_cxt) + { + MemoryContext cxt = qs->image_cxt; + + qs->image_cxt = NULL; + MemoryContextDelete(cxt); + } /* Drop FDW tuplestore if any */ ts = qs->fdw_tuplestore; @@ -5731,6 +5964,7 @@ AfterTriggerEnlargeQueryState(void) qs->events.head = NULL; qs->events.tail = NULL; qs->events.tailfree = NULL; + qs->image_cxt = NULL; qs->fdw_tuplestore = NULL; qs->tables = NIL; @@ -6237,6 +6471,9 @@ AfterTriggerSaveEvent(EState *estate, ResultRelInfo *relinfo, int tgtype_level; int i; Tuplestorestate *fdw_tuplestore = NULL; + bool spool_images; + AfterTriggerEventDataImages img_event; + HeapTuple images[2] = {NULL, NULL}; /* * Check state. We use a normal test not Assert because it is possible to @@ -6415,8 +6652,30 @@ AfterTriggerSaveEvent(EState *estate, ResultRelInfo *relinfo, break; } + /* + * A row event normally records TIDs and re-fetches the images when the + * trigger fires. An AM that updates rows in place cannot reproduce them + * by then -- the TID yields the current version -- so capture them now, + * as heap-format copies that identify the version they were taken of (see + * AfterTriggersQueryData). They are made below, once a trigger wants the + * row, and shared by all of the row's events. + */ + spool_images = (row_trigger && RelationUpdatesInPlace(rel)); + /* Determine flags */ - if (!(relkind == RELKIND_FOREIGN_TABLE && row_trigger)) + if (spool_images) + { + /* img_event is laid out per its flags; see afterTriggerImageArea */ + img_event.ate_ctid1 = new_event.ate_ctid1; + if (oldslot && newslot) + { + new_event.ate_flags = AFTER_TRIGGER_IMAGES; + img_event.ate_ctid2 = new_event.ate_ctid2; + } + else + new_event.ate_flags = AFTER_TRIGGER_1IMAGE; + } + else if (!(relkind == RELKIND_FOREIGN_TABLE && row_trigger)) { if (row_trigger && event == TRIGGER_EVENT_UPDATE) { @@ -6611,8 +6870,36 @@ AfterTriggerSaveEvent(EState *estate, ResultRelInfo *relinfo, new_shared.ats_table = NULL; new_shared.ats_modifiedcols = modifiedCols; - afterTriggerAddEvent(&afterTriggers.query_stack[afterTriggers.query_depth].events, - &new_event, &new_shared); + if (spool_images) + { + AfterTriggersQueryData *qs = + &afterTriggers.query_stack[afterTriggers.query_depth]; + + if (images[0] == NULL) + { + MemoryContext oldcxt; + + if (qs->image_cxt == NULL) + qs->image_cxt = + AllocSetContextCreate(TopTransactionContext, + "AfterTriggerImages", + ALLOCSET_DEFAULT_SIZES); + oldcxt = MemoryContextSwitchTo(qs->image_cxt); + images[0] = ExecCopySlotHeapTuple(oldslot ? oldslot : newslot); + if (oldslot && newslot) + images[1] = ExecCopySlotHeapTuple(newslot); + MemoryContextSwitchTo(oldcxt); + } + img_event.ate_flags = new_event.ate_flags; + afterTriggerSetImage((AfterTriggerEvent) &img_event, 0, images[0]); + if (images[1] != NULL) + afterTriggerSetImage((AfterTriggerEvent) &img_event, 1, images[1]); + afterTriggerAddEvent(&qs->events, + (AfterTriggerEvent) &img_event, &new_shared); + } + else + afterTriggerAddEvent(&afterTriggers.query_stack[afterTriggers.query_depth].events, + &new_event, &new_shared); } /* diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h index bbe6a9ba1e5c1..43ec164802f04 100644 --- a/src/include/access/tableam.h +++ b/src/include/access/tableam.h @@ -377,6 +377,15 @@ typedef struct TableAmRoutine * SnapshotAny that would be the newest version, possibly one the query's * snapshot does not see, so the row is fetched with that snapshot. * + * AFTER row triggers do not re-fetch their rows by TID either: the + * trigger queue stores each event's rows as heap-format copies, made by + * the AM slot's copy_heap_tuple callback, and fires the trigger with + * slots of the AM's own type loaded from them by its copyslot callback. + * The RI triggers ask tuple_satisfies_snapshot of such a slot whether + * that exact row version is still live, which heap answers for the + * version its TID names; to answer it, the AM must carry what identifies + * the version through that copy and back. + * * The heap AM leaves this false. */ bool am_inplace_update_keeps_tid; From 53ab3d89b6b2b6b0de68f1a9ecbc2d120ceb08f2 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 12 Oct 2026 01:10:44 +0000 Subject: [PATCH 15/22] logical replication: pre-check unique keys an in-place UPDATE changes The apply worker detects that a replicated UPDATE conflicts with a local row after the fact: table_tuple_update stores the new row, ExecInsertIndexTuples inserts its index entries without raising duplicate errors, and CheckAndReportConflict reports update_exists or multiple_unique_conflicts. An AM that updates rows in place may maintain the indexes of a key change inside table_tuple_update and report TU_None; a duplicate then surfaces as a plain unique violation, with no conflict statistics, no conflict log entry and no DETAIL naming the local row and its origin for ALTER SUBSCRIPTION ... SKIP. For such an AM, look for the conflict before the update, and only in the unique (arbiter) indexes whose key the update changes: an unchanged key still belongs to the row alone. index_key_may_change() compares the columns the index uses, including those of its expressions and predicate. The row keeps its TID, so passing it as the new row's TID makes the check ignore the row's own entries. A conflicting row committed between the check and the update still makes the update fail, with a unique violation; the retried transaction then reports the conflict. Heap-neutral: gated by RelationUpdatesInPlace(). Tested by subscription test 104_inplace_am_update_conflict, added with FLUX. --- src/backend/executor/execReplication.c | 84 ++++++++++++++++++++++++++ src/include/access/tableam.h | 6 ++ 2 files changed, 90 insertions(+) diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c index d74e58e3b5eda..86e5da9a3b968 100644 --- a/src/backend/executor/execReplication.c +++ b/src/backend/executor/execReplication.c @@ -27,10 +27,12 @@ #include "commands/trigger.h" #include "executor/executor.h" #include "executor/nodeModifyTable.h" +#include "optimizer/optimizer.h" #include "replication/conflict.h" #include "replication/logicalrelation.h" #include "storage/lmgr.h" #include "utils/builtins.h" +#include "utils/datum.h" #include "utils/lsyscache.h" #include "utils/rel.h" #include "utils/snapmgr.h" @@ -783,6 +785,52 @@ FindConflictTuple(ResultRelInfo *resultRelInfo, EState *estate, return true; } +/* + * Can the update from 'oldslot' to 'newslot' give this index a key it did not + * have? Only then can the new version collide with another row in a unique + * index. Compare the columns the index refers to: its key columns and those + * its expressions and predicate use. A value stored differently but equal + * counts as changed, which costs only a needless check. + */ +static bool +index_key_may_change(IndexInfo *indexInfo, TupleTableSlot *oldslot, + TupleTableSlot *newslot) +{ + TupleDesc desc = oldslot->tts_tupleDescriptor; + Bitmapset *attrs = NULL; + int member = -1; + + for (int i = 0; i < indexInfo->ii_NumIndexAttrs; i++) + { + if (indexInfo->ii_IndexAttrNumbers[i] != 0) + attrs = bms_add_member(attrs, indexInfo->ii_IndexAttrNumbers[i] - + FirstLowInvalidHeapAttributeNumber); + } + pull_varattnos((Node *) indexInfo->ii_Expressions, 1, &attrs); + pull_varattnos((Node *) indexInfo->ii_Predicate, 1, &attrs); + + slot_getallattrs(oldslot); + slot_getallattrs(newslot); + + while ((member = bms_next_member(attrs, member)) >= 0) + { + AttrNumber attnum = member + FirstLowInvalidHeapAttributeNumber; + Form_pg_attribute att; + + if (attnum <= 0) + return true; + att = TupleDescAttr(desc, attnum - 1); + if (oldslot->tts_isnull[attnum - 1] != newslot->tts_isnull[attnum - 1]) + return true; + if (!oldslot->tts_isnull[attnum - 1] && + !datumIsEqual(oldslot->tts_values[attnum - 1], + newslot->tts_values[attnum - 1], + att->attbyval, att->attlen)) + return true; + } + return false; +} + /* * Check all the unique indexes in 'recheckIndexes' for conflict with the * tuple in 'remoteslot' and report if found. @@ -969,6 +1017,42 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo, if (rel->rd_rel->relispartition) ExecPartitionCheck(resultRelInfo, slot, estate, true); + /* + * An AM that updates in place may maintain the indexes of a key + * change inside table_tuple_update, where a duplicate key raises a + * plain unique violation rather than the conflict reported below. So + * look for the conflict before the update, in the unique indexes + * whose key the update changes. The row's own index entries are at + * its TID, which the update keeps, so they do not count. A + * conflicting row committed between this check and the update still + * fails the update, with a unique violation; the retried transaction + * then reports the conflict here. + */ + if (RelationUpdatesInPlace(rel) && + resultRelInfo->ri_onConflictArbiterIndexes != NIL) + { + List *changed = NIL; + + for (int i = 0; i < resultRelInfo->ri_NumIndices; i++) + { + Relation index = resultRelInfo->ri_IndexRelationDescs[i]; + + if (index != NULL && + list_member_oid(resultRelInfo->ri_onConflictArbiterIndexes, + RelationGetRelid(index)) && + index_key_may_change(resultRelInfo->ri_IndexRelationInfo[i], + searchslot, slot)) + changed = lappend_oid(changed, RelationGetRelid(index)); + } + if (changed != NIL) + { + slot->tts_tid = *tid; + CheckAndReportConflict(resultRelInfo, estate, CT_UPDATE_EXISTS, + changed, searchslot, slot); + list_free(changed); + } + } + /* * searchslot is read again below (conflict report, AFTER ROW * triggers); an in-place AM is about to overwrite what it points at. diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h index 43ec164802f04..b0b3ad356e1f2 100644 --- a/src/include/access/tableam.h +++ b/src/include/access/tableam.h @@ -377,6 +377,12 @@ typedef struct TableAmRoutine * SnapshotAny that would be the newest version, possibly one the query's * snapshot does not see, so the row is fetched with that snapshot. * + * Logical replication apply reports a unique-key conflict of an UPDATE + * after inserting its index entries. This AM may maintain the indexes of + * a key change inside tuple_update, where a duplicate fails as a plain + * unique violation, so apply checks the unique indexes whose key the + * UPDATE changes beforehand. + * * AFTER row triggers do not re-fetch their rows by TID either: the * trigger queue stores each event's rows as heap-format copies, made by * the AM slot's copy_heap_tuple callback, and fires the trigger with From be98739ba1d2c36d59debb09e99f4f22ef556181 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 12 Oct 2026 01:10:44 +0000 Subject: [PATCH 16/22] tableam: SO_RETURN_DELETED, and update_deleted detection for any AM With retain_dead_tuples, the apply worker tells a replicated UPDATE of a row deleted locally (update_deleted) from one of a row that never existed (update_missing) by searching for the deleted version with a SnapshotAny scan, through the replica identity index or sequentially, and judging each version with HeapTupleSatisfiesVacuum. Both halves assume heap: SnapshotAny is not required to return deleted versions, and update_most_recent_deletion_info casts the scan slot to a BufferHeapTupleTableSlot. Add a scan option, SO_RETURN_DELETED: with SnapshotAny, also return the versions a committed or in-progress DELETE removed. Heap's SnapshotAny always returns them, so heap ignores the flag. RelationFindDeletedTupleInfoSeq and RelationFindDeletedTupleInfoByIndex pass it. update_most_recent_deletion_info keeps the heap test for a buffer heap tuple slot; for any other slot it reads the deleter from the xmax system column and accepts the version as heap accepts HEAPTUPLE_RECENTLY_DEAD: the deleter committed, and not before oldestxmin. Heap behaviour is unchanged (same slot type, same test, flag ignored). Tested by subscription test 105_inplace_am_update_deleted and 035_conflicts with FLUX as the subscriber's table AM. --- src/backend/executor/execReplication.c | 87 ++++++++++++++++++-------- src/include/access/tableam.h | 8 +++ 2 files changed, 68 insertions(+), 27 deletions(-) diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c index 86e5da9a3b968..b18e3ab9c0433 100644 --- a/src/backend/executor/execReplication.c +++ b/src/backend/executor/execReplication.c @@ -31,6 +31,7 @@ #include "replication/conflict.h" #include "replication/logicalrelation.h" #include "storage/lmgr.h" +#include "storage/procarray.h" #include "utils/builtins.h" #include "utils/datum.h" #include "utils/lsyscache.h" @@ -480,6 +481,10 @@ BuildConflictIndexInfo(ResultRelInfo *resultRelInfo, Oid conflictindex) * commit timestamp than previously recorded, update the associated transaction * ID, commit time, and origin. This helps ensure that conflict detection uses * the most recent and relevant deletion metadata. + * + * A heap tuple is judged by HeapTupleSatisfiesVacuum. A tuple of another + * table AM, found by a scan begun with SO_RETURN_DELETED, is judged by its + * xmax system column, the transaction that deleted it. */ static void update_most_recent_deletion_info(TupleTableSlot *scanslot, @@ -488,39 +493,64 @@ update_most_recent_deletion_info(TupleTableSlot *scanslot, TimestampTz *delete_time, ReplOriginId *delete_origin) { - BufferHeapTupleTableSlot *hslot; - HeapTuple tuple; - Buffer buf; - bool recently_dead = false; TransactionId xmax; TimestampTz localts; ReplOriginId localorigin; - hslot = (BufferHeapTupleTableSlot *) scanslot; + if (TTS_IS_BUFFERTUPLE(scanslot)) + { + BufferHeapTupleTableSlot *hslot; + HeapTuple tuple; + Buffer buf; + bool recently_dead = false; - tuple = ExecFetchSlotHeapTuple(scanslot, false, NULL); - buf = hslot->buffer; + hslot = (BufferHeapTupleTableSlot *) scanslot; - LockBuffer(buf, BUFFER_LOCK_SHARE); + tuple = ExecFetchSlotHeapTuple(scanslot, false, NULL); + buf = hslot->buffer; - /* - * We do not consider HEAPTUPLE_DEAD status because it indicates either - * tuples whose inserting transaction was aborted (meaning there is no - * commit timestamp or origin), or tuples deleted by a transaction older - * than oldestxmin, making it safe to ignore them during conflict - * detection (See comments atop worker.c for details). - */ - if (HeapTupleSatisfiesVacuum(tuple, oldestxmin, buf) == HEAPTUPLE_RECENTLY_DEAD) - recently_dead = true; + LockBuffer(buf, BUFFER_LOCK_SHARE); - LockBuffer(buf, BUFFER_LOCK_UNLOCK); + /* + * We do not consider HEAPTUPLE_DEAD status because it indicates + * either tuples whose inserting transaction was aborted (meaning + * there is no commit timestamp or origin), or tuples deleted by a + * transaction older than oldestxmin, making it safe to ignore them + * during conflict detection (See comments atop worker.c for details). + */ + if (HeapTupleSatisfiesVacuum(tuple, oldestxmin, buf) == HEAPTUPLE_RECENTLY_DEAD) + recently_dead = true; - if (!recently_dead) - return; + LockBuffer(buf, BUFFER_LOCK_UNLOCK); - xmax = HeapTupleHeaderGetUpdateXid(tuple->t_data); - if (!TransactionIdIsValid(xmax)) - return; + if (!recently_dead) + return; + + xmax = HeapTupleHeaderGetUpdateXid(tuple->t_data); + if (!TransactionIdIsValid(xmax)) + return; + } + else + { + bool isnull; + + /* + * Accept what heap reports as HEAPTUPLE_RECENTLY_DEAD: the deleter + * committed, and not before oldestxmin. A tuple nobody deleted is + * live; one deleted before oldestxmin is HEAPTUPLE_DEAD, ignored as + * above; and one whose deleter is still running, or aborted, is not + * deleted. As in HeapTupleSatisfiesVacuum, check that the deleter is + * no longer running before asking whether it committed. + */ + xmax = DatumGetTransactionId(slot_getsysattr(scanslot, + MaxTransactionIdAttributeNumber, + &isnull)); + if (isnull || !TransactionIdIsValid(xmax) || + TransactionIdPrecedes(xmax, oldestxmin) || + TransactionIdIsInProgress(xmax) || + !TransactionIdDidCommit(xmax)) + return; + } /* Select the dead tuple with the most recent commit timestamp */ if (TransactionIdGetCommitTsData(xmax, &localts, &localorigin) && @@ -625,10 +655,11 @@ RelationFindDeletedTupleInfoSeq(Relation rel, Oid identidxoid, * Start a heap scan using SnapshotAny to identify dead tuples that are * not visible under a standard MVCC snapshot. Tuples from transactions * not yet committed or those just committed prior to the scan are - * excluded in update_most_recent_deletion_info(). + * excluded in update_most_recent_deletion_info(). SO_RETURN_DELETED asks + * a table AM whose SnapshotAny hides deleted tuples for them. */ scan = table_beginscan(rel, SnapshotAny, 0, NULL, - SO_NONE); + SO_RETURN_DELETED); scanslot = table_slot_create(rel, NULL); table_rescan(scan, NULL); @@ -690,10 +721,12 @@ RelationFindDeletedTupleInfoByIndex(Relation rel, Oid idxoid, * Start an index scan using SnapshotAny to identify dead tuples that are * not visible under a standard MVCC snapshot. Tuples from transactions * not yet committed or those just committed prior to the scan are - * excluded in update_most_recent_deletion_info(). + * excluded in update_most_recent_deletion_info(). SO_RETURN_DELETED asks + * a table AM whose SnapshotAny hides deleted tuples for them. */ scan = index_beginscan(rel, idxrel, false, - SnapshotAny, NULL, skey_attoff, 0, SO_NONE); + SnapshotAny, NULL, skey_attoff, 0, + SO_RETURN_DELETED); index_rescan(scan, skey, skey_attoff, NULL, 0); diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h index b0b3ad356e1f2..e935dec0958ba 100644 --- a/src/include/access/tableam.h +++ b/src/include/access/tableam.h @@ -73,6 +73,14 @@ typedef enum ScanOptions /* collect scan instrumentation */ SO_SCAN_INSTRUMENT = 1 << 11, + + /* + * With SnapshotAny, also return row versions that a committed or + * in-progress DELETE removed, for a caller that judges them itself. + * Heap's SnapshotAny always returns them; an AM whose SnapshotAny hides + * deleted versions returns them only when asked. + */ + SO_RETURN_DELETED = 1 << 12, } ScanOptions; /* From 295fc01c6f3788db3fcea80eba28e2a330a8a48f Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 12 Oct 2026 01:10:44 +0000 Subject: [PATCH 17/22] tableam: let a block AM's size estimate use its own default fillfactor table_block_relation_estimate_size() estimates the tuple density of a never-vacuumed relation from the column widths, the AM's per-tuple and per-page overheads, and the relation's fillfactor. When the relation has no fillfactor reloption it assumes HEAP_DEFAULT_FILLFACTOR (100). An AM that fills its pages to a different default, as FLUX does to leave room for in-place updates that grow a row, would therefore overestimate the rows per page. Its only alternative is to copy the helper. Add table_block_relation_estimate_size_extended(), which takes the AM's default fillfactor. table_block_relation_estimate_size() becomes a wrapper that passes HEAP_DEFAULT_FILLFACTOR, so heap and every existing caller behave exactly as before. Heap-neutral. Exercised by FLUX's planner size-estimate regress tests. --- src/backend/access/table/tableam.c | 28 +++++++++++++++++++++++++++- src/include/access/tableam.h | 8 ++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/backend/access/table/tableam.c b/src/backend/access/table/tableam.c index d82993ab7d656..edd2a64dc2c91 100644 --- a/src/backend/access/table/tableam.c +++ b/src/backend/access/table/tableam.c @@ -672,6 +672,10 @@ table_block_relation_size(Relation rel, ForkNumber forkNumber) * usable_bytes_per_page should contain the approximate number of bytes per * page usable for tuple data, excluding the page header and any anticipated * special space. + * + * A relation without a fillfactor reloption is assumed to be filled to + * HEAP_DEFAULT_FILLFACTOR; an AM that fills its pages to a different default + * should use table_block_relation_estimate_size_extended() instead. */ void table_block_relation_estimate_size(Relation rel, int32 *attr_widths, @@ -679,6 +683,28 @@ table_block_relation_estimate_size(Relation rel, int32 *attr_widths, double *allvisfrac, Size overhead_bytes_per_tuple, Size usable_bytes_per_page) +{ + table_block_relation_estimate_size_extended(rel, attr_widths, pages, + tuples, allvisfrac, + overhead_bytes_per_tuple, + usable_bytes_per_page, + HEAP_DEFAULT_FILLFACTOR); +} + +/* + * table_block_relation_estimate_size_extended + * + * As table_block_relation_estimate_size(), but default_fillfactor is the + * fillfactor the AM fills its pages to when the relation does not set one; + * it should be the same default the AM's insert path uses. + */ +void +table_block_relation_estimate_size_extended(Relation rel, int32 *attr_widths, + BlockNumber *pages, double *tuples, + double *allvisfrac, + Size overhead_bytes_per_tuple, + Size usable_bytes_per_page, + int default_fillfactor) { BlockNumber curpages; BlockNumber relpages; @@ -758,7 +784,7 @@ table_block_relation_estimate_size(Relation rel, int32 *attr_widths, * The other branch considers it implicitly by calculating density * from actual relpages/reltuples statistics. */ - fillfactor = RelationGetFillFactor(rel, HEAP_DEFAULT_FILLFACTOR); + fillfactor = RelationGetFillFactor(rel, default_fillfactor); tuple_width = get_rel_data_width(rel, attr_widths); tuple_width += overhead_bytes_per_tuple; diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h index e935dec0958ba..dc2a7699e16af 100644 --- a/src/include/access/tableam.h +++ b/src/include/access/tableam.h @@ -2357,6 +2357,14 @@ extern void table_block_relation_estimate_size(Relation rel, double *allvisfrac, Size overhead_bytes_per_tuple, Size usable_bytes_per_page); +extern void table_block_relation_estimate_size_extended(Relation rel, + int32 *attr_widths, + BlockNumber *pages, + double *tuples, + double *allvisfrac, + Size overhead_bytes_per_tuple, + Size usable_bytes_per_page, + int default_fillfactor); /* ---------------------------------------------------------------------------- * Functions in tableamapi.c From 62d03b90088561992b455fc9bf589e853e2b4320 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Sun, 11 Oct 2026 02:31:14 +0000 Subject: [PATCH 18/22] tableam: let REPACK (CONCURRENTLY) accept any AM whose changes are decoded REPACK (CONCURRENTLY) replays the changes made during the rewrite from logical decoding, so a table AM whose changes are not decoded would lose them silently. REPACK therefore refused every AM but heap, by comparing the routine with heap's, because nothing in TableAmRoutine said whether an AM's changes are decoded. Add that: a bool am_logical_decoding, which promises that every committed row change of a logically logged relation is decoded as an INSERT, UPDATE or DELETE with the usual tuple images and that an aborted change is not. Heap sets it, and the REPACK check tests it instead of naming heap. No behaviour change for heap or for any AM that leaves it false; only the DETAIL of the refusal changes, so the one test that matched it (recovery/106) is updated. --- doc/src/sgml/ref/repack.sgml | 4 +++- src/backend/access/heap/heapam_handler.c | 2 ++ src/backend/commands/repack.c | 7 ++----- src/include/access/tableam.h | 12 ++++++++++++ 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/doc/src/sgml/ref/repack.sgml b/doc/src/sgml/ref/repack.sgml index 1465171501324..30e2c1d59ed2f 100644 --- a/doc/src/sgml/ref/repack.sgml +++ b/doc/src/sgml/ref/repack.sgml @@ -416,7 +416,9 @@ REPACK [ ( option [, ...] ) ] USING - The table's access method is not heap. + The table's access method does not support logical decoding of + its changes. Of the built-in access methods, only + heap does. diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c index 7d2065dec37c4..2b8e42004b0db 100644 --- a/src/backend/access/heap/heapam_handler.c +++ b/src/backend/access/heap/heapam_handler.c @@ -2652,6 +2652,8 @@ BitmapHeapScanNextBlock(TableScanDesc scan, static const TableAmRoutine heapam_methods = { .type = T_TableAmRoutine, + .am_logical_decoding = true, + .slot_callbacks = heapam_slot_callbacks, .scan_begin = heap_beginscan, diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c index a584e138ada39..9c02201d67c2d 100644 --- a/src/backend/commands/repack.c +++ b/src/backend/commands/repack.c @@ -986,16 +986,13 @@ check_concurrent_repack_requirements(Relation rel, Oid *ident_idx_p) /* * A table AM that doesn't support logical decoding would cause REPACK * (CONCURRENTLY) to silently lose the changes made during the rewrite. - * Nothing in TableAmRoutine tells us whether it does, so for now restrict - * to heap. Check the routine rather than the AM OID, so that an AM - * reusing the heap handler still works. */ - if (rel->rd_tableam != GetHeapamTableAmRoutine()) + if (!rel->rd_tableam->am_logical_decoding) ereport(ERROR, errcode(ERRCODE_FEATURE_NOT_SUPPORTED), errmsg("cannot execute %s on relation \"%s\"", "REPACK (CONCURRENTLY)", RelationGetRelationName(rel)), - errdetail("This operation is only supported for the \"heap\" access method.")); + errdetail("This operation is not supported for the access method of this table.")); /* Data changes in system relations are not logically decoded. */ if (IsCatalogRelation(rel)) diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h index dc2a7699e16af..0929c3dddaa35 100644 --- a/src/include/access/tableam.h +++ b/src/include/access/tableam.h @@ -435,6 +435,18 @@ typedef struct TableAmRoutine */ bool am_index_delete_marking; + /* + * am_logical_decoding: every committed change of a row in a logically + * logged relation of this AM is decoded by logical decoding as an INSERT, + * UPDATE or DELETE carrying the tuple images output plugins expect, and + * an aborted change is not. REPACK (CONCURRENTLY) replays the changes + * made during the rewrite from that stream, so it refuses a table whose + * AM leaves this false; it would lose changes silently. The rewrite + * itself goes through relation_copy_for_cluster with the decoding's + * initial snapshot. + */ + bool am_logical_decoding; + /* ------------------------------------------------------------------------ * Slot related callbacks. From 92801acc213d821bd491b45801663180b23c7f91 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Sat, 10 Oct 2026 20:50:19 +0000 Subject: [PATCH 19/22] vacuum: add parallel_vacuum_set_index_stats() and a bulkdel injection point Two heap-neutral helpers a table AM that is not heap needs from the core VACUUM machinery. vacuumparallel.c assumes a table AM keeps one parallel vacuum context for its whole VACUUM: parallel_vacuum_end() hands back the index statistics only once, at the end, and the next pass of a context reads the statistics the previous pass left in shared memory. A table AM that must leave parallel mode between its index passes (because its table phases do things parallel mode forbids) creates a context per pass and loses the statistics of the earlier passes. parallel_vacuum_set_index_stats() loads an array of per-index statistics, as parallel_vacuum_end() returned them, into a new context, so each index AM gets its earlier statistics back as input, as in a serial VACUUM. Heap does not call it. vac_bulkdel_one_index() is where every table AM's VACUUM hands its dead TIDs to an index, so a test that stops at the vacuum-bulkdel-one-index injection point sees a VACUUM whose table scan is complete and whose index vacuuming has not started; a later test uses it to compare pg_stat_progress_vacuum for heap and FLUX at the same point. No behaviour change; heap reaches the injection point but never calls the parallel helper. --- src/backend/commands/vacuum.c | 3 +++ src/backend/commands/vacuumparallel.c | 24 ++++++++++++++++++++++++ src/include/commands/vacuum.h | 2 ++ 3 files changed, 29 insertions(+) diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c index a257dd8d21e04..f92d0bb957382 100644 --- a/src/backend/commands/vacuum.c +++ b/src/backend/commands/vacuum.c @@ -2717,6 +2717,9 @@ IndexBulkDeleteResult * vac_bulkdel_one_index(IndexVacuumInfo *ivinfo, IndexBulkDeleteResult *istat, TidStore *dead_items, VacDeadItemsInfo *dead_items_info) { + /* Tests stop here to look at a VACUUM between its phases. */ + INJECTION_POINT("vacuum-bulkdel-one-index", NULL); + /* Do bulk deletion */ istat = index_bulk_delete(ivinfo, istat, vac_tid_reaped, dead_items); diff --git a/src/backend/commands/vacuumparallel.c b/src/backend/commands/vacuumparallel.c index 4e432c50d3703..4ca1e2bd4e314 100644 --- a/src/backend/commands/vacuumparallel.c +++ b/src/backend/commands/vacuumparallel.c @@ -561,6 +561,30 @@ parallel_vacuum_dsm_detach(dsm_segment *seg, Datum arg) pv_shared_cost_params = NULL; } +/* + * Load index statistics from earlier passes into the shared state. + * + * For a table AM that creates a parallel vacuum context per index pass + * rather than one for the whole VACUUM: istats[i] (may be NULL) is what + * parallel_vacuum_end of the previous pass returned for index i, and is + * handed back to the index AM as the input statistics of this pass. + */ +void +parallel_vacuum_set_index_stats(ParallelVacuumState *pvs, + IndexBulkDeleteResult **istats) +{ + Assert(!IsParallelWorker()); + + for (int i = 0; i < pvs->nindexes; i++) + { + PVIndStats *indstats = &(pvs->indstats[i]); + + indstats->istat_updated = (istats[i] != NULL); + if (istats[i] != NULL) + memcpy(&indstats->istat, istats[i], sizeof(IndexBulkDeleteResult)); + } +} + /* * Returns the dead items space and dead items information. */ diff --git a/src/include/commands/vacuum.h b/src/include/commands/vacuum.h index 89fa1fcd261ca..c5ab9eb299c13 100644 --- a/src/include/commands/vacuum.h +++ b/src/include/commands/vacuum.h @@ -418,6 +418,8 @@ extern ParallelVacuumState *parallel_vacuum_init(Relation rel, Relation *indrels int vac_work_mem, int elevel, BufferAccessStrategy bstrategy); extern void parallel_vacuum_end(ParallelVacuumState *pvs, IndexBulkDeleteResult **istats); +extern void parallel_vacuum_set_index_stats(ParallelVacuumState *pvs, + IndexBulkDeleteResult **istats); extern TidStore *parallel_vacuum_get_dead_items(ParallelVacuumState *pvs, VacDeadItemsInfo **dead_items_info_p); extern void parallel_vacuum_reset_dead_items(ParallelVacuumState *pvs); From 46309e53ee2d091eb0d80d414871ad3d80b1e30d Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Sun, 11 Oct 2026 15:40:17 +0000 Subject: [PATCH 20/22] pg_visibility: refuse tables of other table AMs in the tuple checks pg_check_visible and pg_check_frozen read every item of a page the visibility map marks all-visible or all-frozen as a heap tuple header and run heap visibility and freezing tests on it. check_relation_relkind accepts any relation kind with a table AM, so a table of another access method gets its items decoded as heap tuples: false reports of corruption, and assertion failures in HeapTupleSatisfiesVacuum on what it takes for xmin and xmax. pgstattuple, pgrowlocks, pg_surgery, pageinspect's heap functions and amcheck's verify_heapam already refuse such tables with "only heap AM is supported"; do the same in collect_corrupt_items, which both functions use. pg_visibility, pg_visibility_map, pg_visibility_map_summary and pg_truncate_visibility_map read only the visibility map fork and the page header's PD_ALL_VISIBLE flag, and keep accepting any table AM that keeps a visibility map in that format. --- contrib/pg_visibility/pg_visibility.c | 11 +++++++++++ doc/src/sgml/pgvisibility.sgml | 6 ++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/contrib/pg_visibility/pg_visibility.c b/contrib/pg_visibility/pg_visibility.c index 4292d077be1c8..43da4acce1a5d 100644 --- a/contrib/pg_visibility/pg_visibility.c +++ b/contrib/pg_visibility/pg_visibility.c @@ -14,6 +14,7 @@ #include "access/htup_details.h" #include "access/visibilitymap.h" #include "access/xloginsert.h" +#include "catalog/pg_am_d.h" #include "catalog/pg_type.h" #include "catalog/storage_xlog.h" #include "funcapi.h" @@ -711,6 +712,16 @@ collect_corrupt_items(Oid relid, bool all_visible, bool all_frozen) /* Only some relkinds have a visibility map */ check_relation_relkind(rel); + /* + * The checks below read each item as a heap tuple. The visibility map + * functions read only the map and the page header, and work for any table + * AM. + */ + if (rel->rd_rel->relam != HEAP_TABLE_AM_OID) + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("only heap AM is supported"))); + if (all_visible) OldestXmin = GetStrictOldestNonRemovableTransactionId(rel); diff --git a/doc/src/sgml/pgvisibility.sgml b/doc/src/sgml/pgvisibility.sgml index 097f7e05661a1..5a1ebfa5ce8a3 100644 --- a/doc/src/sgml/pgvisibility.sgml +++ b/doc/src/sgml/pgvisibility.sgml @@ -106,7 +106,8 @@ Returns the TIDs of non-frozen tuples stored in pages marked all-frozen in the visibility map. If this function returns a non-empty set of - TIDs, the visibility map is corrupt. + TIDs, the visibility map is corrupt. Only tables of the + heap access method are supported. @@ -118,7 +119,8 @@ Returns the TIDs of non-all-visible tuples stored in pages marked all-visible in the visibility map. If this function returns a non-empty - set of TIDs, the visibility map is corrupt. + set of TIDs, the visibility map is corrupt. Only tables of the + heap access method are supported. From 95c25279af92e91e1ba25ca0f6424dd2d307c758 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 14 Sep 2026 13:03:40 +0000 Subject: [PATCH 21/22] Add the FLUX in-place-MVCC table access method FLUX is a faithful realization of what zheap was designed to be: a HEAP replacement that stores old row versions in UNDO instead of leaving dead tuples in the table, built on today's index and executor APIs with no new index-AM contract. Always in-place UPDATE at a stable TID, including indexed-column UPDATE via the nbtree delete-marking introduced earlier; table UNDO flows through the shared per-backend engine; wide values use standard heap TOAST; a per-tuple sLog (on the per-backend engine's LWLock-guarded flat hash) carries the FLUX tuple-lock protocol. Registered as a built-in AM (USING flux). FLUX is the first table AM to set am_index_delete_marking, so it drives its own index maintenance for indexed-column UPDATE (insert the new key, delete-mark the old one at the same TID) and its UNDO reverses that on rollback. It declares UNDO_ENGINE_PERBACKEND and registers FluxUndoRmgrInit. Bumps XLOG_PAGE_MAGIC (new WAL rmgrs) and CATALOG_VERSION_NO (new built-in AM). tidbitmap.h's shared per-page ceiling is sized for the widest surviving table AM (FLUX) so bitmap index scans of dense FLUX pages do not overflow the offset ceiling; a StaticAssert in flux_handler.c keeps the literal bound honest against MaxFluxItemsPerPage. Registers the FLUX WAL resource manager (RM_FLUX_ID) and wires FLUX's 2PC callbacks (TWOPHASE_RM_FLUX_ID) alongside the per-backend UNDO callbacks; adds the fluxdesc rmgrdesc + pg_waldump support, FLUX logical-decoding, the FLUX shmem subsystems, SQL regression coverage, and the flux_conflict / flux_inplace / flux_lostupdate isolation specs. Folds in the full set of FLUX correctness fixes developed as follow-ups: emit no UNDO record for INSERT (a rolled-back INSERT is reclaimed by the page's dirty set); delta before-images for UPDATE undo records; the ON CONFLICT hang and lost-update race fix in the tuple-lock protocol; serialize concurrent plain UPDATE via a persistent tuple lock so EvalPlanQual re-execution cannot lose an update; and the in-place-UPDATE growth underflow / does-not-fit handling. Known core-contract gap for in-place AMs (not FLUX-specific): tmfd.traversed means "the locked tuple is at a different TID than the caller requested", so an AM with stable TIDs and in-place UPDATE must always report false. But nodeModifyTable.c asserts traversed at two sites after a TUPLE_LOCK_FLAG_FIND_LAST_VERSION lock returns TM_Ok, which is to assert that reaching the latest version of a row requires having moved -- true for heap and false for an AM that updates in place. This commit adds am_inplace_update_keeps_tid to TableAmRoutine, sets it for FLUX, and relaxes both asserts to accept traversed = false from an AM that sets it. Heap leaves the flag false, so the asserts are unchanged for heap. AFTER-trigger OLD/NEW images and statement-level transition tables are known open for in-place AMs. AfterTriggerExecute fetches OLD from ate_ctid1 and NEW from ate_ctid2, both through table_tuple_fetch_row_version() under SnapshotAny; for a stable-TID in-place AM those two calls are byte-identical at the AM boundary, so the AM has no information to branch on. Fetching OLD and NEW through the same (tid, SnapshotAny) call is only well defined when the old version is a physically distinct tuple that still exists. Until core captures OLD at queue time -- while the page still holds it -- or requests a version explicitly, no in-place AM can answer both correctly. UPDATE ... RETURNING old.* is correct because the fetched old tuple is materialized while its page is still pinned, before the in-place write. Halloween-problem fix: the reserved, unused t_gen word in FluxTupleHeader is reused as t_cmin, carrying what heap keeps in t_cid -- the command id of the writer identified by t_xmin. The field replaces a word that was already reserved and never read, so the tuple header stays 40 bytes and the on-disk and WAL byte layouts are unchanged (the tuple body, including this field, is logged verbatim). The invariant is that t_cmin always describes the SAME writer as t_xmin. A FLUX in-place UPDATE re-stamps t_xmin to the updater, so it must re-stamp t_cmin to the updater's command id; carrying the original INSERT's cid across an in-place UPDATE pairs a new xmin with an old cid and makes the self-visibility test compare our own curcid against a different transaction's cid. The invariant is enforced at all three sites that stamp the pair: plain INSERT, the CAS/out-of-place new-tuple path, and the in-place UPDATE re-stamp. Without the field a statement cannot distinguish its own output rows from its input rows, which is the Halloween problem: an UPDATE that relocates a row to a new TID (FLUX's out-of-place path) can have the very same scan re-find the row it just wrote and update it again. Measured before the field existed: UPDATE hh SET v = v+1, pad = pad||repeat('y',500) over 2000 rows reported UPDATE 3194 with 1194 rows left holding v=2, where heap reports UPDATE 2000 and every row v=1. Six isolation tests also depended on it, including freeze-the-dead ("unexpected table_tuple_lock status: 1") and a stale SUM in total-cash. The index-capability gate and this fix cannot ship apart. The gate widens the set of UPDATEs that take the relocating out-of-place path, which is the path that can re-find its own output, so it widens exposure to the Halloween defect. Measured on the same reproducer: the published tree without either change gave 500 rows with a maximum of 1 update per row; the gate alone gave 522 with a maximum of 2 -- the defect made visible; the gate together with this fix gives 500 with a maximum of 1, matching the published baseline while retaining the gate. New open bug recorded here: FK referential actions (CASCADE, SET NULL, SET DEFAULT, RESTRICT) are dead on a parent-key UPDATE. The actions are implemented as AFTER UPDATE triggers on the referenced table, and such a trigger receives OLD.k == NEW.k for an in-place AM, so ri_KeysEqual() sees no key change and returns without propagating. This is a direct consequence of the AFTER-trigger OLD/NEW core-contract gap described above, not a separate defect in the RI code. It is why the foreign_key regression diff grew from 26847 to 38009 bytes, with missing-error lines going from 2 to 19. Author: Greg Burd Changes since v2: - Treat an aborted, not-yet-reverted writer as invisible [a582433ff3] - WAL-log t_verptr in the same-size CAS update record [7b8d2317a8] - Make UNDO apply idempotent, keyed on the record's version [564afee338] - Helping -- writers revert aborted, not-yet-reverted versions [fb9df8dcc1] - A restore that cannot be done is an error, not a success [b11d394632] - Fix comments that say key UPDATEs go out of place [1afdbca771] - VACUUM reclaims rows of aborted INSERTs [6f957dc736] - ROLLBACK TO SAVEPOINT applies every persistence level's undo [1689077e0b] - Do not mark savepoint undo applied when it was not [6500c66015] - Finish worker rollbacks that have nothing left to do, cap retries [232892a4f4] - Never help on a hot standby; injection point to hold the undo worker [baa9db02b7] - Reverse a rolled-back key UPDATE's index changes before its tuple [6745aeaa1c] - Name the relation file an UNDO record was written against [e656b1e575] - Delete superseded TOAST values on in-place UPDATE [c558451eb5] - FLUX/nbtree: X2(c) prune delete-marked tombstones of live rows [11345001e4] - Tombstone removal: horizon reasoning, untorn copy, test [edaa8e0c52] - Keep the freeze cutoffs VACUUM FULL / CLUSTER pass in [d1071253e5] - Index build ignores rows of aborted INSERTs [3563fb7690] - Tombstone removal must not trust the UNCOMMITTED hint [fab0d7a3b4] - Scan slots own a copy taken under a writer-excluding lock [8d6ef8eeb8] - VACUUM truncates the whole empty tail, not one page [103880a71a] - TID range and TABLESAMPLE scans reconstruct the snapshot's version [1177ed330f] - Correct the scan comments for copied slots [06a19ab3ca] - Index-fetch decode must not pad after a varlena [4ffd6b2fdb] - A waiting UPDATE must not build on an aborted writer's image [47519340af] - VACUUM must not move a live row to another page (heap control) [151b14356d] - VACUUM must not move live rows to another page [9daa8a86c9] - VACUUM honours TRUNCATE false and truncates only a worthwhile tail [9995069082] - Log the overflow header on UPDATE and speculative insert [d749854b71] - An UPDATE must not hold a tuple lock per row until commit [3407faac7a] - VACUUM frees only the line pointers Phase I recorded [01784b5845] - VACUUM (DISABLE_PAGE_SKIPPING) may truncate, as heap does [b34ce80707] - Report the pages VACUUM truncated; drop dead VM and diff code [0fd8151ce9] - A page with an LP_DEAD slot is not all-visible [d10c4bf34c] - Mask item alignment padding in wal_consistency_checking [e2cd227580] - Walk the whole version chain; a broken chain is an ERROR [4c3b3030ca] - Stop using the tuple header word t_writer as a lock [a7c5b9122c] - A row lock rolled back with its savepoint leaves nothing behind [d1e4248dae] - A slot owns its tuple copy, in its own memory context [ae020545f3] - A table rewrite reverts what an aborted transaction left on the page [db84c2c004] - An existence check sees a row at its committed version while a revert is pending [1db9583be9] - Judge a uniqueness check's index entry by the version carrying its key [638d069488] - Report superseded row versions from tuple_lock as heap does [009c743871] - Don't report the ON CONFLICT row version to the trigger re-lock [9761037e94] - Lock the latest row version when tuple_lock gets no snapshot [5ff627a4e9] - 102: replicated DELETE and UPDATE-vs-local-DELETE; drop key-update cases [2b093dfbae] - Toast every tuple-construction path, not just the single insert [a76b88f135] - A slot copy of a stored version keeps the version [e7a0da3aff] - Regress test flux_deferred_trigger (red: stale deferred RI checks) [e652aa9b4b] - AFTER triggers: an in-place AM's queued rows carry their version identity [f7da3ae7dc] - Judge a queued trigger row by its own version, not the row's newest [3f78b215ad] - Index builds and samples read a row's committed version while a revert is pending [03d520d5b4] - Record the relation's storage with each tracked key [f421e9cde6] - Commit, abort and PREPARE work skips storage a rewrite replaced [588be24f2f] - Another transaction's aborted-insert marker hides no version of ours [96b6f0e287] - Honor key-changing UPDATE lock conflicts in FLUX [49188b3247] - Wrap FLUX update lock-mode arguments and comments [475f20674e] - Keep a revived index entry when its UPDATE rolls back [f46ee71756] - Record and reverse FLUX index revival intent [9bd6cf52d5] - Use the authoritative FLUX before-image after waiting [8105ba6166] - Bound FLUX reservations by undo insertion capacity [61aed71efb] - Apply project formatting to FLUX undo changes [ef0dda76a0] - Preserve prepared row locks across recovery [32000e1a60] - Remove the unused sLog before-image path [94f1a5af28] - Shrink the FLUX header and remove legacy interfaces [00bd6f2045] - Scope tuple sLog visibility to physical storage [a230837613] - Retire aborted INSERT markers after CLOG records abort [9e350a608d] - Retain row lock strength per subtransaction [e685d2f1ea] - Honor scan direction and cursor position [be0167445c] - Include missing defaults in physical tuple deformation [07ef9f911a] - Suppress zero-column index-only scans on delete-marking tables [7fd1c18679] - Initialize dirty snapshot outputs in FLUX sequential scans [2d85c15f7a] - Log FLUX replica identity images with inline TOAST values [713f97c60c] - Decode FLUX speculative insertion outcomes [982dd588e6] - Protect FLUX history and freeze tuples before advancing VACUUM horizons [f0cb126b60] - Report FLUX self-modification with its writer xid [8e319365f5] - Preserve command IDs in materialized FLUX slots [aac52fb769] - Set the table OID on in-place AFTER-trigger images [302f7048c1] - Stop version walks at a snapshot-visible writer [c94d739451] - Leave FLUX subtransaction undo to the pre-abort dispatcher [1b55f46b36] - Add SSI rw-conflict-out isolation twins for FLUX and heap [6e10bce3b8] - Report FLUX SSI conflicts out to committed concurrent writers [482415d425] - Report the writers a FLUX version walk steps over to SSI [4d0b8068c9] - Reverse in-place index changes in the file the UPDATE changed [a8f31e3e0c] - Wake row-lock waiters when a savepoint releases the lock [410e584233] - Report rows moved to another partition as moved, not deleted [95646a89eb] - Serve the snapshot's version of a row fetched by TID [fb87f1e6e0] - EvalPlanQual: refetch an in-place AM's unlocked rows by snapshot [f783afec6e] - Honour the RI crosscheck snapshot in FLUX delete and update [88c5835078] - Count FLUX sequential scans and their rows in the table statistics [8d536c5e30] - Make a DELETE wait for every conflicting row locker [a6dedfa7fc] - Take a tuple's own TID from the caller, not from t_ctid [507b7afcbc] - Link a row an UPDATE moved to its new version [8c7f0718d5] - Sort in CLUSTER when the planner asks for it [8399592749] - Keep what older snapshots see across CLUSTER and VACUUM FULL [530cbd9a00] - Report apply conflicts for an UPDATE of an in-place table AM [44d628d391] - Check only the unique indexes whose key a replicated UPDATE changes [c857ec26df] - Detect update_deleted conflicts on a FLUX subscriber [79939397fa] - Don't forward-declare an enum in flux_xlog.h [6a8e710076] - Write the undo of a relation that skips WAL to the unlogged undo log [b7eef2e0a9] - Resolve hot standby snapshot conflicts when removing tuples [320b50ce4e] - Take a cleanup lock to compact a page in VACUUM and in redo [f9993bda0a] - Invalidate logical slots when freezing catalog before-images [10281e5c30] - Stamp FLUX undo records with the operation's command id [0adf2ff154] - Copy the locked tuple before releasing its buffer lock [6d94828cc8] - Let a non-aggressive VACUUM skip compacting a pinned page [72e54be955] - Say in the UNDO README that FLUX writes per-backend UNDO [0ad0194478] - Add a Limitations section to the FLUX README [4aaff47cdb] - Decode an out-of-place UPDATE as one UPDATE [0daae8a5fa] - Nbtree: report SSI conflict-in when a tombstone is about to be revived [9f8b2696a5] - Log undo-log meta only for undo in the permanent undo log [b017106c04] - Clamp line-pointer offsets to MaxHeapTuplesPerPage like heap [deb4426a68] - Test_deletemark: delete-marking and rollback must handle LP_DEAD [576090c6e6] - Mask FLUX pages that Undo CLRs change [a12b12c77e] - Make DELETE redo set the tuple header exactly as the DELETE did [083f5bacb9] - PANIC when redo cannot apply a record instead of skipping it [2b4c2804fc] - Say why redo of an undo insert may skip a page [69fdeb161e] - Free AFTER-trigger images of FLUX row events with their events [5462311e71] - EvalPlanQual: fail, as for heap, when an in-place AM's reference row is gone [481c6b5839] - Tuple_satisfies_snapshot takes only a FLUX slot [1b27d86e19] - AFTER triggers: fire an in-place AM's queued rows in the AM's own slots [04e3e2b8fb] - Tableam: say what an AM that updates rows in place gets from the core [0c26e2b638] - FLUX decoding: refuse records it cannot decode instead of guessing [986f1fe97a] - Recovery tests: wait for the row lock with poll_query_until [b690148fdb] - Recovery/123: create the heap control table USING heap [8c4a8be06b] - Remove agent working notes from the FLUX tree [76eb3a65b9] - Describe the code without development labels [3b75599e8d] - Tableam: let a block AM's size estimate use its own default fillfactor [16abd691a4] - Estimate relation size like heap, in FLUX's page geometry [9d0ecd40b6] - VACUUM counts rows another transaction is writing as heap does [c3d24c668a] - ANALYZE counts live and dead rows as heap does [a65faf5592] - Count an UPDATE in pgstat by what it leaves behind [1fa4fbfa77] - An index fetch keeps its pin on the same page [7529430aec] - List user_catalog_table reads during decoding as a limitation [e6e01bf3ac] - Apply project formatting to files several layers change [59538a58cc] - Apply project formatting (16 files) [44df951f97] - Nbtree delete-marking: apply project formatting (9 files) [bf7c08d8ac] - Apply pgperltidy to test scripts several layers change [630e3c34e5] - Apply pgperltidy to its tests (31 files) [8133d9b96c] - Nbtree delete-marking: apply pgperltidy to its tests (4 files) [f2f185bd61] - ATM and revert worker: apply pgperltidy to its tests (8 files) [564b19efb1] - Apply project formatting to the planner-statistics changes [87f1b4a03e] - Reverse index changes with the index the rollback restores [67f718509e] - Keep row-lock holders in on-demand shared radix trees [0b59fcef8c] - Judge inserts by t_xmin, not by shared per-tuple markers [358e75b1a1] - Drop the shared tuple flat hash [f3162ad1be] - Keep row locks of UNDO-based table AMs in the tuple sLog [6a8204a1c8] - Keep row locks in the tuple sLog [c11322ed19] - Create the aborted transaction map on first use [39295668de] - Drop the shared dirty-page map [37feb24b2a] - Let a row-lock holder wait without queueing on the tuple lock [74ab0ccc34] - Classify the row again after a DELETE or UPDATE waits [b9f8ff6731] - Mark the two-phase commit record as used only by assertions [88e2077b77] - Keep a transaction's first row locks in its own slot [b2e16a8c88] - Keep a backend's emptied row-lock trees for its next transactions [4c0463f863] - Update out of place while an index is not valid yet [cc90f4b533] - Delete TOAST values at DELETE time, as heap does [2a15600109] - Report VACUUM progress to pg_stat_progress_vacuum [35c4edd70f] - Vacuum in rounds bounded by maintenance_work_mem [f37fb344f5] - Vacuum indexes in parallel [cd11dace81] - Clear the visibility map on a speculative insertion [cd6bd75e21] - Move a row whose index change delete-marking cannot describe [e5afe3612c] - Decode only the repacked table's changes for REPACK (CONCURRENTLY) [44b8e5e963] - Tableam: let REPACK (CONCURRENTLY) accept any AM whose changes are decoded [e2e8f1df24] - Support REPACK (CONCURRENTLY) [7c26afe815] - Do not decode an UPDATE made with TABLE_UPDATE_NO_LOGICAL [55ee1f5b0b] - Set the page LSN of an in-place UPDATE [0639007b96] - Decide an UPDATE's index work again after waiting for a writer [9e689ca899] - Clear the visibility map in the WAL record of the change [c4bbededee] - Move a row whose partial-index membership changes [e1ec1f0c30] - Report a running writer to a DELETE that does not wait [b123684a50] - Read the visibility map size again before a change's clear [28c643295b] - Log visibility map pages set by VACUUM as non-standard [ac2a2752b5] - Keep the tuple lock while an UPDATE decides again [98ba5ff15d] - Scan only the requested blocks in index_build_range_scan [24c701e86e] - Preserve key-share locks across non-key FLUX moves [e3c9030483] - Use UPDATE lock strength for the delete half of a FLUX move [5590970967] - Check only relation predicate locks for FLUX inserts [7e71c78c0c] - Doc: Describe COPY FREEZE behavior on FLUX [c5010bb277] - Check in-place history before following a non-key move [e7f63b2e9e] - Run heap and FLUX isolation twins sequentially [ec2b9c53be] - Keep existing row lockers protected when moving a FLUX row [b4d2684beb] - Let a row-lock probe visit only the holders of its TID's bucket [9253a45e5e] - Keep a slot's spilled row locks in hash tables of its own [5731116ee5] - Number the integrated recovery tests consecutively [c0d6845bc6] - Per-backend undo: decide the meta record against the current redo point [169068fcf9] - Refuse user_catalog_table [2c5187e0b1] Tests: - Renumber to 076/077, assert the undo-worker gate [d0838896dd] - Prove the worker ran and skipped what writers reverted [f3c482959b] - Drop stale recovery test names left by integration merge [d142ba40a0] - Wait for the discard worker, filter on non-permanent logs [ea54653eb0] - Run the FLUX isolation specs; UPDATE/DELETE races match heap [efe3d2ed61] - Test that unlogged and temp DML writes no row-proportional WAL [5b87a5f76d] - Red test for table rewrites while a revert is pending (A7/P3) [5351e6aaa0] - Red test for uniqueness checks while a revert is pending [23d5370457] - Test every update since the snapshot when locking FOR KEY SHARE [524a452a6f] - Red test for apply-worker row locks under a BEFORE trigger [ac64e6acb7] - Add flux_toast_foreign regression test (red on this commit) [e15dab607b] - Red test for index builds while a revert is pending [1ef61e4f20] - Red tests for commit, abort and PREPARE work after a rewrite [afbcfa9981] - Test key-changing FLUX updates against existing row locks [865d9df693] - Exercise FLUX CAS conflicts with a referenced unique key [8baf5c38c1] - Test an indexed key that returns to an earlier value, then rolls back [f575ad45da] - Test the A -> B -> A index rollback from an old snapshot and a deferred revert [b01d7b12f4] - Add physical index rollback regression for FLUX [e35ed32ed4] - Exercise physical FLUX rollback across workers and replay [5d72e1db5a] - Exercise amendment replay without an insert full-page image [4490b96fb2] - Renumber 111_flux_index_aba_revert to 104 [828855758a] - Fix two schedule lines garbled by an integration merge [432115166f] - Control trigger self-modification for tuple visit order [6d1dbfa9f1] - Wait for the standby to replay the rolled-back UPDATE itself [1cb1894037] - A DELETE or UPDATE waits for every conflicting row locker [6bf93820c4] - Follow a row that an UPDATE moved to a new TID [2b18d709ab] - CLUSTER orders the table whichever plan it uses [90511b9f04] - CLUSTER and VACUUM FULL keep what older snapshots see [ff185c9f91] - Replicated key update that hits a unique key [a62dbd17c2] - Replicated UPDATE of a locally deleted row [645440bd7a] - Undo of a relation that skips WAL at wal_level=minimal [3d8c4a78e8] - Hot standby conflicts for removed tuples [2ace35b6f9] - Add a FLUX regression test for a DELETE lost behind a statement trigger [7641959880] - Replicated UPDATE racing a local key update [11863bcf33] - Rows written while VACUUM truncates survive [1ba02a7f00] - An out-of-place UPDATE must decode as an UPDATE [dbb7872a63] - SSI conflict on a revived index entry [73ebb69bd0] - Rolling back a revive on a full leaf re-marks the entry atomically [72c6130993] - Undo-log meta record of a non-permanent undo log [0a10717309] - Tuple offsets stay within MaxHeapTuplesPerPage [532036f607] - Amcheck on indexes with delete-marked entries [c4a2b7b021] - A near-limit index key must survive delete-marking [5efa72fa6b] - Crash between ROLLBACK PREPARED's abort record and its UNDO [5ef0fd5e21] - DELETE after an aborted out-of-place UPDATE, under WAL checking [0eebbe0c3e] - Undo redo without full-page writes keeps a later amendment [ece85bac35] - Add a test for AFTER-trigger image memory across a long transaction [b1893db984] - Planner size estimates and the I/O they cost [5e5068bc72] - The relation statistics VACUUM, ANALYZE and UPDATE report [a395e2d63a] - An index scan in physical order visits each page once [63ea4b5988] - ROLLBACK TO SAVEPOINT of an UPDATE then DROP INDEX on FLUX [c0096e65b0] - FLUX rollback of an in-place UPDATE followed by DDL on its storage [6f9ba7fe2e] - Row locks must need no fixed shared memory or capacity [5a54afe55d] - Inserts must need no per-tuple shared marker [3f20f4eebf] - A row-lock upgrader must not queue behind a waiter [9e0b79b870] - A writer must classify the row again after a wait [da3357f19b] - A row-lock conflict probe must never miss a lock [bbb1b25c44] - Row-lock probes must find locks however few rows a holder locks [8d5c2a7452] - A row lock that runs the sLog out of memory changes nothing [8b419deae0] - A row-lock tree kept for reuse must not alias a prepared one [a0975132c8] - Add isolation test for FLUX concurrent index builds under key UPDATEs [07f91f941c] - Test that VACUUM handles the TOAST values of the rows it removes [007c4cd62f] - Test the paths that retire a version's TOAST values [96121fdeb1] - Renumber recovery tests added by concurrent tracks [1ed5f983da] - Add isolation test for REPACK (CONCURRENTLY) while another table changes [4096da0629] - Add isolation test for FLUX REPACK (CONCURRENTLY) with concurrent changes [6569a5231e] - Add isolation test for decoding while FLUX REPACK (CONCURRENTLY) replays [1a2a87965a] - Add TAP test for FLUX REPACK (CONCURRENTLY) across crashes and on a standby [0d80b4b0fd] - Make flux_cic's REINDEX CONCURRENTLY completion order deterministic [1851e7bd4d] - A change must set the LSN of the pages it logs [021575a149] - Test an UPDATE that waits for a writer changing the same key [7b33708dd7] - Test that a change's WAL record clears the visibility map [ae87569765] - Renumber recovery tests that share a number [e2d8a49835] - A tombstone revived with another image [49e14c3efb] - A row that leaves a partial index [fc13d0c2bc] - A DELETE that does not wait [e46cdd871b] - A change after VACUUM created the visibility map [cb4487eaf3] - VACUUM's visibility map bits after replay [a19bb86c4d] - A waiter keeps its place when it decides its UPDATE again [2be8f9f0ba] - An UPDATE whose row changes after its index decision [9d85d137d9] - Hold the WAL writer before the crash [fcde30418c] - Keep the crash target dirty until eviction [b8eeeee054] - Describe the REINDEX rollback window precisely [b35d5df66f] - Use the configured TOAST page size [f76c697bba] - Allow a safe relfrozenxid equality [e8b6297880] - BRIN summarizes only the block range it was asked for [233b103b11] - Pg_visibility's tuple checks refuse a FLUX table [a7012d50a6] - Test key-share locking across moved non-key updates [719333d9f6] - Test subsequent non-key updates of a key-share-locked row [325c1e2339] - Test SSI conflict detection for bulk FLUX insertion [fbaf032019] - Test replay of non-key FLUX move metadata [83b47e721e] - Test spilled row-lock holders across their lifetime [de6e5bb7a8] - Test row-lock allocation failures caught by savepoints [d987cbdfd6] --- contrib/test_decoding/Makefile | 4 +- .../expected/flux_outofplace.out | 186 + contrib/test_decoding/meson.build | 2 + contrib/test_decoding/sql/flux_outofplace.sql | 79 + .../t/002_inplace_replica_identity.pl | 126 + meson.build | 11 + src/Makefile.global.in | 4 + src/backend/access/Makefile | 1 + src/backend/access/flux/Makefile | 30 + src/backend/access/flux/README | 273 + src/backend/access/flux/flux_fsm.c | 234 + src/backend/access/flux/flux_handler.c | 6809 ++++++++++++ src/backend/access/flux/flux_lock.c | 388 + src/backend/access/flux/flux_mvcc.c | 1433 +++ src/backend/access/flux/flux_operations.c | 9328 +++++++++++++++++ src/backend/access/flux/flux_pbu.c | 927 ++ src/backend/access/flux/flux_pvs.c | 786 ++ src/backend/access/flux/flux_slot.c | 1058 ++ src/backend/access/flux/flux_stats.c | 286 + src/backend/access/flux/flux_tuple.c | 1221 +++ src/backend/access/flux/flux_undo.c | 833 ++ src/backend/access/flux/flux_vm.c | 716 ++ src/backend/access/flux/flux_xlog.c | 2826 +++++ src/backend/access/flux/meson.build | 17 + src/backend/access/index/indexam.c | 19 +- src/backend/access/meson.build | 1 + src/backend/access/nbtree/nbtdedup.c | 8 +- src/backend/access/nbtree/nbtinsert.c | 24 +- src/backend/access/nbtree/nbtreadpage.c | 11 +- src/backend/access/nbtree/nbtree.c | 36 +- src/backend/access/nbtree/nbtutils.c | 25 +- src/backend/access/rmgrdesc/Makefile | 1 + src/backend/access/rmgrdesc/fluxdesc.c | 534 + src/backend/access/rmgrdesc/meson.build | 2 + src/backend/access/transam/rmgr.c | 3 + src/backend/access/transam/twophase.c | 6 + src/backend/access/transam/twophase_rmgr.c | 13 +- src/backend/access/transam/xact.c | 8 +- src/backend/access/undo/Makefile | 1 - src/backend/access/undo/README | 24 +- src/backend/access/undo/atm.c | 19 +- src/backend/access/undo/meson.build | 1 - .../access/undo/perbackend/pbu_undoworker.c | 4 + src/backend/access/undo/slog.c | 278 +- src/backend/access/undo/slog_flathash.c | 796 -- src/backend/access/undo/slog_tuple.c | 3432 +++--- src/backend/access/undo/undo.c | 16 +- src/backend/access/undo/undo_xlog.c | 9 +- src/backend/access/undo/xactundo.c | 37 +- src/backend/commands/tablecmds.c | 54 + src/backend/optimizer/util/plancat.c | 6 +- src/backend/replication/logical/decode.c | 517 + src/backend/utils/misc/guc_parameters.dat | 15 +- src/backend/utils/misc/guc_tables.c | 1 + src/backend/utils/misc/postgresql.conf.sample | 1 - src/bin/pg_waldump/rmgrdesc.c | 1 + src/bin/pg_waldump/t/001_basic.pl | 1 + src/include/access/amapi.h | 7 +- src/include/access/atm.h | 1 + src/include/access/flux.h | 1095 ++ src/include/access/flux_pbu.h | 102 + src/include/access/flux_undo.h | 269 + src/include/access/flux_xlog.h | 619 ++ src/include/access/genam.h | 2 + src/include/access/nbtree.h | 12 +- src/include/access/relscan.h | 10 +- src/include/access/rmgrlist.h | 3 + src/include/access/slog.h | 265 +- src/include/access/slog_flathash.h | 282 - src/include/access/slog_internal.h | 46 +- src/include/access/tableam.h | 3 +- src/include/access/twophase_rmgr.h | 3 +- src/include/access/undormgrlist.h | 3 + src/include/access/xact.h | 7 +- src/include/catalog/pg_am.dat | 3 + src/include/catalog/pg_proc.dat | 4 + src/include/storage/fileops.h | 2 +- src/include/storage/subsystemlist.h | 3 + .../expected/flux_cluster_sort_snapshot.out | 311 + src/test/isolation/expected/flux_conflict.out | 42 + .../expected/flux_delete_conflict.out | 214 + .../isolation/expected/flux_fk_crosscheck.out | 81 + .../isolation/expected/flux_index_aba.out | 104 + src/test/isolation/expected/flux_inplace.out | 34 + .../expected/flux_lock_delete_lockers.out | 51 + .../isolation/expected/flux_lock_recheck.out | 2626 +++++ .../expected/flux_lock_update_lockers.out | 33 + .../expected/flux_lock_upgrade_queue.out | 101 + .../isolation/expected/flux_lostupdate.out | 14 + .../isolation/expected/flux_nonkey_move.out | 831 ++ .../expected/flux_outofplace_grow.out | 35 + .../expected/flux_outofplace_update.out | 196 + .../expected/flux_partition_move.out | 172 + src/test/isolation/expected/flux_pvs_deep.out | 35 + .../expected/flux_recheck_after_wait.out | 165 + src/test/isolation/expected/flux_relstats.out | 242 + .../expected/flux_savepoint_lock_rollback.out | 1945 ++++ .../expected/flux_savepoint_lock_wake.out | 173 + .../isolation/expected/flux_scan_history.out | 149 + .../isolation/expected/flux_scan_stable.out | 264 + .../isolation/expected/flux_seqscan_stats.out | 55 + .../expected/flux_slot_tuple_context.out | 18 + .../expected/flux_specconflict_toast.out | 62 + .../expected/flux_ssi_conflict_out.out | 130 + src/test/isolation/expected/flux_ssi_copy.out | 22 + .../isolation/expected/flux_ssi_revive.out | 145 + .../expected/flux_tid_fetch_snapshot.out | 103 + .../expected/flux_update_deadlock.out | 83 + .../expected/flux_update_key_waiter.out | 259 + .../expected/flux_update_serialize.out | 169 + .../isolation/expected/flux_update_victim.out | 63 + .../expected/flux_update_waiter_abort.out | 338 + .../isolation/expected/flux_vacuum_move.out | 55 + .../expected/heap_cluster_sort_snapshot.out | 311 + .../isolation/expected/heap_fk_crosscheck.out | 81 + .../expected/heap_lock_delete_lockers.out | 51 + .../isolation/expected/heap_lock_recheck.out | 2626 +++++ .../expected/heap_lock_update_lockers.out | 33 + .../expected/heap_lock_upgrade_queue.out | 101 + .../isolation/expected/heap_nonkey_move.out | 830 ++ .../expected/heap_outofplace_grow.out | 35 + .../expected/heap_outofplace_update.out | 196 + .../expected/heap_partition_move.out | 172 + .../expected/heap_recheck_after_wait.out | 165 + .../expected/heap_savepoint_lock_rollback.out | 1945 ++++ .../expected/heap_savepoint_lock_wake.out | 173 + .../isolation/expected/heap_seqscan_stats.out | 55 + .../expected/heap_ssi_conflict_out.out | 130 + src/test/isolation/expected/heap_ssi_copy.out | 22 + .../isolation/expected/heap_ssi_revive.out | 145 + .../expected/heap_tid_fetch_snapshot.out | 103 + .../expected/heap_update_deadlock.out | 83 + .../expected/heap_update_key_waiter.out | 259 + .../expected/heap_update_serialize.out | 169 + .../isolation/expected/heap_update_victim.out | 63 + .../expected/heap_update_waiter_abort.out | 338 + .../isolation/expected/heap_vacuum_move.out | 55 + src/test/isolation/isolation_schedule | 61 + .../specs/flux_cluster_sort_snapshot.spec | 62 + src/test/isolation/specs/flux_conflict.spec | 54 + .../isolation/specs/flux_delete_conflict.spec | 47 + .../isolation/specs/flux_fk_crosscheck.spec | 41 + src/test/isolation/specs/flux_index_aba.spec | 77 + src/test/isolation/specs/flux_inplace.spec | 45 + .../specs/flux_lock_delete_lockers.spec | 39 + .../isolation/specs/flux_lock_recheck.spec | 406 + .../specs/flux_lock_update_lockers.spec | 32 + .../specs/flux_lock_upgrade_queue.spec | 51 + src/test/isolation/specs/flux_lostupdate.spec | 40 + .../isolation/specs/flux_nonkey_move.spec | 88 + .../isolation/specs/flux_outofplace_grow.spec | 37 + .../specs/flux_outofplace_update.spec | 71 + .../isolation/specs/flux_partition_move.spec | 69 + src/test/isolation/specs/flux_pvs_deep.spec | 45 + .../specs/flux_recheck_after_wait.spec | 57 + src/test/isolation/specs/flux_relstats.spec | 123 + .../specs/flux_savepoint_lock_rollback.spec | 121 + .../specs/flux_savepoint_lock_wake.spec | 50 + .../isolation/specs/flux_scan_history.spec | 45 + .../isolation/specs/flux_scan_stable.spec | 76 + .../isolation/specs/flux_seqscan_stats.spec | 27 + .../specs/flux_slot_tuple_context.spec | 34 + .../specs/flux_specconflict_toast.spec | 75 + .../specs/flux_ssi_conflict_out.spec | 99 + src/test/isolation/specs/flux_ssi_copy.spec | 18 + src/test/isolation/specs/flux_ssi_revive.spec | 54 + .../specs/flux_tid_fetch_snapshot.spec | 48 + .../isolation/specs/flux_update_deadlock.spec | 51 + .../specs/flux_update_key_waiter.spec | 60 + .../specs/flux_update_serialize.spec | 66 + .../isolation/specs/flux_update_victim.spec | 47 + .../specs/flux_update_waiter_abort.spec | 81 + .../isolation/specs/flux_vacuum_move.spec | 45 + .../specs/heap_cluster_sort_snapshot.spec | 62 + .../isolation/specs/heap_fk_crosscheck.spec | 34 + .../specs/heap_lock_delete_lockers.spec | 39 + .../isolation/specs/heap_lock_recheck.spec | 407 + .../specs/heap_lock_update_lockers.spec | 32 + .../specs/heap_lock_upgrade_queue.spec | 42 + .../isolation/specs/heap_nonkey_move.spec | 87 + .../isolation/specs/heap_outofplace_grow.spec | 30 + .../specs/heap_outofplace_update.spec | 58 + .../isolation/specs/heap_partition_move.spec | 59 + .../specs/heap_recheck_after_wait.spec | 46 + .../specs/heap_savepoint_lock_rollback.spec | 113 + .../specs/heap_savepoint_lock_wake.spec | 44 + .../isolation/specs/heap_seqscan_stats.spec | 23 + .../specs/heap_ssi_conflict_out.spec | 99 + src/test/isolation/specs/heap_ssi_copy.spec | 18 + src/test/isolation/specs/heap_ssi_revive.spec | 54 + .../specs/heap_tid_fetch_snapshot.spec | 40 + .../isolation/specs/heap_update_deadlock.spec | 52 + .../specs/heap_update_key_waiter.spec | 50 + .../specs/heap_update_serialize.spec | 67 + .../isolation/specs/heap_update_victim.spec | 48 + .../specs/heap_update_waiter_abort.spec | 75 + .../isolation/specs/heap_vacuum_move.spec | 45 + src/test/modules/brin/Makefile | 4 +- ...summarization-and-inprogress-insertion.out | 51 + .../modules/brin/expected/flux_summarize.out | 177 + src/test/modules/brin/meson.build | 6 + ...ummarization-and-inprogress-insertion.spec | 46 + src/test/modules/brin/sql/flux_summarize.sql | 97 + src/test/modules/injection_points/Makefile | 11 +- .../injection_points/expected/flux_cic.out | 1017 ++ .../expected/flux_commit_window.out | 29 + .../injection_points/expected/flux_repack.out | 3103 ++++++ .../expected/flux_repack_decode.out | 129 + .../expected/flux_update_cas_race.out | 314 + .../expected/flux_update_redecide_queue.out | 141 + .../expected/repack_other_table.out | 131 + .../expected/vacuum_progress.out | 73 + src/test/modules/injection_points/meson.build | 8 + .../injection_points/specs/flux_cic.spec | 239 + .../specs/flux_commit_window.spec | 49 + .../injection_points/specs/flux_repack.spec | 397 + .../specs/flux_repack_decode.spec | 67 + .../specs/flux_update_cas_race.spec | 91 + .../specs/flux_update_redecide_queue.spec | 87 + .../specs/repack_other_table.spec | 83 + .../specs/vacuum_progress.spec | 102 + src/test/modules/test_deletemark/meson.build | 1 + .../t/002_deletemark_lp_dead.pl | 90 + .../test_deletemark/test_deletemark--1.0.sql | 12 + .../modules/test_deletemark/test_deletemark.c | 98 + .../t/008_fileops_standby_abort.pl | 18 +- .../test_fileops/t/010_fileops_abort_redo.pl | 84 +- .../t/011_fileops_undo_ownership.pl | 66 +- .../test_fileops/t/012_fileops_contracts.pl | 6 +- .../test_fileops/t/013_fileops_loser_reuse.pl | 35 +- .../test_idxundo/expected/test_idxundo.out | 28 +- src/test/modules/test_idxundo/meson.build | 2 + .../modules/test_idxundo/sql/test_idxundo.sql | 20 +- .../test_idxundo/t/001_idxundo_crash.pl | 36 +- .../t/005_idxundo_atm_retention.pl | 13 +- .../t/008_idxundo_revert_liveness.pl | 4 +- .../modules/test_idxundo/t/010_slog_abort.pl | 45 + .../test_idxundo/t/012_flux_delete_nowait.pl | 65 + .../test_idxundo/test_idxundo--1.0.sql | 9 + src/test/modules/test_idxundo/test_idxundo.c | 64 +- src/test/recovery/Makefile | 8 +- src/test/recovery/meson.build | 53 + .../t/035_standby_logical_decoding.pl | 2 +- .../recovery/t/076_flux_deferred_rollback.pl | 287 + .../recovery/t/077_flux_undo_idempotency.pl | 56 + .../recovery/t/078_flux_undo_restore_fail.pl | 139 + .../t/079_flux_vacuum_aborted_insert.pl | 111 + .../recovery/t/086_flux_standby_helping.pl | 98 + .../recovery/t/087_flux_index_undo_fail.pl | 103 + .../t/089_perbackend_rewrite_rollback.pl | 84 +- .../recovery/t/093_flux_toast_supersede.pl | 135 + .../recovery/t/095_flux_rewrite_cutoffs.pl | 51 + .../recovery/t/097_flux_vacuum_truncate.pl | 107 + .../t/100_flux_vacuum_recorded_tids.pl | 142 + src/test/recovery/t/101_flux_external_wal.pl | 94 + .../t/102_flux_insert_redo_consistency.pl | 67 + .../recovery/t/103_flux_nonpermanent_wal.pl | 246 + .../recovery/t/104_flux_index_aba_revert.pl | 203 + .../t/106_flux_rewrite_pending_revert.pl | 442 + src/test/recovery/t/107_flux_rewrite_2pc.pl | 176 + src/test/recovery/t/108_flux_tuple_layout.pl | 209 + .../recovery/t/109_flux_savepoint_lock_2pc.pl | 137 + .../t/110_flux_unique_pending_revert.pl | 462 + .../t/111_flux_index_build_pending_revert.pl | 420 + .../recovery/t/112_flux_index_no_orphan.pl | 355 + .../recovery/t/113_flux_prepared_row_locks.pl | 380 + src/test/recovery/t/115_flux_freeze.pl | 184 + src/test/recovery/t/116_flux_minimal.pl | 40 + .../recovery/t/118_flux_minimal_undo_log.pl | 100 + .../recovery/t/119_flux_standby_conflict.pl | 158 + .../recovery/t/120_flux_undo_remark_split.pl | 212 + .../recovery/t/122_flux_amcheck_deletemark.pl | 71 + .../recovery/t/123_flux_tombstone_size.pl | 83 + .../t/125_flux_undo_meta_nonpermanent.pl | 143 + .../t/127_flux_rollback_prepared_crash.pl | 114 + .../t/128_flux_delete_superseded_redo.pl | 155 + src/test/recovery/t/129_undo_redo_no_fpw.pl | 102 + .../recovery/t/130_flux_single_user_abort.pl | 86 + src/test/recovery/t/131_flux_vacuum_rounds.pl | 193 + src/test/recovery/t/132_flux_rowlock_store.pl | 116 + .../recovery/t/133_flux_insert_markers.pl | 115 + .../recovery/t/135_flux_rowlock_filter.pl | 528 + src/test/recovery/t/136_flux_rowlock_oom.pl | 318 + .../recovery/t/137_flux_vacuum_parallel.pl | 275 + src/test/recovery/t/138_flux_vacuum_toast.pl | 310 + src/test/recovery/t/139_flux_toast_delete.pl | 304 + .../recovery/t/140_flux_spec_insert_vm.pl | 102 + .../recovery/t/141_flux_index_exact_keys.pl | 276 + src/test/recovery/t/142_flux_vm_clear_redo.pl | 147 + .../recovery/t/143_flux_update_page_lsn.pl | 315 + src/test/recovery/t/145_flux_slog_storage.pl | 57 + ...146_flux_subxact_abort_dropped_storage.pl} | 21 + .../t/147_flux_repack_concurrently.pl | 517 + src/test/recovery/t/148_flux_pg_visibility.pl | 87 + .../t/149_flux_partial_index_member.pl | 196 + src/test/recovery/t/150_flux_vm_stale_size.pl | 121 + src/test/recovery/t/151_flux_vm_set_fpi.pl | 91 + src/test/regress/expected/create_am.out | 3 +- src/test/regress/expected/flux.out | 1436 +++ src/test/regress/expected/flux_abort_ddl.out | 409 + src/test/regress/expected/flux_cluster.out | 75 + .../expected/flux_deferred_trigger.out | 699 ++ .../regress/expected/flux_dml_semantics.out | 202 + src/test/regress/expected/flux_estimate.out | 201 + src/test/regress/expected/flux_fk.out | 1054 ++ src/test/regress/expected/flux_index_aba.out | 873 ++ .../regress/expected/flux_merge_delete.out | 136 + .../regress/expected/flux_rewrite_xact.out | 651 ++ src/test/regress/expected/flux_tidbitmap.out | 129 + .../regress/expected/flux_toast_foreign.out | 462 + .../regress/expected/flux_trigger_memory.out | 115 + .../regress/expected/flux_user_catalog.out | 38 + .../regress/expected/flux_vacuum_stats.out | 100 + src/test/regress/expected/psql.out | 12 +- src/test/regress/parallel_schedule | 15 + src/test/regress/sql/flux.sql | 790 ++ src/test/regress/sql/flux_abort_ddl.sql | 197 + src/test/regress/sql/flux_cluster.sql | 54 + .../regress/sql/flux_deferred_trigger.sql | 500 + src/test/regress/sql/flux_dml_semantics.sql | 116 + src/test/regress/sql/flux_estimate.sql | 240 + src/test/regress/sql/flux_fk.sql | 601 ++ src/test/regress/sql/flux_index_aba.sql | 321 + src/test/regress/sql/flux_merge_delete.sql | 85 + src/test/regress/sql/flux_rewrite_xact.sql | 194 + src/test/regress/sql/flux_tidbitmap.sql | 71 + src/test/regress/sql/flux_toast_foreign.sql | 445 + src/test/regress/sql/flux_trigger_memory.sql | 143 + src/test/regress/sql/flux_user_catalog.sql | 38 + src/test/regress/sql/flux_vacuum_stats.sql | 45 + src/test/subscription/meson.build | 7 + .../t/101_inplace_am_logicalrep.pl | 66 + .../t/102_inplace_am_apply_locks.pl | 216 + .../t/103_inplace_am_replica_identity.pl | 87 + .../t/104_inplace_am_update_conflict.pl | 117 + .../t/105_inplace_am_update_deleted.pl | 116 + .../t/106_inplace_am_key_update_wait.pl | 187 + .../t/107_inplace_am_outofplace_update.pl | 134 + src/tools/pgindent/typedefs.list | 223 +- 339 files changed, 82569 insertions(+), 4036 deletions(-) create mode 100644 contrib/test_decoding/expected/flux_outofplace.out create mode 100644 contrib/test_decoding/sql/flux_outofplace.sql create mode 100644 contrib/test_decoding/t/002_inplace_replica_identity.pl create mode 100644 src/backend/access/flux/Makefile create mode 100644 src/backend/access/flux/README create mode 100644 src/backend/access/flux/flux_fsm.c create mode 100644 src/backend/access/flux/flux_handler.c create mode 100644 src/backend/access/flux/flux_lock.c create mode 100644 src/backend/access/flux/flux_mvcc.c create mode 100644 src/backend/access/flux/flux_operations.c create mode 100644 src/backend/access/flux/flux_pbu.c create mode 100644 src/backend/access/flux/flux_pvs.c create mode 100644 src/backend/access/flux/flux_slot.c create mode 100644 src/backend/access/flux/flux_stats.c create mode 100644 src/backend/access/flux/flux_tuple.c create mode 100644 src/backend/access/flux/flux_undo.c create mode 100644 src/backend/access/flux/flux_vm.c create mode 100644 src/backend/access/flux/flux_xlog.c create mode 100644 src/backend/access/flux/meson.build create mode 100644 src/backend/access/rmgrdesc/fluxdesc.c delete mode 100644 src/backend/access/undo/slog_flathash.c create mode 100644 src/include/access/flux.h create mode 100644 src/include/access/flux_pbu.h create mode 100644 src/include/access/flux_undo.h create mode 100644 src/include/access/flux_xlog.h delete mode 100644 src/include/access/slog_flathash.h create mode 100644 src/test/isolation/expected/flux_cluster_sort_snapshot.out create mode 100644 src/test/isolation/expected/flux_conflict.out create mode 100644 src/test/isolation/expected/flux_delete_conflict.out create mode 100644 src/test/isolation/expected/flux_fk_crosscheck.out create mode 100644 src/test/isolation/expected/flux_index_aba.out create mode 100644 src/test/isolation/expected/flux_inplace.out create mode 100644 src/test/isolation/expected/flux_lock_delete_lockers.out create mode 100644 src/test/isolation/expected/flux_lock_recheck.out create mode 100644 src/test/isolation/expected/flux_lock_update_lockers.out create mode 100644 src/test/isolation/expected/flux_lock_upgrade_queue.out create mode 100644 src/test/isolation/expected/flux_lostupdate.out create mode 100644 src/test/isolation/expected/flux_nonkey_move.out create mode 100644 src/test/isolation/expected/flux_outofplace_grow.out create mode 100644 src/test/isolation/expected/flux_outofplace_update.out create mode 100644 src/test/isolation/expected/flux_partition_move.out create mode 100644 src/test/isolation/expected/flux_pvs_deep.out create mode 100644 src/test/isolation/expected/flux_recheck_after_wait.out create mode 100644 src/test/isolation/expected/flux_relstats.out create mode 100644 src/test/isolation/expected/flux_savepoint_lock_rollback.out create mode 100644 src/test/isolation/expected/flux_savepoint_lock_wake.out create mode 100644 src/test/isolation/expected/flux_scan_history.out create mode 100644 src/test/isolation/expected/flux_scan_stable.out create mode 100644 src/test/isolation/expected/flux_seqscan_stats.out create mode 100644 src/test/isolation/expected/flux_slot_tuple_context.out create mode 100644 src/test/isolation/expected/flux_specconflict_toast.out create mode 100644 src/test/isolation/expected/flux_ssi_conflict_out.out create mode 100644 src/test/isolation/expected/flux_ssi_copy.out create mode 100644 src/test/isolation/expected/flux_ssi_revive.out create mode 100644 src/test/isolation/expected/flux_tid_fetch_snapshot.out create mode 100644 src/test/isolation/expected/flux_update_deadlock.out create mode 100644 src/test/isolation/expected/flux_update_key_waiter.out create mode 100644 src/test/isolation/expected/flux_update_serialize.out create mode 100644 src/test/isolation/expected/flux_update_victim.out create mode 100644 src/test/isolation/expected/flux_update_waiter_abort.out create mode 100644 src/test/isolation/expected/flux_vacuum_move.out create mode 100644 src/test/isolation/expected/heap_cluster_sort_snapshot.out create mode 100644 src/test/isolation/expected/heap_fk_crosscheck.out create mode 100644 src/test/isolation/expected/heap_lock_delete_lockers.out create mode 100644 src/test/isolation/expected/heap_lock_recheck.out create mode 100644 src/test/isolation/expected/heap_lock_update_lockers.out create mode 100644 src/test/isolation/expected/heap_lock_upgrade_queue.out create mode 100644 src/test/isolation/expected/heap_nonkey_move.out create mode 100644 src/test/isolation/expected/heap_outofplace_grow.out create mode 100644 src/test/isolation/expected/heap_outofplace_update.out create mode 100644 src/test/isolation/expected/heap_partition_move.out create mode 100644 src/test/isolation/expected/heap_recheck_after_wait.out create mode 100644 src/test/isolation/expected/heap_savepoint_lock_rollback.out create mode 100644 src/test/isolation/expected/heap_savepoint_lock_wake.out create mode 100644 src/test/isolation/expected/heap_seqscan_stats.out create mode 100644 src/test/isolation/expected/heap_ssi_conflict_out.out create mode 100644 src/test/isolation/expected/heap_ssi_copy.out create mode 100644 src/test/isolation/expected/heap_ssi_revive.out create mode 100644 src/test/isolation/expected/heap_tid_fetch_snapshot.out create mode 100644 src/test/isolation/expected/heap_update_deadlock.out create mode 100644 src/test/isolation/expected/heap_update_key_waiter.out create mode 100644 src/test/isolation/expected/heap_update_serialize.out create mode 100644 src/test/isolation/expected/heap_update_victim.out create mode 100644 src/test/isolation/expected/heap_update_waiter_abort.out create mode 100644 src/test/isolation/expected/heap_vacuum_move.out create mode 100644 src/test/isolation/specs/flux_cluster_sort_snapshot.spec create mode 100644 src/test/isolation/specs/flux_conflict.spec create mode 100644 src/test/isolation/specs/flux_delete_conflict.spec create mode 100644 src/test/isolation/specs/flux_fk_crosscheck.spec create mode 100644 src/test/isolation/specs/flux_index_aba.spec create mode 100644 src/test/isolation/specs/flux_inplace.spec create mode 100644 src/test/isolation/specs/flux_lock_delete_lockers.spec create mode 100644 src/test/isolation/specs/flux_lock_recheck.spec create mode 100644 src/test/isolation/specs/flux_lock_update_lockers.spec create mode 100644 src/test/isolation/specs/flux_lock_upgrade_queue.spec create mode 100644 src/test/isolation/specs/flux_lostupdate.spec create mode 100644 src/test/isolation/specs/flux_nonkey_move.spec create mode 100644 src/test/isolation/specs/flux_outofplace_grow.spec create mode 100644 src/test/isolation/specs/flux_outofplace_update.spec create mode 100644 src/test/isolation/specs/flux_partition_move.spec create mode 100644 src/test/isolation/specs/flux_pvs_deep.spec create mode 100644 src/test/isolation/specs/flux_recheck_after_wait.spec create mode 100644 src/test/isolation/specs/flux_relstats.spec create mode 100644 src/test/isolation/specs/flux_savepoint_lock_rollback.spec create mode 100644 src/test/isolation/specs/flux_savepoint_lock_wake.spec create mode 100644 src/test/isolation/specs/flux_scan_history.spec create mode 100644 src/test/isolation/specs/flux_scan_stable.spec create mode 100644 src/test/isolation/specs/flux_seqscan_stats.spec create mode 100644 src/test/isolation/specs/flux_slot_tuple_context.spec create mode 100644 src/test/isolation/specs/flux_specconflict_toast.spec create mode 100644 src/test/isolation/specs/flux_ssi_conflict_out.spec create mode 100644 src/test/isolation/specs/flux_ssi_copy.spec create mode 100644 src/test/isolation/specs/flux_ssi_revive.spec create mode 100644 src/test/isolation/specs/flux_tid_fetch_snapshot.spec create mode 100644 src/test/isolation/specs/flux_update_deadlock.spec create mode 100644 src/test/isolation/specs/flux_update_key_waiter.spec create mode 100644 src/test/isolation/specs/flux_update_serialize.spec create mode 100644 src/test/isolation/specs/flux_update_victim.spec create mode 100644 src/test/isolation/specs/flux_update_waiter_abort.spec create mode 100644 src/test/isolation/specs/flux_vacuum_move.spec create mode 100644 src/test/isolation/specs/heap_cluster_sort_snapshot.spec create mode 100644 src/test/isolation/specs/heap_fk_crosscheck.spec create mode 100644 src/test/isolation/specs/heap_lock_delete_lockers.spec create mode 100644 src/test/isolation/specs/heap_lock_recheck.spec create mode 100644 src/test/isolation/specs/heap_lock_update_lockers.spec create mode 100644 src/test/isolation/specs/heap_lock_upgrade_queue.spec create mode 100644 src/test/isolation/specs/heap_nonkey_move.spec create mode 100644 src/test/isolation/specs/heap_outofplace_grow.spec create mode 100644 src/test/isolation/specs/heap_outofplace_update.spec create mode 100644 src/test/isolation/specs/heap_partition_move.spec create mode 100644 src/test/isolation/specs/heap_recheck_after_wait.spec create mode 100644 src/test/isolation/specs/heap_savepoint_lock_rollback.spec create mode 100644 src/test/isolation/specs/heap_savepoint_lock_wake.spec create mode 100644 src/test/isolation/specs/heap_seqscan_stats.spec create mode 100644 src/test/isolation/specs/heap_ssi_conflict_out.spec create mode 100644 src/test/isolation/specs/heap_ssi_copy.spec create mode 100644 src/test/isolation/specs/heap_ssi_revive.spec create mode 100644 src/test/isolation/specs/heap_tid_fetch_snapshot.spec create mode 100644 src/test/isolation/specs/heap_update_deadlock.spec create mode 100644 src/test/isolation/specs/heap_update_key_waiter.spec create mode 100644 src/test/isolation/specs/heap_update_serialize.spec create mode 100644 src/test/isolation/specs/heap_update_victim.spec create mode 100644 src/test/isolation/specs/heap_update_waiter_abort.spec create mode 100644 src/test/isolation/specs/heap_vacuum_move.spec create mode 100644 src/test/modules/brin/expected/flux-summarization-and-inprogress-insertion.out create mode 100644 src/test/modules/brin/expected/flux_summarize.out create mode 100644 src/test/modules/brin/specs/flux-summarization-and-inprogress-insertion.spec create mode 100644 src/test/modules/brin/sql/flux_summarize.sql create mode 100644 src/test/modules/injection_points/expected/flux_cic.out create mode 100644 src/test/modules/injection_points/expected/flux_commit_window.out create mode 100644 src/test/modules/injection_points/expected/flux_repack.out create mode 100644 src/test/modules/injection_points/expected/flux_repack_decode.out create mode 100644 src/test/modules/injection_points/expected/flux_update_cas_race.out create mode 100644 src/test/modules/injection_points/expected/flux_update_redecide_queue.out create mode 100644 src/test/modules/injection_points/expected/repack_other_table.out create mode 100644 src/test/modules/injection_points/expected/vacuum_progress.out create mode 100644 src/test/modules/injection_points/specs/flux_cic.spec create mode 100644 src/test/modules/injection_points/specs/flux_commit_window.spec create mode 100644 src/test/modules/injection_points/specs/flux_repack.spec create mode 100644 src/test/modules/injection_points/specs/flux_repack_decode.spec create mode 100644 src/test/modules/injection_points/specs/flux_update_cas_race.spec create mode 100644 src/test/modules/injection_points/specs/flux_update_redecide_queue.spec create mode 100644 src/test/modules/injection_points/specs/repack_other_table.spec create mode 100644 src/test/modules/injection_points/specs/vacuum_progress.spec create mode 100644 src/test/modules/test_deletemark/t/002_deletemark_lp_dead.pl create mode 100644 src/test/modules/test_idxundo/t/010_slog_abort.pl create mode 100644 src/test/modules/test_idxundo/t/012_flux_delete_nowait.pl create mode 100644 src/test/recovery/t/076_flux_deferred_rollback.pl create mode 100644 src/test/recovery/t/077_flux_undo_idempotency.pl create mode 100644 src/test/recovery/t/078_flux_undo_restore_fail.pl create mode 100644 src/test/recovery/t/079_flux_vacuum_aborted_insert.pl create mode 100644 src/test/recovery/t/086_flux_standby_helping.pl create mode 100644 src/test/recovery/t/087_flux_index_undo_fail.pl create mode 100644 src/test/recovery/t/093_flux_toast_supersede.pl create mode 100644 src/test/recovery/t/095_flux_rewrite_cutoffs.pl create mode 100644 src/test/recovery/t/097_flux_vacuum_truncate.pl create mode 100644 src/test/recovery/t/100_flux_vacuum_recorded_tids.pl create mode 100644 src/test/recovery/t/101_flux_external_wal.pl create mode 100644 src/test/recovery/t/102_flux_insert_redo_consistency.pl create mode 100644 src/test/recovery/t/103_flux_nonpermanent_wal.pl create mode 100644 src/test/recovery/t/104_flux_index_aba_revert.pl create mode 100644 src/test/recovery/t/106_flux_rewrite_pending_revert.pl create mode 100644 src/test/recovery/t/107_flux_rewrite_2pc.pl create mode 100644 src/test/recovery/t/108_flux_tuple_layout.pl create mode 100644 src/test/recovery/t/109_flux_savepoint_lock_2pc.pl create mode 100644 src/test/recovery/t/110_flux_unique_pending_revert.pl create mode 100644 src/test/recovery/t/111_flux_index_build_pending_revert.pl create mode 100644 src/test/recovery/t/112_flux_index_no_orphan.pl create mode 100644 src/test/recovery/t/113_flux_prepared_row_locks.pl create mode 100644 src/test/recovery/t/115_flux_freeze.pl create mode 100644 src/test/recovery/t/116_flux_minimal.pl create mode 100644 src/test/recovery/t/118_flux_minimal_undo_log.pl create mode 100644 src/test/recovery/t/119_flux_standby_conflict.pl create mode 100644 src/test/recovery/t/120_flux_undo_remark_split.pl create mode 100644 src/test/recovery/t/122_flux_amcheck_deletemark.pl create mode 100644 src/test/recovery/t/123_flux_tombstone_size.pl create mode 100644 src/test/recovery/t/125_flux_undo_meta_nonpermanent.pl create mode 100644 src/test/recovery/t/127_flux_rollback_prepared_crash.pl create mode 100644 src/test/recovery/t/128_flux_delete_superseded_redo.pl create mode 100644 src/test/recovery/t/129_undo_redo_no_fpw.pl create mode 100644 src/test/recovery/t/130_flux_single_user_abort.pl create mode 100644 src/test/recovery/t/131_flux_vacuum_rounds.pl create mode 100644 src/test/recovery/t/132_flux_rowlock_store.pl create mode 100644 src/test/recovery/t/133_flux_insert_markers.pl create mode 100644 src/test/recovery/t/135_flux_rowlock_filter.pl create mode 100644 src/test/recovery/t/136_flux_rowlock_oom.pl create mode 100644 src/test/recovery/t/137_flux_vacuum_parallel.pl create mode 100644 src/test/recovery/t/138_flux_vacuum_toast.pl create mode 100644 src/test/recovery/t/139_flux_toast_delete.pl create mode 100644 src/test/recovery/t/140_flux_spec_insert_vm.pl create mode 100644 src/test/recovery/t/141_flux_index_exact_keys.pl create mode 100644 src/test/recovery/t/142_flux_vm_clear_redo.pl create mode 100644 src/test/recovery/t/143_flux_update_page_lsn.pl create mode 100644 src/test/recovery/t/145_flux_slog_storage.pl rename src/test/recovery/t/{115_flux_subxact_abort_dropped_storage.pl => 146_flux_subxact_abort_dropped_storage.pl} (86%) create mode 100644 src/test/recovery/t/147_flux_repack_concurrently.pl create mode 100644 src/test/recovery/t/148_flux_pg_visibility.pl create mode 100644 src/test/recovery/t/149_flux_partial_index_member.pl create mode 100644 src/test/recovery/t/150_flux_vm_stale_size.pl create mode 100644 src/test/recovery/t/151_flux_vm_set_fpi.pl create mode 100644 src/test/regress/expected/flux.out create mode 100644 src/test/regress/expected/flux_abort_ddl.out create mode 100644 src/test/regress/expected/flux_cluster.out create mode 100644 src/test/regress/expected/flux_deferred_trigger.out create mode 100644 src/test/regress/expected/flux_dml_semantics.out create mode 100644 src/test/regress/expected/flux_estimate.out create mode 100644 src/test/regress/expected/flux_fk.out create mode 100644 src/test/regress/expected/flux_index_aba.out create mode 100644 src/test/regress/expected/flux_merge_delete.out create mode 100644 src/test/regress/expected/flux_rewrite_xact.out create mode 100644 src/test/regress/expected/flux_tidbitmap.out create mode 100644 src/test/regress/expected/flux_toast_foreign.out create mode 100644 src/test/regress/expected/flux_trigger_memory.out create mode 100644 src/test/regress/expected/flux_user_catalog.out create mode 100644 src/test/regress/expected/flux_vacuum_stats.out create mode 100644 src/test/regress/sql/flux.sql create mode 100644 src/test/regress/sql/flux_abort_ddl.sql create mode 100644 src/test/regress/sql/flux_cluster.sql create mode 100644 src/test/regress/sql/flux_deferred_trigger.sql create mode 100644 src/test/regress/sql/flux_dml_semantics.sql create mode 100644 src/test/regress/sql/flux_estimate.sql create mode 100644 src/test/regress/sql/flux_fk.sql create mode 100644 src/test/regress/sql/flux_index_aba.sql create mode 100644 src/test/regress/sql/flux_merge_delete.sql create mode 100644 src/test/regress/sql/flux_rewrite_xact.sql create mode 100644 src/test/regress/sql/flux_tidbitmap.sql create mode 100644 src/test/regress/sql/flux_toast_foreign.sql create mode 100644 src/test/regress/sql/flux_trigger_memory.sql create mode 100644 src/test/regress/sql/flux_user_catalog.sql create mode 100644 src/test/regress/sql/flux_vacuum_stats.sql create mode 100644 src/test/subscription/t/101_inplace_am_logicalrep.pl create mode 100644 src/test/subscription/t/102_inplace_am_apply_locks.pl create mode 100644 src/test/subscription/t/103_inplace_am_replica_identity.pl create mode 100644 src/test/subscription/t/104_inplace_am_update_conflict.pl create mode 100644 src/test/subscription/t/105_inplace_am_update_deleted.pl create mode 100644 src/test/subscription/t/106_inplace_am_key_update_wait.pl create mode 100644 src/test/subscription/t/107_inplace_am_outofplace_update.pl diff --git a/contrib/test_decoding/Makefile b/contrib/test_decoding/Makefile index a7f7b7ae789ef..ee488961adc10 100644 --- a/contrib/test_decoding/Makefile +++ b/contrib/test_decoding/Makefile @@ -5,13 +5,13 @@ PGFILEDESC = "test_decoding - example of a logical decoding output plugin" REGRESS = ddl xact rewrite toast permissions decoding_in_xact \ decoding_into_rel binary prepared replorigin time messages \ - repack spill slot truncate stream stats twophase twophase_stream + repack spill slot truncate stream stats twophase twophase_stream \ + flux_outofplace # slot_creation_error terminates another backend, whose FATAL message can be # lost on Windows, making the test unstable there. # See https://postgr.es/m/088AB35D-0860-494A-A9CF-8B301543AA51@yandex-team.ru SLOT_CREATION_TST = $(if $(filter win32,$(PORTNAME)),,slot_creation_error) - ISOLATION = mxact delayed_startup ondisk_startup concurrent_ddl_dml \ oldest_xmin snapshot_transfer subxact_without_top concurrent_stream \ twophase_snapshot $(SLOT_CREATION_TST) catalog_change_snapshot \ diff --git a/contrib/test_decoding/expected/flux_outofplace.out b/contrib/test_decoding/expected/flux_outofplace.out new file mode 100644 index 0000000000000..589c68633430f --- /dev/null +++ b/contrib/test_decoding/expected/flux_outofplace.out @@ -0,0 +1,186 @@ +-- An UPDATE that FLUX performs out of place (the row moves to a new TID) +-- must decode as one UPDATE, as heap's cross-page UPDATE does, not as a +-- DELETE plus an INSERT. Each case runs on heap and on FLUX. +SET synchronous_commit = on; +SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding'); + ?column? +---------- + init +(1 row) + +-- A changed exclusion-constraint key moves the row. +CREATE TABLE xt_heap (id int PRIMARY KEY, r int4range, v int, + EXCLUDE USING gist (r WITH &&)) USING heap; +CREATE TABLE xt_flux (id int PRIMARY KEY, r int4range, v int, + EXCLUDE USING gist (r WITH &&)) USING flux; +INSERT INTO xt_heap VALUES (1, '[1,10)', 0); +INSERT INTO xt_flux VALUES (1, '[1,10)', 0); +SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + data +-------------------------------------------------------------------------------- + BEGIN + table public.xt_heap: INSERT: id[integer]:1 r[int4range]:'[1,10)' v[integer]:0 + COMMIT + BEGIN + table public.xt_flux: INSERT: id[integer]:1 r[int4range]:'[1,10)' v[integer]:0 + COMMIT +(6 rows) + +UPDATE xt_heap SET r = '[1,11)', v = v + 1 WHERE id = 1; +UPDATE xt_flux SET r = '[1,11)', v = v + 1 WHERE id = 1; +-- the primary key changes too: the old key is logged +UPDATE xt_heap SET id = 2, r = '[1,12)' WHERE id = 1; +UPDATE xt_flux SET id = 2, r = '[1,12)' WHERE id = 1; +SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + data +------------------------------------------------------------------------------------------------------------------ + BEGIN + table public.xt_heap: UPDATE: id[integer]:1 r[int4range]:'[1,11)' v[integer]:1 + COMMIT + BEGIN + table public.xt_flux: UPDATE: id[integer]:1 r[int4range]:'[1,11)' v[integer]:1 + COMMIT + BEGIN + table public.xt_heap: UPDATE: old-key: id[integer]:1 new-tuple: id[integer]:2 r[int4range]:'[1,12)' v[integer]:1 + COMMIT + BEGIN + table public.xt_flux: UPDATE: old-key: id[integer]:1 new-tuple: id[integer]:2 r[int4range]:'[1,12)' v[integer]:1 + COMMIT +(12 rows) + +ALTER TABLE xt_heap REPLICA IDENTITY FULL; +ALTER TABLE xt_flux REPLICA IDENTITY FULL; +UPDATE xt_heap SET r = '[1,13)', v = v + 1 WHERE id = 2; +UPDATE xt_flux SET r = '[1,13)', v = v + 1 WHERE id = 2; +SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + data +----------------------------------------------------------------------------------------------------------------------------------------------------- + BEGIN + table public.xt_heap: UPDATE: old-key: id[integer]:2 r[int4range]:'[1,12)' v[integer]:1 new-tuple: id[integer]:2 r[int4range]:'[1,13)' v[integer]:2 + COMMIT + BEGIN + table public.xt_flux: UPDATE: old-key: id[integer]:2 r[int4range]:'[1,12)' v[integer]:1 new-tuple: id[integer]:2 r[int4range]:'[1,13)' v[integer]:2 + COMMIT +(6 rows) + +-- Rolled back: nothing is decoded. +BEGIN; +UPDATE xt_flux SET r = '[1,14)' WHERE id = 2; +ROLLBACK; +SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + data +------ +(0 rows) + +-- A row that cannot grow on its full page moves. +CREATE TABLE gt_heap (id int PRIMARY KEY, t text) USING heap WITH (fillfactor = 100); +CREATE TABLE gt_flux (id int PRIMARY KEY, t text) USING flux WITH (fillfactor = 100); +ALTER TABLE gt_heap ALTER COLUMN t SET STORAGE PLAIN; +ALTER TABLE gt_flux ALTER COLUMN t SET STORAGE PLAIN; +INSERT INTO gt_heap SELECT g, repeat('a', 100) FROM generate_series(1, 200) g; +INSERT INTO gt_flux SELECT g, repeat('a', 100) FROM generate_series(1, 200) g; +SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'skip-empty-xacts', '1'); + count +------- + 404 +(1 row) + +UPDATE gt_heap SET t = repeat('b', 2000) WHERE id = 1; +UPDATE gt_flux SET t = repeat('b', 2000) WHERE id = 1; +SELECT (ctid::text::point)[0] > 0 AS moved FROM gt_heap WHERE id = 1; + moved +------- + t +(1 row) + +SELECT (ctid::text::point)[0] > 0 AS moved FROM gt_flux WHERE id = 1; + moved +------- + t +(1 row) + +SELECT regexp_replace(data, '''b+''', '''b...''') FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + regexp_replace +------------------------------------------------------------ + BEGIN + table public.gt_heap: UPDATE: id[integer]:1 t[text]:'b...' + COMMIT + BEGIN + table public.gt_flux: UPDATE: id[integer]:1 t[text]:'b...' + COMMIT +(6 rows) + +-- The same, with REPLICA IDENTITY FULL and a TOASTed new value. +ALTER TABLE gt_heap REPLICA IDENTITY FULL; +ALTER TABLE gt_flux REPLICA IDENTITY FULL; +ALTER TABLE gt_heap ALTER COLUMN t SET STORAGE EXTERNAL; +ALTER TABLE gt_flux ALTER COLUMN t SET STORAGE EXTERNAL; +UPDATE gt_heap SET t = repeat('c', 10000) WHERE id = 2; +UPDATE gt_flux SET t = repeat('c', 10000) WHERE id = 2; +SELECT regexp_replace(data, '''c+''', '''c...''') FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + regexp_replace +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + BEGIN + table public.gt_heap: UPDATE: old-key: id[integer]:2 t[text]:'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' new-tuple: id[integer]:2 t[text]:'c...' + COMMIT + BEGIN + table public.gt_flux: UPDATE: old-key: id[integer]:2 t[text]:'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' new-tuple: id[integer]:2 t[text]:'c...' + COMMIT +(6 rows) + +SELECT length(t) FROM gt_flux WHERE id = 2; + length +-------- + 10000 +(1 row) + +-- A moved row whose new value is TOASTed, and one whose TOASTed value is +-- unchanged. +CREATE TABLE tt_heap (id int PRIMARY KEY, r int4range, t text, + EXCLUDE USING gist (r WITH &&)) USING heap; +CREATE TABLE tt_flux (id int PRIMARY KEY, r int4range, t text, + EXCLUDE USING gist (r WITH &&)) USING flux; +ALTER TABLE tt_heap ALTER COLUMN t SET STORAGE EXTERNAL; +ALTER TABLE tt_flux ALTER COLUMN t SET STORAGE EXTERNAL; +INSERT INTO tt_heap VALUES (1, '[1,10)', 'x'); +INSERT INTO tt_flux VALUES (1, '[1,10)', 'x'); +UPDATE tt_heap SET r = '[1,11)', t = repeat('d', 10000) WHERE id = 1; +UPDATE tt_flux SET r = '[1,11)', t = repeat('d', 10000) WHERE id = 1; +UPDATE tt_heap SET r = '[1,12)' WHERE id = 1; +UPDATE tt_flux SET r = '[1,12)' WHERE id = 1; +SELECT regexp_replace(data, '''d+''', '''d...''') FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + regexp_replace +------------------------------------------------------------------------------------------------- + BEGIN + table public.tt_heap: INSERT: id[integer]:1 r[int4range]:'[1,10)' t[text]:'x' + COMMIT + BEGIN + table public.tt_flux: INSERT: id[integer]:1 r[int4range]:'[1,10)' t[text]:'x' + COMMIT + BEGIN + table public.tt_heap: UPDATE: id[integer]:1 r[int4range]:'[1,11)' t[text]:'d...' + COMMIT + BEGIN + table public.tt_flux: UPDATE: id[integer]:1 r[int4range]:'[1,11)' t[text]:'d...' + COMMIT + BEGIN + table public.tt_heap: UPDATE: id[integer]:1 r[int4range]:'[1,12)' t[text]:unchanged-toast-datum + COMMIT + BEGIN + table public.tt_flux: UPDATE: id[integer]:1 r[int4range]:'[1,12)' t[text]:'d...' + COMMIT +(18 rows) + +SELECT r, length(t) FROM tt_flux; + r | length +--------+-------- + [1,12) | 10000 +(1 row) + +DROP TABLE xt_heap, xt_flux, gt_heap, gt_flux, tt_heap, tt_flux; +SELECT pg_drop_replication_slot('regression_slot'); + pg_drop_replication_slot +-------------------------- + +(1 row) + diff --git a/contrib/test_decoding/meson.build b/contrib/test_decoding/meson.build index dbc0ef0f63e3b..e1ece6abb342f 100644 --- a/contrib/test_decoding/meson.build +++ b/contrib/test_decoding/meson.build @@ -65,6 +65,7 @@ tests += { 'stats', 'twophase', 'twophase_stream', + 'flux_outofplace', ], 'regress_args': [ '--temp-config', files('logical.conf'), @@ -84,6 +85,7 @@ tests += { 'tap': { 'tests': [ 't/001_repl_stats.pl', + 't/002_inplace_replica_identity.pl', ], }, } diff --git a/contrib/test_decoding/sql/flux_outofplace.sql b/contrib/test_decoding/sql/flux_outofplace.sql new file mode 100644 index 0000000000000..2a171b261f685 --- /dev/null +++ b/contrib/test_decoding/sql/flux_outofplace.sql @@ -0,0 +1,79 @@ +-- An UPDATE that FLUX performs out of place (the row moves to a new TID) +-- must decode as one UPDATE, as heap's cross-page UPDATE does, not as a +-- DELETE plus an INSERT. Each case runs on heap and on FLUX. +SET synchronous_commit = on; + +SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding'); + +-- A changed exclusion-constraint key moves the row. +CREATE TABLE xt_heap (id int PRIMARY KEY, r int4range, v int, + EXCLUDE USING gist (r WITH &&)) USING heap; +CREATE TABLE xt_flux (id int PRIMARY KEY, r int4range, v int, + EXCLUDE USING gist (r WITH &&)) USING flux; +INSERT INTO xt_heap VALUES (1, '[1,10)', 0); +INSERT INTO xt_flux VALUES (1, '[1,10)', 0); +SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + +UPDATE xt_heap SET r = '[1,11)', v = v + 1 WHERE id = 1; +UPDATE xt_flux SET r = '[1,11)', v = v + 1 WHERE id = 1; +-- the primary key changes too: the old key is logged +UPDATE xt_heap SET id = 2, r = '[1,12)' WHERE id = 1; +UPDATE xt_flux SET id = 2, r = '[1,12)' WHERE id = 1; +SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + +ALTER TABLE xt_heap REPLICA IDENTITY FULL; +ALTER TABLE xt_flux REPLICA IDENTITY FULL; +UPDATE xt_heap SET r = '[1,13)', v = v + 1 WHERE id = 2; +UPDATE xt_flux SET r = '[1,13)', v = v + 1 WHERE id = 2; +SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + +-- Rolled back: nothing is decoded. +BEGIN; +UPDATE xt_flux SET r = '[1,14)' WHERE id = 2; +ROLLBACK; +SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + +-- A row that cannot grow on its full page moves. +CREATE TABLE gt_heap (id int PRIMARY KEY, t text) USING heap WITH (fillfactor = 100); +CREATE TABLE gt_flux (id int PRIMARY KEY, t text) USING flux WITH (fillfactor = 100); +ALTER TABLE gt_heap ALTER COLUMN t SET STORAGE PLAIN; +ALTER TABLE gt_flux ALTER COLUMN t SET STORAGE PLAIN; +INSERT INTO gt_heap SELECT g, repeat('a', 100) FROM generate_series(1, 200) g; +INSERT INTO gt_flux SELECT g, repeat('a', 100) FROM generate_series(1, 200) g; +SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'skip-empty-xacts', '1'); + +UPDATE gt_heap SET t = repeat('b', 2000) WHERE id = 1; +UPDATE gt_flux SET t = repeat('b', 2000) WHERE id = 1; +SELECT (ctid::text::point)[0] > 0 AS moved FROM gt_heap WHERE id = 1; +SELECT (ctid::text::point)[0] > 0 AS moved FROM gt_flux WHERE id = 1; +SELECT regexp_replace(data, '''b+''', '''b...''') FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + +-- The same, with REPLICA IDENTITY FULL and a TOASTed new value. +ALTER TABLE gt_heap REPLICA IDENTITY FULL; +ALTER TABLE gt_flux REPLICA IDENTITY FULL; +ALTER TABLE gt_heap ALTER COLUMN t SET STORAGE EXTERNAL; +ALTER TABLE gt_flux ALTER COLUMN t SET STORAGE EXTERNAL; +UPDATE gt_heap SET t = repeat('c', 10000) WHERE id = 2; +UPDATE gt_flux SET t = repeat('c', 10000) WHERE id = 2; +SELECT regexp_replace(data, '''c+''', '''c...''') FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); +SELECT length(t) FROM gt_flux WHERE id = 2; + +-- A moved row whose new value is TOASTed, and one whose TOASTed value is +-- unchanged. +CREATE TABLE tt_heap (id int PRIMARY KEY, r int4range, t text, + EXCLUDE USING gist (r WITH &&)) USING heap; +CREATE TABLE tt_flux (id int PRIMARY KEY, r int4range, t text, + EXCLUDE USING gist (r WITH &&)) USING flux; +ALTER TABLE tt_heap ALTER COLUMN t SET STORAGE EXTERNAL; +ALTER TABLE tt_flux ALTER COLUMN t SET STORAGE EXTERNAL; +INSERT INTO tt_heap VALUES (1, '[1,10)', 'x'); +INSERT INTO tt_flux VALUES (1, '[1,10)', 'x'); +UPDATE tt_heap SET r = '[1,11)', t = repeat('d', 10000) WHERE id = 1; +UPDATE tt_flux SET r = '[1,11)', t = repeat('d', 10000) WHERE id = 1; +UPDATE tt_heap SET r = '[1,12)' WHERE id = 1; +UPDATE tt_flux SET r = '[1,12)' WHERE id = 1; +SELECT regexp_replace(data, '''d+''', '''d...''') FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); +SELECT r, length(t) FROM tt_flux; + +DROP TABLE xt_heap, xt_flux, gt_heap, gt_flux, tt_heap, tt_flux; +SELECT pg_drop_replication_slot('regression_slot'); diff --git a/contrib/test_decoding/t/002_inplace_replica_identity.pl b/contrib/test_decoding/t/002_inplace_replica_identity.pl new file mode 100644 index 0000000000000..bd28d0d607605 --- /dev/null +++ b/contrib/test_decoding/t/002_inplace_replica_identity.pl @@ -0,0 +1,126 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Old-key images and unchanged toast must decode exactly as heap does. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('decode'); +$node->init(allows_streaming => 'logical'); +$node->append_conf('postgresql.conf', 'synchronous_commit = on'); +$node->start; + +for my $identity ('DEFAULT', 'FULL', 'USING INDEX t_key', 'NOTHING') +{ + my %output; + for my $am ('heap', 'flux') + { + $node->safe_psql( + 'postgres', qq{ + CREATE TABLE t (id int PRIMARY KEY, k text NOT NULL, v text, n int) USING $am; + CREATE UNIQUE INDEX t_key ON t(k); + ALTER TABLE t ALTER COLUMN k SET STORAGE EXTERNAL; + ALTER TABLE t ALTER COLUMN v SET STORAGE EXTERNAL; + ALTER TABLE t REPLICA IDENTITY $identity; + SELECT pg_create_logical_replication_slot('s', 'test_decoding'); + }); + my @changes = ( + "INSERT INTO t VALUES (1, repeat('k', 2000), repeat('v', 5000), 0)", + "INSERT INTO t VALUES (3, 'third', 'value', 0) ON CONFLICT (id) DO NOTHING", + "INSERT INTO t VALUES (1, 'duplicate', 'value', 0) ON CONFLICT (id) DO UPDATE SET n = 2", + 'DELETE FROM t WHERE id = 3', + 'UPDATE t SET n = 1', + 'UPDATE t SET id = 2', + "UPDATE t SET k = repeat('j', 2000)", + "UPDATE t SET v = repeat('w', 6000)", + 'ALTER TABLE t ADD COLUMN extra int DEFAULT 42', + 'UPDATE t SET extra = 43', + 'ALTER TABLE t DROP COLUMN n', + 'UPDATE t SET extra = 44', + 'DELETE FROM t'); + for my $sql (@changes) + { + $node->safe_psql('postgres', $sql); + my $decoded = $node->safe_psql( + 'postgres', q{ + SELECT data FROM pg_logical_slot_get_changes('s', NULL, NULL, + 'include-xids', '0', 'skip-empty-xacts', '1') + }); + if ($am eq 'heap') + { + $output{$sql} = $decoded; + } + else + { + is($decoded, $output{$sql}, "$identity: $sql matches heap"); + } + } + $node->safe_psql('postgres', + "SELECT pg_drop_replication_slot('s'); DROP TABLE t"); + } +} + +# Stop both sessions after speculative heap/FLUX insertion but before the +# unique index insertion. The second session must kill its speculative row. +my %spec_output; +for my $am ('heap', 'flux') +{ + $node->safe_psql( + 'postgres', qq{ + CREATE TABLE spec (id int, v text) USING $am; + ALTER TABLE spec ALTER COLUMN v SET STORAGE EXTERNAL; + CREATE FUNCTION spec_key(int) RETURNS int IMMUTABLE LANGUAGE plpgsql AS \$\$ + BEGIN + IF current_setting('spec.calls')::int = 1 THEN + PERFORM pg_advisory_xact_lock(current_setting('spec.session')::int); + END IF; + PERFORM set_config('spec.calls', '1', false); + RETURN \$1; + END \$\$; + CREATE UNIQUE INDEX spec_key_idx ON spec (spec_key(id)); + SELECT pg_create_logical_replication_slot('s', 'test_decoding'); + }); + my $controller = $node->background_psql('postgres'); + $controller->query_safe( + 'SELECT pg_advisory_lock(1), pg_advisory_lock(2)'); + my @sessions; + for my $id (1, 2) + { + my $bg = $node->background_psql('postgres'); + $bg->query_safe("SET spec.calls = 0; SET spec.session = $id"); + $bg->query_until( + qr/starting/, q{ + \echo starting + INSERT INTO spec VALUES (1, repeat('toast', 1000)) ON CONFLICT DO NOTHING; + }); + push @sessions, $bg; + } + $node->poll_query_until( + 'postgres', q{ + SELECT count(*) = 2 FROM pg_locks WHERE locktype = 'advisory' AND NOT granted + }) or die 'speculative inserts did not reach the index'; + for my $id (1, 2) + { + $controller->query_safe("SELECT pg_advisory_unlock($id)"); + $sessions[ $id - 1 ]->query_safe('SELECT 1'); + $sessions[ $id - 1 ]->quit; + } + $controller->quit; + is($node->safe_psql('postgres', 'SELECT count(*) FROM spec'), + '1', "$am speculative loser discarded"); + $spec_output{$am} = $node->safe_psql( + 'postgres', q{ + SELECT data FROM pg_logical_slot_get_changes('s', NULL, NULL, + 'include-xids', '0', 'skip-empty-xacts', '1') + }); + $node->safe_psql( + 'postgres', q{ + SELECT pg_drop_replication_slot('s'); DROP TABLE spec; DROP FUNCTION spec_key(int); + }); +} +is($spec_output{flux}, $spec_output{heap}, + 'speculative abort decoding matches heap'); +$node->stop; +done_testing(); diff --git a/meson.build b/meson.build index 345ef815072d6..29e9891b12393 100644 --- a/meson.build +++ b/meson.build @@ -1585,6 +1585,17 @@ else endif +############################################################### +# Library: FLUX +############################################################### + +# FLUX table access method (UNDO-based heap replacement) is always built: +# guc_parameters.dat and pg_proc.dat reference flux symbols unconditionally, +# so a build with USE_FLUX unset fails to link. USE_FLUX is therefore always +# defined and the sources are always compiled. +flux = declare_dependency() +cdata.set('USE_FLUX', 1) + ############################################################### # Library: FILEOPS diff --git a/src/Makefile.global.in b/src/Makefile.global.in index e33be14ba539d..518fea3d7b263 100644 --- a/src/Makefile.global.in +++ b/src/Makefile.global.in @@ -256,6 +256,10 @@ PG_SYSROOT = @PG_SYSROOT@ override CPPFLAGS += $(ICU_CFLAGS) $(LIBNUMA_CFLAGS) $(LIBURING_CFLAGS) +# FLUX table access method (UNDO-based heap replacement) is always built +USE_FLUX = 1 +override CPPFLAGS += -DUSE_FLUX + # FILEOPS transactional filesystem operations (WAL rmgr RM_FILEOPS_ID) is # always built. Its own guard, independent of USE_FLUX, so the FILEOPS WAL # resource manager can be built and registered without the FLUX table AM. diff --git a/src/backend/access/Makefile b/src/backend/access/Makefile index 5778d0d7f8225..88f863ae3946c 100644 --- a/src/backend/access/Makefile +++ b/src/backend/access/Makefile @@ -24,5 +24,6 @@ SUBDIRS = \ tablesample \ transam \ undo \ + flux include $(top_srcdir)/src/backend/common.mk diff --git a/src/backend/access/flux/Makefile b/src/backend/access/flux/Makefile new file mode 100644 index 0000000000000..a2e405e61cbf2 --- /dev/null +++ b/src/backend/access/flux/Makefile @@ -0,0 +1,30 @@ +#------------------------------------------------------------------------- +# +# Makefile-- +# Makefile for access/flux +# +# IDENTIFICATION +# src/backend/access/flux/Makefile +# +#------------------------------------------------------------------------- + +subdir = src/backend/access/flux +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global + +OBJS = \ + flux_handler.o \ + flux_tuple.o \ + flux_mvcc.o \ + flux_stats.o \ + flux_xlog.o \ + flux_fsm.o \ + flux_operations.o \ + flux_lock.o \ + flux_slot.o \ + flux_vm.o \ + flux_undo.o \ + flux_pbu.o \ + flux_pvs.o + +include $(top_srcdir)/src/backend/common.mk \ No newline at end of file diff --git a/src/backend/access/flux/README b/src/backend/access/flux/README new file mode 100644 index 0000000000000..b7b3bb3847b76 --- /dev/null +++ b/src/backend/access/flux/README @@ -0,0 +1,273 @@ +FLUX +==== + +FLUX is a table access method that updates rows in place and stores old row +versions in UNDO instead of leaving dead tuples in the main relation. It works +on today's executor APIs and on plain 6-byte-TID indexes, but it does add one +index-AM entry point: nbtree delete-marking (amdeletemark), which is what lets +an indexed-column UPDATE keep the row's TID. The design follows the zheap +proposal (in-place UPDATE with old versions in UNDO); zheap's README describes +delete-marking, which zheap did not implement. + +This README describes FLUX's design and how it differs from heap. For the +UNDO substrate FLUX relies on, see src/backend/access/undo/README; FLUX writes +its table UNDO to the per-backend engine (am_undo_engine = +UNDO_ENGINE_PERBACKEND, see src/include/access/flux_pbu.h). + + +In-place UPDATE and UNDO +------------------------ + +* In-place UPDATE. The new tuple version overwrites the old one in the same + slot (same TID). The old version is pushed to the per-backend undo log via + the tuple header's version pointer (t_verptr). A snapshot that cannot see + the updater reconstructs the old version by walking that UNDO chain + (flux_pvs.c, FluxReconstructVisibleVersion -> FluxPbuFetchVersion). So an + UPDATE that does not change any indexed column touches no index at all, and + FLUX keeps the TID even when an indexed column does change (see the + secondary-index section below). + +* UNDO-based rollback. ROLLBACK (and error/abort) replays the undo chain + backward, restoring before-images in place (flux_undo.c's rm_undo, dispatched + by execute_undo_actions). There are no dead tuples to vacuum away for a + rolled-back change. + +* UNDO-based old-version reconstruction for MVCC. Older snapshots read the + pre-update image from the UNDO fork, not from a second on-page copy. + +* Crash safety. Every page modification and its UNDO record are WAL-logged + (resource manager RM_FLUX_ID, flux_xlog.c); recovery replays them and + per-backend UNDO recovery restores any before-images for transactions that + were in flight at the crash. + +* sLog for uncommitted-writer tracking. FLUX shares the cluster's sLog + extension (slog.c, registered once as SLogShmemCallbacks) for self-visibility, + command-level (CID) checks, and write-write conflict serialization. The + authoritative commit oracle is CLOG; visibility is heap-shaped xmin/xmax + (see below). + + +How FLUX differs from heap +-------------------------- + +1. Old versions live in UNDO, not in the table. Heap keeps every old row + version in the relation until VACUUM removes it (bloat). FLUX keeps only + the newest version on the page and stores the old one in the undo log, + which is discarded once no snapshot needs it. A non-in-place change's old + version and a DELETE's tombstone are reclaimable as soon as the transaction + commits and the version falls below the removable horizon. + +2. In-place for key-changing UPDATEs too, via nbtree delete-marking, with a + capability gate that falls back to a new TID where in-place index + maintenance would be unsound. See the next section. + +3. Wide values use standard heap TOAST. A FLUX relation gets an ordinary + heap TOAST table (relation_needs_toast_table mirrors heap; relation_toast_am + returns the heap AM). On INSERT/UPDATE, oversized varlena columns are + externalized through the AM-agnostic toast_helper routines (flux_toast_tuple + in flux_tuple.c) exactly as heap does. As in heap, a DELETE (also the + delete half of an out-of-place UPDATE) deletes the external values of the + version it deletes (flux_toast_delete), as an MVCC delete that commits or + rolls back with it, and TOAST VACUUM reclaims them; VACUUM of the table + does not touch TOAST. FLUX has no on-page overflow mechanism of its own. + +4. Heap-shaped xmin/xmax visibility. FLUX uses the "boring correct" + visibility model: each tuple carries t_xmin (inserter) and t_xmax + (deleter/updater), resolved against CLOG and the reader's snapshot exactly + like HeapTupleSatisfiesMVCC. + + +Secondary indexes and the key-changing UPDATE +-------------------------------------------- + +Secondary indexes on a FLUX table are ordinary heap-TID nbtree (or any +standard index-AM) entries: plain 6-byte TIDs, exactly like heap. No per-tuple +generation and no wider index identity. FLUX does rely on one index-AM +addition -- nbtree delete-marking (amdeletemark / index_delete_mark) -- which is +what lets it keep the TID across an indexed-column UPDATE. + +The hazard an in-place-with-stable-TID AM creates for a plain-TID index is the +"A -> B -> A" problem: if the tuple stays at one TID while an indexed column +changes, the index would accumulate a stale (k_old, TID) entry beside the new +(k_new, TID) entry, both pointing at the same live TID; an index scan would then +double-count or return rows under the wrong key. + +FLUX handles it in three cases: + + * An UPDATE that changes NO indexed column is done IN PLACE (same TID). The + index entries still point at the right key and TID, so no index + maintenance is needed. flux_tuple_update sets *update_indexes = TU_None + and the executor inserts no index entries. + + * An UPDATE that changes an indexed column, where FLUX can maintain every + index itself, is ALSO done IN PLACE at the same TID + (am_index_delete_marking = true). flux_inplace_index_maintenance inserts + the new (k_new, TID) entry and delete-marks the old (k_old, TID) entry, and + reports TU_None so the executor does no index work. The delete-marked + entry is kept, not removed, so a snapshot that predates the key change still + reaches the row under k_old; a scan that arrives through a delete-marked + entry sets xs_recheck so the executor re-evaluates the qual. FLUX's own + table UNDO reverses both halves on rollback (clear the old delete-mark, + remove the new entry), which is why nbtree/hash suppress their separate + index UNDO for a delete-marking parent. + + * An UPDATE that changes an indexed column where in-place maintenance would + NOT be sound falls back to OUT OF PLACE (new TID, TU_All, executor does all + index work including the constraint checks). The gate is + flux_inplace_index_maint_possible(), and it refuses four cases, each of + which was first observed as a silent wrong answer: + 1. an index AM without delete-marking (only nbtree has it; gist, gin, + spgist and brin do not) -- otherwise the stale entry stays live and an + index scan returns a row that does not satisfy the qual; + 2. an EXCLUSION constraint (indisexclusion) -- the check lives in the + executor, so reporting TU_None would skip it entirely; + 3. a DEFERRABLE constraint (!indimmediate) -- FLUX cannot queue the + end-of-statement recheck event; + 4. an index that is not valid yet (!indisvalid), which CREATE INDEX + CONCURRENTLY or REINDEX CONCURRENTLY is building -- the build + indexes each TID once and its validation adds only missing TIDs, so + a key changed in place at a TID it already has is never indexed. + Heap never updates such a row HOT either: the new TID is what the + build and the validation pick up. + A SUMMARIZING index (BRIN) is exempt from rule 1: it has no per-row + (key, TID) entry to delete-mark and a range scan rechecks the table tuple. + + * The same out-of-place fallback is taken when delete-marking cannot + describe the change in some btree index (flux_index_change_exact): the + old and new entries must be different keys, since the index tells them + apart only by its comparison. That fails when only an INCLUDE column + changed (same key, so the entry would keep the old INCLUDE values), and + for any key change in an index whose key columns are not all + "equalimage" (an opclass that calls different images equal, e.g. numeric + 3 and 3.00). The rule is per index, from the index's own metadata: the + opclasses' BTEQUALIMAGE_PROC, which nbtree also uses to decide + deduplication. + +Stock PostgreSQL no longer tells the AM which columns changed, so FLUX +self-computes it (flux_indexed_attr_changed), matching heap's internal +HeapDetermineColumnsInfo: it deforms the old on-page tuple, reads the new +values from the slot, and value-compares every attribute referenced by any +index (RelationGetIndexAttrBitmap HOT_BLOCKING + SUMMARIZED). + +Because a delete-marked entry's key may not describe the live tuple, index-only +scans are suppressed for indexes on a delete-marking table (enforced in the +planner). + +Consequence: idxscan, seqscan, and bitmapscan return identical results across +any sequence of key-changing UPDATEs (including A -> B -> A recurrences). +Verified by amcheck (heapallindexed) and by the scan cross-check in the test +suite. Note that amcheck's heapallindexed treats a delete-marked entry whose +key mismatches the table tuple as expected rather than as corruption. + + +Limitations +----------- + +* COPY FREEZE is accepted but does not pre-freeze rows or set visibility-map + bits on FLUX. Rows retain ordinary MVCC visibility, so older snapshots + do not see the new rows as they can with heap's COPY FREEZE. + +* Index-only scans are not used on a FLUX table: get_relation_info() + (plancat.c) sets canreturn to false for every index of a table whose AM + does delete-marking. A delete-marked entry left by an in-place key UPDATE + can carry a key that no longer matches the row until VACUUM removes the + entry. + +* An UPDATE that FLUX cannot do in place -- an indexed-column change refused + by flux_inplace_index_maint_possible() (exclusion or deferrable constraint, + an index AM without delete-marking, or an index that is not valid yet), or a new version that does not fit + on its page -- moves the row to a new TID. The old version is marked + FLUX_TUPLE_SUPERSEDED and linked to the new one, as heap's t_ctid chain + does, so a waiter sees the row as updated rather than deleted. + +* An UPDATE that changes only INCLUDE columns of a btree index, or that + changes the key of a btree index with a key opclass that is not + "equalimage", moves the row to a new TID too (flux_index_change_exact), + like heap's non-HOT UPDATE. Common such opclasses: numeric, float4 and + float8, interval, jsonb, arrays (anyarray), ranges and multiranges, + record, tsvector/tsquery, and text or varchar with a nondeterministic + collation. Only the key columns count: a key change in an index on (int) + INCLUDE (numeric) stays in place. UPDATEs that change no column of such + an index stay in place. + +* Row locks are held in the shared tuple sLog, not on the row. Its hash + partitions have a fixed size; a row lock that does not fit fails with + "could not store row lock in sLog". A prepared transaction's row locks are + made durable with a FLUX two-phase record (FLUX_2PC_ROWLOCK). + +* VACUUM scans the table, then vacuums each index, then compacts the pages. + It does not use parallel index vacuuming and does not report progress in + pg_stat_progress_vacuum. A non-aggressive VACUUM does not compact a page + that another backend has pinned (it needs a cleanup lock), as heap's + lazy_scan_noprune does; an aggressive one waits for the pin. + +* While an index of the table is not valid, every UPDATE that changes an + indexed column moves the row to a new TID (rule 4 above), as heap makes + such an UPDATE non-HOT. That lasts for the whole of a CREATE INDEX + CONCURRENTLY or REINDEX CONCURRENTLY, and indefinitely for an invalid index + that a failed concurrent build leaves behind, until it is dropped or rebuilt + with REINDEX. UPDATEs that change no indexed column stay in place. + +* REPACK (CONCURRENTLY) works as for heap, and like heap's it is not + MVCC-safe: the copy takes the rows the decoding's initial snapshot sees, + and the changes committed after it are decoded from the WAL (flux_decode) + and replayed by key onto the new table. CLUSTER and VACUUM FULL keep the + row versions that older snapshots still need. + +* user_catalog_table is refused on FLUX, at CREATE TABLE ... USING flux, at + ALTER TABLE ... SET (user_catalog_table = true), and at ALTER TABLE ... + SET ACCESS METHOD flux on an already-marked heap table + (CheckFluxUserCatalogTable in tablecmds.c, with a hint to use heap). An + output plugin reads a user_catalog_table under a historic snapshot that + reconstructs a past catalog state from older row versions; FLUX visibility + has no branch for those historic snapshots (heap's + HeapTupleSatisfiesHistoricMVCC) and FLUX does not log the command ids + decoding needs to see catalog rows changed by the transaction being + decoded, so a FLUX user_catalog_table would silently return wrong rows to + decoding. Put logical-decoding plugin metadata in a heap table. Because + a FLUX relation can therefore never be a catalog relation, + RelationIsAccessibleInLogicalDecoding() is always false for FLUX: the + catalog_rel flag in the FLUX freeze/defrag WAL records is always false and + the catalog branch of ResolveRecoveryConflictWithSnapshot() is unreachable + for FLUX records. The ordinary (non-catalog) snapshot recovery conflicts a + freeze creates on a standby are still raised (flux_pbu.c, flux_xlog.c). + + +Registration +------------ + +* pg_am: amname 'flux', amhandler flux_tableam_handler, amtype 't' + (FLUX_TABLE_AM_OID = 9316; pg_proc oid 9402). +* WAL resource manager: RM_FLUX_ID (rmgrlist.h), redo/desc/identify/mask in + flux_xlog.c / fluxdesc.c, logical decoding in decode.c (flux_decode). +* UNDO resource manager: UNDO_RMID_FLUX (=5), FluxUndoRmgrInit + (undormgrlist.h). FLUX's table UNDO goes to the per-backend engine + (am_undo_engine = UNDO_ENGINE_PERBACKEND) and its rm_undo also reverses the + in-place index maintenance, which is why nbtree/hash write no separate index + UNDO for a FLUX-parented index. +* Two-phase commit: TWOPHASE_RM_FLUX_ID (=5), flux_twophase_* callbacks. +* Shared memory: FluxMvccShmemCallbacks (subsystemlist.h); row locks are in + the tuple sLog (SLogTupleLock*), allocated on first use. +* GUC: flux_lazy_uncommitted_clear. + +FLUX is always compiled (USE_FLUX) because the catalog and rmgr +lists reference its symbols unconditionally. + + +Source layout +------------- + + flux_handler.c table-AM routine table (flux_methods), scans, TOAST hooks + flux_operations.c INSERT / DELETE / UPDATE (in-place + out-of-place), + in-place index maintenance + its capability gate, VACUUM + flux_tuple.c tuple form/deform, flux_toast_tuple / flux_toast_delete + flux_mvcc.c heap-shaped xmin/xmax visibility state, shmem + flux_pvs.c prior-version reconstruction from the undo chain + flux_slot.c FLUX minimal tuple slot + flux_xlog.c WAL record types, redo, mask + flux_undo.c UNDO_RMID_FLUX resource manager (reverse-apply) + flux_pbu.c per-backend UNDO engine glue (insert, fetch, chain) + flux_lock.c tuple locking (SELECT FOR UPDATE/SHARE) + flux_vm.c visibility map maintenance + flux_fsm.c free-space map integration + flux_stats.c relation size / density estimation helpers diff --git a/src/backend/access/flux/flux_fsm.c b/src/backend/access/flux/flux_fsm.c new file mode 100644 index 0000000000000..278b55716225c --- /dev/null +++ b/src/backend/access/flux/flux_fsm.c @@ -0,0 +1,234 @@ +/*------------------------------------------------------------------------- + * + * flux_fsm.c + * FLUX free space management + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_fsm.c + * + * NOTES + * This is a thin wrapper over PostgreSQL's standard free space map + * (src/backend/storage/freespace). It adds FLUX-specific relation + * extension (page initialization and WAL-logging of the new page) on + * top of the generic GetPageWithFreeSpace/RecordPageWithFreeSpace API. + * + * Page defragmentation is handled entirely by VACUUM via + * FluxPageDefragment() in flux_tuple.c; the FSM only tracks free + * space, it does not schedule or perform compaction. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/flux.h" +#include "access/flux_xlog.h" +#include "storage/bufmgr.h" +#include "storage/freespace.h" +#include "utils/rel.h" +#include "miscadmin.h" + +/* + * FluxGetPageWithFreeSpace + * + * Find or create a page with at least 'needed' bytes of free space. + * + * First queries PostgreSQL's standard FSM (GetPageWithFreeSpace). If a + * page is returned, that block is used directly. + * + * If no suitable existing page is found, extends the relation by allocating + * a new page with ExtendBufferedRel(), initializes it with FluxInitPage(), + * WAL-logs the initialization, and returns the new block. + * + * Parameters: + * rel - open relation + * needed - minimum number of free bytes required + * + * Returns the block number of a page with sufficient free space. + */ +/* + * FluxTargetFreeSpace + * + * Convert a raw tuple length into the amount of free space an insert should + * ASK the FSM for, applying the fillfactor reservation the way heap does. + * + * Heap does this in RelationGetBufferForTuple (hio.c), and the clamp it + * applies is not optional. Adding the fillfactor reservation unconditionally + * can push the request past what the FSM can represent (MaxFSMRequestSize == + * MaxHeapTupleSize); fsm_space_needed_to_cat() then rejects it outright with + * "invalid FSM request size", which surfaces as a hard ERROR on a perfectly + * legal INSERT. FLUX hit exactly this: with fillfactor=10 the reservation is + * 7428 bytes, so a 1000-byte row asked for 8428 and the INSERT failed where + * heap accepted it (regression tests insert, brin, brin_bloom). + * + * The clamp must be expressed in FLUX's OWN page geometry, not heap's. A FLUX + * page reserves special space (FluxPageOpaqueData) that a heap page does not, + * so the largest tuple that can ever live on a FLUX page is + * FLUX_MAX_TUPLE_SIZE, which is strictly smaller than MaxHeapTupleSize. Using + * heap's bound here asks for more free space than any FLUX page can offer, the + * FSM can never satisfy it, and the insert path's "retry until we find a + * suitable page" loop spins forever -- an INSERT that HANGS instead of erroring. + * Hence every quantity below is FLUX-native. + * + * Rule, mirroring heap: a nearly-empty page is good enough for a tuple that is + * oversize relative to fillfactor, so once the reservation would push us past + * nearlyEmptyFreeSpace we ask only for what the tuple itself needs (but at + * least nearlyEmptyFreeSpace, so we don't settle for a page with a few spare + * bytes). That keeps the request satisfiable AND inside the FSM's range, + * while preserving "don't extend the relation needlessly for a big row in a + * low-fillfactor table". + * + * The result is only a hint: every caller re-checks the actual free space under + * its own buffer lock and falls back to extending the relation. + */ +Size +FluxTargetFreeSpace(Relation rel, Size len) +{ + Size saveFreeSpace; + Size nearlyEmptyFreeSpace; + + saveFreeSpace = RelationGetTargetPageFreeSpace(rel, FLUX_DEFAULT_FILLFACTOR); + + /* + * Pages with no live tuples can still carry line pointers, so allow a + * small slack before calling a page "nearly empty" (heap's reasoning, in + * FLUX's geometry). + */ + nearlyEmptyFreeSpace = FLUX_MAX_TUPLE_SIZE - + (FLUX_MAX_TUPLE_SIZE / BLCKSZ + 32 * sizeof(ItemIdData)); + + if (len + saveFreeSpace > nearlyEmptyFreeSpace) + { + /* + * Never ask for more than a FLUX page can physically hold, or the FSM + * can never answer and the caller's retry loop never terminates. + */ + Size want = Max(len, nearlyEmptyFreeSpace); + + return Min(want, (Size) FLUX_MAX_TUPLE_SIZE); + } + + return len + saveFreeSpace; +} + +BlockNumber +FluxGetPageWithFreeSpace(Relation rel, Size needed) +{ + BlockNumber target_block; + Buffer buffer; + Page page; + Size free_space; + + /* + * Ask the FSM for a page with enough free space. Note: we do NOT verify + * the page by locking it here, because callers may already hold buffer + * locks (e.g., the update path holds the old tuple's page lock). Locking + * a page here would risk self-deadlock if the FSM returns a block that + * the caller already has locked. Callers are responsible for rechecking + * free space after they acquire their own lock on the returned page. + */ + target_block = GetPageWithFreeSpace(rel, needed); + + if (target_block != InvalidBlockNumber) + return target_block; + + /* + * No suitable page found -- extend the relation. + * + * Use the modern ExtendBufferedRel() API which properly handles + * concurrent extension by multiple backends. The old + * ReadBufferExtended(P_NEW) path had a race condition that caused + * BM_IO_IN_PROGRESS assertion failures under concurrency. + */ + buffer = ExtendBufferedRel(BMR_REL(rel), MAIN_FORKNUM, NULL, + EB_LOCK_FIRST); + target_block = BufferGetBlockNumber(buffer); + + page = BufferGetPage(buffer); + FluxInitPage(page, BufferGetPageSize(buffer)); + + START_CRIT_SECTION(); + + MarkBufferDirty(buffer); + + /* Log page initialization */ + if (RelationNeedsWAL(rel)) + { + uint64 init_commit_ts = FluxGetCommitTimestamp(); + FluxPageOpaque phdr; + XLogRecPtr recptr; + + /* + * Set the page's opaque data to match what the REDO handler will + * produce. This is essential for WAL consistency checking: the page + * image stored with the WAL record must match what REDO generates + * when replaying. + */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, init_commit_ts); + + recptr = FluxXLogInitPage(rel, buffer, 0, init_commit_ts); + + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + + /* Capture free space before releasing the buffer */ + free_space = PageGetFreeSpace(page); + + UnlockReleaseBuffer(buffer); + + /* Record the new page in FSM */ + FluxRecordFreeSpace(rel, target_block, free_space); + + /* + * Propagate the new FSM leaf value up through the FSM tree so that + * subsequent GetPageWithFreeSpace() calls can find it. Without this, the + * root of the FSM tree remains at zero and all searches fail. + */ + FreeSpaceMapVacuumRange(rel, target_block, target_block + 1); + + return target_block; +} + +/* + * FluxRecordFreeSpace + * + * Update the FSM with the actual free space for a page. + * + * Parameters: + * rel - open relation + * page - block number of the page + * freespace - actual free space in bytes on the page + */ +void +FluxRecordFreeSpace(Relation rel, BlockNumber page, Size freespace) +{ + RecordPageWithFreeSpace(rel, page, freespace); +} + +/* + * FluxVacuumFSM + * + * Update the FSM after a relation truncation. If the new block count is + * smaller than the old count, calls FreeSpaceMapPrepareTruncateRel() to + * remove FSM entries for the truncated pages. + * + * Parameters: + * rel - open relation + * new_nblocks - new number of blocks after truncation + */ +void +FluxVacuumFSM(Relation rel, BlockNumber new_nblocks) +{ + BlockNumber old_nblocks = RelationGetNumberOfBlocks(rel); + + if (new_nblocks < old_nblocks) + { + /* Truncated - update FSM */ + FreeSpaceMapPrepareTruncateRel(rel, new_nblocks); + } +} diff --git a/src/backend/access/flux/flux_handler.c b/src/backend/access/flux/flux_handler.c new file mode 100644 index 0000000000000..82f9523cad351 --- /dev/null +++ b/src/backend/access/flux/flux_handler.c @@ -0,0 +1,6809 @@ +/*------------------------------------------------------------------------- + * + * flux_handler.c + * FLUX table access method handler + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_handler.c + * + * NOTES + * This file implements the FLUX table access method, which provides + * time-based MVCC with in-place updates, overflow pages for large + * attributes, compression, and advanced space management. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/genam.h" +#include "access/heapam.h" +#include "access/heaptoast.h" +#include "access/detoast.h" +#include "access/flux.h" +#include "access/flux_pbu.h" +#include "access/flux_undo.h" +#include "access/slog.h" +#include "access/flux_xlog.h" +#include "access/tableam.h" +#include "access/tableam_indexscan.h" +#include "access/tsmapi.h" +#include "access/multixact.h" +#include "access/xact.h" +#include "access/xloginsert.h" +#include "catalog/index.h" +#include "catalog/pg_am.h" +#include "catalog/pg_index.h" +#include "catalog/storage.h" +#include "catalog/storage_xlog.h" +#include "commands/progress.h" +#include "executor/executor.h" +#include "nodes/execnodes.h" +#include "nodes/tidbitmap.h" +#include "utils/backend_progress.h" +#include "utils/builtins.h" +#include "utils/snapmgr.h" +#include "utils/timestamp.h" +#include "utils/tuplesort.h" +#include "miscadmin.h" +#include "storage/bufmgr.h" +#include "storage/lmgr.h" +#include "storage/predicate.h" +#include "storage/procarray.h" +#include "storage/read_stream.h" +#include "storage/smgr.h" +#include "utils/datum.h" +#include "utils/memutils.h" +#include "utils/rel.h" + +/* + * A FLUX bitmap index scan feeds FLUX TIDs into tbm_add_tuples(), whose + * per-page bitmaps hold TBM_MAX_TUPLES_PER_PAGE offsets. + */ +StaticAssertDecl(MaxFluxItemsPerPage <= TBM_MAX_TUPLES_PER_PAGE, + "TBM_MAX_TUPLES_PER_PAGE must cover MaxFluxItemsPerPage"); + +/* Forward declarations */ +static bool flux_index_fetch_tuple(IndexScanFluxData *scan, + ItemPointer tid, + Snapshot snapshot, + TupleTableSlot *tts, + bool *call_again, bool *all_dead); + +static void flux_prepare_pagescan(FluxScanDesc scan, Buffer buffer); +static BlockNumber flux_scan_stream_read_next(ReadStream *stream, + void *callback_private_data, + void *per_buffer_data); +static BlockNumber flux_bitmap_stream_read_next(ReadStream *stream, + void *callback_private_data, + void *per_buffer_data); +static bool flux_scan_analyze_next_block(TableScanDesc scan, ReadStream *stream); +static bool flux_scan_analyze_next_tuple(TableScanDesc scan, + double *liverows, double *deadrows, + TupleTableSlot *slot); +static void flux_scan_set_tidrange(TableScanDesc sscan, ItemPointer mintid, + ItemPointer maxtid); +static bool flux_scan_getnextslot_tidrange(TableScanDesc sscan, ScanDirection direction, + TupleTableSlot *slot); +static bool flux_scan_bitmap_next_tuple(TableScanDesc scan, + TupleTableSlot *slot, + bool *recheck, + uint64 *lossy_pages, + uint64 *exact_pages); + +/* Include operations from other modules */ +extern void flux_tuple_insert(Relation relation, TupleTableSlot *slot, CommandId cid, + uint32 options, BulkInsertState bistate); +extern TM_Result flux_tuple_delete(Relation relation, ItemPointer tid, CommandId cid, + uint32 options, Snapshot snapshot, Snapshot crosscheck, + bool wait, TM_FailureData *tmfd); +extern TM_Result flux_tuple_update(Relation relation, ItemPointer otid, TupleTableSlot *slot, + CommandId cid, uint32 options, + Snapshot snapshot, Snapshot crosscheck, + bool wait, TM_FailureData *tmfd, + LockTupleMode *lockmode, TU_UpdateIndexes *update_indexes); +extern void flux_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, + CommandId cid, uint32 options, BulkInsertState bistate); +extern void flux_relation_vacuum(Relation onerel, const VacuumParams *params, + BufferAccessStrategy bstrategy); + +/* + * Read stream callback for sequential scan prefetching. + * + * Returns the next block number to read ahead for the sequential scan. + * The read_stream infrastructure will prefetch these blocks asynchronously, + * reducing I/O wait time for cold data. + * + * Uses rs_prefetch_block (separate from rs_cblock) to track the prefetch + * position independently of the scan's current position. + */ +static BlockNumber +flux_scan_stream_read_next(ReadStream *stream, + void *callback_private_data, + void *per_buffer_data) +{ + FluxScanDesc scan = (FluxScanDesc) callback_private_data; + BlockNumber block; + + block = scan->rs_prefetch_block; + if (block < scan->rs_startblock || block >= scan->rs_endblock) + return InvalidBlockNumber; + + scan->rs_prefetch_block = block + scan->rs_dir; + return block; +} + +/* + * Read stream callback for bitmap heap scans. + * + * Pulls the next block from the TBM iterator and hands it to the read + * stream so upcoming bitmap pages are prefetched asynchronously, matching + * the HEAP bitmapheap_stream_read_next() behaviour. The TBMIterateResult + * for each block is stashed in per_buffer_data so the consumer can read + * lossy/recheck flags and the exact tuple offsets without re-iterating. + */ +static BlockNumber +flux_bitmap_stream_read_next(ReadStream *stream, + void *callback_private_data, + void *per_buffer_data) +{ + FluxScanDesc scan = (FluxScanDesc) callback_private_data; + TableScanDesc sscan = &scan->rs_base; + TBMIterateResult *tbmres = per_buffer_data; + + for (;;) + { + CHECK_FOR_INTERRUPTS(); + + /* no more entries in the bitmap */ + if (!tbm_iterate(&sscan->st.rs_tbmiterator, tbmres)) + return InvalidBlockNumber; + + /* + * Ignore any claimed entries past what we think is the end of the + * relation. It may have been extended after the start of our scan. + * Skip this optimization under SERIALIZABLE, where all + * index-reachable tuples must be examined for conflict detection. + */ + if (!IsolationIsSerializable() && + tbmres->blockno >= scan->rs_nblocks) + continue; + + return tbmres->blockno; + } + + Assert(false); + return InvalidBlockNumber; +} + +/* + * ------------------------------------------------------------------------ + * Slot related callbacks for FLUX AM + * ------------------------------------------------------------------------ + */ + +/* + * Return slot implementation suitable for storing FLUX tuples + */ +static const TupleTableSlotOps * +flux_slot_callbacks(Relation relation) +{ + (void) relation; + return &TTSOpsFluxTuple; +} + +/* + * ------------------------------------------------------------------------ + * Table scan callbacks for FLUX AM + * ------------------------------------------------------------------------ + */ + +/* + * Start a scan of the FLUX relation + */ +static TableScanDesc +flux_scan_begin(Relation relation, Snapshot snapshot, + int nkeys, ScanKey key, + ParallelTableScanDesc pscan, + uint32 flags) +{ + FluxScanDesc scan; + + + scan = (FluxScanDesc) palloc0(sizeof(FluxScanDescData)); + + scan->rs_base.rs_rd = relation; + scan->rs_base.rs_snapshot = snapshot; + scan->rs_base.rs_nkeys = nkeys; + scan->rs_base.rs_key = key; + scan->rs_base.rs_flags = flags; + scan->rs_base.rs_parallel = pscan; + + scan->rs_cbuf = InvalidBuffer; + scan->rs_cblock = InvalidBlockNumber; + scan->rs_nblocks = RelationGetNumberOfBlocks(relation); + scan->rs_startblock = 0; + scan->rs_endblock = scan->rs_nblocks; + scan->rs_dir = ForwardScanDirection; + scan->rs_coffset = FirstOffsetNumber; + scan->rs_cindex = InvalidOffsetNumber; + scan->rs_inited = false; + scan->rs_ntuples = 0; + scan->rs_vistuples = NULL; + scan->rs_vm_buffer = InvalidBuffer; + scan->rs_vm_blockno = InvalidBlockNumber; + scan->rs_keep_deleted = (flags & SO_RETURN_DELETED) != 0; + + /* + * SSI: a seqscan (or samplescan) reads the whole relation, so under + * SERIALIZABLE it must take a relation-level SIREAD predicate lock, + * exactly as heap_beginscan does. Per-tuple PredicateLockTID() in the + * page path only covers tuples that are actually returned, so without + * this a seqscan-based read left no trace for a concurrent writer to + * conflict against and FLUX silently missed write-skew anomalies that + * heap catches ("could not serialize access due to read/write + * dependencies among transactions"). + * + * Predicate locking is a no-op outside SERIALIZABLE; the Assert mirrors + * heap's, so a missing snapshot is still caught in every isolation level. + */ + if (scan->rs_base.rs_flags & (SO_TYPE_SEQSCAN | SO_TYPE_SAMPLESCAN)) + { + Assert(snapshot); + PredicateLockRelation(relation, snapshot); + } + + /* Count a sequential scan, as heap's initscan does. */ + if (scan->rs_base.rs_flags & SO_TYPE_SEQSCAN) + pgstat_count_heap_scan(relation); + + /* Allocate parallel scan worker data if doing a parallel scan */ + if (pscan != NULL) + scan->rs_parallelworkerdata = palloc_object(ParallelBlockTableScanWorkerData); + else + scan->rs_parallelworkerdata = NULL; + + /* Set up MVCC bookkeeping timestamps (visibility uses xmin/xmax + CLOG) */ + scan->rs_snapshot_ts = GetCurrentTimestamp(); + scan->rs_xact_ts = GetCurrentTimestamp(); + + /* + * Initialize read stream for sequential prefetching (non-parallel only). + * The read stream uses the kernel readahead and our callback to prefetch + * upcoming pages, reducing I/O wait time for cold sequential scans. + * Parallel scans use their own block coordination, so skip the stream. + */ + scan->rs_prefetch_block = 0; + if (flags & SO_TYPE_BITMAPSCAN) + { + /* + * Bitmap scans drive the stream from the TBM iterator. The iterator + * is attached by the executor after beginscan, so the callback only + * runs once read_stream_next_buffer() is first called. Per-buffer + * data carries the TBMIterateResult for each prefetched block. + */ + scan->rs_read_stream = read_stream_begin_relation(READ_STREAM_DEFAULT, + NULL, /* bstrategy */ + relation, + MAIN_FORKNUM, + flux_bitmap_stream_read_next, + scan, + sizeof(TBMIterateResult)); + } + else if (pscan == NULL && scan->rs_nblocks > 0) + { + scan->rs_read_stream = read_stream_begin_relation(READ_STREAM_SEQUENTIAL | + READ_STREAM_USE_BATCHING, + NULL, /* bstrategy */ + relation, + MAIN_FORKNUM, + flux_scan_stream_read_next, + scan, + 0); + } + else + { + scan->rs_read_stream = NULL; + } + + return (TableScanDesc) scan; +} + +/* + * End the scan and release resources + */ +static void +flux_scan_end(TableScanDesc sscan) +{ + FluxScanDesc scan = (FluxScanDesc) sscan; + + /* + * FLUX does not compress attributes, so there is no compression + * dictionary to retrain at ANALYZE scan end. + */ + + /* End read stream before releasing buffers */ + if (scan->rs_read_stream != NULL) + { + read_stream_end(scan->rs_read_stream); + scan->rs_read_stream = NULL; + } + + /* Release buffer if held */ + if (BufferIsValid(scan->rs_cbuf)) + { + ReleaseBuffer(scan->rs_cbuf); + scan->rs_cbuf = InvalidBuffer; + } + + /* Release cached visibility map buffer */ + if (BufferIsValid(scan->rs_vm_buffer)) + { + ReleaseBuffer(scan->rs_vm_buffer); + scan->rs_vm_buffer = InvalidBuffer; + } + + if (scan->rs_vistuples) + pfree(scan->rs_vistuples); + + if (scan->rs_parallelworkerdata != NULL) + pfree(scan->rs_parallelworkerdata); + + /* + * Unregister the snapshot if this scan owns it (SO_TEMP_SNAPSHOT). + * Without this, catalog scans and parallel worker scans leak snapshot + * references, causing "resource was not closed" warnings. + */ + if (scan->rs_base.rs_flags & SO_TEMP_SNAPSHOT) + UnregisterSnapshot(scan->rs_base.rs_snapshot); + + pfree(scan); +} + +/* + * Restart a relation scan + */ +static void +flux_scan_rescan(TableScanDesc sscan, ScanKey key, + bool set_params, bool allow_strat, + bool allow_sync, bool allow_pagemode) +{ + FluxScanDesc scan = (FluxScanDesc) sscan; + + /* Release current buffer */ + if (BufferIsValid(scan->rs_cbuf)) + { + ReleaseBuffer(scan->rs_cbuf); + scan->rs_cbuf = InvalidBuffer; + } + + /* Release cached VM buffer on rescan (relation may have changed) */ + if (BufferIsValid(scan->rs_vm_buffer)) + { + ReleaseBuffer(scan->rs_vm_buffer); + scan->rs_vm_buffer = InvalidBuffer; + scan->rs_vm_blockno = InvalidBlockNumber; + } + + /* Reset read stream for rescan */ + if (scan->rs_read_stream != NULL) + { + read_stream_reset(scan->rs_read_stream); + scan->rs_prefetch_block = 0; + } + + /* Reset scan position to start of relation */ + scan->rs_cblock = InvalidBlockNumber; + scan->rs_nblocks = RelationGetNumberOfBlocks(sscan->rs_rd); + scan->rs_startblock = 0; + scan->rs_endblock = scan->rs_nblocks; + scan->rs_dir = ForwardScanDirection; + scan->rs_cindex = 0; + scan->rs_coffset = FirstOffsetNumber; + scan->rs_inited = false; + scan->rs_ntuples = 0; + + /* Update scan key if provided */ + scan->rs_base.rs_nkeys = key ? scan->rs_base.rs_nkeys : 0; + scan->rs_base.rs_key = key; + + /* A rescan is another sequential scan, as in heap's initscan. */ + if (sscan->rs_flags & SO_TYPE_SEQSCAN) + pgstat_count_heap_scan(sscan->rs_rd); +} + +/* + * Get the next block to scan. + * + * For serial scans, simply advances sequentially. For parallel scans, + * coordinates with other workers via table_block_parallelscan_nextpage() + * so that each block is scanned by exactly one worker. + * + * Returns the next block number, or InvalidBlockNumber when finished. + */ +static BlockNumber +flux_scan_nextblock(FluxScanDesc scan) +{ + BlockNumber nblocks = scan->rs_nblocks; + + if (nblocks == 0) + return InvalidBlockNumber; + + if (scan->rs_base.rs_parallel != NULL) + { + ParallelBlockTableScanDesc pbscan = + (ParallelBlockTableScanDesc) scan->rs_base.rs_parallel; + + /* Initialize parallel worker state on first call */ + if (!scan->rs_inited) + { + table_block_parallelscan_startblock_init(scan->rs_base.rs_rd, + scan->rs_parallelworkerdata, + pbscan, + scan->rs_startblock, + InvalidBlockNumber); + scan->rs_inited = true; + } + + return table_block_parallelscan_nextpage(scan->rs_base.rs_rd, + scan->rs_parallelworkerdata, + pbscan); + } + else + { + /* Serial scan: advance sequentially within the scan's bounds */ + if (scan->rs_cblock == InvalidBlockNumber) + return scan->rs_startblock < scan->rs_endblock ? + scan->rs_startblock : InvalidBlockNumber; + + if (scan->rs_cblock + 1 >= scan->rs_endblock) + return InvalidBlockNumber; + + return scan->rs_cblock + 1; + } +} + +/* + * SSI bookkeeping for a read that examined the on-page image hdr of the row + * at tid, as heap does for each tuple it examines: a conflict out to the + * image's writer or remover (FluxCheckForSerializableConflictOut), and a + * SIREAD lock on the TID if the read serves a version of this row, the page + * image (visible) or one rebuilt from UNDO. The UNDO walk reports the + * writers it steps over itself (FluxReconstructVisibleVersion). No-op + * outside SERIALIZABLE. + */ +static void +flux_ssi_read(Relation rel, ItemPointer tid, FluxTupleHeader *hdr, + Buffer buffer, Snapshot snapshot, bool visible, bool served) +{ + if (snapshot == NULL || !IsolationIsSerializable()) + return; + FluxCheckForSerializableConflictOut(visible, rel, hdr, buffer, snapshot); + if (served) + PredicateLockTID(rel, tid, snapshot, + visible ? FluxTupleGetXmin(hdr) : InvalidTransactionId); +} + +/* + * flux_prepare_pagescan -- collect visible tuple offsets for page-mode scan + * + * Called once per page. Locks share-exclusive, checks visibility for all + * tuples, collects the candidate offsets into scan->rs_vistuples[], then + * unlocks. The buffer remains pinned via scan->rs_cbuf. + * + * This is the FLUX equivalent of heapgetpage(), with one difference: the + * offsets are only candidates. FLUX updates in place, so + * flux_scan_getnextslot re-locks the page for each candidate, decides + * visibility again and copies the tuple (see FluxSlotStoreTuple). + * + * The visibility map optimization (1D) is integrated here: if the page is + * marked all-visible, per-tuple visibility checks are skipped entirely. + */ +static void +flux_prepare_pagescan(FluxScanDesc scan, Buffer buffer) +{ + Page page; + OffsetNumber maxoff; + OffsetNumber offnum; + int ntup = 0; + bool all_visible; + + /* Share-exclusive: excludes the in-place byte-CAS UPDATE, see below. */ + LockBuffer(buffer, BUFFER_LOCK_SHARE_EXCLUSIVE); + page = BufferGetPage(buffer); + + /* Skip new/empty pages */ + if (PageIsNew(page)) + { + scan->rs_ntuples = 0; + scan->rs_cindex = 0; + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + return; + } + + maxoff = PageGetMaxOffsetNumber(page); + + /* Allocate vistuples array if needed (shared with bitmap scan path) */ + if (scan->rs_vistuples == NULL) + { + scan->rs_vistuples = (OffsetNumber *) + MemoryContextAlloc(TopMemoryContext, + MaxOffsetNumber * sizeof(OffsetNumber)); + } + + /* + * Check visibility: first check the in-page PD_ALL_VISIBLE flag (zero + * cost since the page is already pinned and locked). Only fall through + * to the VM fork if the in-page flag is not set. Use the cached VM + * buffer to avoid per-page ReadBufferExtended overhead. + */ + if (PageIsAllVisible(page)) + all_visible = true; + else + all_visible = FluxVMCheckCached(scan->rs_base.rs_rd, scan->rs_cblock, + FLUX_VM_ALL_VISIBLE, + &scan->rs_vm_buffer, + &scan->rs_vm_blockno); + + for (offnum = FirstOffsetNumber; offnum <= maxoff; + offnum = OffsetNumberNext(offnum)) + { + ItemId itemid; + FluxTupleHeader *tuple_header; + ItemPointerData item_tid; + + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + continue; + + tuple_header = (FluxTupleHeader *) PageGetItem(page, itemid); + ItemPointerSet(&item_tid, BufferGetBlockNumber(buffer), offnum); + + /* Skip overflow records - they are not tuples */ + if (FluxIsOverflowRecordInline(tuple_header, ItemIdGetLength(itemid))) + continue; + + /* Dirty scans must also consider pending inserts and deletes. */ + if (scan->rs_base.rs_snapshot != NULL && + scan->rs_base.rs_snapshot->snapshot_type == SNAPSHOT_DIRTY) + { + scan->rs_vistuples[ntup++] = offnum; + continue; + } + + /* Skip speculative tuples not yet confirmed */ + if (tuple_header->t_flags & FLUX_TUPLE_SPECULATIVE) + continue; + + /* + * Check MVCC visibility. If the page is marked all-visible in the + * visibility map, skip the expensive per-tuple check. Otherwise, + * consult the commit timestamp and the sLog for in-progress + * transaction state. + * + * NOTE: Do NOT skip FLUX_TUPLE_DELETED tuples here. The DELETED flag + * is set physically at DELETE time, but the delete may be in-progress + * or aborted. The visibility function correctly consults the sLog to + * determine actual delete status. + */ + if (!all_visible && + scan->rs_base.rs_snapshot && + !FluxTupleVisibleToSnapshotDual(tuple_header, + scan->rs_base.rs_snapshot, + RelationGetRelid(scan->rs_base.rs_rd), + buffer, &item_tid)) + { + if (scan->rs_keep_deleted && + !IsMVCCSnapshot(scan->rs_base.rs_snapshot) && + (tuple_header->t_flags & FLUX_TUPLE_DELETED)) + { + scan->rs_vistuples[ntup++] = offnum; + continue; + } + + /* + * The on-page (newest) version is not visible to our snapshot. If + * this tuple was updated in place and still has a version chain + * in the UNDO log, an OLDER version may be visible -- keep it as + * a candidate so flux_scan_getnextslot can reconstruct and serve + * the before-image. Only truly-invisible tuples (no history, or + * history exhausted) are dropped here. + */ + if ((tuple_header->t_flags & FLUX_TUPLE_UPDATED) && + IsMVCCSnapshot(scan->rs_base.rs_snapshot) && + UndoRecPtrIsValid(FluxTupleGetVersionPtr(tuple_header))) + { + /* + * We will serve an OLDER version of this row, so from SSI's + * point of view we are reading a version that the concurrent + * in-place updater is superseding -- heap's "visible + * RECENTLY_DEAD / DELETE_IN_PROGRESS" case, where it reports + * the updater's xid as a conflict out. This branch used to + * `continue` straight past both the conflict-out check and + * the per-tuple SIREAD lock, so a reader that saw a + * before-image left no rw-dependency at all and write-skew + * anomalies heap rejects were silently accepted. + */ + ItemPointerSet(&item_tid, BufferGetBlockNumber(buffer), offnum); + flux_ssi_read(scan->rs_base.rs_rd, &item_tid, tuple_header, + buffer, scan->rs_base.rs_snapshot, false, true); + + scan->rs_vistuples[ntup++] = offnum; + continue; + } + + /* + * Tuple is not visible. If we're in a serializable transaction, + * check for rw-conflict out: a concurrent writer modified this + * tuple after our snapshot. + */ + ItemPointerSet(&item_tid, BufferGetBlockNumber(buffer), offnum); + flux_ssi_read(scan->rs_base.rs_rd, &item_tid, tuple_header, + buffer, scan->rs_base.rs_snapshot, false, false); + continue; + } + + /* + * Tuple is visible. Acquire SIREAD predicate lock for SSI so that + * concurrent writers can detect rw-antidependencies on this tuple. + * + * A VISIBLE tuple can still be a conflict-out source: if a concurrent + * transaction is deleting or updating it (visible to us, being + * written by them), we have read a version they are superseding, + * which is an rw-dependency they must know about. Heap covers this + * by calling HeapCheckForSerializableConflictOut() unconditionally, + * which reports the updater's xid in the visible RECENTLY_DEAD / + * DELETE_IN_PROGRESS cases. FLUX previously only reported conflicts + * for invisible tuples, so the reader never registered the incoming + * rw-conflict and a later writer was not recognised as a pivot -- + * silently accepting write-skew anomalies heap rejects. + */ + ItemPointerSet(&item_tid, BufferGetBlockNumber(buffer), offnum); + flux_ssi_read(scan->rs_base.rs_rd, &item_tid, tuple_header, buffer, + scan->rs_base.rs_snapshot, true, true); + + scan->rs_vistuples[ntup++] = offnum; + } + + scan->rs_ntuples = ntup; + scan->rs_cindex = 0; + + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); +} + +/* + * Reconstruct from UNDO the version of the row at tid that the latest + * snapshot sees: the newest one written by a committed transaction (or by + * our own), walking past those of running and aborted ones. onpage is a + * stable copy of the page version, of length len. Returns false if the walk + * served nothing older than onpage, see FluxReconstructVisibleVersion. + */ +static bool +flux_reconstruct_latest(Relation rel, ItemPointer tid, char *onpage, + Size len, char **bi_data, int *bi_len) +{ + SnapshotData latest = {0}; + bool found; + + /* + * A private snapshot: GetLatestSnapshot() would overwrite the static one + * a caller such as an RI crosscheck may still hold, and errors out in a + * parallel worker. + */ + latest.snapshot_type = SNAPSHOT_MVCC; + latest.xip = palloc(GetMaxSnapshotXidCount() * sizeof(TransactionId)); + latest.subxip = palloc(GetMaxSnapshotSubxidCount() * sizeof(TransactionId)); + GetSnapshotData(&latest); + found = FluxReconstructVisibleVersionEx(rel, tid, onpage, len, &latest, + bi_data, bi_len, NULL); + pfree(latest.xip); + pfree(latest.subxip); + return found; +} + +/* + * What flux_pending_version found. + */ +typedef enum FluxPendingVersion +{ + FLUX_PENDING_NONE, /* no aborted version: the page version stands */ + FLUX_PENDING_RECONSTRUCTED, /* slot holds the row the revert leaves */ + FLUX_PENDING_GONE, /* the revert leaves no row */ +} FluxPendingVersion; + +/* + * Is the row at offnum of buffer's page at a version whose writer aborted, + * with the revert still pending (FluxTupleHasAbortedVersion)? Readers that + * look at the page itself rather than through an MVCC snapshot (an index + * build with SnapshotAny, ANALYZE) must see the row as the revert will leave + * it, and must not revert it themselves: they hold too weak a table lock to + * run the revert's index reversal, a parallel worker cannot write, and the + * index being built is not complete yet. So reconstruct that row from UNDO: + * after an aborted UPDATE its last committed version, after an aborted + * DELETE the row as it was before, and nothing after an aborted INSERT + * (FLUX_PENDING_GONE). That reconstructed version is stored into slot + * (FLUX_PENDING_RECONSTRUCTED). The page is not changed. + * + * The caller holds buffer locked share-exclusive. So it is on return, but + * it may have been released meanwhile (the UNDO walk must not run under the + * content lock), so the caller must re-read the page. + */ +static FluxPendingVersion +flux_pending_version(Relation rel, Buffer buffer, OffsetNumber offnum, + TupleTableSlot *slot) +{ + Page page = BufferGetPage(buffer); + ItemPointerData tid; + + ItemPointerSet(&tid, BufferGetBlockNumber(buffer), offnum); + + for (;;) + { + ItemId itemid = PageGetItemId(page, offnum); + Size len; + char *onpage; + char *bi_data = NULL; + int bi_len = 0; + bool found; + + if (!ItemIdIsNormal(itemid) || + !FluxTupleHasAbortedVersion((FluxTupleHeader *) + PageGetItem(page, itemid))) + return FLUX_PENDING_NONE; + + /* Walk the chain from a stable copy, without the lock. */ + len = ItemIdGetLength(itemid); + onpage = palloc(len); + memcpy(onpage, PageGetItem(page, itemid), len); + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + found = flux_reconstruct_latest(rel, &tid, onpage, len, + &bi_data, &bi_len); + LockBuffer(buffer, BUFFER_LOCK_SHARE_EXCLUSIVE); + + /* + * The walk stops at the newest committed version. The one it serves + * has an aborted writer only if the chain broke off before that. + */ + if (found && + !FluxTupleHasAbortedVersion((FluxTupleHeader *) bi_data)) + { + FluxSlotStoreTuple(slot, (FluxTupleHeader *) bi_data, bi_len, + InvalidBuffer); + pfree(bi_data); + pfree(onpage); + slot->tts_tableOid = RelationGetRelid(rel); + slot->tts_tid = tid; + return FLUX_PENDING_RECONSTRUCTED; + } + if (found) + pfree(bi_data); + + /* + * If the revert ran meanwhile, and its UNDO was discarded after it, + * the walk may have broken off for that reason: decide again on what + * the page holds now. If not, the UNDO is still there, as discarding + * waits for the revert. Then a walk that found no committed version + * ended at the row's aborted INSERT, and one that did not end at all + * broke off on a damaged chain. + */ + itemid = PageGetItemId(page, offnum); + if (ItemIdIsNormal(itemid) && ItemIdGetLength(itemid) == len && + memcmp(PageGetItem(page, itemid), onpage, len) == 0) + { + pfree(onpage); + if (found) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg_internal("could not reconstruct the committed version of tuple (%u,%u) in relation \"%s\"", + ItemPointerGetBlockNumber(&tid), + offnum, RelationGetRelationName(rel)))); + return FLUX_PENDING_GONE; + } + pfree(onpage); + } +} + +/* + * The on-page version of the tuple at tid is not visible to snapshot. If it + * was updated in place and has a version chain, store into slot the older + * version the snapshot sees, reconstructed from UNDO, and return true. + * Called with buffer locked share-exclusive; returns with it unlocked if the + * tuple has history (the chain walk must not run under the content lock), + * locked otherwise. *unlocked says which. + */ +static bool +flux_store_visible_older(Relation rel, Buffer buffer, ItemPointer tid, + FluxTupleHeader *hdr, Size len, Snapshot snapshot, + TupleTableSlot *slot, bool *unlocked) +{ + char *onpage; + char *bi_data = NULL; + int bi_len = 0; + bool found; + + *unlocked = false; + if (!(hdr->t_flags & FLUX_TUPLE_UPDATED) || snapshot == NULL || + !IsMVCCSnapshot(snapshot) || + !UndoRecPtrIsValid(FluxTupleGetVersionPtr(hdr))) + return false; + + onpage = palloc(len); + memcpy(onpage, hdr, len); + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + *unlocked = true; + found = FluxReconstructVisibleVersion(rel, tid, onpage, len, snapshot, + &bi_data, &bi_len); + pfree(onpage); + if (!found) + return false; + FluxSlotStoreTuple(slot, (FluxTupleHeader *) bi_data, bi_len, InvalidBuffer); + pfree(bi_data); + slot->tts_tableOid = RelationGetRelid(rel); + slot->tts_tid = *tid; + return true; +} + +/* + * Get next tuple from scan (page-mode) + * + * Uses page-mode scanning: for each page, flux_prepare_pagescan() collects + * candidate tuple offsets. This function then takes each candidate under a + * short share-exclusive lock of the already pinned buffer: one ReadBuffer per + * page, one lock per returned tuple. + * + * For parallel scans, block assignment is coordinated via + * flux_scan_nextblock() which uses the parallel scan infrastructure. + */ +static bool +flux_scan_getnext(TableScanDesc sscan, ScanDirection direction, TupleTableSlot *slot) +{ + FluxScanDesc scan = (FluxScanDesc) sscan; + int index; + + Assert(!ScanDirectionIsNoMovement(direction)); + Assert(sscan->rs_parallel == NULL || ScanDirectionIsForward(direction)); + + /* Discard read-ahead from the old direction, not the current page. */ + if (scan->rs_read_stream != NULL && + (direction != scan->rs_dir || !BufferIsValid(scan->rs_cbuf))) + { + read_stream_reset(scan->rs_read_stream); + scan->rs_prefetch_block = BufferIsValid(scan->rs_cbuf) ? + scan->rs_cblock + direction : + (ScanDirectionIsForward(direction) ? scan->rs_startblock : + scan->rs_endblock - 1); + } + scan->rs_dir = direction; + index = scan->rs_cindex + direction; + + /* If relation is empty, return false immediately */ + if (scan->rs_startblock >= scan->rs_endblock) + { + ExecClearTuple(slot); + return false; + } + + for (;;) + { + Page page; + + /* + * Try to return the next candidate from the current page. Unlike + * heap's page mode, FLUX cannot serve tuples from the merely pinned + * page: an in-place UPDATE may have rewritten a candidate since + * flux_prepare_pagescan looked at it. So re-lock (share-exclusive, + * which excludes the byte-CAS writer), decide visibility on the bytes + * as they are now and copy them, in one lock hold. A tuple the + * prepare pass found invisible stays invisible to an MVCC snapshot: + * an UPDATE since then installs a version that snapshot cannot see, + * and the prepare pass already kept every row with history as a + * candidate. + */ + for (; index >= 0 && index < scan->rs_ntuples; index += direction) + { + OffsetNumber offnum; + ItemId itemid; + FluxTupleHeader *tuple_header; + ItemPointerData self_tid; + + offnum = scan->rs_vistuples[index]; + scan->rs_cindex = index; + ItemPointerSet(&self_tid, scan->rs_cblock, offnum); + + /* Use the same dirty visibility and xid outputs as index scans. */ + if (sscan->rs_snapshot != NULL && + sscan->rs_snapshot->snapshot_type == SNAPSHOT_DIRTY) + { + IndexScanFluxData fscan; + ItemPointerData tid; + bool call_again = false; + bool found; + + fscan.rel = sscan->rs_rd; + fscan.buffer = InvalidBuffer; + fscan.keyless = false; + fscan.keep_deleted = false; + ItemPointerSet(&tid, scan->rs_cblock, offnum); + found = flux_index_fetch_tuple(&fscan, &tid, + sscan->rs_snapshot, slot, + &call_again, NULL); + if (BufferIsValid(fscan.buffer)) + ReleaseBuffer(fscan.buffer); + if (found) + return true; + continue; + } + + LockBuffer(scan->rs_cbuf, BUFFER_LOCK_SHARE_EXCLUSIVE); + page = BufferGetPage(scan->rs_cbuf); + itemid = PageGetItemId(page, offnum); + + if (!ItemIdIsNormal(itemid)) + { + LockBuffer(scan->rs_cbuf, BUFFER_LOCK_UNLOCK); + continue; + } + + tuple_header = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Before-image substitution for committed in-place UPDATEs. + * + * The on-page image is the NEWEST version, stamped with the + * updater's xmin. If that updater is not visible to our MVCC + * snapshot, we must serve the older version reconstructed from + * the UNDO log: FluxReconstructVisibleVersion walks the t_verptr + * chain and stops at the version whose producing xid is visible + * (XidInMVCCSnapshot). Visibility is decided purely by the xid + * snapshot + CLOG, matching FluxTupleSatisfiesMVCC. + * + * Determine on-page visibility once; only reconstruct when the + * newest version is NOT visible to us. When it IS visible, serve + * on-page bytes directly. + */ + if ((tuple_header->t_flags & FLUX_TUPLE_UPDATED) && + scan->rs_base.rs_snapshot != NULL && + IsMVCCSnapshot(scan->rs_base.rs_snapshot) && + UndoRecPtrIsValid(FluxTupleGetVersionPtr(tuple_header)) && + !FluxTupleVisibleToSnapshotDual(tuple_header, + scan->rs_base.rs_snapshot, + RelationGetRelid(scan->rs_base.rs_rd), + scan->rs_cbuf, &self_tid)) + { + char *bi_data = NULL; + int bi_len = 0; + ItemPointerData item_tid; + Size onpage_len = ItemIdGetLength(itemid); + char *onpage = palloc(onpage_len); + bool found; + + ItemPointerSet(&item_tid, scan->rs_cblock, offnum); + + /* Walk the chain from a stable copy, without the lock. */ + memcpy(onpage, tuple_header, onpage_len); + LockBuffer(scan->rs_cbuf, BUFFER_LOCK_UNLOCK); + found = FluxReconstructVisibleVersion(scan->rs_base.rs_rd, + &item_tid, onpage, + onpage_len, + scan->rs_base.rs_snapshot, + &bi_data, &bi_len); + pfree(onpage); + if (found) + { + FluxTupleHeader *bi_tuple = (FluxTupleHeader *) bi_data; + + FluxSlotStoreTuple(slot, bi_tuple, bi_len, InvalidBuffer); + pfree(bi_tuple); + ItemPointerSet(&slot->tts_tid, scan->rs_cblock, offnum); + return true; + } + + /* + * No visible older version: the row is genuinely invisible to + * this snapshot (e.g. it was inserted-then-updated all after + * our snapshot). Skip it. + */ + continue; + } + + /* + * Serve the on-page version if this snapshot sees it. The + * prepare pass said so, but the row may have been rewritten + * since. + */ + if (!(scan->rs_base.rs_snapshot != NULL && + IsMVCCSnapshot(scan->rs_base.rs_snapshot) && + !FluxTupleVisibleToSnapshotDual(tuple_header, + scan->rs_base.rs_snapshot, + RelationGetRelid(scan->rs_base.rs_rd), + scan->rs_cbuf, &self_tid))) + { + FluxSlotStoreTuple(slot, tuple_header, + ItemIdGetLength(itemid), scan->rs_cbuf); + LockBuffer(scan->rs_cbuf, BUFFER_LOCK_UNLOCK); + ItemPointerSet(&slot->tts_tid, scan->rs_cblock, offnum); + return true; + } + LockBuffer(scan->rs_cbuf, BUFFER_LOCK_UNLOCK); + } + + /* + * Exhausted all visible tuples on the current page. Release the + * buffer pin and advance to the next block. + */ + if (BufferIsValid(scan->rs_cbuf)) + { + ReleaseBuffer(scan->rs_cbuf); + scan->rs_cbuf = InvalidBuffer; + } + + /* + * Get the next page. Use the read stream if available (prefetches + * upcoming pages for I/O efficiency), otherwise fall back to + * ReadBuffer for parallel scans. + */ + if (scan->rs_read_stream != NULL) + { + scan->rs_cbuf = read_stream_next_buffer(scan->rs_read_stream, NULL); + if (!BufferIsValid(scan->rs_cbuf)) + { + scan->rs_cblock = InvalidBlockNumber; + scan->rs_ntuples = 0; + ExecClearTuple(slot); + return false; + } + scan->rs_cblock = BufferGetBlockNumber(scan->rs_cbuf); + } + else + { + BlockNumber block = flux_scan_nextblock(scan); + + if (!BlockNumberIsValid(block)) + { + scan->rs_cblock = InvalidBlockNumber; + scan->rs_ntuples = 0; + ExecClearTuple(slot); + return false; + } + + scan->rs_cblock = block; + scan->rs_cbuf = ReadBuffer(scan->rs_base.rs_rd, scan->rs_cblock); + } + + /* + * Opportunistic cleanup: try to prune dead tuples before scanning. + * FluxPagePruneOpt() expects only a pin (no lock) and will + * non-blockingly try to get an exclusive lock. + */ + FluxPagePruneOpt(scan->rs_base.rs_rd, scan->rs_cbuf); + + /* + * Prepare the page scan: collect candidate offsets into + * rs_vistuples[]. The buffer stays pinned; each candidate is + * re-locked when returned. + */ + flux_prepare_pagescan(scan, scan->rs_cbuf); + index = ScanDirectionIsForward(direction) ? 0 : scan->rs_ntuples - 1; + } +} + +/* + * Set TID range for TID range scans + * + * This restricts the scan to only return tuples within the given TID range. + * Used by TID range scans (WHERE ctid >= ... AND ctid < ...). + * + * Following the heap AM pattern, we store the effective min/max TIDs in the + * base scan descriptor's st.tidrange fields and configure the scan start + * position accordingly. + */ +static void +flux_scan_set_tidrange(TableScanDesc sscan, ItemPointer mintid, + ItemPointer maxtid) +{ + FluxScanDesc scan = (FluxScanDesc) sscan; + ItemPointerData highestItem; + ItemPointerData lowestItem; + + flux_scan_rescan(sscan, NULL, false, false, false, false); + scan->rs_endblock = 0; + ItemPointerSetInvalid(&sscan->st.tidrange.rs_mintid); + ItemPointerSetInvalid(&sscan->st.tidrange.rs_maxtid); + if (scan->rs_nblocks == 0) + return; + + ItemPointerSet(&highestItem, scan->rs_nblocks - 1, MaxOffsetNumber); + ItemPointerSet(&lowestItem, 0, FirstOffsetNumber); + if (ItemPointerCompare(maxtid, &highestItem) < 0) + ItemPointerCopy(maxtid, &highestItem); + if (ItemPointerCompare(mintid, &lowestItem) > 0) + ItemPointerCopy(mintid, &lowestItem); + if (ItemPointerCompare(&highestItem, &lowestItem) < 0) + return; + + scan->rs_startblock = ItemPointerGetBlockNumberNoCheck(&lowestItem); + scan->rs_endblock = ItemPointerGetBlockNumberNoCheck(&highestItem) + 1; + ItemPointerCopy(&lowestItem, &sscan->st.tidrange.rs_mintid); + ItemPointerCopy(&highestItem, &sscan->st.tidrange.rs_maxtid); +} + +/* + * Restrict a scan that has not started to numblocks blocks from startblock + * (InvalidBlockNumber: to the end of the relation), as heap_setscanlimits. + * BRIN summarizes one block range this way. + */ +static void +flux_scan_setlimits(TableScanDesc sscan, BlockNumber startblock, + BlockNumber numblocks) +{ + FluxScanDesc scan = (FluxScanDesc) sscan; + + Assert(!BufferIsValid(scan->rs_cbuf) && + scan->rs_cblock == InvalidBlockNumber); + + /* A parallel scan hands out its own blocks: whole relation only. */ + if (sscan->rs_parallel != NULL) + { + Assert(startblock == 0 && numblocks == InvalidBlockNumber); + return; + } + + scan->rs_startblock = startblock; + if (numblocks == InvalidBlockNumber || startblock >= scan->rs_nblocks || + numblocks >= scan->rs_nblocks - startblock) + scan->rs_endblock = scan->rs_nblocks; + else + scan->rs_endblock = startblock + numblocks; +} + +/* Count each returned row, as heap_getnextslot does. */ +static bool +flux_scan_getnextslot(TableScanDesc sscan, ScanDirection direction, TupleTableSlot *slot) +{ + if (!flux_scan_getnext(sscan, direction, slot)) + return false; + pgstat_count_heap_getnext(sscan->rs_rd); + return true; +} + +/* Use the same positioning and visibility rules as a sequential scan. */ +static bool +flux_scan_getnextslot_tidrange(TableScanDesc sscan, ScanDirection direction, + TupleTableSlot *slot) +{ + ItemPointer mintid = &sscan->st.tidrange.rs_mintid; + ItemPointer maxtid = &sscan->st.tidrange.rs_maxtid; + + /* A normalized exclusive bound can have offset zero. */ + if (((FluxScanDesc) sscan)->rs_endblock > 0) + { + while (flux_scan_getnext(sscan, direction, slot)) + { + if (ItemPointerCompare(&slot->tts_tid, mintid) >= 0 && + ItemPointerCompare(&slot->tts_tid, maxtid) <= 0) + { + /* Only rows in range, as heap_getnextslot_tidrange. */ + pgstat_count_heap_getnext(sscan->rs_rd); + return true; + } + } + } + ExecClearTuple(slot); + return false; +} + +/* + * Bitmap heap scan: fetch next tuple from bitmap + * + * This is the scan_bitmap_next_tuple callback. It iterates over TIDs from + * the TBM (TID bitmap) built by a BitmapIndexScan, fetches and checks + * visibility for each tuple, and returns visible ones in the slot. + * + * For each block indicated by the bitmap: + * - If the block is "lossy" (the bitmap lost per-tuple precision for this + * block), we check every tuple on the page. + * - If the block is "exact", we only check the specific offsets indicated. + * + * The scan descriptor was set up via table_beginscan_bm() and the TBM + * iterator is stored in scan->st.rs_tbmiterator. + */ +static bool +flux_scan_bitmap_next_tuple(TableScanDesc scan, + TupleTableSlot *slot, + bool *recheck, + uint64 *lossy_pages, + uint64 *exact_pages) +{ + FluxScanDesc rscan = (FluxScanDesc) scan; + + Assert(rscan->rs_read_stream); + + for (;;) + { + void *per_buffer_data; + TBMIterateResult *tbmres; + + /* + * If we have tuples remaining from a previously fetched page, try to + * return one. + */ + if (rscan->rs_ntuples > 0 && + rscan->rs_cindex < rscan->rs_ntuples) + { + OffsetNumber offnum; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + ItemPointerData self_tid; + + offnum = rscan->rs_vistuples[rscan->rs_cindex]; + rscan->rs_cindex++; + ItemPointerSet(&self_tid, rscan->rs_cblock, offnum); + + /* Re-read the page (we released the lock after visibility check) */ + if (!BufferIsValid(rscan->rs_cbuf)) + rscan->rs_cbuf = ReadBuffer(scan->rs_rd, rscan->rs_cblock); + + /* + * Excludes in-place writers while we decide and copy (see + * FluxSlotStoreTuple). The row may have been rewritten since the + * page was collected, so visibility is decided again below. + */ + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_SHARE_EXCLUSIVE); + page = BufferGetPage(rscan->rs_cbuf); + + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + continue; + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * NOTE: Do NOT skip FLUX_TUPLE_DELETED here. This tuple passed + * the visibility check during page scan preparation. The DELETED + * flag may be set but the delete could be in-progress or aborted. + */ + + /* + * FLUX updates in place keeping the same TID, so a changed + * indexed column leaves a stale (oldkey -> tid) secondary entry + * beside the new one. A bitmap scan keeps only TIDs and discards + * the index tuple, so it cannot compare the stored key against + * the live tuple. Force the executor to re-evaluate + * bitmapqualorig against the live tuple whenever we return a + * committed in-place UPDATE: an exact (non-lossy) bitmap would + * otherwise trust the stale index entry and return a row whose + * live key no longer matches the scan key. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) + *recheck = true; + + /* + * Before-image substitution for in-place UPDATEs, mirroring the + * sequential-scan and index-fetch paths. A snapshot that cannot + * see the updater's xmin must observe the before-image, not the + * on-page (new) data. Serve it from the UNDO log; + * FluxReconstructVisibleVersion walks the t_verptr chain and + * stops at the version whose producing xid is visible. Visibility + * is ordinary heap-shaped xmin/xmax + CLOG + snapshot + * (FluxTupleSatisfiesMVCC). + */ + if ((tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) && + scan->rs_snapshot != NULL && + IsMVCCSnapshot(scan->rs_snapshot) && + UndoRecPtrIsValid(FluxTupleGetVersionPtr(tuple_hdr)) && + !FluxTupleVisibleToSnapshotDual(tuple_hdr, scan->rs_snapshot, + RelationGetRelid(scan->rs_rd), + rscan->rs_cbuf, &self_tid)) + { + char *bi_data = NULL; + int bi_len = 0; + ItemPointerData item_tid; + + ItemPointerSet(&item_tid, rscan->rs_cblock, offnum); + + /* + * Unlike the other scan paths, this walks the chain with the + * share-exclusive lock still held (the UNDO reads take their + * own locks and release them before returning). + */ + if (FluxReconstructVisibleVersion( + scan->rs_rd, + &item_tid, + (const char *) tuple_hdr, + ItemIdGetLength(itemid), + scan->rs_snapshot, + &bi_data, &bi_len)) + { + FluxTupleHeader *bi_tuple = (FluxTupleHeader *) bi_data; + + FluxSlotStoreTuple(slot, bi_tuple, bi_len, InvalidBuffer); + pfree(bi_tuple); + slot->tts_tableOid = RelationGetRelid(scan->rs_rd); + ItemPointerSet(&slot->tts_tid, rscan->rs_cblock, offnum); + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + + return true; + } + + /* No visible older version: skip this tuple. */ + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + continue; + } + + /* Rewritten since collection into a version we cannot see? */ + if (scan->rs_snapshot != NULL && IsMVCCSnapshot(scan->rs_snapshot) && + !FluxTupleVisibleToSnapshotDual(tuple_hdr, scan->rs_snapshot, + RelationGetRelid(scan->rs_rd), + rscan->rs_cbuf, &self_tid)) + { + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + continue; + } + + FluxSlotStoreTuple(slot, tuple_hdr, + ItemIdGetLength(itemid), rscan->rs_cbuf); + slot->tts_tableOid = RelationGetRelid(scan->rs_rd); + ItemPointerSet(&slot->tts_tid, rscan->rs_cblock, offnum); + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + + return true; + } + + /* Release buffer from previous block */ + if (BufferIsValid(rscan->rs_cbuf)) + { + ReleaseBuffer(rscan->rs_cbuf); + rscan->rs_cbuf = InvalidBuffer; + } + + /* + * Advance to the next block in the bitmap. The read stream pulls + * blocks from the TBM iterator (via flux_bitmap_stream_read_next), + * prefetching upcoming bitmap pages, and hands back the matching + * TBMIterateResult in per_buffer_data. Out-of-range blocks were + * already filtered by the callback. + */ + rscan->rs_cbuf = read_stream_next_buffer(rscan->rs_read_stream, + &per_buffer_data); + + if (!BufferIsValid(rscan->rs_cbuf)) + return false; /* bitmap exhausted */ + + tbmres = per_buffer_data; + + Assert(BlockNumberIsValid(tbmres->blockno)); + Assert(BufferGetBlockNumber(rscan->rs_cbuf) == tbmres->blockno); + + *recheck = tbmres->recheck; + + rscan->rs_cblock = tbmres->blockno; + + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_SHARE_EXCLUSIVE); + { + Page page = BufferGetPage(rscan->rs_cbuf); + int ntup = 0; + OffsetNumber maxoff = PageGetMaxOffsetNumber(page); + + /* Allocate vistuples array if needed */ + if (rscan->rs_vistuples == NULL) + { + rscan->rs_vistuples = (OffsetNumber *) + MemoryContextAlloc(TopMemoryContext, + MaxOffsetNumber * sizeof(OffsetNumber)); + } + + if (!tbmres->lossy) + { + /* + * Exact page: only examine offsets listed in the bitmap. + */ + OffsetNumber offsets[TBM_MAX_TUPLES_PER_PAGE]; + int noffsets; + + noffsets = tbm_extract_page_tuple(tbmres, offsets, + TBM_MAX_TUPLES_PER_PAGE); + + for (int j = 0; j < noffsets; j++) + { + OffsetNumber offnum = offsets[j]; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + + if (offnum < FirstOffsetNumber || offnum > maxoff) + continue; + + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Skip overflow records */ + if (FluxIsOverflowRecordInline(tuple_hdr, ItemIdGetLength(itemid))) + continue; + + /* Skip speculative tuples */ + if (tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE) + continue; + + /* + * Check visibility. Keep an invisible-but-updated tuple + * that still has a version chain as a candidate so the + * tuple loop can reconstruct and serve the before-image. + */ + { + bool visible; + bool keep; + ItemPointerData item_tid; + + ItemPointerSet(&item_tid, rscan->rs_cblock, offnum); + visible = scan->rs_snapshot == NULL || + FluxTupleVisibleToSnapshotDual(tuple_hdr, scan->rs_snapshot, + RelationGetRelid(scan->rs_rd), + rscan->rs_cbuf, &item_tid); + keep = visible || + ((tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) && + IsMVCCSnapshot(scan->rs_snapshot) && + UndoRecPtrIsValid(FluxTupleGetVersionPtr(tuple_hdr))); + + /* as heap's bitmap scan, for each tuple examined */ + flux_ssi_read(scan->rs_rd, &item_tid, tuple_hdr, + rscan->rs_cbuf, scan->rs_snapshot, + visible, keep); + if (!keep) + continue; + } + + rscan->rs_vistuples[ntup++] = offnum; + } + + (*exact_pages)++; + } + else + { + /* + * Lossy page: examine every tuple on the page. tbmres->lossy + * is true here. + */ + OffsetNumber offnum; + + for (offnum = FirstOffsetNumber; offnum <= maxoff; + offnum = OffsetNumberNext(offnum)) + { + ItemId itemid; + FluxTupleHeader *tuple_hdr; + + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Skip overflow records */ + if (FluxIsOverflowRecordInline(tuple_hdr, ItemIdGetLength(itemid))) + continue; + + /* Skip speculative tuples */ + if (tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE) + continue; + + /* + * Check visibility. Keep an invisible-but-updated tuple + * that still has a version chain as a candidate so the + * tuple loop can reconstruct and serve the before-image. + */ + { + bool visible; + bool keep; + ItemPointerData item_tid; + + ItemPointerSet(&item_tid, rscan->rs_cblock, offnum); + visible = scan->rs_snapshot == NULL || + FluxTupleVisibleToSnapshotDual(tuple_hdr, scan->rs_snapshot, + RelationGetRelid(scan->rs_rd), + rscan->rs_cbuf, &item_tid); + keep = visible || + ((tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) && + IsMVCCSnapshot(scan->rs_snapshot) && + UndoRecPtrIsValid(FluxTupleGetVersionPtr(tuple_hdr))); + + /* as heap's bitmap scan, for each tuple examined */ + flux_ssi_read(scan->rs_rd, &item_tid, tuple_hdr, + rscan->rs_cbuf, scan->rs_snapshot, + visible, keep); + if (!keep) + continue; + } + + rscan->rs_vistuples[ntup++] = offnum; + } + + (*lossy_pages)++; + } + + rscan->rs_ntuples = ntup; + rscan->rs_cindex = 0; + } + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + + /* Loop back to return the first visible tuple from this block */ + } +} + +/* + * ------------------------------------------------------------------------ + * Index scan callbacks for FLUX AM + * ------------------------------------------------------------------------ + */ + +static bool flux_index_getnext_slot(IndexScanDesc scan, + ScanDirection direction, + TupleTableSlot *slot); + +/* + * Prepare table-AM state for a FLUX index scan (slot-based interface, added by + * upstream ddce1da5b1b). Allocates FLUX's private IndexScanFluxData into + * scan->xs_table_opaque and resolves the getnext_slot implementation. + * + * FLUX enables in-place delete-marking (am_index_delete_marking = true), so a + * plain index scan can reach a heap TID through an entry whose key no longer + * matches the visible version (a delete-marked tombstone carrying an OLD key, + * or a live NEW-key entry while an old-snapshot reader is served the + * reconstructed before-image). flux_index_getnext_slot rechecks the entry key + * against the visible tuple and skips on mismatch, so + * we ask the index AM to keep the index tuple (xs_want_itup) for the whole + * scan. The planner suppresses genuine index-only scans on a delete-marking + * table (RelationSupportsDeleteMarking in get_relation_info), so xs_want_itup + * here always means "plain scan that wants xs_itup for the recheck", never a + * true index-only scan. + */ +static void +flux_index_scan_begin(IndexScanDesc scan, uint32 flags) +{ + IndexScanFluxData *fscan = palloc0_object(IndexScanFluxData); + + fscan->rel = scan->heapRelation; + fscan->buffer = InvalidBuffer; + fscan->keep_deleted = (flags & SO_RETURN_DELETED) != 0; + + /* + * On a delete-marking table, ask the index AM to keep the index tuple + * (xs_itup) so flux_index_getnext_slot can recheck the key against the + * visible version. Use xs_want_itup_recheck, NOT xs_want_itup: this is a + * plain (buffer-slot) index scan, never a genuine index-only scan + * (planner-suppressed), and table_index_getnext_slot asserts that + * xs_want_itup implies a virtual slot. + */ + if (RelationSupportsDeleteMarking(scan->heapRelation)) + scan->xs_want_itup_recheck = true; + + scan->xs_getnext_slot = flux_index_getnext_slot; + scan->xs_table_opaque = fscan; +} + +static void +flux_index_scan_reset(IndexScanDesc scan) +{ + IndexScanFluxData *fscan = (IndexScanFluxData *) scan->xs_table_opaque; + + if (BufferIsValid(fscan->buffer)) + { + ReleaseBuffer(fscan->buffer); + fscan->buffer = InvalidBuffer; + } +} + +static void +flux_index_scan_end(IndexScanDesc scan) +{ + IndexScanFluxData *fscan = (IndexScanFluxData *) scan->xs_table_opaque; + + if (BufferIsValid(fscan->buffer)) + ReleaseBuffer(fscan->buffer); + + pfree(fscan); +} + +/* + * flux_committed_version + * The newest version of a row written by a transaction that committed + * (or by our own), reconstructed from UNDO; NULL if there is none (an + * INSERT that never committed) or the chain cannot be read. onpage is + * a stable copy of the on-page image; the caller holds no buffer lock. + * + * Existence checks (SnapshotDirty, SnapshotSelf, SnapshotNonVacuumable) need + * it when the newest change is not committed: an aborted transaction's + * change whose revert is still pending (deferred to an undo apply worker, or + * a crash loser), and, for the old key of an in-place key UPDATE, a writer + * that is still running. A fresh MVCC snapshot sees exactly the committed + * writers, and FluxReconstructVisibleVersion steps past the others. + */ +static FluxTupleHeader * +flux_committed_version(Relation rel, ItemPointer tid, const char *onpage, + Size len, Size *out_len) +{ + SnapshotData latest = {0}; + char *bi_data = NULL; + int bi_len = 0; + + /* Private: GetLatestSnapshot() would overwrite a caller's snapshot. */ + latest.snapshot_type = SNAPSHOT_MVCC; + latest.xip = palloc(GetMaxSnapshotXidCount() * sizeof(TransactionId)); + latest.subxip = palloc(GetMaxSnapshotSubxidCount() * + sizeof(TransactionId)); + GetSnapshotData(&latest); + latest.curcid = InvalidCommandId; /* all of our own work */ + if (!FluxReconstructVisibleVersionEx(rel, tid, onpage, len, &latest, + &bi_data, &bi_len, NULL)) + bi_data = NULL; + pfree(latest.xip); + pfree(latest.subxip); + *out_len = bi_len; + return (FluxTupleHeader *) bi_data; +} + +/* flux_committed_version of the tuple at tid, read from the page. */ +static FluxTupleHeader * +flux_read_committed_version(Relation rel, ItemPointer tid, Size *out_len) +{ + Buffer buffer = ReadBuffer(rel, ItemPointerGetBlockNumber(tid)); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + Page page; + char *onpage = NULL; + Size len = 0; + FluxTupleHeader *cv = NULL; + + LockBuffer(buffer, BUFFER_LOCK_SHARE_EXCLUSIVE); + page = BufferGetPage(buffer); + if (offnum >= FirstOffsetNumber && offnum <= PageGetMaxOffsetNumber(page) && + ItemIdIsNormal(PageGetItemId(page, offnum))) + { + ItemId lp = PageGetItemId(page, offnum); + + len = ItemIdGetLength(lp); + onpage = palloc(len); + memcpy(onpage, PageGetItem(page, lp), len); + } + UnlockReleaseBuffer(buffer); + if (onpage != NULL) + { + cv = flux_committed_version(rel, tid, onpage, len, out_len); + pfree(onpage); + } + return cv; +} + +/* + * fetch_tid: TID-based visibility check for constraint enforcement code + * (_bt_check_unique, unique_key_recheck), the special-purpose table-AM entry + * point upstream ddce1da5b1b split out of the index-scan machinery. These + * callers pass a TID taken from an index but do not run an index scan, so they + * need no scan state -- just "is any version reachable through this TID visible + * to `snapshot`?". FLUX has no HOT chains, so this is a single fetch through + * the same visibility logic the index scan uses. + */ +static bool +flux_fetch_tid(Relation rel, ItemPointer tid, Snapshot snapshot, + bool *all_dead) +{ + IndexScanFluxData fscan; + TupleTableSlot *slot; + bool call_again = false; + bool found; + + fscan.rel = rel; + fscan.buffer = InvalidBuffer; + fscan.keyless = true; + fscan.keep_deleted = false; + + slot = table_slot_create(rel, NULL); + found = flux_index_fetch_tuple(&fscan, tid, snapshot, slot, + &call_again, all_dead); + if (BufferIsValid(fscan.buffer)) + ReleaseBuffer(fscan.buffer); + ExecDropSingleTupleTableSlot(slot); + + return found; +} + +/* + * Recheck a delete-marking index entry's key against the tuple just fetched + * into `slot` for the scan's snapshot. + * + * On a delete-marking (in-place UPDATE) table an index scan can reach a heap + * TID through an entry whose key differs from the VISIBLE version's key: + * - a delete-marked (tombstone) entry carries the OLD key while a new + * snapshot reader sees the NEW version; and + * - a LIVE (new-key) entry, while an OLD-snapshot reader is served the + * reconstructed before-image (OLD key) by the table AM. + * Returning the tuple via such an entry would be wrong (a duplicate for a + * range scan, or a phantom key for an old reader). Re-form the visible + * tuple's key for this index and compare to the entry (scan->xs_itup); + * returns true on mismatch (caller must skip this entry). The matching + * version is always reachable through the entry whose key equals the visible + * key. Moved here from indexam.c index_fetch_heap() when upstream ddce1da5b1b + * put the whole index-scan heap-fetch loop under the table AM. + */ +static bool +flux_entry_key_mismatch(Relation irel, IndexTuple itup, TupleTableSlot *slot) +{ + IndexInfo *ii; + EState *estate; + ExprContext *econtext; + Datum vis_values[INDEX_MAX_KEYS]; + bool vis_isnull[INDEX_MAX_KEYS]; + Datum ent_values[INDEX_MAX_KEYS]; + bool ent_isnull[INDEX_MAX_KEYS]; + TupleDesc itupdesc = RelationGetDescr(irel); + bool mismatch = false; + int i; + + ii = BuildIndexInfo(irel); + estate = CreateExecutorState(); + econtext = GetPerTupleExprContext(estate); + + econtext->ecxt_scantuple = slot; + FormIndexDatum(ii, slot, estate, vis_values, vis_isnull); + index_deform_tuple(itup, itupdesc, ent_values, ent_isnull); + + for (i = 0; i < ii->ii_NumIndexKeyAttrs; i++) + { + Form_pg_attribute att = TupleDescAttr(itupdesc, i); + + if (vis_isnull[i] != ent_isnull[i]) + { + mismatch = true; + break; + } + if (vis_isnull[i]) + continue; + if (!datum_image_eq(vis_values[i], ent_values[i], + att->attbyval, att->attlen)) + { + mismatch = true; + break; + } + } + + FreeExecutorState(estate); + + return mismatch; +} + +static bool +flux_index_entry_key_mismatch(IndexScanDesc scan, TupleTableSlot *slot) +{ + return flux_entry_key_mismatch(scan->indexRelation, scan->xs_itup, slot); +} + +/* + * The version a dirty snapshot judged at tid (in slot) does not carry the + * entry's key. If its writer is still running (the snapshot reports an + * xmin or xmax to wait for), the entry may be the old key of an in-place + * key UPDATE in progress: heap would find the old version there, live until + * the updater commits, and make the caller wait. So do the same when the + * committed version carries the entry's key: store it in slot, keep the + * snapshot's xid, and return true. + */ +static bool +flux_dirty_old_key_match(Relation rel, Relation irel, IndexTuple itup, + ItemPointer tid, Snapshot snapshot, + TupleTableSlot *slot) +{ + FluxTupleHeader *cv; + Size cv_len; + + if (snapshot == NULL || snapshot->snapshot_type != SNAPSHOT_DIRTY || + (!TransactionIdIsValid(snapshot->xmin) && + !TransactionIdIsValid(snapshot->xmax))) + return false; + cv = flux_read_committed_version(rel, tid, &cv_len); + if (cv == NULL) + return false; + FluxSlotStoreTuple(slot, cv, cv_len, InvalidBuffer); + pfree(cv); + slot->tts_tableOid = RelationGetRelid(rel); + slot->tts_tid = *tid; + if (flux_entry_key_mismatch(irel, itup, slot)) + return false; + + /* + * The running writer is replacing this version, as heap reports for an + * old version: xmax, and no speculative token (the version is committed, + * so its writer is not a speculative inserter). + */ + if (TransactionIdIsValid(snapshot->xmin)) + { + snapshot->xmax = snapshot->xmin; + snapshot->xmin = InvalidTransactionId; + snapshot->speculativeToken = 0; + } + return true; +} + +/* + * index_fetch_tid: table_index_fetch_tid() for FLUX, for _bt_check_unique. + * + * An in-place key UPDATE leaves the row at its TID under two entries, the + * delete-marked old key and the live new one, so whether the entry's key is + * taken depends on which version carries it. Judge the version the snapshot + * finds (flux_index_fetch_tuple: the committed one if the newest change is + * an aborted transaction's with its revert pending), and count it only if it + * carries the entry's key: + * + * - updater running: the new-key entry matches the page image (xmin, the + * updater, is reported); the old-key entry matches the committed version + * (flux_dirty_old_key_match, the updater reported as xmax). The caller + * waits either way, then checks again. + * - updater aborted: the committed version carries the old key, which is + * taken; the new key is not. + * - updater committed: the page image carries the new key; the old key is + * free. *all_dead is flux_index_fetch_tuple's per-TID verdict, and the + * caller never kills a tombstone with it. + * - a speculative insertion in progress: its token, as heap reports it. + */ +static bool +flux_index_fetch_tid(Relation rel, Relation irel, IndexTuple itup, + ItemPointer tid, Snapshot snapshot, bool *all_dead) +{ + IndexScanFluxData fscan; + TupleTableSlot *slot; + bool call_again = false; + bool found; + + fscan.rel = rel; + fscan.buffer = InvalidBuffer; + fscan.keyless = false; + fscan.keep_deleted = false; + + slot = table_slot_create(rel, NULL); + found = flux_index_fetch_tuple(&fscan, tid, snapshot, slot, + &call_again, all_dead); + if (BufferIsValid(fscan.buffer)) + ReleaseBuffer(fscan.buffer); + if (found && flux_entry_key_mismatch(irel, itup, slot)) + found = flux_dirty_old_key_match(rel, irel, itup, tid, snapshot, + slot); + ExecDropSingleTupleTableSlot(slot); + + return found; +} + +/* + * xs_getnext_slot callback for FLUX (slot-based table-AM index scan + * interface). This is the single entry point the executor reaches through + * table_index_getnext_slot(): it drives the index AM (amgettuple via + * tableam_index_getnext_tid) and, for each TID, fetches and visibility-checks + * the FLUX tuple into the caller's slot, applying the delete-marking key + * recheck. Mirrors heapam_index_getnext_slot's structure, minus HOT chains + * (FLUX does in-place, stable-TID updates and never sets call_again). + */ +static bool +flux_index_getnext_slot(IndexScanDesc scan, ScanDirection direction, + TupleTableSlot *slot) +{ + IndexScanFluxData *fscan = (IndexScanFluxData *) scan->xs_table_opaque; + + Assert(TransactionIdIsValid(RecentXmin)); + + for (;;) + { + bool call_again = false; + bool all_dead = false; + + /* Get the next TID from the index */ + if (!tableam_index_getnext_tid(scan, direction)) + return false; + + Assert(ItemPointerIsValid(&scan->xs_heaptid)); + + /* Fetch and visibility-check the FLUX tuple for this TID */ + if (!flux_index_fetch_tuple(fscan, &scan->xs_heaptid, + scan->xs_snapshot, slot, + &call_again, &all_dead)) + { + /* + * No visible tuple for this entry. Tell the index AM to kill its + * entry only when the TID is dead to all (never carry a sticky + * verdict), and never in recovery. + */ + if (all_dead && !scan->xactStartedInRecovery) + scan->kill_prior_tuple = true; + continue; /* try next index entry */ + } + + pgstat_count_heap_fetch(scan->indexRelation); + + /* delete-marking key recheck */ + if (scan->xs_itup != NULL && + RelationSupportsDeleteMarking(scan->heapRelation) && + flux_index_entry_key_mismatch(scan, slot) && + !flux_dirty_old_key_match(scan->heapRelation, + scan->indexRelation, scan->xs_itup, + &scan->xs_heaptid, scan->xs_snapshot, + slot)) + { + /* + * The entry's key does not describe the visible version. Do NOT + * let this count toward kill_prior_tuple (the entry is not dead + * -- an older snapshot legitimately needs it). Fetch the next + * TID. + */ + continue; + } + + return true; + } + + pg_unreachable(); + return false; +} + +/* + * HeapTupleSatisfiesDirty for a FLUX tuple: is the version live or being + * inserted, and who is still writing it? Reports an in-progress inserter in + * snapshot->xmin (with a speculative insert's token, which t_ctid holds as in + * heap) and an in-progress deleter in snapshot->xmax, for _bt_check_unique and + * ON CONFLICT. Everything is read from the header. The caller holds a lock + * on the buffer. + */ +static bool +flux_tuple_satisfies_dirty(FluxTupleHeader *tuple_hdr, Snapshot snapshot, + Buffer buffer) +{ + snapshot->xmin = InvalidTransactionId; + snapshot->xmax = InvalidTransactionId; + snapshot->speculativeToken = 0; + + /* + * A speculative insert that lost its arbiter race is killed by + * flux_tuple_complete_speculative(succeeded=false), which marks it + * DELETED with t_xmax == t_xmin and leaves it on the page. Such a tuple + * can never become live again (its inserter and deleter are the same xid, + * so they commit or roll back together), so it is dead to every snapshot + * -- the verdict heap_abort_speculative gets by zeroing xmin. Without + * this, the killer's own next arbiter pass finds the tuple, reports its + * own top xid as xmin, and XactLockTableWait spins forever on itself + * (TransactionIdIsInProgress(own xid) is always true); other backends + * would wait on a speculative token already released. + */ + if ((tuple_hdr->t_flags & FLUX_TUPLE_DELETED) && + TransactionIdIsValid(FluxTupleGetXmin(tuple_hdr)) && + TransactionIdEquals(FluxTupleGetXmin(tuple_hdr), + FluxTupleGetXmax(tuple_hdr))) + { + return false; + } + + /* + * Resolve the inserter (t_xmin) unless it is ALREADY known committed via + * the FLUX_TUPLE_XMIN_COMMITTED hint. + * + * We must NOT gate this on FLUX_TUPLE_UNCOMMITTED: that flag is a pure + * hint cleared at PRE_COMMIT (FluxClearUncommittedFlags), which runs + * BEFORE the CLOG write / ProcArrayEndTransaction. So an inserter that + * is still in progress -- past PRE_COMMIT but not yet committed -- has + * UNCOMMITTED already clear, and gating on it would skip resolution and + * wrongly treat the row as a committed conflict. In ON CONFLICT that + * surfaced as TM_Invisible -> slot_getsysattr on an empty slot -> Assert. + * FLUX_TUPLE_XMIN_COMMITTED, by contrast, is set ONLY from a CLOG read + * (below and in FluxSetHintBits), so it can never precede the actual + * commit: gating on its absence resolves every not-yet-committed inserter + * correctly. t_xmin is the inserter (heap xmin semantics). + */ + if (!(tuple_hdr->t_flags & FLUX_TUPLE_XMIN_COMMITTED)) + { + TransactionId hint_xid = FluxTupleGetXmin(tuple_hdr); + + if (TransactionIdIsValid(hint_xid) && + !TransactionIdIsCurrentTransactionId(hint_xid)) + { + if (TransactionIdIsInProgress(hint_xid)) + { + /* + * Another transaction is inserting this tuple. Report xmin + * for SpeculativeInsertionWait. + */ + snapshot->xmin = hint_xid; + + if (tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE) + snapshot->speculativeToken = + ItemPointerGetBlockNumber(&tuple_hdr->t_ctid); + + return true; + } + else if (!TransactionIdDidCommit(hint_xid)) + { + /* + * Inserter aborted or crashed -- invisible. Use !DidCommit + * rather than DidAbort: a crashed xid reports neither + * committed nor aborted, and with no INSERT undo record the + * aborted-inserter CLOG verdict is the only rollback signal + * (heap does the same). + */ + return false; + } + else + { + /* + * Inserter committed. Cache the CLOG verdict as the + * FLUX_TUPLE_XMIN_COMMITTED hint (set ONLY from a CLOG read, + * so it can never precede commit) and clear the stale + * UNCOMMITTED hint. BufferSetHintBits16 handles the lock + * upgrade. + */ + BufferSetHintBits16(&tuple_hdr->t_flags, + (tuple_hdr->t_flags | + FLUX_TUPLE_XMIN_COMMITTED) & + ~FLUX_TUPLE_UNCOMMITTED, + buffer); + } + } + + /* + * Otherwise the insert is our own (an own insert rolled back with its + * savepoint has an aborted subtransaction's xid in t_xmin, which is + * not current and did not commit), or t_xmin is invalid. Our own + * delete is the DELETED case below. + */ + } + + /* Inserting xact is committed (or ours). Check deletion. */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + /* + * Deletion state is read from the on-page header, not the per-tuple + * sLog (per-page conflict tracking no longer writes a shared DELETE + * entry). The deleter is t_xmax; classify it via current-xact / + * in-progress / CLOG, exactly as heap's HeapTupleSatisfiesDirty does + * for xmax. This drives _bt_check_unique: an in-progress deleter + * sets snapshot->xmax (caller waits), our own or a committed delete + * makes the entry dead (visible=false -> not a uniqueness conflict), + * and an aborted delete leaves the row live. + */ + TransactionId del_xid = FluxTupleGetXmax(tuple_hdr); + + if (!TransactionIdIsValid(del_xid)) + { + /* + * DELETED flag with no deleter (xmax invalid). The tracked-key + * COMMIT sweep marks a savepoint-rolled-back INSERT as DELETED + * and clears UNCOMMITTED but stamps no deleter. With no INSERT + * undo record, the inserter's t_xmin is the only rollback signal: + * if it never committed the row was never really there, so it is + * invisible. Only a genuinely committed inserter with a stale + * DELETED hint is live. + */ + TransactionId ins_xid = FluxTupleGetXmin(tuple_hdr); + + if (TransactionIdIsValid(ins_xid) && + !TransactionIdIsCurrentTransactionId(ins_xid) && + !TransactionIdIsInProgress(ins_xid) && + !TransactionIdDidCommit(ins_xid)) + { + return false; + } + + BufferSetHintBits16(&tuple_hdr->t_flags, + tuple_hdr->t_flags & ~FLUX_TUPLE_DELETED, + buffer); + /* fall through -- tuple is live */ + } + else if (TransactionIdIsCurrentTransactionId(del_xid)) + { + /* We deleted it ourselves. */ + return false; + } + else if (TransactionIdIsInProgress(del_xid)) + { + /* Deleter still running: report xmax so the caller waits. */ + snapshot->xmax = del_xid; + return true; + } + else if (TransactionIdDidCommit(del_xid)) + { + /* + * Delete committed: the row (and its index entry) is dead. + */ + return false; + } + else + { + /* + * Deleter aborted: the delete did not take effect. Clear the + * stale DELETED hint and treat the tuple as live. + */ + BufferSetHintBits16(&tuple_hdr->t_flags, + tuple_hdr->t_flags & ~FLUX_TUPLE_DELETED, + buffer); + /* fall through -- tuple is live */ + } + } + + /* + * FLUX_TUPLE_UPDATED alone does not make the tuple an old version: FLUX + * updates in place, so an UPDATED tuple is the live current version, kept + * marked for before-image serving. An UPDATE that moves the row to a new + * TID deletes the old version, which the DELETED handling above has + * decided, and a row lock is not a delete. + */ + return true; +} + +/* + * Fetch the FLUX tuple at `tid` into `tts` after a visibility test according + * to `snapshot`; returns true if a visible version was produced. Called only + * from flux_index_getnext_slot (the xs_getnext_slot callback). + * + * *call_again is always left false: FLUX does in-place, stable-TID updates, so + * there is never more than one row version reachable via a single index entry + * (no HOT-style chain to keep walking). *all_dead, if non-NULL, is set true + * only when this specific TID is dead to every snapshot; the caller uses it to + * drive kill_prior_tuple. It is strictly PER-TID -- never a sticky scan-level + * verdict -- or a live entry could be wrongly marked LP_DEAD (silent data + * loss). + */ +static bool +flux_index_fetch_tuple(IndexScanFluxData *scan, + ItemPointer tid, + Snapshot snapshot, + TupleTableSlot *tts, + bool *call_again, bool *all_dead) +{ + Size tuple_len; + Relation rel = scan->rel; + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + Buffer buffer; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + bool visible = false; + + /* + * Initialize output parameters. all_dead is strictly PER-TID: it must + * reflect only whether *this* TID is dead to every snapshot, never a + * verdict carried over from a previously-fetched TID in the same scan. + * FLUX does in-place updates and never sets call_again, so each TID is + * fetched exactly once; a sticky scan-level all_dead would leak an + * earlier TID's dead verdict onto a later LIVE TID, making indexam.c set + * kill_prior_tuple and _bt_killitems mark the live entry LP_DEAD -- the + * silent-data-loss "kill LIVE entries" failure. So always start false + * and set true only in the truly-dead branch below. + */ + *call_again = false; + if (all_dead) + *all_dead = false; + + /* + * FLUX secondary indexes hold plain 6-byte heap-style TIDs. A + * key-changing UPDATE stays in place (stable TID) and delete-marks the + * old (k_old, TID) entry, so an entry reached here may carry a key the + * served version does not have; flux_index_getnext_slot rechecks the + * entry key against the served version. This function is a plain TID + * fetch that checks visibility. + */ + + /* Clear the slot */ + ExecClearTuple(tts); + + /* + * Release any buffer from a previous index_fetch_tuple call, but keep the + * pin when the next TID is on the same page, as heap's index fetch does: + * an index scan in physical order then visits each page once, which is + * what the planner's correlation costing assumes. + */ + buffer = ReleaseAndReadBuffer(scan->buffer, rel, blkno); + scan->buffer = InvalidBuffer; + LockBuffer(buffer, BUFFER_LOCK_SHARE_EXCLUSIVE); + + page = BufferGetPage(buffer); + + /* Validate offset */ + if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + return false; + } + + /* Get the item */ + itemid = PageGetItemId(page, offnum); + + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + return false; + } + + /* Get tuple header */ + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * An existence check -- a uniqueness or exclusion check (SnapshotDirty, + * SnapshotSelf) or SnapshotNonVacuumable -- whose row's newest change + * belongs to an aborted transaction with its revert still pending (an + * undo apply worker's, or a crash loser's). The row exists, at the + * version before that change: an in-place UPDATE re-stamped t_xmin to the + * aborted updater, so judging the page image would call the row an + * aborted insert and let a duplicate in. Judge the committed version + * instead, as an MVCC reader does (FluxWriterVisibleToSnapshot). Never + * revert here (flux_help_revert): _bt_check_unique holds a btree buffer + * lock, which the revert's index work (flux_undo_index_first) would wait + * for. + * + * The committed version is live: nothing can update or delete a row on + * top of an aborted change without reverting it first. A caller without + * the index entry's key (fetch_tid) cannot tell the old key of an aborted + * in-place key UPDATE from its new one; for those the entry decides (see + * flux_index_fetch_tid), and fetch_tid keeps the old verdict. + */ + if (snapshot != NULL && !IsMVCCSnapshot(snapshot) && + snapshot->snapshot_type != SNAPSHOT_ANY && + FluxTupleHasAbortedVersion(tuple_hdr) && + !(scan->keyless && (tuple_hdr->t_flags & FLUX_TUPLE_KEYS_UPDATED))) + { + Size len = ItemIdGetLength(itemid); + char *onpage = palloc(len); + FluxTupleHeader *cv; + Size cv_len; + + memcpy(onpage, tuple_hdr, len); + UnlockReleaseBuffer(buffer); + if (snapshot->snapshot_type == SNAPSHOT_DIRTY) + { + snapshot->xmin = InvalidTransactionId; + snapshot->xmax = InvalidTransactionId; + snapshot->speculativeToken = 0; + } + cv = flux_committed_version(rel, tid, onpage, len, &cv_len); + pfree(onpage); + if (cv == NULL) + return false; + FluxSlotStoreTuple(tts, cv, cv_len, InvalidBuffer); + pfree(cv); + tts->tts_tableOid = RelationGetRelid(rel); + tts->tts_tid = *tid; + return true; + } + + /* Check visibility (heap-shaped xmin/xmax) */ + if (snapshot) + { + if (snapshot->snapshot_type == SNAPSHOT_DIRTY) + visible = flux_tuple_satisfies_dirty(tuple_hdr, snapshot, buffer); + else + { + visible = FluxTupleVisibleToSnapshotDual(tuple_hdr, snapshot, + RelationGetRelid(rel), + buffer, tid); + + /* + * The caller judges deleted versions itself (SO_RETURN_DELETED), + * except a speculative insertion that lost its race, which is + * dead to everyone; the sequential path skips those too. + */ + if (!visible && scan->keep_deleted && + snapshot->snapshot_type == SNAPSHOT_ANY && + (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) && + !(tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE)) + visible = true; + } + } + else + { + /* + * No snapshot means fetch unconditionally (e.g., system catalog + * scans, VACUUM FULL table rewrite). Only truly deleted tuples are + * invisible. UPDATED tuples are live (they contain the current + * version of the data after an in-place update). + */ + visible = !(tuple_hdr->t_flags & FLUX_TUPLE_DELETED); + } + + /* + * SSI bookkeeping for the index-fetch path, mirroring heap (see + * heapam_indexscan.c: HeapCheckForSerializableConflictOut on every tuple + * examined, PredicateLockTID on the one served). Done here, while we + * still hold the buffer's share lock. A version rebuilt from UNDO below + * takes its TID lock there, once the walk has found it; the walk reports + * the writers it steps over. + */ + flux_ssi_read(rel, tid, tuple_hdr, buffer, snapshot, visible, visible); + + if (!visible) + { + /* + * Index fetch: the on-page (newest) version is not visible to our + * snapshot, but if this tuple was updated in place and still has a + * version chain in the UNDO log, an OLDER version may be visible. + * Reconstruct it and serve the before-image, mirroring the + * sequential-scan path. This is what lets a REPEATABLE READ reader + * whose snapshot predates a committed UPDATE still find the row at + * its old indexed key. + */ + if ((tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) && + snapshot != NULL && IsMVCCSnapshot(snapshot) && + UndoRecPtrIsValid(FluxTupleGetVersionPtr(tuple_hdr))) + { + char *bi_data = NULL; + int bi_len = 0; + Size onpage_len = ItemIdGetLength(itemid); + char *onpage = palloc(onpage_len); + bool found; + + /* Walk the chain from a stable copy, without the lock. */ + memcpy(onpage, tuple_hdr, onpage_len); + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + found = FluxReconstructVisibleVersion(rel, tid, onpage, onpage_len, + snapshot, &bi_data, &bi_len); + pfree(onpage); + if (found) + { + FluxTupleHeader *bi_tuple = (FluxTupleHeader *) bi_data; + + PredicateLockTID(rel, tid, snapshot, InvalidTransactionId); + FluxSlotStoreTuple(tts, bi_tuple, bi_len, InvalidBuffer); + pfree(bi_tuple); + tts->tts_tableOid = RelationGetRelid(rel); + ItemPointerCopy(tid, &tts->tts_tid); + ReleaseBuffer(buffer); + return true; + } + ReleaseBuffer(buffer); + return false; + } + + /* + * Set the all_dead hint only if the tuple is committed-deleted AND + * its deleter (t_xmax) precedes the oldest-xmin horizon, meaning no + * running or future snapshot can still see it. FluxTupleDeadToAll + * does the XID-horizon check (t_xmax committed and < oldest_xmin), as + * heap does. + * + * Setting all_dead prematurely would let the index AM remove the + * entry while concurrent transactions still need it. + */ + if (FluxTupleDeadToAll(tuple_hdr, FluxGetOldestXminHorizon(rel))) + { + if (all_dead) + *all_dead = true; + } + + /* + * Do NOT set call_again for UPDATED tuples. For out-of-place updates + * the executor inserts new index entries (TU_All), so the index scan + * will naturally find the new version through its own index entry. + * Setting call_again=true here with the same tid causes an infinite + * loop because the caller retries with the unchanged tid parameter. + */ + + UnlockReleaseBuffer(buffer); + return false; + } + + /* + * Copy the version we decided on while still holding the share-exclusive + * lock: FLUX rewrites tuples in place, so the page bytes may change the + * moment we unlock (see FluxSlotStoreTuple). Everything below works on + * the copy, which lives in the slot's context. + */ + tuple_len = ItemIdGetLength(itemid); + { + FluxTupleHeader *copy = MemoryContextAlloc(tts->tts_mcxt, tuple_len); + + memcpy(copy, tuple_hdr, tuple_len); + tuple_hdr = copy; + } + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + + /* + * Before-image substitution for in-place UPDATEs, mirroring the + * sequential-scan path (flux_getnextslot) and the fetch-by-TID path + * (flux_tuple_fetch_row_version). If the reader can see the updater's + * xmin the on-page value is correct and the callee returns false (serve + * on-page); if not, an older visible version is reconstructed from the + * UNDO log so a REPEATABLE READ / SERIALIZABLE reader whose snapshot + * cannot see the update observes the before-image through any index -- + * including one whose key the UPDATE never touched (e.g. a primary-key + * lookup). Visibility is ordinary heap-shaped xmin/xmax + CLOG + + * snapshot (XidInMVCCSnapshot in the callee). + * + * Key UPDATEs are in place too, so the before-image reached through an + * index entry may carry a different key than the entry; + * flux_index_getnext_slot rechecks the entry key against the version + * served here. + */ + if ((tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) && + !(tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) && + snapshot != NULL && IsMVCCSnapshot(snapshot)) + { + char *bi_data = NULL; + int bi_len = 0; + + if (FluxReconstructVisibleVersion(rel, tid, + (const char *) tuple_hdr, + tuple_len, + snapshot, + &bi_data, &bi_len)) + { + FluxTupleHeader *bi_tuple = (FluxTupleHeader *) bi_data; + + pfree(tuple_hdr); + FluxSlotStoreTuple(tts, bi_tuple, bi_len, InvalidBuffer); + pfree(bi_tuple); + tts->tts_tableOid = RelationGetRelid(rel); + tts->tts_tid = *tid; + + scan->buffer = buffer; + return true; + } + } + + /* + * Tuple is visible. A committed-DELETED tuple can still be visible here: + * an RR/SERIALIZABLE reader whose snapshot predates the delete sees the + * row (FluxTupleVisibleToSnapshotDual already returned true because the + * deleter xid is not visible to this snapshot). + * FluxTupleToSlotWithOverflow refuses a DELETED tuple outright (it can't + * tell visible-to-this-snapshot from physically-dead), so serve the + * on-page image via FluxSlotStoreTuple -- the same virtual-slot store the + * sequential-scan path uses, which has no DELETED reject. This mirrors + * seqscan and fixes a lost-row/wrong-result under REPEATABLE READ via an + * index path. + */ + if (visible && (tuple_hdr->t_flags & FLUX_TUPLE_DELETED)) + { + FluxSlotStoreTuple(tts, tuple_hdr, tuple_len, InvalidBuffer); + pfree(tuple_hdr); + tts->tts_tableOid = RelationGetRelid(rel); + tts->tts_tid = *tid; + scan->buffer = buffer; + return true; + } + + /* Tuple is visible - convert to slot (with overflow fetch) */ + if (FluxTupleToSlotWithOverflow(tuple_hdr, tts, rel)) + { + tts->tts_tableOid = RelationGetRelid(rel); + tts->tts_tid = *tid; + /* Slot is already marked valid by FluxTupleToSlotWithOverflow */ + } + else + { + pfree(tuple_hdr); + ReleaseBuffer(buffer); + return false; + } + + /* + * FluxTupleToSlotWithOverflow left datums pointing into our copy; + * materialize them into slot-owned memory, then drop the copy. + */ + ExecMaterializeSlot(tts); + pfree(tuple_hdr); + + /* + * Keep the pin for the next fetch (see ReleaseAndReadBuffer above); the + * slot no longer points into the page. The scan's reset and end release + * it. + */ + scan->buffer = buffer; + + return true; +} + +/* + * ------------------------------------------------------------------------ + * Tuple manipulation callbacks for FLUX AM + * ------------------------------------------------------------------------ + */ + +/* + * Fetch tuple at given TID + */ +static bool +flux_tuple_fetch_row_version(Relation relation, + ItemPointer tid, + Snapshot snapshot, + TupleTableSlot *slot) +{ + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + Buffer buffer; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + bool visible = false; + + /* Clear the slot */ + ExecClearTuple(slot); + + /* Read the page */ + buffer = ReadBuffer(relation, blkno); + + /* + * SnapshotAny is how the executor fetches the latest version of a row it + * is about to update or delete (ExecUpdate building the new tuple, + * RETURNING OLD, EvalPlanQual). That version must never be an aborted + * transaction's not-yet-reverted image, or the new row would be built on + * top of it. The writer reverts such versions itself before working on + * the row (flux_help_revert); do the same here, under an exclusive lock, + * so the row we hand back is the one the write will apply to. + */ + if (snapshot == SnapshotAny) + { + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + if (flux_help_revert(relation, buffer, offnum)) + buffer = ReadBuffer(relation, blkno); + else + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + } + + /* + * Excludes in-place writers while we decide and copy + * (FluxSlotStoreTuple). + */ + LockBuffer(buffer, BUFFER_LOCK_SHARE_EXCLUSIVE); + + page = BufferGetPage(buffer); + + /* Validate offset */ + if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + return false; + } + + /* Get the item */ + itemid = PageGetItemId(page, offnum); + + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + return false; + } + + /* Get tuple header */ + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Check visibility (heap-shaped xmin/xmax). Special case: SnapshotAny is + * used by DELETE RETURNING to fetch the just-deleted tuple, so we must + * allow deleted tuples in that case. + */ + if (snapshot && snapshot != SnapshotAny) + { + /* For normal snapshots, check visibility */ + visible = FluxTupleVisibleToSnapshotDual(tuple_hdr, snapshot, + RelationGetRelid(relation), + buffer, tid); + + /* SSI, as heap_fetch */ + flux_ssi_read(relation, tid, tuple_hdr, buffer, snapshot, visible, + visible); + + /* + * The on-page version is not visible. An in-place update may have + * replaced the version the snapshot sees, as for an index fetch: + * reconstruct it from UNDO. Heap's TID still names that version. Do + * NOT additionally check FLUX_TUPLE_DELETED here; the visibility + * function already decided whether the delete is visible. + */ + if (!visible) + { + bool unlocked; + + if (flux_store_visible_older(relation, buffer, tid, tuple_hdr, + ItemIdGetLength(itemid), snapshot, + slot, &unlocked)) + { + ReleaseBuffer(buffer); + return true; + } + if (unlocked) + ReleaseBuffer(buffer); + else + UnlockReleaseBuffer(buffer); + return false; + } + } + else if (snapshot == SnapshotAny) + { + /* SnapshotAny: fetch any tuple, even if deleted (for RETURNING) */ + visible = true; + } + else + { + /* No snapshot means fetch unconditionally if not deleted */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + UnlockReleaseBuffer(buffer); + return false; + } + visible = true; + } + + if (!visible) + { + UnlockReleaseBuffer(buffer); + return false; + } + + /* + * Before-image substitution for committed in-place UPDATEs, mirroring the + * sequential-scan path. An index fetch that lands on a tuple updated in + * place by a transaction not visible to our MVCC snapshot must serve the + * before-image, not the on-page (new) value -- otherwise an UPDATE driven + * by this fetch (e.g. UPDATE ... WHERE pk = const) would recompute on top + * of a concurrent committed update and silently lose it. Serve from the + * UNDO log. Visibility is ordinary heap-shaped xmin/xmax + CLOG + + * snapshot (XidInMVCCSnapshot in the callee): if the reader can see the + * updater the callee returns false and the on-page value is served. + */ + if ((tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) && + !(tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) && + snapshot != NULL && IsMVCCSnapshot(snapshot)) + { + char *bi_data = NULL; + int bi_len = 0; + + /* Walked with the share-exclusive lock held, as in the bitmap path. */ + if (FluxReconstructVisibleVersion(relation, tid, + (const char *) tuple_hdr, + ItemIdGetLength(itemid), + snapshot, + &bi_data, &bi_len)) + { + FluxTupleHeader *bi_tuple = (FluxTupleHeader *) bi_data; + + FluxSlotStoreTuple(slot, bi_tuple, bi_len, InvalidBuffer); + pfree(bi_tuple); + slot->tts_tableOid = RelationGetRelid(relation); + slot->tts_tid = *tid; + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + ReleaseBuffer(buffer); + return true; + } + } + + /* + * SnapshotAny, and another transaction that is still running has changed + * the row in place: serve the latest committed version instead, which is + * what heap returns (its TID still holds the version the running + * transaction superseded). The executor fetches the row this way before + * it updates it and builds the new row on top of what it got. If the + * other transaction aborts, the update applies that new row without + * fetching again, so it must not carry the aborted values, nor report + * them as RETURNING OLD. If the other transaction commits, the update + * reports TM_Updated and the executor fetches again. The latest snapshot + * sees every committed writer and none that is still running. + */ + if (snapshot == SnapshotAny && UndoRecPtrIsValid(tuple_hdr->t_verptr)) + { + bool is_insert; + + if (TransactionIdIsValid(flux_header_dirty_xid(tuple_hdr, &is_insert)) && + !is_insert) + { + Size len = ItemIdGetLength(itemid); + char *onpage = palloc(len); + char *bi_data = NULL; + int bi_len = 0; + + /* Walk the chain from a stable copy, without the lock. */ + memcpy(onpage, tuple_hdr, len); + UnlockReleaseBuffer(buffer); + if (flux_reconstruct_latest(relation, tid, onpage, len, + &bi_data, &bi_len)) + { + FluxSlotStoreTuple(slot, (FluxTupleHeader *) bi_data, bi_len, + InvalidBuffer); + pfree(bi_data); + } + else /* it committed meanwhile: as on page */ + FluxSlotStoreTuple(slot, (FluxTupleHeader *) onpage, len, + InvalidBuffer); + pfree(onpage); + slot->tts_tableOid = RelationGetRelid(relation); + slot->tts_tid = *tid; + return true; + } + } + + /* Store tuple into slot with buffer pin for safe access */ + FluxSlotStoreTuple(slot, tuple_hdr, + ItemIdGetLength(itemid), buffer); + slot->tts_tableOid = RelationGetRelid(relation); + slot->tts_tid = *tid; + + /* + * FluxSlotStoreTuple copied the tuple. This function's principal caller + * is the executor's "fetch the old tuple before updating it" step, which + * then updates that very tuple in place, so the slot must not alias the + * page; materializing also detaches its datums from the copy. + */ + ExecMaterializeSlot(slot); + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + ReleaseBuffer(buffer); + + return true; +} + +/* + * Check if TID is valid for relation scan + */ +static bool +flux_tuple_tid_valid(TableScanDesc scan, ItemPointer tid) +{ + BlockNumber nblocks = RelationGetNumberOfBlocks(scan->rs_rd); + + return ItemPointerIsValid(tid) && + ItemPointerGetBlockNumber(tid) < nblocks; +} + +/* + * Get latest version of tuple (for updates) + */ +static void +flux_tuple_get_latest_tid(TableScanDesc scan, ItemPointer tid) +{ + /* + * FLUX updates in place, so the latest version of a row is at its TID, + * unless an UPDATE moved the row: follow FLUX_TUPLE_SUPERSEDED links + * whose updater committed or is ourselves. + */ + for (;;) + { + BlockNumber block = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + Buffer buffer; + Page page; + bool follow = false; + ItemPointerData next; + + if (block >= RelationGetNumberOfBlocks(scan->rs_rd)) + return; + buffer = ReadBuffer(scan->rs_rd, block); + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page = BufferGetPage(buffer); + if (offnum >= FirstOffsetNumber && offnum <= PageGetMaxOffsetNumber(page)) + { + ItemId itemid = PageGetItemId(page, offnum); + + if (ItemIdIsNormal(itemid)) + { + FluxTupleHeader *tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + TransactionId xmax = FluxTupleGetXmax(tuple_hdr); + + follow = (tuple_hdr->t_flags & (FLUX_TUPLE_DELETED | FLUX_TUPLE_SUPERSEDED)) == + (FLUX_TUPLE_DELETED | FLUX_TUPLE_SUPERSEDED) && + (TransactionIdIsCurrentTransactionId(xmax) || + TransactionIdDidCommit(xmax)); + next = tuple_hdr->t_ctid; + } + } + UnlockReleaseBuffer(buffer); + if (!follow || ItemPointerEquals(&next, tid)) + return; + *tid = next; + } +} + +/* + * Does a version of a row that a later version has replaced satisfy the + * snapshot? The version was written by (xmin, cmin), the writer that + * replaced it by (sup_xid, sup_cid), heap's xmax and cmax of the version. + * This is HeapTupleSatisfiesSelf/Dirty/MVCC for a tuple with that xmin and + * xmax: a version replaced in place is still live to a snapshot that does + * not see its replacement, exactly as heap's old version is. + */ +static bool +flux_superseded_version_satisfies(TransactionId xmin, CommandId cmin, + TransactionId sup_xid, CommandId sup_cid, + Snapshot snapshot) +{ + bool mvcc = (snapshot->snapshot_type == SNAPSHOT_MVCC); + bool dirty = (snapshot->snapshot_type == SNAPSHOT_DIRTY); + + Assert(mvcc || dirty || snapshot->snapshot_type == SNAPSHOT_SELF); + + if (dirty) + { + snapshot->xmin = InvalidTransactionId; + snapshot->xmax = InvalidTransactionId; + snapshot->speculativeToken = 0; + } + + /* The version's writer. */ + if (TransactionIdIsCurrentTransactionId(xmin)) + { + if (mvcc && cmin >= snapshot->curcid) + return false; /* written after the scan started */ + } + else if (mvcc) + { + if (!FluxWriterVisibleToSnapshot(xmin, snapshot, false)) + return false; + } + else if (TransactionIdIsInProgress(xmin)) + { + if (!dirty) + return false; + snapshot->xmin = xmin; + return true; /* in insertion by other */ + } + else if (!TransactionIdDidCommit(xmin)) + return false; /* aborted or crashed */ + + /* The writer that replaced it. */ + if (TransactionIdIsCurrentTransactionId(sup_xid)) + return mvcc && sup_cid >= snapshot->curcid; /* replaced after scan + * start */ + if (mvcc) + return !FluxWriterVisibleToSnapshot(sup_xid, snapshot, false); + if (TransactionIdIsInProgress(sup_xid)) + { + if (dirty) + snapshot->xmax = sup_xid; + return true; + } + return !TransactionIdDidCommit(sup_xid); +} + +/* + * Check if tuple satisfies snapshot + * + * The version judged is the one in the slot. FLUX updates in place, so the + * slot's TID leads to the row's newest version, which may not be the one in + * the slot: an AFTER trigger's queued image (am_inplace_update_keeps_tid) of + * a row its own transaction rewrote since, as the RI triggers check a + * deferred row. A slot that knows its version's identity (FluxSlotGetVersion) + * and finds another version on the page is judged as heap judges the old + * version its TID still names: by its writer and by the writer that replaced + * it, found by walking the row's version chain. + */ +static bool +flux_tuple_satisfies_snapshot(Relation rel, TupleTableSlot *slot, + Snapshot snapshot) +{ + TransactionId vxmin; + CommandId vcmin; + Buffer buffer; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + BlockNumber blkno; + OffsetNumber offnum; + bool visible; + + Assert(TTS_IS_FLUXTUPLE(slot)); + + /* + * Re-fetch the on-disk tuple header by TID so we can check the real + * commit timestamps and transaction status. The previous implementation + * used flux_tuple_from_slot() which fabricated a new tuple with current + * timestamps, making the visibility check meaningless. + */ + if (!ItemPointerIsValid(&slot->tts_tid)) + return false; + + blkno = ItemPointerGetBlockNumber(&slot->tts_tid); + offnum = ItemPointerGetOffsetNumber(&slot->tts_tid); + + buffer = ReadBuffer(rel, blkno); + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page = BufferGetPage(buffer); + + if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + return false; + } + + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + return false; + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* The slot holds a version the row has moved past: judge that one. */ + if ((snapshot->snapshot_type == SNAPSHOT_SELF || + snapshot->snapshot_type == SNAPSHOT_DIRTY || + snapshot->snapshot_type == SNAPSHOT_MVCC) && + FluxSlotGetVersion(slot, &vxmin, &vcmin) && + !(TransactionIdEquals(FluxTupleGetXmin(tuple_hdr), vxmin) && + tuple_hdr->t_cmin == vcmin)) + { + Size len = ItemIdGetLength(itemid); + char *onpage = palloc(len); + TransactionId sup_xid; + CommandId sup_cid; + bool found; + + /* Walk the chain from a stable copy, without the lock. */ + memcpy(onpage, tuple_hdr, len); + UnlockReleaseBuffer(buffer); + found = FluxFindVersionSuperseder(rel, &slot->tts_tid, onpage, len, + vxmin, vcmin, &sup_xid, &sup_cid); + pfree(onpage); + + /* + * Not in the chain: a version whose UNDO has been discarded was + * replaced by a writer every snapshot sees, or the version was an + * aborted writer's that has been reverted. Either way it is dead. + */ + if (!found) + { + if (snapshot->snapshot_type == SNAPSHOT_DIRTY) + { + snapshot->xmin = InvalidTransactionId; + snapshot->xmax = InvalidTransactionId; + snapshot->speculativeToken = 0; + } + return false; + } + return flux_superseded_version_satisfies(vxmin, vcmin, + sup_xid, sup_cid, snapshot); + } + + /* + * The row's newest change is an aborted transaction's, with its revert + * still pending: for an existence check the row is there, at its + * committed version (see flux_index_fetch_tuple). + */ + if (!IsMVCCSnapshot(snapshot) && snapshot->snapshot_type != SNAPSHOT_ANY && + FluxTupleHasAbortedVersion(tuple_hdr)) + { + if (snapshot->snapshot_type == SNAPSHOT_DIRTY) + { + snapshot->xmin = InvalidTransactionId; + snapshot->xmax = InvalidTransactionId; + snapshot->speculativeToken = 0; + } + UnlockReleaseBuffer(buffer); + return true; + } + + if (snapshot->snapshot_type == SNAPSHOT_DIRTY) + { + visible = flux_tuple_satisfies_dirty(tuple_hdr, snapshot, buffer); + UnlockReleaseBuffer(buffer); + return visible; + } + + visible = FluxTupleVisibleToSnapshotDual(tuple_hdr, snapshot, + RelationGetRelid(rel), buffer, + &slot->tts_tid); + + UnlockReleaseBuffer(buffer); + + return visible; +} + +/* + * Speculative tuple insertion for FLUX + * This is used for INSERT ... ON CONFLICT operations + */ +static void +flux_tuple_insert_speculative(Relation relation, TupleTableSlot *slot, + CommandId cid, uint32 options, + BulkInsertState bistate, uint32 specToken) +{ + FluxTuple tuple; + Buffer buf; + Page page; + Size tuple_size; + BlockNumber target_block; + OffsetNumber offnum; + FluxTupleHeader *tuple_hdr; + uint64 commit_ts; + FluxOverflowBuffers overflow_buffers; + int i; + int natts = RelationGetDescr(relation)->natts; + Datum *toast_values; + bool *toast_isnull; + ToastAttrInfo *toast_attr; + ToastTupleContext ttc; + bool toasted = false; + FluxLogicalImage logical_img; + Buffer vmbuffer; + + slot_getallattrs(slot); + + /* + * TOAST the values exactly as flux_tuple_insert does: a value that + * arrives as an on-disk pointer into another relation's TOAST table is + * detoasted and stored in ours (or inline), and an oversized value is + * compressed or externalized. If the insertion is later killed, + * flux_tuple_complete_speculative deletes these values again. + */ + toast_values = palloc_array(Datum, natts); + toast_isnull = palloc_array(bool, natts); + toast_attr = palloc_array(ToastAttrInfo, natts); + memcpy(toast_values, slot->tts_values, natts * sizeof(Datum)); + memcpy(toast_isnull, slot->tts_isnull, natts * sizeof(bool)); + flux_toast_tuple(relation, toast_values, toast_isnull, NULL, NULL, + &ttc, toast_attr, &toasted, 0); + + overflow_buffers.count = 0; + tuple = FluxFormTuple(RelationGetDescr(relation), + toast_values, toast_isnull, + relation, &overflow_buffers); + tuple_size = tuple->t_len; + + /* The tuple holds copies of the values; release the temporaries. */ + if (toasted) + flux_toast_cleanup(&ttc); + pfree(toast_values); + pfree(toast_isnull); + pfree(toast_attr); + + /* + * A row whose untoastable part still exceeds a FLUX page cannot be + * stored; report it as flux_tuple_insert does (and heap's hio.c), rather + * than asking the FSM for an impossible amount of space. + */ + if (tuple_size > FLUX_MAX_TUPLE_SIZE) + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("row is too big: size %zu, maximum size %zu", + tuple_size, (Size) FLUX_MAX_TUPLE_SIZE))); + + /* + * Get timestamp BEFORE entering critical section, as this may allocate + * memory. + */ + commit_ts = FluxGetCommitTimestamp(); + + /* + * Mark the tuple as uncommitted so that SNAPSHOT_DIRTY callers can detect + * in-progress insertions via the sLog. The sLog entry is registered + * after the tuple is placed on the page (below) so that the TID is valid. + */ + tuple->t_data->t_flags |= FLUX_TUPLE_UNCOMMITTED; + tuple->t_data->t_xmin = GetCurrentTransactionId(); /* subxid: heap-shaped, + * so savepoint rollback + * marks it aborted in + * CLOG */ + tuple->t_data->t_cmin = cid; /* see flux_tuple_insert */ + + /* + * Use the FSM to find a page with enough free space, or extend the + * relation with a properly initialized new page. + */ + target_block = FluxGetPageWithFreeSpace(relation, tuple_size); + if (target_block == InvalidBlockNumber) + { + /* Clean up overflow buffers before throwing error */ + for (i = 0; i < overflow_buffers.count; i++) + { + UnlockReleaseBuffer(overflow_buffers.buffers[i].buffer); + pfree(overflow_buffers.buffers[i].record_data); + } + FluxFreeTuple(tuple); + elog(ERROR, "FLUX failed to allocate page for speculative insertion"); + } + + buf = ReadBuffer(relation, target_block); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + + /* Verify page has sufficient space */ + if (PageGetHeapFreeSpace(page) < tuple_size) + { + /* FSM was stale, update and retry */ + FluxRecordFreeSpace(relation, target_block, PageGetHeapFreeSpace(page)); + UnlockReleaseBuffer(buf); + + target_block = FluxGetPageWithFreeSpace(relation, tuple_size); + if (target_block == InvalidBlockNumber) + { + /* Clean up overflow buffers before throwing error */ + for (i = 0; i < overflow_buffers.count; i++) + { + UnlockReleaseBuffer(overflow_buffers.buffers[i].buffer); + pfree(overflow_buffers.buffers[i].record_data); + } + FluxFreeTuple(tuple); + elog(ERROR, "FLUX failed to allocate page for speculative insertion after retry"); + } + + buf = ReadBuffer(relation, target_block); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + + if (PageGetHeapFreeSpace(page) < tuple_size) + { + /* + * Both FSM attempts returned stale pages. Use P_NEW as final + * fallback; extend the relation to get a guaranteed- empty page. + * Update FSM for accuracy on the bad page. + */ + FluxRecordFreeSpace(relation, BufferGetBlockNumber(buf), + PageGetHeapFreeSpace(page)); + UnlockReleaseBuffer(buf); + + buf = ReadBuffer(relation, P_NEW); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + FluxInitPage(page, BufferGetPageSize(buf)); + } + } + + /* + * Try adding tuple before critical section. If the page is too full (FSM + * was optimistic about alignment/line-pointer overhead), extend the + * relation and use a fresh page. + */ + offnum = PageAddItem(page, tuple->t_data, tuple_size, + InvalidOffsetNumber, false, true); + if (offnum == InvalidOffsetNumber) + { + FluxRecordFreeSpace(relation, BufferGetBlockNumber(buf), + PageGetHeapFreeSpace(page)); + UnlockReleaseBuffer(buf); + + buf = ReadBuffer(relation, P_NEW); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + FluxInitPage(page, BufferGetPageSize(buf)); + + offnum = PageAddItem(page, tuple->t_data, tuple_size, + InvalidOffsetNumber, false, true); + if (offnum == InvalidOffsetNumber) + elog(ERROR, "failed to add FLUX tuple to fresh page during " + "speculative insert (tuple_size=%zu)", tuple_size); + } + + /* + * Reserve WAL room for the overflow blocks and their header + data chunks + * outside the critical section, as the plain insert path does. + */ + if (RelationNeedsWAL(relation) && overflow_buffers.count > 0) + XLogEnsureRecordSpace(XLR_MAX_BLOCK_ID, 3 + MAX_OVERFLOW_BUFFERS * 2); + + FluxXLogPrepareLogicalImage(relation, tuple, &logical_img); + + vmbuffer = FluxVMPinForChange(relation, buf); + + /* NO EREPORT(ERROR) from here till changes are logged */ + START_CRIT_SECTION(); + + /* Mark as speculative insertion */ + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, PageGetItemId(page, offnum)); + tuple_hdr->t_flags |= FLUX_TUPLE_SPECULATIVE; + tuple_hdr->t_commit_ts = 0; /* heap-shaped: t_xmax = Invalid */ + + /* + * Store the speculative insertion token in t_ctid, using the same + * encoding as heap: block number holds the token, offset is set to + * SpecTokenOffsetNumber so callers can distinguish a token from a real + * TID. + */ + ItemPointerSet(&tuple_hdr->t_ctid, specToken, SpecTokenOffsetNumber); + + /* Set TID in slot */ + ItemPointerSet(&slot->tts_tid, BufferGetBlockNumber(buf), offnum); + + /* + * The page now holds a tuple no snapshot sees yet, so it is not + * all-visible (or all-frozen) any more, as for any insert. + */ + FluxVMClearForChange(buf, vmbuffer); + MarkBufferDirty(buf); + + /* WAL logging */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr; + xl_flux_insert xlrec; + xl_flux_overflow_write ovf_xlrecs[MAX_OVERFLOW_BUFFERS]; + int ovf_block_ids[MAX_OVERFLOW_BUFFERS]; + int ovf_next_block_id = 1; + + xlrec.offnum = offnum; + xlrec.relid = RelationGetRelid(relation); + xlrec.flags = FLUX_WAL_SPECULATIVE; + if (logical_img.data != NULL) + xlrec.flags |= FLUX_WAL_LOGICAL_TUPLE; + xlrec.tuple_len = (uint32) tuple_size; + xlrec.commit_ts = 0; /* heap-shaped: t_xmax = Invalid */ + + /* + * Force a full-page image and append the tuple body to the main data + * channel (matching FluxXLogInsert's layout, which flux_xlog_insert_ + * redo parses). The redo handler restores the tuple from the FPI and + * never reaches the PageAddItem path for these records; the body is + * carried so logical decoding and any future non-FPI replay see a + * well-formed record. Registering the body as block data instead + * (the historical behavior) left tuple_len uninitialized and made + * redo read past the record on a BLK_NEEDS_REDO replay. + */ + XLogBeginInsert(); + XLogRegisterBuffer(0, buf, REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_insert)); + XLogRegisterData((char *) tuple->t_data, tuple_size); + FluxVMRegisterClear(&xlrec.flags); + + /* + * Register the overflow pages the way the plain insert path does, + * which is what insert redo parses: each page once (records can share + * a page), and each record's data preceded by its + * xl_flux_overflow_write header. + */ + for (i = 0; i < overflow_buffers.count; i++) + { + FluxOverflowBuffer *ovb = &overflow_buffers.buffers[i]; + int j; + + Assert(ovb->buffer != buf); + ovf_block_ids[i] = -1; + for (j = 0; j < i && ovf_block_ids[i] < 0; j++) + { + if (overflow_buffers.buffers[j].buffer == ovb->buffer) + ovf_block_ids[i] = ovf_block_ids[j]; + } + if (ovf_block_ids[i] < 0) + { + ovf_block_ids[i] = ovf_next_block_id++; + XLogRegisterBuffer(ovf_block_ids[i], ovb->buffer, + REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + } + + ovf_xlrecs[i].offnum = ovb->offset; + ovf_xlrecs[i].flags = ovb->flags; + ovf_xlrecs[i].data_len = ovb->record_len; + ovf_xlrecs[i].commit_ts = commit_ts; + XLogRegisterBufData(ovf_block_ids[i], (char *) &ovf_xlrecs[i], + sizeof(xl_flux_overflow_write)); + XLogRegisterBufData(ovf_block_ids[i], ovb->record_data, + ovb->record_len); + } + + if (logical_img.data != NULL) + { + XLogRegisterData(logical_img.data, logical_img.len); + XLogRegisterData((char *) &logical_img.len, sizeof(uint32)); + } + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_INSERT); + PageSetLSN(page, recptr); + } + + FluxVMFinishClear(RelationNeedsWAL(relation) ? + PageGetLSN(page) : InvalidXLogRecPtr); + END_CRIT_SECTION(); + if (BufferIsValid(vmbuffer)) + ReleaseBuffer(vmbuffer); + FluxXLogReleaseLogicalImage(&logical_img); + + /* + * SNAPSHOT_DIRTY callers find the inserter in t_xmin and the token in + * t_ctid, as in heap. Commit clears the UNCOMMITTED hint and savepoint + * rollback discards the row through the same backend-local tracking as a + * plain INSERT. + */ + FluxEnsureSLogCallbacks(); + SLogTupleTrackLocalOnly(RelationGetRelid(relation), + relation->rd_locator.relNumber, &slot->tts_tid, + GetTopTransactionId(), + GetCurrentSubTransactionId()); + + /* Update FSM with remaining free space */ + FluxRecordFreeSpace(relation, BufferGetBlockNumber(buf), + PageGetHeapFreeSpace(page)); + + UnlockReleaseBuffer(buf); + + /* Release overflow buffers, deduplicating shared buffers */ + for (i = 0; i < overflow_buffers.count; i++) + { + Buffer ovf_buf = overflow_buffers.buffers[i].buffer; + bool already_released = (ovf_buf == buf); + int j; + + for (j = 0; j < i && !already_released; j++) + { + if (overflow_buffers.buffers[j].buffer == ovf_buf) + already_released = true; + } + + if (!already_released) + UnlockReleaseBuffer(ovf_buf); + pfree(overflow_buffers.buffers[i].record_data); + } + + FluxFreeTuple(tuple); + + /* The slot now stands for the version just stored (AFTER triggers). */ + FluxSlotSetVersion(slot, GetCurrentTransactionId(), cid); +} + +/* + * Complete speculative insertion for FLUX + */ +static void +flux_tuple_complete_speculative(Relation relation, TupleTableSlot *slot, + uint32 specToken, bool succeeded) +{ + ItemPointer tid = &slot->tts_tid; + Buffer buf; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + FluxTupleData killed = {0}; + + buf = ReadBuffer(relation, ItemPointerGetBlockNumber(tid)); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + + itemid = PageGetItemId(page, ItemPointerGetOffsetNumber(tid)); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buf); + return; + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + if (succeeded) + { + /* + * Speculative insertion succeeded. Clear the speculative flag and + * restore t_ctid to point to the tuple itself (removing the + * speculative token), mirroring heap_finish_speculative(). + * + * Dirty-read hole fix: this used to ALSO clear FLUX_TUPLE_UNCOMMITTED + * here, i.e. well before the inserting transaction actually commits. + * That made the row immediately visible to every concurrent snapshot + * the instant the speculative insert was confirmed (INSERT ... ON + * CONFLICT DO NOTHING/UPDATE), a straight dirty read -- HEAP never + * exposes an uncommitted speculative tuple this way. The speculative + * INSERT tracked the tuple as a plain INSERT does (see + * flux_tuple_insert_speculative), so leaving FLUX_TUPLE_UNCOMMITTED + * set here is safe and correct: the commit-time machinery + * (FluxClearUncommittedFlags / flux_stamp_tuple_committed, driven by + * the tracked key) clears the UNCOMMITTED hint at actual transaction + * commit, exactly like a plain INSERT. + */ + START_CRIT_SECTION(); + + tuple_hdr->t_flags &= ~FLUX_TUPLE_SPECULATIVE; + ItemPointerSet(&tuple_hdr->t_ctid, + ItemPointerGetBlockNumber(tid), + ItemPointerGetOffsetNumber(tid)); + + MarkBufferDirty(buf); + + /* WAL-log the confirmation */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr; + xl_flux_insert xlrec; + + xlrec.relid = RelationGetRelid(relation); + xlrec.offnum = ItemPointerGetOffsetNumber(tid); + xlrec.flags = FLUX_WAL_SPEC_CONFIRM; + xlrec.tuple_len = 0; /* no body: confirm only flips flags */ + xlrec.commit_ts = tuple_hdr->t_commit_ts; + + /* + * Confirmation does not add a tuple; it clears the speculative + * flag on a tuple already present on the page. Force a full-page + * image so redo restores the (already-updated) page rather than + * re-adding the tuple via PageAddItem. tuple_len == 0 keeps the + * record body-less, which flux_xlog_insert_redo treats as + * "nothing to add" on the (unreachable, given the forced FPI) + * non-FPI path. + */ + XLogBeginInsert(); + XLogRegisterBuffer(0, buf, REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_insert)); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_INSERT); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + } + else + { + TransactionId abort_xid; + + /* + * Speculative abort: mark the tuple deleted by the same xid that + * inserted it (the current subxid stamped in t_xmin), so the inserter + * and deleter commit or roll back together and the tuple is dead to + * every snapshot from here on. The transaction itself goes on to + * retry the arbiter check, so this must not rely on an abort; the + * DIRTY-snapshot fetch recognises t_xmin == t_xmax as dead, and + * VACUUM reclaims it once the xid is committed and past the horizon. + */ + abort_xid = GetCurrentTransactionId(); + Assert(TransactionIdEquals(abort_xid, FluxTupleGetXmin(tuple_hdr))); + + START_CRIT_SECTION(); + + tuple_hdr->t_flags |= FLUX_TUPLE_DELETED; + FluxTupleSetXmax(tuple_hdr, abort_xid); + + MarkBufferDirty(buf); + + /* WAL log the speculative abort as a delete */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr; + xl_flux_delete xlrec; + + xlrec.offnum = ItemPointerGetOffsetNumber(tid); + xlrec.flags = FLUX_WAL_SPECULATIVE; + xlrec.tuple_len = ItemIdGetLength(itemid); + xlrec.commit_ts = (uint64) abort_xid; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_delete)); + XLogRegisterBuffer(0, buf, REGBUF_STANDARD); + XLogRegisterBufData(0, (char *) tuple_hdr, ItemIdGetLength(itemid)); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_DELETE); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + + /* + * The killed tuple's TOAST values were inserted for it alone; delete + * them now, as heap_abort_speculative does, once the page is + * unlocked. Deform a private copy of the tuple. + */ + if (tuple_hdr->t_infomask & FLUX_INFOMASK_HASEXTERNAL) + { + killed.t_len = ItemIdGetLength(itemid); + killed.t_data = palloc(killed.t_len); + memcpy(killed.t_data, tuple_hdr, killed.t_len); + killed.t_self = *tid; + } + } + + UnlockReleaseBuffer(buf); + + if (killed.t_data != NULL) + { + TupleDesc tupdesc = RelationGetDescr(relation); + Datum *values = palloc_array(Datum, tupdesc->natts); + bool *isnull = palloc_array(bool, tupdesc->natts); + + FluxDeformTuple(relation, &killed, tupdesc, values, isnull); + flux_toast_delete(relation, values, isnull, true); + pfree(values); + pfree(isnull); + pfree(killed.t_data); + } +} + +/* + * The last superseded row version flux_tuple_lock reported (traversed, or + * TM_Updated to a READ COMMITTED caller without FIND_LAST_VERSION) or + * locked. See the "Lock succeeded" comment in flux_tuple_lock. + */ +static struct +{ + TransactionId top_xid; + Oid relid; + ItemPointerData tid; + CommandId cid; + TransactionId changed_xid; +} flux_lock_reported; + +static bool +flux_lock_was_reported(Relation relation, ItemPointer tid, CommandId cid, + TransactionId top_xid, TransactionId changed_xid) +{ + return TransactionIdEquals(flux_lock_reported.top_xid, top_xid) && + flux_lock_reported.relid == RelationGetRelid(relation) && + ItemPointerEquals(&flux_lock_reported.tid, tid) && + flux_lock_reported.cid == cid && + TransactionIdEquals(flux_lock_reported.changed_xid, changed_xid); +} + +static void +flux_lock_set_reported(Relation relation, ItemPointer tid, CommandId cid, + TransactionId top_xid, TransactionId changed_xid) +{ + flux_lock_reported.top_xid = top_xid; + flux_lock_reported.relid = RelationGetRelid(relation); + flux_lock_reported.tid = *tid; + flux_lock_reported.cid = cid; + flux_lock_reported.changed_xid = changed_xid; +} + +/* + * NOWAIT met a conflicting locker or writer. Heap raises this error inside + * heap_lock_tuple; TM_WouldBlock means SKIP LOCKED to every caller + * (nodeLockRows skips the row), so returning it here made NOWAIT behave like + * SKIP LOCKED. + */ +pg_noreturn static void +flux_lock_nowait_error(Relation relation, Buffer buf, bool have_tuple_lock, + ItemPointer tid, LockTupleMode mode) +{ + UnlockReleaseBuffer(buf); + if (have_tuple_lock) + FluxUnlockTuple(relation, tid, mode); + ereport(ERROR, + (errcode(ERRCODE_LOCK_NOT_AVAILABLE), + errmsg("could not obtain lock on row in relation \"%s\"", + RelationGetRelationName(relation)))); +} + +/* + * Lock a tuple in FLUX table: the version at tid, which flux_tuple_lock may + * have reached by following a moved row + */ +static TM_Result +flux_tuple_lock_version(Relation relation, ItemPointer tid, Snapshot snapshot, + TupleTableSlot *slot, CommandId cid, LockTupleMode mode, + LockWaitPolicy wait_policy, uint8 flags, + TM_FailureData *tmfd, bool follow_update, ItemPointer next_tid) +{ + Buffer buf; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + TM_Result result = TM_Ok; + TransactionId current_xid; + bool have_tuple_lock = false; + char *keyintact_image = NULL; + TransactionId changed_xid = InvalidTransactionId; + + /* + * Copy the target TID before touching the slot. Callers pass the slot's + * own tts_tid as this argument (execReplication.c, ri_triggers.c), and + * populating the slot below clears it, which would invalidate *tid + * mid-function. heapam_tuple_lock() copies into tuple->t_self for the + * same reason. + */ + ItemPointerData tidcopy = *tid; + + tid = &tidcopy; + + /* + * Get transaction XID BEFORE entering critical section, as this may + * allocate memory. + */ + current_xid = GetTopTransactionId(); + + /* + * The sLog lock marker names this subtransaction's own xid as the one a + * conflicting waiter sleeps on, so that ROLLBACK TO wakes it, as heap's + * xmax does. Assign it now, outside the buffer lock. + */ + (void) GetCurrentTransactionId(); + +reacquire: + + /* + * Any retry re-reads the header from scratch, so a before-image captured + * by the key-intact fast path on a previous pass is stale: drop it. This + * is the single point that guarantees no skip decision (and no image) is + * ever carried across a buffer-lock gap. + */ + if (keyintact_image != NULL) + { + pfree(keyintact_image); + keyintact_image = NULL; + } + changed_xid = InvalidTransactionId; + ItemPointerSetInvalid(next_tid); + + buf = ReadBuffer(relation, ItemPointerGetBlockNumber(tid)); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + + /* Helping: see flux_help_revert. */ + if (flux_help_revert(relation, buf, ItemPointerGetOffsetNumber(tid))) + goto reacquire; + + itemid = PageGetItemId(page, ItemPointerGetOffsetNumber(tid)); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buf); + return TM_Invisible; + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Already modified by our own transaction in this or a later command. + * heap_lock_tuple gets this verdict straight from + * HeapTupleSatisfiesUpdate(); FLUX must supply it explicitly. MERGE's + * ExecMergeMatched() relies on it: when a BEFORE trigger modifies the row + * being merged, the lock must report TM_SelfModified so the executor + * raises "tuple to be updated or deleted was already modified ...". + * Without it, ExecMergeMatched loops back to lmerge_matched forever, + * which showed up as a backend growing to 60 GB until the OOM killer + * fired. + * + * Note the FLUX_TUPLE_DELETED handling below must come after this, so our + * own in-progress delete is not misreported as a concurrent TM_Deleted. + */ + { + CommandId self_cmax; + + if (FluxCheckSelfModified(relation, tid, tuple_hdr, cid, &self_cmax)) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) ? + FluxTupleGetXmax(tuple_hdr) : tuple_hdr->t_xmin; + tmfd->cmax = self_cmax; + tmfd->traversed = false; + } + + /* + * A locker sees the current version, not the superseded one + * targeted by UPDATE/DELETE. ON CONFLICT needs the same verdict + * as heap for a version created by this command. + */ + if (!(tuple_hdr->t_flags & FLUX_TUPLE_DELETED) && + self_cmax == cid && tuple_hdr->t_cmin >= cid) + { + result = TM_Invisible; + goto out_unlock; + } + UnlockReleaseBuffer(buf); + return TM_SelfModified; + } + } + + /* + * Tuple is delete-marked. FLUX_TUPLE_DELETED alone does NOT mean the row + * is gone -- it means *someone* stamped a delete, and that delete may + * still be in progress. Reporting TM_Deleted straight off the flag made + * a locker treat an UNCOMMITTED delete as final: SELECT ... FOR SHARE / + * FOR KEY SHARE against a row with a pending DELETE returned the EMPTY + * set immediately, where heap blocks until the deleter commits or aborts + * (heap_delete stamps xmax with LockTupleExclusive, so heap_lock_tuple + * takes its ordinary xmax wait). If the deleter then ROLLS BACK that is + * a WRONG RESULT -- the row is still live and lockable -- and for an FK + * parent probe (ri_LockPKTuple, LockTupleKeyShare) it is an integrity + * hole: the FK child is admitted against a parent row the locker was told + * does not exist. + * + * So resolve the deleter before answering, mirroring what + * flux_tuple_delete already does for a concurrent delete: wait out an + * in-progress non-self deleter, then jump back to 'reacquire' and decide + * on the re-read, post-wait header. An aborted delete has had its + * before-image restored by UNDO (FLUX_TUPLE_DELETED cleared), so the + * retry finds a live row and locks it normally. A committed delete, and + * our own delete, still report TM_Deleted. + * + * This check sits after the TM_SelfModified handling above so our own + * in-progress delete is never misreported as a concurrent one. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + TransactionId del_xid; + bool del_is_insert = false; + + /* A non-key move is compatible, but its successors need locks too. */ + if (mode == LockTupleKeyShare && + (tuple_hdr->t_flags & FLUX_TUPLE_SUPERSEDED) && + (tuple_hdr->t_flags & FLUX_TUPLE_KEY_INTACT)) + { + int image_len; + FluxSkippedUpdates skipped; + + bool have_image; + + have_image = FluxReconstructVisibleVersionEx(relation, tid, + (const char *) tuple_hdr, + ItemIdGetLength(itemid), snapshot, + &keyintact_image, &image_len, &skipped); + if ((follow_update || have_image) && skipped.complete && + !TransactionIdIsValid(skipped.committed_key_xid) && + !TransactionIdIsValid(skipped.inprogress_key_xid)) + { + *next_tid = tuple_hdr->t_ctid; + goto lock_tuple; + } + if (follow_update && + TransactionIdIsValid(skipped.committed_key_xid)) + { + tmfd->ctid = *tid; + tmfd->xmax = skipped.committed_key_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + result = TM_Updated; + goto out_unlock; + } + + if (keyintact_image != NULL) + { + pfree(keyintact_image); + keyintact_image = NULL; + } + } + + del_xid = flux_header_dirty_xid(tuple_hdr, &del_is_insert); + + if (TransactionIdIsValid(del_xid) && !del_is_insert) + { + /* + * In-progress DELETE by another transaction. Honour the caller's + * wait policy exactly as the writer-wait branch below does, so + * NOWAIT/SKIP LOCKED keep their semantics. + */ + if (wait_policy == LockWaitBlock) + { + TransactionId wait_xid = del_xid; + + UnlockReleaseBuffer(buf); + FluxLockTupleToWait(relation, tid, mode, &have_tuple_lock); + XactLockTableWait(wait_xid, relation, tid, XLTW_Lock); + goto reacquire; + } + else if (wait_policy == LockWaitError) + flux_lock_nowait_error(relation, buf, have_tuple_lock, tid, + mode); + else /* LockWaitSkip */ + { + result = TM_WouldBlock; + goto out_unlock; + } + } + + /* + * The deleter moved the row to another partition: report it as heap + * does, whose t_ctid then holds the moved-partitions marker. + * heapam_tuple_lock raises the error itself when asked to follow the + * row to its latest version. + */ + if (FluxTupleMovedPartition(tuple_hdr)) + { + if (flags & TUPLE_LOCK_FLAG_FIND_LAST_VERSION) + { + UnlockReleaseBuffer(buf); + if (have_tuple_lock) + FluxUnlockTuple(relation, tid, mode); + ereport(ERROR, + (errcode(ERRCODE_T_R_SERIALIZATION_FAILURE), + errmsg("tuple to be locked was already moved to another partition due to concurrent update"))); + } + if (tmfd) + { + ItemPointerSetMovedPartitions(&tmfd->ctid); + tmfd->xmax = FluxTupleGetXmax(tuple_hdr); + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + result = TM_Updated; + goto out_unlock; + } + + /* + * The deleter moved the row to a new TID: report it as heap does, + * with the new TID, which flux_tuple_lock follows when asked for the + * latest version. + */ + if (FluxTupleSuperseded(tuple_hdr)) + { + if (tmfd) + { + tmfd->ctid = tuple_hdr->t_ctid; + tmfd->xmax = FluxTupleGetXmax(tuple_hdr); + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + result = TM_Updated; + goto out_unlock; + } + + /* Deleter is committed, aborted, or ourselves: the mark stands. */ + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + } + result = TM_Deleted; + goto out_unlock; + } + + /* + * A forward-linked version belongs to the row we already locked. Its + * insertion need not be visible, but every later key change conflicts. + * Walk its in-place history even when the snapshot predates insertion. + */ + if (follow_update) + { + char *image = NULL; + int image_len; + FluxSkippedUpdates skipped; + TransactionId conflict; + + Assert(mode == LockTupleKeyShare); + if (TransactionIdDidAbort(FluxTupleGetXmin(tuple_hdr))) + goto out_unlock; + (void) FluxReconstructVisibleVersionEx(relation, tid, + (const char *) tuple_hdr, ItemIdGetLength(itemid), snapshot, + &image, &image_len, &skipped); + if (image != NULL) + pfree(image); + conflict = skipped.committed_key_xid; + if (TransactionIdIsValid(conflict)) + { + tmfd->ctid = *tid; + tmfd->xmax = conflict; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + result = TM_Updated; + goto out_unlock; + } + if (skipped.complete && + !TransactionIdIsValid(skipped.inprogress_key_xid)) + goto lock_tuple; + } + + /* + * Check visibility using timestamp-based MVCC and handle concurrent + * modifications. Same pattern as UPDATE/DELETE: distinguish truly + * invisible tuples from concurrent modifications. + * + * A caller may pass no snapshot: GetTupleForTrigger passes + * estate->es_snapshot, which the logical replication apply worker never + * sets, so every replicated UPDATE or DELETE on a table with an ENABLE + * ALWAYS BEFORE trigger arrives here with NULL. Heap never uses the + * snapshot to lock, so it locks the latest version. Do the same: test + * the on-page version with FluxTupleSatisfiesLatest instead, which sends + * an in-progress writer to the wait below and an uncommitted or + * later-command version to TM_Invisible. There is no version the caller + * saw for a newer one to supersede, so nothing is reported as changed + * (changed_xid stays invalid): no TM_Updated, no traversed, and so no + * serialization failure. + */ + if (snapshot == NULL ? + !FluxTupleSatisfiesLatest(tuple_hdr, RelationGetRelid(relation), cid, + buf, tid) : + !FluxTupleVisibleToSnapshotDual(tuple_hdr, snapshot, + RelationGetRelid(relation), + buf, tid)) + { + TransactionId dirty_xid; + bool is_insert_entry; + + /* + * Writer-only probe: this branch waits on an in-progress + * INSERT/UPDATE/DELETE writer. Row locks are handled by the + * dedicated lock-conflict check below (SLogTupleLockConflictXid), + * which serializes lockers via the heavyweight LOCKTAG_TUPLE lock. + * Waiting-as-reader on a pure locker's xid here lets two lockers each + * XactLockTableWait on the other and deadlock. + * + * The writer's xid is read from the on-page header (t_xmin inserter / + * in-place updater, t_xmax deleter), as the UPDATE and DELETE paths + * do. The header stays authoritative through the writer's PRE_COMMIT + * window (UNCOMMITTED hint cleared before the CLOG write). + */ + dirty_xid = flux_header_dirty_xid(tuple_hdr, &is_insert_entry); + + if (TransactionIdIsValid(dirty_xid) && is_insert_entry) + { + /* Another txn's in-progress INSERT - truly invisible */ + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = dirty_xid; + tmfd->cmax = InvalidCommandId; + } + result = TM_Invisible; + goto out_unlock; + } + + /* + * KEY-INTACT FAST PATH -- heap's require_sleep = false for + * LockTupleKeyShare when HEAP_KEYS_UPDATED is clear (heapam.c, "If + * we're requesting KeyShare, and there's no update present, we don't + * need to wait. Even if there is an update, we can still continue if + * the key hasn't been modified."). + * + * Without this, an FK check's parent-row probe (ri_LockPKTuple -> + * table_tuple_lock with LockTupleKeyShare) waited on ANY in-progress + * in-place updater, because the branch below reads the writer's xid + * from the on-page header and waits unconditionally, never looking at + * the requested LockTupleMode. Two concurrent FK inserts referencing + * the same parent row then each waited on the other's xid with no + * heavyweight lock cycle for the deadlock detector to break, so the + * backend hung forever (isolation test fk-deadlock). Heap treats + * KeyShare vs a non-key UPDATE as COMPATIBLE and never waits. + * + * LOCK ORDERING (this is what makes the skip safe): + * + * The inputs to the decision -- the on-page image, whose + * FLUX_TUPLE_KEYS_UPDATED and xid describe the in-progress update, + * and the version chain behind it -- are read while this function + * holds the tuple's buffer content lock BUFFER_LOCK_EXCLUSIVE, taken + * at the 'reacquire' label above and never released anywhere between + * that LockBuffer() and this point. Every path that stamps an + * in-place image holds a conflicting buffer content lock while doing + * so (the regular update path takes BUFFER_LOCK_EXCLUSIVE, the CAS + * fast path BUFFER_LOCK_SHARE_EXCLUSIVE), so no concurrent + * key-changing update can set the bit between our read of the flag + * and our read of the xid -- we observe one self-consistent header. + * Any path that does release and re-take the buffer lock jumps back + * to 'reacquire', which re-reads the header from scratch, so a + * decision is never carried across a lock gap. This mirrors heap, + * which re-checks the tuple after re-grabbing the buffer lock and + * only then clears require_sleep. + * + * We additionally require that the pre-update image actually be + * reconstructible from the UNDO log BEFORE committing to the skip. + * The on-page bytes here are the updater's uncommitted new image + * (this whole branch is the "not visible to our snapshot" case), so + * returning them in the caller's slot would leak uncommitted data. If + * the visible before-image cannot be produced we do NOT skip and fall + * through to the ordinary wait, which is always correct. + * + * EVERY UPDATE SINCE THE SNAPSHOT counts, not only the in-progress + * one on the page. Heap tests the caller's version and then, in + * heap_lock_updated_tuple_rec, each later version in turn, oldest + * first: a committed update that conflicts with the requested mode + * (for KeyShare, one that changed the key; for the other modes, any) + * fails the lock at once, without waiting for the in-progress update + * at the end of the chain; an in-progress key update makes KeyShare + * wait. The updates in between survive only in the UNDO chain, so + * walk it back to the snapshot's version + * (FluxReconstructVisibleVersionEx) and classify each one by the + * FLUX_TUPLE_KEYS_UPDATED bit of the image it produced. The fail-at- + * once case only matters to a caller that is told TM_Updated (a + * transaction snapshot without FIND_LAST_VERSION, see below); the + * others follow the chain to the latest version, which heap does by + * waiting for its in-progress writer, as we do below. If no version + * is visible to the snapshot (the row was inserted after it), the + * caller did not find this row through its snapshot and heap would be + * locking the latest version directly: there is no older version to + * conflict with, so wait as before. + */ + if (TransactionIdIsValid(dirty_xid) && !is_insert_entry && + snapshot != NULL && IsMVCCSnapshot(snapshot) && + (mode == LockTupleKeyShare || + (!(flags & TUPLE_LOCK_FLAG_FIND_LAST_VERSION) && + IsolationUsesXactSnapshot()))) + { + char *bi_data = NULL; + int bi_len = 0; + FluxSkippedUpdates skipped; + bool have_image; + TransactionId conflict_xid; + + have_image = FluxReconstructVisibleVersionEx(relation, tid, + (const char *) tuple_hdr, + ItemIdGetLength(itemid), + snapshot, + &bi_data, &bi_len, + &skipped); + conflict_xid = (mode == LockTupleKeyShare) ? + skipped.committed_key_xid : skipped.committed_xid; + + if (have_image && TransactionIdIsValid(conflict_xid) && + !(flags & TUPLE_LOCK_FLAG_FIND_LAST_VERSION) && + IsolationUsesXactSnapshot()) + { + pfree(bi_data); + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = conflict_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + result = TM_Updated; + goto out_unlock; + } + + if (mode == LockTupleKeyShare && have_image && skipped.complete && + !TransactionIdIsValid(skipped.committed_key_xid) && + !TransactionIdIsValid(skipped.inprogress_key_xid)) + { + /* + * Key is intact and we hold the committed pre-update image: + * grant the KeyShare lock without waiting, and serve that + * image (never the updater's uncommitted bytes) to the + * caller. bi_data is palloc'd in the caller's context, so it + * outlives the buffer we are about to release. + */ + keyintact_image = bi_data; + + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + goto lock_tuple; + } + + if (have_image) + pfree(bi_data); + } + + if (TransactionIdIsValid(dirty_xid) && !is_insert_entry) + { + /* Another txn's in-progress UPDATE/DELETE - wait and retry */ + if (wait_policy == LockWaitBlock) + { + TransactionId wait_xid = dirty_xid; + + UnlockReleaseBuffer(buf); + FluxLockTupleToWait(relation, tid, mode, &have_tuple_lock); + XactLockTableWait(wait_xid, relation, tid, XLTW_Lock); + goto reacquire; + } + else if (wait_policy == LockWaitError) + flux_lock_nowait_error(relation, buf, have_tuple_lock, tid, + mode); + else /* LockWaitSkip */ + { + result = TM_WouldBlock; + goto out_unlock; + } + } + + /* + * No in-progress writer. The on-page inserter/updater (t_xmin) is + * therefore either committed, aborted, or our own transaction. + * Resolve it the way HeapTupleSatisfiesUpdate does -- against CLOG + * and the current transaction, NOT against the caller's snapshot. A + * row whose inserter or in-place updater committed after this + * statement's snapshot was taken is invisible to the snapshot yet + * perfectly lockable; FLUX keeps the newest version at the same TID, + * so the on-page tuple is already "the latest version" heap would + * chase via t_ctid. Reporting TM_Updated here instead sent ON + * CONFLICT DO UPDATE (ExecOnConflictLockRow, flags == 0) back to its + * arbiter scan, which found the same committed row, re-locked it + * under the same snapshot, got TM_Updated again, and so on forever. + * + * An in-progress or aborted inserter is TM_Invisible, as is our own + * inserter when the MVCC check rejected it (later command in this + * transaction, or a rolled-back savepoint). + */ + { + TransactionId xmin = FluxTupleGetXmin(tuple_hdr); + + if (TransactionIdIsCurrentTransactionId(xmin) || + !TransactionIdDidCommit(xmin)) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = xmin; + tmfd->cmax = InvalidCommandId; + } + result = TM_Invisible; + goto out_unlock; + } + } + + /* + * The on-page version was written by a transaction that committed + * after our snapshot was taken: the version the caller saw has been + * superseded. Heap reaches this state as TM_Updated on the old + * version (its xmax is the committed updater) and tells the caller + * so: heapam_tuple_lock either follows the chain and reports + * traversed (FIND_LAST_VERSION), or returns TM_Updated. FLUX must + * report the same, or READ COMMITTED callers skip their EvalPlanQual + * recheck (SELECT ... WHERE v = 1 FOR UPDATE returned a row with v = + * 2, a BEFORE-trigger UPDATE overwrote the concurrent update, an FK + * check accepted a parent whose key had changed) and REPEATABLE READ + * loses its serialization failure. + * + * As in heap, FOR KEY SHARE is compatible with updates that left the + * key intact: heap locks the old version and reports nothing. Serve + * the snapshot's version of the row in that case, as heap does. + * + * That takes every update committed since the snapshot leaving the + * key intact, not only the latest one, whose FLUX_TUPLE_KEYS_UPDATED + * is the bit on the page: heap_lock_updated_tuple_rec tests each + * version of the chain and fails on any committed key update, even + * one the next update changed back. So walk the UNDO chain back to + * the snapshot's version and test the bit of every image on the way + * (FluxReconstructVisibleVersionEx). If the walk cannot reach the + * snapshot's version the history is unknown: report the change, which + * is never wrong for a recheck and at worst a serialization failure + * heap would not have raised. + */ + if (snapshot == NULL) + { + /* + * No snapshot (see above): the writer committed while we looked, + * and what it wrote is the latest version, which we lock. + */ + } + else if (!UndoRecPtrIsValid(FluxTupleGetVersionPtr(tuple_hdr))) + { + /* + * No version chain: t_xmin is the inserter, not an updater (an + * INSERT leaves t_verptr invalid; FLUX_TUPLE_UPDATED is not used + * because it is a hint that may already be cleared). A row + * inserted after our snapshot is locked by heap with TM_Ok and no + * traversal, and so do we. + */ + } + else if (mode == LockTupleKeyShare && + !(tuple_hdr->t_flags & FLUX_TUPLE_KEYS_UPDATED)) + { + char *bi_data = NULL; + int bi_len = 0; + FluxSkippedUpdates skipped; + bool have_image; + + if (snapshot != NULL && IsMVCCSnapshot(snapshot)) + { + have_image = FluxReconstructVisibleVersionEx(relation, tid, + (const char *) tuple_hdr, + ItemIdGetLength(itemid), + snapshot, + &bi_data, &bi_len, + &skipped); + if (have_image && skipped.complete && + !TransactionIdIsValid(skipped.committed_key_xid)) + keyintact_image = bi_data; + else + { + if (have_image) + pfree(bi_data); + + /* + * A complete walk that found no version visible to the + * snapshot means the row was inserted after it: the + * caller found the latest version directly, so lock it + * and report nothing, as heap would. + */ + if (have_image || !skipped.complete) + changed_xid = FluxTupleGetXmin(tuple_hdr); + } + } + } + else + changed_xid = FluxTupleGetXmin(tuple_hdr); + + /* + * Without FIND_LAST_VERSION heap returns TM_Updated. Under a + * transaction snapshot every caller turns that into "could not + * serialize access due to concurrent update". The READ COMMITTED + * callers without the flag (ON CONFLICT, ExecOnConflictLockRow; the + * logical replication apply worker, execReplication.c) go back to + * their index search, which looks for the row by key: heap's finds + * the new version at its new TID and locks it, or, if the key + * changed, does not find the row at all (ON CONFLICT then inserts). + * FLUX returns TM_Updated as well, so the search decides. When it + * finds this TID again the snapshot is the same, so report a given + * version once per command and then lock it, which is the row heap's + * retry would lock; reporting it every time would loop forever. The + * "Lock succeeded" block records it again, so a second lock of the + * same version in the command (GetTupleForTrigger under ON CONFLICT) + * reports nothing either. + */ + if (TransactionIdIsValid(changed_xid) && + !(flags & TUPLE_LOCK_FLAG_FIND_LAST_VERSION) && + (IsolationUsesXactSnapshot() || + !flux_lock_was_reported(relation, tid, cid, current_xid, + changed_xid))) + { + flux_lock_set_reported(relation, tid, cid, current_xid, + changed_xid); + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = changed_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + result = TM_Updated; + goto out_unlock; + } + /* Fall through to lock the current (latest) version */ + } + +lock_tuple: + + /* + * Check for lock conflicts using the sLog. The sLog tracks all + * in-progress lock/delete/update operations, replacing the old + * t_xmax/MultiXact-based scheme. + * + * Pass the precise LockTupleMode so the real heavyweight matrix decides: + * a KeyShare FK probe must stay compatible with an in-progress + * NoKeyExclusive UPDATE, as it is in heap. + */ + { + TransactionId xwait; + + xwait = SLogTupleLockConflictXid(RelationGetRelid(relation), + relation->rd_locator.relNumber, tid, + mode); + if (TransactionIdIsValid(xwait)) + { + /* There's a conflict - check wait policy */ + if (wait_policy == LockWaitError) + flux_lock_nowait_error(relation, buf, have_tuple_lock, tid, + mode); + else if (wait_policy == LockWaitSkip) + { + result = TM_WouldBlock; + goto out_unlock; + } + else /* LockWaitBlock */ + { + /* + * Wait on the specific conflicting transaction identified by + * SLogTupleLockConflictXid -- never on an arbitrary + * in-progress peer, which could be a compatible locker queued + * behind us and would form a spurious mutual-wait cycle. + */ + + /* Release buffer and wait */ + UnlockReleaseBuffer(buf); + + if (TransactionIdIsValid(xwait)) + { + /* Acquire tuple-level lock to wait */ + FluxLockTupleToWait(relation, tid, mode, + &have_tuple_lock); + + /* Wait for the conflicting transaction */ + XactLockTableWait(xwait, relation, tid, XLTW_Lock); + } + + /* Re-acquire buffer and retry */ + goto reacquire; + } + } + } + + /* + * Lock succeeded. + * + * tmfd->traversed tells a FIND_LAST_VERSION caller that the row it saw + * was superseded and that the slot now holds the latest version, which it + * must recheck (EvalPlanQual, FK key recheck). Heap sets it after + * following the update chain to a new TID; FLUX keeps the latest version + * at the same TID, so it sets it when the version on the page committed + * after the caller's snapshot (changed_xid, above). + * + * Heap moves the caller to the new TID (heapam_tuple_lock overwrites + * *tid). The executor's modify-table paths keep that TID and lock it + * again later in the same command: MERGE with a BEFORE ROW trigger gets + * TM_Updated from GetTupleForTrigger, re-locks and rechecks in + * ExecMergeMatched, and retries the action, whose trigger locks the row + * once more. In heap that last lock names the latest version and reports + * nothing. FLUX's TID and snapshot are unchanged, so it would report the + * change again and MERGE would loop forever; reporting true on every + * FIND_LAST_VERSION lock was once the cause of exactly that loop (a + * backend growing until the OOM killer fired). So for those callers + * report a given superseded version once per command, like the + * once-per-command EvalPlanQual bounce in flux_tuple_update_inplace + * (FluxEpqReconcileMatches). No later update can slip in between the two + * locks: the lock we hold conflicts with it. + * + * A lock without FIND_LAST_VERSION that takes the latest version (ON + * CONFLICT under READ COMMITTED, once its TM_Updated above has been + * reported) records it as well. ON CONFLICT then locks the row again, + * from GetTupleForTrigger when the table has a BEFORE UPDATE trigger. + * Heap reports nothing to that second lock: its TID is the latest + * version, which we already hold locked. ON CONFLICT sets up no + * EvalPlanQual, so reporting traversed there sent GetTupleForTrigger into + * EvalPlanQual with no plan, and the backend crashed in + * ExecGetUpdateNewTuple. + * + * Row-locking callers (nodeLockRows, ri_LockPKTuple) take every TID from + * a scan of the caller's snapshot and never re-lock a TID they were + * handed back, so heap reports the change to each of their locks: a FOR + * UPDATE join that repeats a row rechecks every repetition. They are the + * callers that pass TUPLE_LOCK_FLAG_LOCK_UPDATE_IN_PROGRESS, and the + * modify-table ones never do, so that flag selects which rule applies. + */ + if (tmfd) + { + bool relocks_own_tid = + (flags & TUPLE_LOCK_FLAG_LOCK_UPDATE_IN_PROGRESS) == 0; + + tmfd->traversed = false; + if (TransactionIdIsValid(changed_xid)) + { + if ((flags & TUPLE_LOCK_FLAG_FIND_LAST_VERSION) && + !(relocks_own_tid && + flux_lock_was_reported(relation, tid, cid, current_xid, + changed_xid))) + tmfd->traversed = true; + flux_lock_set_reported(relation, tid, cid, current_xid, + changed_xid); + } + tmfd->ctid = *tid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + } + + /* + * Record the lock holder before changing the page: running out of shared + * memory ERRORs here with nothing changed. It is registered under the + * buffer lock, as every conflict probe runs, so a concurrent writer sees + * either the lock or nothing (heap stamps xmax under the buffer lock). + * The precise mode lets a probe apply the heavyweight conflict matrix: + * FOR KEY SHARE stays compatible with a NoKeyExclusive UPDATE. + */ + if (result == TM_Ok) + { + FluxEnsureSLogCallbacks(); + SLogTupleLockAcquire(RelationGetRelid(relation), + relation->rd_locator.relNumber, tid, mode); + } + + tuple_hdr->t_flags |= FLUX_TUPLE_LOCKED; + + START_CRIT_SECTION(); + + MarkBufferDirty(buf); + + /* Log the lock operation */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr; + xl_flux_lock xlrec; + + xlrec.offnum = ItemPointerGetOffsetNumber(tid); + xlrec.flags = 0; + xlrec.infomask = tuple_hdr->t_infomask; + xlrec.lock_mode = (uint8) mode; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_lock)); + XLogRegisterBuffer(0, buf, REGBUF_STANDARD); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_LOCK); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + + /* + * Populate the slot with the locked tuple's data. This must happen after + * END_CRIT_SECTION (since overflow fetch may do I/O and ereport). We must + * unlock the buffer (but keep the pin) before calling + * FluxTupleToSlotWithOverflow because it may fetch overflow data, and if + * that overflow is on the same page, it would try to lock an + * already-locked buffer causing an assertion failure. + * + * FK constraint triggers and other callers of table_tuple_lock() expect + * the slot to contain valid tuple data. + */ + if (slot && result == TM_Ok) + { + FluxTupleHeader *locked_copy = NULL; + + /* + * Copy the locked version while we still hold the content lock. A + * pin does not keep the bytes in place: the UPDATE path that makes + * room for a growing row compacts the page under an exclusive lock + * alone, moving the other tuples on it, and an in-place UPDATE by a + * transaction that does not conflict with our lock mode rewrites the + * row itself. Decoding from the page after unlocking could read a + * torn or moved tuple. + */ + if (keyintact_image == NULL) + { + Size locked_len = ItemIdGetLength(itemid); + + locked_copy = (FluxTupleHeader *) palloc(locked_len); + memcpy(locked_copy, tuple_hdr, locked_len); + } + + /* + * Unlock buffer but keep pin for slot materialization. The row lock + * was already recorded under the buffer lock above, so nothing to + * register here. + */ + LockBuffer(buf, BUFFER_LOCK_UNLOCK); + + /* + * Key-intact fast path: the on-page bytes belong to a still + * in-progress updater and are invisible to our snapshot, so convert + * the committed pre-update image reconstructed above instead. The + * caller's slot is an arbitrary ops (ri_LockPKTuple passes a plain + * heap/virtual slot), so it must go through the same conversion + + * ExecMaterializeSlot path as the on-page case below -- which also + * deep-copies it, leaving keyintact_image ours to free. + */ + if (!FluxTupleToSlotWithOverflow(keyintact_image != NULL ? + (FluxTupleHeader *) keyintact_image : + locked_copy, slot, relation)) + { + /* + * Conversion failed (e.g., tuple was concurrently deleted). + * Return TM_Deleted rather than leaving the slot empty. + */ + ReleaseBuffer(buf); + if (keyintact_image != NULL) + pfree(keyintact_image); + if (locked_copy != NULL) + pfree(locked_copy); + if (have_tuple_lock) + FluxUnlockTuple(relation, tid, mode); + return TM_Deleted; + } + slot->tts_tid = *tid; + slot->tts_tableOid = RelationGetRelid(relation); + + /* + * FluxTupleToSlotWithOverflow leaves the slot virtual with + * tts_values[] pointing into the page buffer (and records no pin in + * the slot). Deep-copy those pass-by-reference datums into + * slot-owned memory BEFORE dropping the pin, so a returned varlena + * (e.g. numeric via UPDATE ... RETURNING / FK / EPQ locking) does not + * alias a buffer that is about to be unpinned and reused. This + * matches heap's slot discipline (ExecStorePinnedBufferHeapTuple + * keeps the pin in the slot). + */ + ExecMaterializeSlot(slot); + + /* Release the buffer pin now that slot is materialized */ + ReleaseBuffer(buf); + + if (keyintact_image != NULL) + pfree(keyintact_image); + if (locked_copy != NULL) + pfree(locked_copy); + + /* Release tuple-level lock if we acquired it */ + if (have_tuple_lock) + FluxUnlockTuple(relation, tid, mode); + + return result; + } + +out_unlock: + + /* + * TM_Invisible still has to leave the tuple in the caller's slot. + * + * heap does: heap_lock_tuple returns TM_Invisible for the ON CONFLICT DO + * UPDATE/DO SELECT case (heapam.c: "We return this value here rather than + * throwing an error in order to give that case the opportunity to throw a + * more specific error"), and heapam_tuple_lock then falls through to + * ExecStorePinnedBufferHeapTuple, so the slot is populated on that path + * too. The executor relies on it: ExecOnConflictUpdate's TM_Invisible + * arm reads xmin off this very slot via slot_getsysattr() to recognise a + * self-conflict and raise "ON CONFLICT DO UPDATE command cannot affect + * row a second time" (nodeModifyTable.c). + * + * FLUX returned TM_Invisible with the slot left EMPTY, so that + * slot_getsysattr() hit the no-tuple path and raised "cannot retrieve a + * system column in this context" instead -- the wrong error, and a + * user-visible difference on plain INSERT ... ON CONFLICT (regression + * tests insert_conflict, update). + * + * Fill the slot here, at the single shared exit, so every TM_Invisible + * return is covered rather than just the two current ones. The store is + * done while the buffer is still pinned and then materialized, matching + * the discipline of the success path below. + */ + + /* + * The ItemPointerIsValid() test is not redundant: the FK fast-path batch + * flush (ri_LockPKTuple -> ri_FastPathFlushArray) can reach a locker with + * slot->tts_tid already invalidated, and ItemPointerGetOffsetNumber() + * asserts on an invalid pointer under cassert. See the KNOWN-OPEN note + * on fk-crosstype-recheck in flux_default_triage.md. + */ + if (result == TM_Invisible && slot != NULL && BufferIsValid(buf) && + tid != NULL && ItemPointerIsValid(tid)) + { + Page ipage = BufferGetPage(buf); + OffsetNumber ioff = ItemPointerGetOffsetNumber(tid); + + if (ioff >= FirstOffsetNumber && ioff <= PageGetMaxOffsetNumber(ipage)) + { + ItemId iid = PageGetItemId(ipage, ioff); + + if (ItemIdIsNormal(iid)) + { + FluxTupleHeader *ihdr = + (FluxTupleHeader *) PageGetItem(ipage, iid); + + /* + * Use the plain store (not FluxTupleToSlotWithOverflow): the + * caller only needs the system columns here, and the DELETED + * reject inside the overflow-aware path would refuse a + * delete-marked tuple outright. + */ + FluxSlotStoreTuple(slot, ihdr, ItemIdGetLength(iid), buf); + slot->tts_tableOid = RelationGetRelid(relation); + slot->tts_tid = *tid; + ExecMaterializeSlot(slot); + } + } + } + + UnlockReleaseBuffer(buf); + + if (keyintact_image != NULL) + pfree(keyintact_image); + + /* Release tuple-level lock if we acquired it */ + if (have_tuple_lock) + FluxUnlockTuple(relation, tid, mode); + + return result; +} + +/* + * Lock a tuple in FLUX table. + * + * FLUX keeps a row's latest version at its TID, except when an UPDATE moved + * it (FLUX_TUPLE_SUPERSEDED): then the old version reports TM_Updated with + * the new TID. With TUPLE_LOCK_FLAG_FIND_LAST_VERSION follow it, as + * heapam_tuple_lock follows t_ctid: lock the new version, move the caller's + * tid to it and report traversed. + */ +static TM_Result +flux_tuple_lock(Relation relation, ItemPointer tid, Snapshot snapshot, + TupleTableSlot *slot, CommandId cid, LockTupleMode mode, + LockWaitPolicy wait_policy, uint8 flags, + TM_FailureData *tmfd) +{ + ItemPointerData cur = *tid; + bool traversed = false; + ItemPointerData next_tid; + TM_Result result; + + for (;;) + { + result = flux_tuple_lock_version(relation, &cur, snapshot, slot, cid, + mode, wait_policy, flags, tmfd, false, &next_tid); + if (result == TM_Ok && ItemPointerIsValid(&next_tid)) + { + TM_FailureData follow_tmfd; + + do + { + ItemPointerData next = next_tid; + + result = flux_tuple_lock_version(relation, &next, snapshot, + NULL, cid, mode, wait_policy, flags, + &follow_tmfd, true, &next_tid); + } while (result == TM_Ok && ItemPointerIsValid(&next_tid)); + if (result != TM_Ok && tmfd != NULL) + *tmfd = follow_tmfd; + } + if (result != TM_Updated || tmfd == NULL || + !(flags & TUPLE_LOCK_FLAG_FIND_LAST_VERSION) || + !ItemPointerIsValid(&tmfd->ctid) || + ItemPointerIndicatesMovedPartitions(&tmfd->ctid) || + ItemPointerEquals(&tmfd->ctid, &cur)) + break; + cur = tmfd->ctid; + traversed = true; + } + + if (traversed) + { + *tid = cur; + tmfd->traversed = true; + } + return result; +} + +/* + * Nontransactional truncate for FLUX relation + * + * This is called for TRUNCATE operations. We use RelationTruncate which + * properly handles all forks (main, FSM, VM), WAL logging, shared buffer + * invalidation, and cache coherency. + */ +static void +flux_relation_nontransactional_truncate(Relation rel) +{ + RelationTruncate(rel, 0); + SLogTupleForgetStorage(RelationGetRelid(rel), rel->rd_locator.relNumber); +} + +/* + * Set new filelocator for FLUX relation + */ +static void +flux_relation_set_new_filelocator(Relation rel, + const RelFileLocator *newrlocator, + char persistence, + TransactionId *freezeXid, + MultiXactId *minmulti) +{ + SMgrRelation srel; + + /* Set freeze XID to current transaction minimum */ + *freezeXid = RecentXmin; + + /* Set minimum multixact ID */ + *minmulti = GetOldestMultiXactId(); + + /* Create the storage file (empty, no blocks yet) */ + srel = RelationCreateStorage(*newrlocator, persistence, true); + + /* WAL-log the file creation */ + if (persistence == RELPERSISTENCE_PERMANENT) + log_smgrcreate(newrlocator, MAIN_FORKNUM); + + /* + * Note: We do not initialize block 0 here. Block 0 will be created + * on-demand during the first scan or insert operation via + * FluxGetPageWithFreeSpace() or the scan's RBM_ZERO_AND_LOCK logic. + */ + + /* Set up init fork for unlogged tables if needed */ + if (persistence == RELPERSISTENCE_UNLOGGED) + { + Assert(rel->rd_rel->relkind == RELKIND_RELATION || + rel->rd_rel->relkind == RELKIND_TOASTVALUE); + smgrcreate(srel, INIT_FORKNUM, false); + log_smgrcreate(newrlocator, INIT_FORKNUM); + } + + smgrclose(srel); + + /* + * FLUX does not maintain a UNDO log. Its DML UNDO goes to the + * per-backend UNDO engine (access/undo/perbackend), which owns its own + * log segments and discard worker, so there is nothing to create here. + */ +} + +/* + * flux_index_tombstone_removable -- table_index_tombstone_removable() for FLUX. + * + * An in-place UPDATE of an indexed column delete-marks the old (key, TID) + * entry so that snapshots still reading an older version of the row reach it + * by the old key. Older versions live only in UNDO, behind the version on + * the page. Once that version's writer (t_xmin) committed and is older than + * every snapshot, no snapshot reads anything but the version on the page, so + * an entry whose key image differs from that version's key is rejected by + * every reader's recheck (flux_index_entry_key_mismatch) and needed by + * nobody. Rows whose writer is in progress or aborted (not yet reverted), + * and deleted rows, keep their entries; a dead TID is VACUUM's ordinary + * business. + * + * The version's xmin is also the recovery conflict horizon. A snapshot that + * reads an older version cannot see that xmin, so the xmin is in its xip or + * not below its xmax, and either way the snapshot's xmin is not above it: + * exactly the snapshots a conflict on it cancels. This holds whatever order + * the chain's writers got their XIDs in (an older XID can write the newest + * version). A subtransaction's xmin follows its parent's, which bounds the + * snapshot's xmin the same way. + * + * Called with the index leaf page cleanup-locked, which also keeps a + * concurrent key UPDATE from reviving the entry until it is gone (that UPDATE + * then inserts a fresh one). The table page lock nests inside it, the same + * order as bottom-up index deletion; FLUX never holds a table page lock while + * it locks an index page. Nothing that could run user code or read the + * catalogs or TOAST may run under the leaf lock, so only indexes on plain + * columns whose index and table types agree (no expression or partial index, + * no opclass storage type, no exclusion constraint) are handled, and rows + * with an out-of-line key value keep their entries. + */ +static bool +flux_index_tombstone_removable(Relation rel, Relation irel, IndexTuple itup, + ItemPointer tid, TransactionId *conflict_xid) +{ + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + TupleDesc itupdesc = RelationGetDescr(irel); + int nkeys = IndexRelationGetNumberOfKeyAttributes(irel); + Datum ent_values[INDEX_MAX_KEYS]; + bool ent_isnull[INDEX_MAX_KEYS]; + FluxTupleHeader *hdr = NULL; + TransactionId xmin; + TupleTableSlot *slot; + Buffer buf; + Page page; + bool removable = false; + + if (blkno >= RelationGetNumberOfBlocks(rel) || + irel->rd_index->indisexclusion || + !heap_attisnull(irel->rd_indextuple, Anum_pg_index_indexprs, NULL) || + !heap_attisnull(irel->rd_indextuple, Anum_pg_index_indpred, NULL)) + return false; + for (int i = 0; i < nkeys; i++) + { + AttrNumber attno = irel->rd_index->indkey.values[i]; + + if (attno <= 0 || + TupleDescAttr(itupdesc, i)->atttypid != + TupleDescAttr(RelationGetDescr(rel), attno - 1)->atttypid) + return false; + } + + /* + * Share-exclusive, not share: the byte-CAS UPDATE rewrites the tuple in + * place under share-exclusive, and the copy must not be torn. + */ + buf = ReadBuffer(rel, blkno); + LockBuffer(buf, BUFFER_LOCK_SHARE_EXCLUSIVE); + page = BufferGetPage(buf); + if (!PageIsNew(page) && offnum >= FirstOffsetNumber && + offnum <= PageGetMaxOffsetNumber(page)) + { + ItemId lp = PageGetItemId(page, offnum); + + if (ItemIdIsNormal(lp)) + { + FluxTupleHeader *phdr = (FluxTupleHeader *) PageGetItem(page, lp); + Size len = ItemIdGetLength(lp); + + if (!(phdr->t_flags & (FLUX_TUPLE_DELETED | + FLUX_TUPLE_SPECULATIVE | + FLUX_TUPLE_HAS_OVERFLOW)) && + !FluxIsOverflowRecordInline(phdr, len)) + { + hdr = palloc(len); + memcpy(hdr, phdr, len); + } + } + } + UnlockReleaseBuffer(buf); + if (hdr == NULL) + return false; + + /* + * The writer's state comes from the horizon and CLOG, never from the + * UNCOMMITTED hint: that is cleared only by the committing backend, by + * top-level XID, so it stays set on a subtransaction's rows and on every + * image redo restored. Past the removable horizon the writer is not + * running (a prepared transaction counts as running); a subcommitted + * writer whose parent has not committed fails TransactionIdDidCommit. + */ + xmin = FluxTupleGetXmin(hdr); + if (!TransactionIdIsNormal(xmin) || + !GlobalVisTestIsRemovableXid(GlobalVisTestFor(rel), xmin, true) || + !TransactionIdDidCommit(xmin)) + { + pfree(hdr); + return false; + } + + slot = MakeSingleTupleTableSlot(RelationGetDescr(rel), + table_slot_callbacks(rel)); + FluxTupleToSlotWithOverflow(hdr, slot, rel); + slot_getallattrs(slot); + index_deform_tuple(itup, itupdesc, ent_values, ent_isnull); + for (int i = 0; i < nkeys; i++) + { + Form_pg_attribute att = TupleDescAttr(itupdesc, i); + AttrNumber attno = irel->rd_index->indkey.values[i]; + Datum val = slot->tts_values[attno - 1]; + bool isnull = slot->tts_isnull[attno - 1]; + + if (!isnull && att->attlen == -1 && + VARATT_IS_EXTERNAL(DatumGetPointer(val))) + { + removable = false; /* would read TOAST: keep */ + break; + } + if (isnull != ent_isnull[i] || + (!isnull && !datum_image_eq(val, ent_values[i], + att->attbyval, att->attlen))) + removable = true; + } + ExecDropSingleTupleTableSlot(slot); + pfree(hdr); + + *conflict_xid = xmin; + return removable; +} + +/* + * Check whether table tuples referenced by index entries are dead. + * + * This is called by index AMs during index tuple deletion (both simple + * deletion during VACUUM and bottom-up deletion during retail inserts). + * The index AM passes a list of TIDs and we check each one's liveness. + * We set knowndeletable=true for entries whose table tuples are dead, + * allowing the index AM to remove its entries. + * + * IMPORTANT: This function must NEVER modify table data. It only reads + * tuple headers to check visibility status. + * + * Modeled on heap_index_delete_tuples() but simplified for FLUX's + * timestamp-based MVCC. + */ +static TransactionId +flux_index_delete_tuples(Relation rel, TM_IndexDeleteOp *delstate) +{ + TransactionId snapshotConflictHorizon = InvalidTransactionId; + BlockNumber blkno = InvalidBlockNumber; + Buffer buf = InvalidBuffer; + Page page = NULL; + OffsetNumber maxoff = InvalidOffsetNumber; + int finalndeltids = 0; + + Assert(delstate->ndeltids > 0); + + /* Iterate over deltids, determine which are deletable */ + for (int i = 0; i < delstate->ndeltids; i++) + { + TM_IndexDelete *ideltid = &delstate->deltids[i]; + TM_IndexStatus *istatus = delstate->status + ideltid->id; + ItemPointer htid = &ideltid->tid; + OffsetNumber offnum; + + /* + * Read buffer for this block if we haven't already. Avoid refetching + * if it's the same block as the previous entry. + */ + if (blkno == InvalidBlockNumber || + ItemPointerGetBlockNumber(htid) != blkno) + { + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); + + blkno = ItemPointerGetBlockNumber(htid); + buf = ReadBuffer(rel, blkno); + LockBuffer(buf, BUFFER_LOCK_SHARE); + page = BufferGetPage(buf); + maxoff = PageGetMaxOffsetNumber(page); + } + + offnum = ItemPointerGetOffsetNumber(htid); + + /* Sanity check: offset must be valid */ + if (offnum < FirstOffsetNumber || offnum > maxoff) + { + /* + * Index entry points to invalid offset. Mark as deletable to + * clean up the corruption. + */ + istatus->knowndeletable = true; + finalndeltids = i + 1; + continue; + } + + /* Already known to be deletable by the index AM? */ + if (istatus->knowndeletable) + { + Assert(!delstate->bottomup && !istatus->promising); + finalndeltids = i + 1; + continue; + } + + { + ItemId lp = PageGetItemId(page, offnum); + + if (!ItemIdIsNormal(lp)) + { + /* + * LP_DEAD, LP_UNUSED, or LP_REDIRECT: the tuple is gone. The + * index entry can be removed. + */ + istatus->knowndeletable = true; + } + else + { + FluxTupleHeader *tuple_hdr; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, lp); + + /* + * For FLUX, a tuple is vacuumable (and its index entry + * deletable) if it is deleted AND old enough that no snapshot + * can see it. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (FluxTupleDeadToAll(tuple_hdr, FluxGetOldestXminHorizon(rel))) + { + istatus->knowndeletable = true; + } + else + { + /* Recently dead -- cannot delete index entry yet */ + continue; + } + } + else + { + /* Live tuple -- cannot delete index entry */ + continue; + } + } + } + + /* Track progress for bottom-up deletion */ + if (delstate->bottomup && istatus->knowndeletable) + { + int actualfreespace = 0; + + actualfreespace += istatus->freespace; + if (actualfreespace >= delstate->bottomupfreespace) + { + /* Met the space target -- stop early */ + finalndeltids = i + 1; + break; + } + } + + finalndeltids = i + 1; + } + + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); + + /* + * Shrink deltids array to exclude non-deletable entries at the end. + */ + Assert(finalndeltids > 0 || delstate->bottomup); + delstate->ndeltids = finalndeltids; + + return snapshotConflictHorizon; +} + +/* + * Copy data for FLUX relation (used by ALTER TABLE SET ACCESS METHOD, etc.) + * + * This performs a block-level copy of all storage forks from the old + * relation files to new ones. Since we copy directly without examining + * shared buffers, we must flush any dirty pages first. The old physical + * files are scheduled for deletion. + */ +/* + * flux_revert_pending + * Revert every aborted version of rel whose UNDO has not been applied + * yet, before rel's contents are copied to a new file. + * + * FLUX updates in place, so a transaction whose rollback was handed to an + * undo apply worker, or a crash loser, leaves its images on the page until + * the worker gets to them. A rewrite copies the page into a new file, and + * the UNDO records name the old one (FluxUndoPayloadHeader.relnumber), so + * once the rewrite commits nothing would ever restore the copies: the copied + * aborted UPDATE would be the row's value for good, and an aborted DELETE + * would lose the row. So the rewrite reverts them first, as a writer does + * before it changes a row (flux_help_revert). + * + * The caller holds AccessExclusiveLock on rel. An undo apply worker that + * wants to apply the same records waits for that lock in flux_undo_apply, + * and finds them no longer current afterwards (or the file gone), so only + * this backend changes the pages here. flux_help_revert releases the buffer + * when it reverted something; the page is then locked again and the same + * offset is retried, until nothing on it is left to revert. The index + * reversal of an in-place key UPDATE (flux_undo_index_first) runs on the old + * indexes, which the rewrite rebuilds from the new file anyway. + * + * A hot standby does not rewrite tables, and flux_help_revert never changes + * a page during recovery. + */ +static void +flux_revert_pending(Relation rel) +{ + BufferAccessStrategy bstrategy; + BlockNumber nblocks; + + if (RecoveryInProgress()) + return; + + bstrategy = GetAccessStrategy(BAS_BULKREAD); + nblocks = RelationGetNumberOfBlocks(rel); + for (BlockNumber blkno = 0; blkno < nblocks; blkno++) + { + Buffer buf; + Page page; + bool pending = false; + + CHECK_FOR_INTERRUPTS(); + + /* Look under a share lock first: most pages have nothing pending. */ + buf = ReadBufferExtended(rel, MAIN_FORKNUM, blkno, RBM_NORMAL, + bstrategy); + LockBuffer(buf, BUFFER_LOCK_SHARE); + page = BufferGetPage(buf); + if (!PageIsNew(page)) + { + for (OffsetNumber off = FirstOffsetNumber; + !pending && off <= PageGetMaxOffsetNumber(page); + off = OffsetNumberNext(off)) + { + ItemId lp = PageGetItemId(page, off); + + pending = ItemIdIsNormal(lp) && + !FluxIsOverflowRecordInline(PageGetItem(page, lp), + ItemIdGetLength(lp)) && + FluxTupleHasAbortedVersion((FluxTupleHeader *) + PageGetItem(page, lp)); + } + } + if (!pending) + { + UnlockReleaseBuffer(buf); + continue; + } + + LockBuffer(buf, BUFFER_LOCK_UNLOCK); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + for (OffsetNumber off = FirstOffsetNumber; + off <= PageGetMaxOffsetNumber(page); + off = OffsetNumberNext(off)) + { + ItemId lp = PageGetItemId(page, off); + + if (!ItemIdIsNormal(lp) || + FluxIsOverflowRecordInline(PageGetItem(page, lp), + ItemIdGetLength(lp))) + continue; + + while (flux_help_revert(rel, buf, off)) + { + buf = ReadBufferExtended(rel, MAIN_FORKNUM, blkno, + RBM_NORMAL, bstrategy); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + } + } + UnlockReleaseBuffer(buf); + } + FreeAccessStrategy(bstrategy); +} + +static void +flux_relation_copy_data(Relation rel, const RelFileLocator *newrlocator) +{ + SMgrRelation dstrel; + + /* + * The file is copied as it is, so first revert what an aborted + * transaction left on it: see flux_revert_pending. + */ + flux_revert_pending(rel); + + /* + * Since we copy the file directly without looking at the shared buffers, + * we'd better first flush out any pages of the source relation that are + * in shared buffers. We assume no new changes will be made while we are + * holding exclusive lock on the rel. + */ + FlushRelationBuffers(rel); + + /* + * Create and copy all forks of the relation, and schedule unlinking of + * old physical files. + * + * NOTE: any conflict in relfilenumber value will be caught in + * RelationCreateStorage(). + */ + dstrel = RelationCreateStorage(*newrlocator, rel->rd_rel->relpersistence, + true); + + /* Copy main fork */ + RelationCopyStorage(RelationGetSmgr(rel), dstrel, MAIN_FORKNUM, + rel->rd_rel->relpersistence); + + /* Copy any extra forks that exist (FSM, etc.) */ + for (ForkNumber forkNum = MAIN_FORKNUM + 1; + forkNum <= MAX_FORKNUM; forkNum++) + { + if (smgrexists(RelationGetSmgr(rel), forkNum)) + { + smgrcreate(dstrel, forkNum, false); + + /* + * WAL log creation if the relation is persistent, or this is the + * init fork of an unlogged relation. + */ + if (RelationIsPermanent(rel) || + (rel->rd_rel->relpersistence == RELPERSISTENCE_UNLOGGED && + forkNum == INIT_FORKNUM)) + log_smgrcreate(newrlocator, forkNum); + RelationCopyStorage(RelationGetSmgr(rel), dstrel, forkNum, + rel->rd_rel->relpersistence); + } + } + + /* Drop old relation storage, and close new one */ + RelationDropStorage(rel); + smgrclose(dstrel); +} + +/* + * flux_cluster_write + * Copy one row of a CLUSTER / VACUUM FULL rewrite into the new relation, + * with the writers of the version it copies (FLUX_INSERT_REWRITE), as + * heap's rewriteheap.c keeps xmin and xmax: a snapshot taken before the + * rewrite must see the same rows after it. CLUSTER and VACUUM FULL are + * MVCC-safe (see "Caveats" in mvcc.sgml); only TRUNCATE, the rewriting + * ALTER TABLE forms and REPACK CONCURRENTLY are not. + */ +static void +flux_cluster_write(Relation NewTable, TupleTableSlot *slot, + const FluxRewriteVersion *v) +{ + FluxSetRewriteVersion(v); + table_tuple_insert(NewTable, slot, v->cmin, FLUX_INSERT_REWRITE, NULL); +} + +/* + * The writers of the version hdr, for a rewrite whose freeze cutoff is + * xid_cutoff. Its inserter committed or is this transaction: an aborted one + * was dropped or reverted (flux_revert_pending), and nobody else can be + * writing, as we hold AccessExclusiveLock. A committed inserter before the + * cutoff is frozen, as heap's rewrite_heap_tuple freezes with + * heap_freeze_tuple: the cutoff becomes the new relfrozenxid. A deleter + * that survives the rewrite is recently dead, so it is at or after the + * cutoff; it committed unless it is ours. + */ +static void +flux_rewrite_version_of(const FluxTupleHeader *hdr, TransactionId xid_cutoff, + FluxRewriteVersion *v) +{ + TransactionId xmax = FluxTupleGetXmax(hdr); + bool own = TransactionIdIsCurrentTransactionId(FluxTupleGetXmin(hdr)); + + memset(v, 0, sizeof(*v)); + v->xmin = FluxTupleGetXmin(hdr); + v->cmin = own ? hdr->t_cmin : FirstCommandId; + v->xmin_committed = !own; + if (!own && TransactionIdIsNormal(v->xmin) && + TransactionIdPrecedes(v->xmin, xid_cutoff)) + v->xmin = FrozenTransactionId; + if ((hdr->t_flags & FLUX_TUPLE_DELETED) && TransactionIdIsValid(xmax)) + { + v->xmax = xmax; + v->xmax_committed = !TransactionIdIsCurrentTransactionId(xmax); + v->moved_partition = + (hdr->t_flags & FLUX_TUPLE_MOVED_PARTITION) != 0; + } +} + +/* + * Copy the version in slot, whose writers are v, into the new relation, or + * into the CLUSTER sort. The sort keeps a heap tuple, header included, so + * the writers travel in its header as a heap tuple's would, and the version + * needs no other identity: the versions of one row share its old TID. + */ +static void +flux_cluster_emit(Relation NewTable, TupleTableSlot *slot, + const FluxRewriteVersion *v, Tuplesortstate *tuplesort) +{ + bool shouldFree; + HeapTuple tuple; + HeapTupleHeader htup; + + if (tuplesort == NULL) + { + flux_cluster_write(NewTable, slot, v); + return; + } + + tuple = ExecFetchSlotHeapTuple(slot, true, &shouldFree); + htup = tuple->t_data; + htup->t_infomask &= ~HEAP_XACT_MASK; + HeapTupleHeaderSetXmin(htup, v->xmin); + HeapTupleHeaderSetCmin(htup, v->cmin); + HeapTupleHeaderSetXmax(htup, v->xmax); + if (v->xmin_committed) + htup->t_infomask |= HEAP_XMIN_COMMITTED; + if (!TransactionIdIsValid(v->xmax)) + htup->t_infomask |= HEAP_XMAX_INVALID; + else if (v->xmax_committed) + htup->t_infomask |= HEAP_XMAX_COMMITTED; + htup->t_ctid = slot->tts_tid; + if (v->moved_partition) + HeapTupleHeaderSetMovedPartitions(htup); + tuplesort_putheaptuple(tuplesort, tuple); + if (shouldFree) + heap_freetuple(tuple); +} + +/* The writers flux_cluster_emit put in a sorted tuple's header. */ +static void +flux_cluster_sorted_version(HeapTupleHeader htup, FluxRewriteVersion *v) +{ + memset(v, 0, sizeof(*v)); + v->xmin = HeapTupleHeaderGetRawXmin(htup); + v->cmin = HeapTupleHeaderGetRawCommandId(htup); + v->xmin_committed = (htup->t_infomask & HEAP_XMIN_COMMITTED) != 0; + if (!(htup->t_infomask & HEAP_XMAX_INVALID)) + { + v->xmax = HeapTupleHeaderGetRawXmax(htup); + v->xmax_committed = (htup->t_infomask & HEAP_XMAX_COMMITTED) != 0; + v->moved_partition = HeapTupleHeaderIndicatesMovedPartitions(htup); + } +} + +/* + * The copy of REPACK (CONCURRENTLY): the rows 'snapshot' sees, in the order + * of OldIndex or physical order, inserted into the new relation by this + * transaction. repack.c then replays the changes committed after the + * snapshot, which it decodes from the WAL, onto the new relation. + * + * Unlike the copy for CLUSTER and VACUUM FULL, this one is not MVCC-safe, + * exactly like heap's: a version only an older snapshot sees is not copied, + * and the copied rows are this transaction's, so a snapshot taken before + * REPACK committed finds the table empty afterwards (see "Caveats" in + * mvcc.sgml). Only that way do the replayed changes, which this + * transaction makes too, apply to the copied rows. + * + * The scan is an ordinary MVCC scan, which reconstructs the version the + * snapshot sees from UNDO. So an aborted transaction's change, even with its + * revert still pending, is not copied (its writer is not committed in the + * snapshot), and the revert, when it comes, finds the record naming the old + * file and does nothing to the new one. The UNDO the snapshot needs stays: + * UNDO is discarded only behind GetOldestNonRemovableTransactionId(), the + * horizon that also keeps heap from pruning the tuples the snapshot sees. + * + * An index scan of OldIndex returns each row once: an in-place key UPDATE + * leaves a delete-marked entry under the old key, and the key recheck in + * flux_index_getnext_slot skips the entry whose key the version the snapshot + * sees does not carry. + */ +static void +flux_copy_for_concurrent_repack(Relation OldTable, Relation NewTable, + Relation OldIndex, bool use_sort, + Snapshot snapshot, double *num_tuples) +{ + TableScanDesc tableScan = NULL; + IndexScanDesc indexScan = NULL; + Tuplesortstate *tuplesort = NULL; + TupleTableSlot *slot; + BulkInsertState bistate = GetBulkInsertState(); + CommandId cid = GetCurrentCommandId(true); + + if (OldIndex != NULL && !use_sort) + { + pgstat_progress_update_param(PROGRESS_REPACK_PHASE, + PROGRESS_REPACK_PHASE_INDEX_SCAN_HEAP); + indexScan = index_beginscan(OldTable, OldIndex, false, snapshot, + NULL, 0, 0, SO_NONE); + index_rescan(indexScan, NULL, 0, NULL, 0); + } + else + { + pgstat_progress_update_param(PROGRESS_REPACK_PHASE, + PROGRESS_REPACK_PHASE_SEQ_SCAN_HEAP); + tableScan = table_beginscan(OldTable, snapshot, 0, NULL, SO_NONE); + } + + if (use_sort) + tuplesort = tuplesort_begin_cluster(RelationGetDescr(OldTable), + OldIndex, maintenance_work_mem, + NULL, TUPLESORT_NONE); + + slot = table_slot_create(OldTable, NULL); + for (;;) + { + CHECK_FOR_INTERRUPTS(); + + if (indexScan != NULL) + { + if (!table_index_getnext_slot(indexScan, ForwardScanDirection, + slot)) + break; + } + else if (!table_scan_getnextslot(tableScan, ForwardScanDirection, + slot)) + break; + + *num_tuples += 1; + if (tuplesort != NULL) + { + bool shouldFree; + HeapTuple tuple = ExecFetchSlotHeapTuple(slot, true, &shouldFree); + + tuplesort_putheaptuple(tuplesort, tuple); + if (shouldFree) + heap_freetuple(tuple); + } + else + { + const int ct_index[] = { + PROGRESS_REPACK_HEAP_TUPLES_SCANNED, + PROGRESS_REPACK_HEAP_TUPLES_INSERTED + }; + int64 ct_val[2] = {*num_tuples, *num_tuples}; + + table_tuple_insert(NewTable, slot, cid, TABLE_INSERT_NO_LOGICAL, + bistate); + pgstat_progress_update_multi_param(2, ct_index, ct_val); + continue; + } + pgstat_progress_update_param(PROGRESS_REPACK_HEAP_TUPLES_SCANNED, + *num_tuples); + } + + if (indexScan != NULL) + index_endscan(indexScan); + if (tableScan != NULL) + table_endscan(tableScan); + + if (tuplesort != NULL) + { + double n_tuples = 0; + + pgstat_progress_update_param(PROGRESS_REPACK_PHASE, + PROGRESS_REPACK_PHASE_SORT_TUPLES); + tuplesort_performsort(tuplesort); + pgstat_progress_update_param(PROGRESS_REPACK_PHASE, + PROGRESS_REPACK_PHASE_WRITE_NEW_HEAP); + for (;;) + { + HeapTuple tuple; + + CHECK_FOR_INTERRUPTS(); + + tuple = tuplesort_getheaptuple(tuplesort, true); + if (tuple == NULL) + break; + ExecForceStoreHeapTuple(tuple, slot, false); + table_tuple_insert(NewTable, slot, cid, TABLE_INSERT_NO_LOGICAL, + bistate); + pgstat_progress_update_param(PROGRESS_REPACK_HEAP_TUPLES_INSERTED, + ++n_tuples); + } + tuplesort_end(tuplesort); + } + + ExecDropSingleTupleTableSlot(slot); + FreeBulkInsertState(bistate); +} + +/* + * Copy data for cluster operation + * + * This is called by CLUSTER and VACUUM FULL to copy tuples from the old + * table to the new one, optionally reordering by an index. We scan the + * old table using SnapshotAny and perform our own MVCC visibility checks + * to decide which tuples to keep, which to discard as dead, and which + * are recently dead. + * + * For FLUX, visibility is determined by the tuple's timestamp-based MVCC + * flags (deleted flag, commit timestamp, etc.) rather than heap-style xmin/xmax. + */ +static void +flux_relation_copy_for_cluster(Relation OldTable, Relation NewTable, + Relation OldIndex, bool use_sort, + TransactionId OldestXmin, + Snapshot snapshot, + TransactionId *xid_cutoff, + MultiXactId *multi_cutoff, + double *num_tuples, + double *tups_vacuumed, + double *tups_recently_dead) +{ + TableScanDesc tableScan; + IndexScanDesc indexScan; + TupleTableSlot *slot; + TupleTableSlot *vslot; + Tuplesortstate *tuplesort = NULL; + double live_tuples = 0; + double dead_tuples = 0; + double recent_dead = 0; + + /* + * Keep the caller's *xid_cutoff / *multi_cutoff: they become the new + * relfrozenxid / relminmxid of the table and of its TOAST table. Rows + * keep the writers of the versions they copy (flux_cluster_write). A + * version survives the copy only while some snapshot may see it, so its + * deleter is ours or committed at or after OldestXmin, which the cutoff + * does not follow; an inserter before the cutoff is frozen + * (flux_rewrite_version_of). So no copied xid precedes the cutoff. FLUX + * tuples carry no multixacts. + */ + *num_tuples = 0; + *tups_vacuumed = 0; + *tups_recently_dead = 0; + + /* Only REPACK (CONCURRENTLY) passes a snapshot. */ + if (snapshot != NULL) + { + flux_copy_for_concurrent_repack(OldTable, NewTable, OldIndex, + use_sort, snapshot, num_tuples); + return; + } + + /* + * Valid smgr_targblock implies something already wrote to the relation. + * This may be harmless, but this function hasn't planned for it. + */ + Assert(RelationGetTargetBlock(NewTable) == InvalidBlockNumber); + + /* + * Copy the committed state, not what an aborted transaction left on the + * page: see flux_revert_pending. This must precede the scan, which reads + * the page versions with SnapshotAny. + */ + flux_revert_pending(OldTable); + + /* + * Set up the scan. If we have an index and are not doing a sort, use an + * index scan to get tuples in index order. Otherwise do a sequential scan + * (and optionally sort afterward). + */ + if (OldIndex != NULL && !use_sort) + { + pgstat_progress_update_param(PROGRESS_REPACK_PHASE, + PROGRESS_REPACK_PHASE_INDEX_SCAN_HEAP); + + tableScan = NULL; + indexScan = index_beginscan(OldTable, OldIndex, false, SnapshotAny, + NULL, 0, 0, SO_RETURN_DELETED); + index_rescan(indexScan, NULL, 0, NULL, 0); + } + else + { + pgstat_progress_update_param(PROGRESS_REPACK_PHASE, + PROGRESS_REPACK_PHASE_SEQ_SCAN_HEAP); + + tableScan = table_beginscan(OldTable, SnapshotAny, 0, NULL, + SO_RETURN_DELETED); + indexScan = NULL; + } + + /* + * Both scans hand over deleted versions too (SO_RETURN_DELETED): the loop + * below keeps those some snapshot may still see. + */ + slot = table_slot_create(OldTable, NULL); + vslot = table_slot_create(OldTable, NULL); + + /* + * Scan and sort, as heap does: repack.c asks for a sort when the planner + * finds it cheaper than an index scan, and always for an expression + * index. Ignoring use_sort copied the rows in physical order. + */ + if (use_sort) + tuplesort = tuplesort_begin_cluster(RelationGetDescr(OldTable), + OldIndex, maintenance_work_mem, + NULL, TUPLESORT_NONE); + + /* + * Scan through the old table. For each tuple, check visibility using + * FLUX's timestamp-based MVCC and either copy it to the new table or skip + * it. + */ + for (;;) + { + bool isdead = false; + FluxRewriteVersion version; + char *image = NULL; + Size image_len = 0; + ItemPointerData otid; + List *older; + + CHECK_FOR_INTERRUPTS(); + + if (indexScan != NULL) + { + if (!table_index_getnext_slot(indexScan, ForwardScanDirection, + slot)) + break; + } + else + { + if (!table_scan_getnextslot(tableScan, ForwardScanDirection, slot)) + break; + } + + /* + * For FLUX, check tuple visibility using our page-level access. Read + * the tuple header from the page to check MVCC flags. + */ + { + Buffer buf; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + BlockNumber blkno = ItemPointerGetBlockNumber(&slot->tts_tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(&slot->tts_tid); + + buf = ReadBuffer(OldTable, blkno); + LockBuffer(buf, BUFFER_LOCK_SHARE); + page = BufferGetPage(buf); + + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + /* Item pointer is dead or unused -- skip */ + UnlockReleaseBuffer(buf); + dead_tuples++; + continue; + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * A version written by an INSERT (no UNDO history) whose inserter + * aborted is never visible to anyone, whatever OldestXmin says: + * drop it, as heap does with HEAPTUPLE_DEAD and as + * flux_index_build_range_scan does. INSERT writes no UNDO, so + * flux_revert_pending leaves it alone; FluxTupleDeadToAll only + * reclaims it once the inserter is behind the horizon, which a + * pending revert may hold back. Our own transaction's rows are + * kept. Nobody else can still be inserting, since we hold + * AccessExclusiveLock. + */ + if (!UndoRecPtrIsValid(tuple_hdr->t_verptr) && + !(tuple_hdr->t_flags & FLUX_TUPLE_XMIN_COMMITTED)) + { + TransactionId xmin = FluxTupleGetXmin(tuple_hdr); + + if (TransactionIdIsNormal(xmin) && + !TransactionIdIsCurrentTransactionId(xmin) && + !TransactionIdIsInProgress(xmin) && + !TransactionIdDidCommit(xmin)) + isdead = true; + } + + /* + * An UPDATED tuple is the live current version: FLUX updates in + * place, and an UPDATE that moves the row deletes the old + * version. + */ + if (!isdead && (tuple_hdr->t_flags & FLUX_TUPLE_DELETED)) + { + /* + * Tuple has been deleted. Check whether it's old enough to be + * truly dead vs recently dead (still needed for MVCC + * snapshots), by the XID horizon (FluxTupleDeadToAll). + */ + if (FluxTupleDeadToAll(tuple_hdr, OldestXmin)) + { + /* Definitely dead -- can discard */ + isdead = true; + } + else + { + /* + * Recently dead -- still needed by some snapshots. Copy + * it with its deleter, as heap copies a RECENTLY_DEAD + * tuple. + */ + recent_dead++; + isdead = false; + } + } + + /* + * The writers of the version to copy, and the version itself if + * it has history (the chain is walked without the lock). + */ + if (!isdead) + { + flux_rewrite_version_of(tuple_hdr, *xid_cutoff, &version); + if (UndoRecPtrIsValid(FluxTupleGetVersionPtr(tuple_hdr))) + { + image_len = ItemIdGetLength(itemid); + image = palloc(image_len); + memcpy(image, tuple_hdr, image_len); + } + } + + UnlockReleaseBuffer(buf); + } + + if (isdead) + { + dead_tuples++; + continue; + } + + /* Live or recently-dead tuple -- copy to new table */ + otid = slot->tts_tid; /* the insert sets the new TID */ + flux_cluster_emit(NewTable, slot, &version, tuplesort); + live_tuples++; + + /* + * A row updated in place holds its older versions in UNDO, which the + * new relation does not inherit. Copy each one some snapshot may + * still see as a row of its own, deleted by the update that replaced + * it, as heap copies the RECENTLY_DEAD versions of an update chain. + * Each is a version of the same row: they share its old TID, and + * VACUUM reclaims them like any committed delete. + * + * The version another transaction wrote and we updated is copied + * deleted by us. One we wrote ourselves and updated again is + * dropped: no other transaction can see it, and this one's later + * commands see only the newest version. A cursor of ours opened + * before our own update loses the row, as the per-TID command ids of + * our own updates (FluxLocalCidGet) do not carry over to the new + * relation. + */ + older = image != NULL ? + FluxRewriteChain(OldTable, &otid, image, image_len, + OldestXmin) : NIL; + if (image != NULL) + pfree(image); + foreach_ptr(FluxChainVersion, cv, older) + { + FluxRewriteVersion ov; + + if (TransactionIdIsCurrentTransactionId(FluxTupleGetXmin((FluxTupleHeader *) cv->image))) + { + pfree(cv->image); + continue; + } + flux_rewrite_version_of((FluxTupleHeader *) cv->image, + *xid_cutoff, &ov); + ov.xmax = cv->xmax; + ov.xmax_committed = !TransactionIdIsCurrentTransactionId(cv->xmax); + ov.moved_partition = false; + FluxSlotStoreTuple(vslot, (FluxTupleHeader *) cv->image, cv->len, + InvalidBuffer); + vslot->tts_tableOid = RelationGetRelid(OldTable); + vslot->tts_tid = otid; + flux_cluster_emit(NewTable, vslot, &ov, tuplesort); + pfree(cv->image); + recent_dead++; + live_tuples++; + } + list_free_deep(older); + + pgstat_progress_update_param(PROGRESS_REPACK_HEAP_TUPLES_SCANNED, + live_tuples + dead_tuples + recent_dead); + } + + /* Clean up scan resources */ + if (indexScan != NULL) + index_endscan(indexScan); + if (tableScan != NULL) + table_endscan(tableScan); + + if (tuplesort != NULL) + { + double n_tuples = 0; + + pgstat_progress_update_param(PROGRESS_REPACK_PHASE, + PROGRESS_REPACK_PHASE_SORT_TUPLES); + tuplesort_performsort(tuplesort); + pgstat_progress_update_param(PROGRESS_REPACK_PHASE, + PROGRESS_REPACK_PHASE_WRITE_NEW_HEAP); + + for (;;) + { + HeapTuple tuple; + FluxRewriteVersion version; + + CHECK_FOR_INTERRUPTS(); + + tuple = tuplesort_getheaptuple(tuplesort, true); + if (tuple == NULL) + break; + flux_cluster_sorted_version(tuple->t_data, &version); + ExecForceStoreHeapTuple(tuple, slot, false); + flux_cluster_write(NewTable, slot, &version); + pgstat_progress_update_param(PROGRESS_REPACK_HEAP_TUPLES_INSERTED, + ++n_tuples); + } + tuplesort_end(tuplesort); + } + + ExecDropSingleTupleTableSlot(vslot); + ExecDropSingleTupleTableSlot(slot); + + /* Return statistics to caller */ + *num_tuples = live_tuples; + *tups_vacuumed = dead_tuples; + *tups_recently_dead = recent_dead; +} + +/* + * Build range scan for index creation + * + * Scans the FLUX table and feeds tuples to the index AM's callback for + * index building. Handles partial indexes, expression indexes, uniqueness + * checking, concurrent index builds, and proper visibility classification. + * + * Modeled on heapam_index_build_range_scan(). + */ +static double +flux_index_build_range_scan(Relation tablerel, Relation indexrel, + IndexInfo *indexInfo, bool allow_sync, + bool anyvisible, bool progress, + BlockNumber start_blockno, BlockNumber numblocks, + IndexBuildCallback callback, void *callback_state, + TableScanDesc scan) +{ + double reltuples = 0; + bool checking_uniqueness; + Datum values[INDEX_MAX_KEYS]; + bool isnull[INDEX_MAX_KEYS]; + ExprState *predicate; + TupleTableSlot *slot; + EState *estate; + ExprContext *econtext; + Snapshot snapshot; + bool need_unregister_snapshot = false; + + /* See whether we're verifying uniqueness/exclusion properties */ + checking_uniqueness = (indexInfo->ii_Unique || + indexInfo->ii_ExclusionOps != NULL); + + /* "Any visible" mode is not compatible with uniqueness checks */ + Assert(!(anyvisible && checking_uniqueness)); + + /* + * Need an EState for evaluation of index expressions and partial-index + * predicates. Also a slot to hold the current tuple. + */ + estate = CreateExecutorState(); + econtext = GetPerTupleExprContext(estate); + slot = table_slot_create(tablerel, NULL); + + /* Arrange for econtext's scan tuple to be the tuple under test */ + econtext->ecxt_scantuple = slot; + + /* Set up execution state for predicate, if any */ + predicate = ExecPrepareQual(indexInfo->ii_Predicate, estate); + + /* + * Prepare for scan. Normal index build uses SnapshotAny (we do our own + * visibility checks). Concurrent/bootstrap uses an MVCC snapshot. + */ + if (!scan) + { + if (IsBootstrapProcessingMode() || indexInfo->ii_Concurrent) + { + snapshot = RegisterSnapshot(GetTransactionSnapshot()); + need_unregister_snapshot = true; + } + else + snapshot = SnapshotAny; + + scan = table_beginscan_strat(tablerel, snapshot, 0, NULL, + true, allow_sync); + } + else + { + snapshot = scan->rs_snapshot; + } + + /* Recently dead rows, and those of aborted DELETEs; see below. */ + if (snapshot == SnapshotAny) + ((FluxScanDesc) scan)->rs_keep_deleted = true; + + /* Scan only the blocks asked for, as heap does (BRIN summarization). */ + if (!allow_sync) + flux_scan_setlimits(scan, start_blockno, numblocks); + else + { + /* syncscan can only be requested on whole relation */ + Assert(start_blockno == 0); + Assert(numblocks == InvalidBlockNumber); + } + + /* Scan all tuples in the base relation */ + while (table_scan_getnextslot(scan, ForwardScanDirection, slot)) + { + bool tupleIsAlive; + + CHECK_FOR_INTERRUPTS(); + + if (snapshot == SnapshotAny) + { + /* + * Classify the tuple using FLUX's timestamp-based MVCC by + * re-reading the tuple header from the page. + */ + Buffer buf; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr = NULL; + BlockNumber blkno = ItemPointerGetBlockNumber(&slot->tts_tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(&slot->tts_tid); + bool indexIt; + FluxPendingVersion pending; + + buf = ReadBuffer(tablerel, blkno); + /* Share-exclusive, for flux_pending_version and the copy below. */ + LockBuffer(buf, BUFFER_LOCK_SHARE_EXCLUSIVE); + page = BufferGetPage(buf); + + /* + * A row whose version is an aborted transaction's, with the + * revert still pending, is indexed as the revert will leave it: + * an aborted UPDATE's row at its committed version, an aborted + * DELETE's row (the scan hands those over too, see + * rs_keep_deleted) as it was before, and an aborted INSERT's row + * not at all. Indexing the aborted version would leave the index + * wrong for good, as the revert reverses only the index changes + * the aborted transaction made itself. The slot then holds the + * version to index. + */ + pending = flux_pending_version(tablerel, buf, offnum, slot); + itemid = PageGetItemId(page, offnum); + if (pending == FLUX_PENDING_GONE || + (pending == FLUX_PENDING_NONE && !ItemIdIsNormal(itemid))) + { + UnlockReleaseBuffer(buf); + continue; + } + + if (pending == FLUX_PENDING_NONE) + { + /* + * Index the version on the page now, not the scan's copy of + * it: a revert may have run since. + */ + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + FluxSlotStoreTuple(slot, tuple_hdr, ItemIdGetLength(itemid), + buf); + slot->tts_tableOid = RelationGetRelid(tablerel); + ItemPointerSet(&slot->tts_tid, blkno, offnum); + } + + if (pending == FLUX_PENDING_RECONSTRUCTED) + { + /* Committed (or our own) version: live */ + indexIt = true; + tupleIsAlive = true; + reltuples += 1; + } + else if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (FluxTupleDeadToAll(tuple_hdr, FluxGetOldestXminHorizon(tablerel))) + { + /* Definitely dead -- skip */ + UnlockReleaseBuffer(buf); + continue; + } + else + { + /* Recently dead -- index for MVCC, don't count */ + indexIt = true; + tupleIsAlive = false; + } + } + else if (tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE) + { + /* Speculative insertion not yet confirmed -- skip */ + UnlockReleaseBuffer(buf); + continue; + } + else if (!UndoRecPtrIsValid(tuple_hdr->t_verptr) && + !(tuple_hdr->t_flags & FLUX_TUPLE_XMIN_COMMITTED) && + !TransactionIdIsCurrentTransactionId(FluxTupleGetXmin(tuple_hdr))) + { + /* + * Version written by an INSERT (no UNDO history), by another + * transaction: its inserter decides, as for heap. An aborted + * INSERT's row is dead (only VACUUM removes it). An + * in-progress one is indexed, but when checking uniqueness we + * first wait for it, exactly like heap. The caller's + * ShareLock keeps any new inserter from starting. + */ + TransactionId xmin = FluxTupleGetXmin(tuple_hdr); + + UnlockReleaseBuffer(buf); + + /* + * In "any visible" mode (BRIN summarization) the row is + * simply indexed, with no warning, as heap does. + */ + if (!anyvisible && TransactionIdIsInProgress(xmin)) + { + elog(WARNING, "concurrent insert in progress within table \"%s\"", + RelationGetRelationName(tablerel)); + if (checking_uniqueness) + { + XactLockTableWait(xmin, tablerel, &slot->tts_tid, + XLTW_InsertIndexUnique); + CHECK_FOR_INTERRUPTS(); + } + } + /* In progress first: it may commit between the two tests. */ + if (TransactionIdIsInProgress(xmin)) + { + indexIt = true; + tupleIsAlive = true; + } + else if (TransactionIdDidCommit(xmin)) + { + indexIt = true; + tupleIsAlive = true; + reltuples += 1; + } + else + continue; /* aborted: never visible to anyone */ + buf = InvalidBuffer; + } + else + { + /* Live tuple -- index and count it */ + indexIt = true; + tupleIsAlive = true; + reltuples += 1; + } + + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); + + if (!indexIt) + continue; + } + else + { + /* MVCC snapshot already filtered for visibility */ + tupleIsAlive = true; + reltuples += 1; + } + + MemoryContextReset(econtext->ecxt_per_tuple_memory); + + /* In a partial index, discard tuples that don't satisfy predicate */ + if (predicate != NULL) + { + if (!ExecQual(predicate, econtext)) + continue; + } + + /* + * Extract all indexed attributes. This also evaluates any index + * expressions. + */ + FormIndexDatum(indexInfo, slot, estate, values, isnull); + + /* + * Call the AM's callback with the tuple's own TID. FLUX secondary + * indexes hold plain 6-byte heap-style TIDs (no RowID/gen suffix), so + * this matches the stock heap index-build contract exactly. + */ + callback(indexrel, &slot->tts_tid, + values, isnull, tupleIsAlive, callback_state); + } + + table_endscan(scan); + + if (need_unregister_snapshot) + UnregisterSnapshot(snapshot); + + ExecDropSingleTupleTableSlot(slot); + FreeExecutorState(estate); + + /* These may have been pointing to the now-gone estate */ + indexInfo->ii_ExpressionsState = NIL; + indexInfo->ii_PredicateState = NULL; + + return reltuples; +} + +/* + * Validate scan for index + */ +static void +flux_index_validate_scan(Relation tablerel, Relation indexrel, + IndexInfo *indexInfo, Snapshot snapshot, + ValidateIndexState *state) +{ + TableScanDesc scan; + TupleTableSlot *slot; + Datum values[INDEX_MAX_KEYS]; + bool isnull[INDEX_MAX_KEYS]; + ExprState *predicate; + EState *estate; + ExprContext *econtext; + ItemPointer indexcursor = NULL; + ItemPointerData decoded; + bool tuplesort_empty = false; + + /* + * Need an EState for evaluation of index expressions and partial-index + * predicates. + */ + estate = CreateExecutorState(); + econtext = GetPerTupleExprContext(estate); + slot = table_slot_create(tablerel, NULL); + econtext->ecxt_scantuple = slot; + + predicate = ExecPrepareQual(indexInfo->ii_Predicate, estate); + + /* + * Scan the table and the sorted output from tuplesort in parallel. For + * each table tuple, check if there's a matching index entry. Tuples that + * satisfy the predicate but have no index entry need to be inserted into + * the index. + */ + scan = table_beginscan_strat(tablerel, snapshot, 0, NULL, true, false); + + while (table_scan_getnextslot(scan, ForwardScanDirection, slot)) + { + CHECK_FOR_INTERRUPTS(); + + state->htups += 1; + + /* + * Skip tuples that don't satisfy the partial index predicate. + */ + if (predicate != NULL) + { + MemoryContextReset(econtext->ecxt_per_tuple_memory); + if (!ExecQual(predicate, econtext)) + continue; + } + + /* + * Advance the tuplesort cursor past any entries that are for TIDs + * earlier than the current table tuple. + */ + while (!tuplesort_empty && + (!indexcursor || + ItemPointerCompare(indexcursor, &slot->tts_tid) < 0)) + { + Datum ts_val; + bool ts_isnull; + + tuplesort_empty = !tuplesort_getdatum(state->tuplesort, + true, false, + &ts_val, &ts_isnull, + NULL); + Assert(tuplesort_empty || !ts_isnull); + if (!tuplesort_empty) + { + itemptr_decode(&decoded, DatumGetInt64(ts_val)); + indexcursor = &decoded; + } + else + { + indexcursor = NULL; + } + } + + /* + * If the sorted cursor TID matches the current table tuple, the index + * already has this entry. Otherwise, we need to add it. + */ + if (indexcursor != NULL && + ItemPointerCompare(indexcursor, &slot->tts_tid) == 0) + { + /* Already in the index -- skip */ + continue; + } + + MemoryContextReset(econtext->ecxt_per_tuple_memory); + + FormIndexDatum(indexInfo, slot, estate, values, isnull); + + /* + * Insert the missing index entry using the tuple's own TID. + */ + index_insert(indexrel, values, isnull, &slot->tts_tid, + tablerel, indexInfo->ii_Unique ? + UNIQUE_CHECK_YES : UNIQUE_CHECK_NO, + false, indexInfo); + + state->tups_inserted += 1; + } + + /* + * Release any AM-cached insert state (e.g. BRIN's pinned revmap buffer in + * ii_AmCache) before we finish. See the note in + * flux_inplace_index_maintenance. + */ + index_insert_cleanup(indexrel, indexInfo); + + table_endscan(scan); + + ExecDropSingleTupleTableSlot(slot); + FreeExecutorState(estate); + + indexInfo->ii_ExpressionsState = NIL; + indexInfo->ii_PredicateState = NULL; +} + +/* + * Get relation size information + * + * Returns the on-disk size in bytes for the specified fork of the relation. + * This is used by pg_relation_size(), VACUUM, CLUSTER, and many other + * operations that need to know the physical storage footprint. + */ +/* + * Use table_block_relation_size() from tableam.c directly. FLUX uses + * standard BLCKSZ-width forks just like heap, so the generic + * implementation is correct and efficient (no smgrexists() overhead). + */ + +/* + * Check if relation needs a TOAST table + */ +static bool +flux_relation_needs_toast_table(Relation rel) +{ + /* + * FLUX uses standard heap TOAST for wide values (it has no on-page + * overflow mechanism). This mirrors heapam_relation_needs_toast_table: a + * TOAST table is needed iff there is a toastable attribute and the + * maximum tuple length could exceed TOAST_TUPLE_THRESHOLD. + */ + int32 data_length = 0; + bool maxlength_unknown = false; + bool has_toastable_attrs = false; + TupleDesc tupdesc = rel->rd_att; + int32 tuple_length; + int i; + + for (i = 0; i < tupdesc->natts; i++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + + if (att->attisdropped) + continue; + if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL) + continue; + data_length = att_align_nominal(data_length, att->attalign); + if (att->attlen > 0) + { + data_length += att->attlen; + } + else + { + int32 maxlen = type_maximum_size(att->atttypid, + att->atttypmod); + + if (maxlen < 0) + maxlength_unknown = true; + else + data_length += maxlen; + if (att->attstorage != TYPSTORAGE_PLAIN) + has_toastable_attrs = true; + } + } + if (!has_toastable_attrs) + return false; /* nothing to toast? */ + if (maxlength_unknown) + return true; /* any unlimited-length attrs? */ + tuple_length = MAXALIGN(FLUX_TUPLE_OVERHEAD + + BITMAPLEN(tupdesc->natts)) + + MAXALIGN(data_length); + return (tuple_length > TOAST_TUPLE_THRESHOLD); +} + +/* + * TOAST tables for FLUX relations are ordinary heap relations (FLUX reuses + * the standard heap TOAST machinery). + */ +static Oid +flux_relation_toast_am(Relation rel) +{ + return HEAP_TABLE_AM_OID; +} + +/* + * Estimate relation size + * + * Heap's block-oriented estimator, with FLUX's page geometry: it reads no + * buffer (only the relation's block count and pg_class), believes a + * vacuumed empty table, assumes a never-vacuumed one has at least 10 pages, + * and scales reltuples/relpages to the current size. + * + * Every FLUX tuple carries the fixed header plus a null bitmap for all of + * its attributes, NULLs or not (FluxComputeDataSize), and the data starts + * MAXALIGNed after it. Pages carry FLUX's special space and, without a + * fillfactor reloption, are filled to FLUX_DEFAULT_FILLFACTOR, as + * FluxTargetFreeSpace() does on insert. + */ +static void +flux_relation_estimate_size(Relation rel, int32 *attr_widths, + BlockNumber *pages, double *tuples, + double *allvisfrac) +{ + Size overhead; + + overhead = MAXALIGN(SizeOfFluxTupleHeader + + BITMAPLEN(RelationGetNumberOfAttributes(rel))) + + sizeof(ItemIdData); + table_block_relation_estimate_size_extended(rel, attr_widths, pages, + tuples, allvisfrac, + overhead, + BLCKSZ - FLUX_PAGE_OVERHEAD, + FLUX_DEFAULT_FILLFACTOR); +} + +/* + * Sample scan: get next block for sampling (TABLESAMPLE support) + * + * Called by the TABLESAMPLE executor to prepare the next block for tuple + * extraction. The TSM (Table Sample Method) decides which block to visit + * via its NextSampleBlock callback, or, if that callback is NULL, we scan + * sequentially starting from rs_startblock and wrapping around. + * + * We read the selected block into a buffer (pinned, not locked -- locking + * is deferred to flux_scan_sample_next_tuple) and return true. Returns + * false when there are no more blocks to sample. + */ +static bool +flux_scan_sample_next_block(TableScanDesc scan, SampleScanState *scanstate) +{ + FluxScanDesc rscan = (FluxScanDesc) scan; + TsmRoutine *tsm = scanstate->tsmroutine; + BlockNumber blockno; + + /* Return false immediately if relation is empty */ + if (rscan->rs_nblocks == 0) + return false; + + /* Release previous buffer, if any */ + if (BufferIsValid(rscan->rs_cbuf)) + { + ReleaseBuffer(rscan->rs_cbuf); + rscan->rs_cbuf = InvalidBuffer; + } + + if (tsm->NextSampleBlock) + { + /* TSM tells us which block to visit next */ + blockno = tsm->NextSampleBlock(scanstate, rscan->rs_nblocks); + } + else + { + /* No NextSampleBlock callback -- scan sequentially */ + if (rscan->rs_cblock == InvalidBlockNumber) + { + Assert(!rscan->rs_inited); + blockno = rscan->rs_startblock; + } + else + { + Assert(rscan->rs_inited); + + blockno = rscan->rs_cblock + 1; + + if (blockno >= rscan->rs_nblocks) + { + /* Wrap to beginning of relation */ + blockno = 0; + } + + if (blockno == rscan->rs_startblock) + { + /* Completed full cycle -- done */ + blockno = InvalidBlockNumber; + } + } + } + + rscan->rs_cblock = blockno; + + if (!BlockNumberIsValid(blockno)) + { + rscan->rs_inited = false; + return false; + } + + Assert(rscan->rs_cblock < rscan->rs_nblocks); + + CHECK_FOR_INTERRUPTS(); + + /* Read the selected block -- comes back pinned but not locked */ + rscan->rs_cbuf = ReadBufferExtended(scan->rs_rd, MAIN_FORKNUM, + blockno, RBM_NORMAL, NULL); + + rscan->rs_inited = true; + return true; +} + +/* + * Sample scan: get next tuple from current block (TABLESAMPLE support) + * + * Called repeatedly for the block prepared by flux_scan_sample_next_block(). + * The TSM's NextSampleTuple callback decides which tuple offsets to examine. + * We lock the buffer, check the tuple at the selected offset for visibility, + * and either return it in the slot (true) or indicate end-of-page (false). + * + * Unlike the ANALYZE path which iterates all items sequentially, here the + * TSM picks specific offsets, and we loop until it returns InvalidOffsetNumber + * to signal that it is done with this block. + */ +static bool +flux_scan_sample_next_tuple(TableScanDesc scan, SampleScanState *scanstate, + TupleTableSlot *slot) +{ + FluxScanDesc rscan = (FluxScanDesc) scan; + TsmRoutine *tsm = scanstate->tsmroutine; + BlockNumber blockno = rscan->rs_cblock; + Page page; + OffsetNumber maxoffset; + + /* + * Lock the buffer for visibility checks. We hold the lock for the + * duration of this call and release before returning, matching the heap + * AM's non-pagemode pattern. Share-exclusive excludes in-place writers + * while we decide and copy (FluxSlotStoreTuple). + */ + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_SHARE_EXCLUSIVE); + + page = BufferGetPage(rscan->rs_cbuf); + maxoffset = PageGetMaxOffsetNumber(page); + + for (;;) + { + OffsetNumber tupoffset; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + bool visible; + + CHECK_FOR_INTERRUPTS(); + + /* Ask the TSM which tuple to examine next on this page */ + tupoffset = tsm->NextSampleTuple(scanstate, blockno, maxoffset); + + if (OffsetNumberIsValid(tupoffset)) + { + /* Skip invalid item pointers */ + itemid = PageGetItemId(page, tupoffset); + if (!ItemIdIsNormal(itemid)) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Skip overflow records -- not user-visible tuples */ + if (FluxIsOverflowRecordInline(tuple_hdr, ItemIdGetLength(itemid))) + continue; + + /* + * Determine visibility. FLUX uses heap-shaped xmin/xmax MVCC via + * FluxTupleVisibleToSnapshotDual, which handles DELETED/UPDATED + * tuples via sLog consultation. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE) + visible = false; + else if (scan->rs_snapshot) + { + ItemPointerData self_tid; + + ItemPointerSet(&self_tid, blockno, tupoffset); + visible = FluxTupleVisibleToSnapshotDual(tuple_hdr, + scan->rs_snapshot, + RelationGetRelid(scan->rs_rd), + rscan->rs_cbuf, + &self_tid); + } + else + visible = !(tuple_hdr->t_flags & FLUX_TUPLE_DELETED); + + /* SSI, as heap's sample scan; beginscan locked the relation */ + if (!(tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE)) + { + ItemPointerData stid; + + ItemPointerSet(&stid, blockno, tupoffset); + flux_ssi_read(scan->rs_rd, &stid, tuple_hdr, rscan->rs_cbuf, + scan->rs_snapshot, visible, false); + } + + if (!visible && !(tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE)) + { + /* An older version, from UNDO, may be the snapshot's. */ + ItemPointerData stid; + bool unlocked; + bool stored; + + ItemPointerSet(&stid, blockno, tupoffset); + stored = flux_store_visible_older(scan->rs_rd, rscan->rs_cbuf, + &stid, tuple_hdr, + ItemIdGetLength(itemid), + scan->rs_snapshot, slot, + &unlocked); + if (stored) + return true; + if (unlocked) + { + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_SHARE_EXCLUSIVE); + page = BufferGetPage(rscan->rs_cbuf); + maxoffset = PageGetMaxOffsetNumber(page); + } + } + + if (!visible) + continue; + + /* + * Found a visible tuple: copy it into the slot + * (FluxSlotStoreTuple). + */ + FluxSlotStoreTuple(slot, tuple_hdr, + ItemIdGetLength(itemid), rscan->rs_cbuf); + slot->tts_tableOid = RelationGetRelid(scan->rs_rd); + ItemPointerSet(&slot->tts_tid, blockno, tupoffset); + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + + return true; + } + else + { + /* + * NextSampleTuple returned InvalidOffsetNumber -- done with this + * block. Unlock, clear the slot, and tell the caller to move on. + */ + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + ExecClearTuple(slot); + return false; + } + } + + /* unreachable */ + Assert(false); +} + +/* + * ------------------------------------------------------------------------ + * Main table AM routine structure for FLUX + * ------------------------------------------------------------------------ + */ +static const TableAmRoutine flux_methods = { + .type = T_TableAmRoutine, + + /* + * FLUX table DML records UNDO into the per-backend UNDO engine + * (access/undo/perbackend): flux_operations.c prepares one + * UnpackedUndoRecord per INSERT/UPDATE/DELETE via the flux_pbu.c shim + * (PrepareUndoInsert/InsertPreparedUndo), tagged uur_rmid = + * UNDO_RMID_FLUX, folding the undo page onto the FLUX forward WAL record + * as a full-page image for crash-safety. On abort AbortTransaction() -> + * PbuAtAbort_ApplyUndo() -> execute_undo_actions() dispatches each record + * to flux_undo_apply() (the FLUX undo rmgr's rm_undo) which restores the + * before-image in place. + * + * FLUX declares am_undo_engine = UNDO_ENGINE_PERBACKEND and + * am_index_delete_marking = true. An indexed-column UPDATE stays IN + * PLACE (stable TID): flux_tuple_update inserts the new (k_new,TID) index + * entry and delete-marks the old (k_old,TID) entry (index_delete_mark), + * reporting TU_None so the executor does not re-insert. There is NO + * separate index UNDO: nbtree/hash suppress their index-UNDO writes for a + * delete-marking parent (RelationSupportsDeleteMarking) because FLUX's + * own table UNDO (flux_undo_apply, FLUX_UNDO_UPDATE) drives index cleanup + * on rollback -- it clears the old delete-mark and removes the new + * (k_new,TID) entry, restoring the index to its exact pre-update state. + * So no index UNDO is written for a FLUX table's indexes. + */ + .am_supports_undo = true, + /* updates in place at a stable TID; never reports traversed */ + .am_inplace_update_keeps_tid = true, + .am_undo_engine = UNDO_ENGINE_PERBACKEND, + .am_index_delete_marking = true, + /* every committed row change is decoded; see flux_decode */ + .am_logical_decoding = true, + + + /* Use minimal tuple slot */ + .slot_callbacks = flux_slot_callbacks, + + .scan_begin = flux_scan_begin, + .scan_end = flux_scan_end, + .scan_rescan = flux_scan_rescan, + .scan_getnextslot = flux_scan_getnextslot, + + .scan_set_tidrange = flux_scan_set_tidrange, + .scan_getnextslot_tidrange = flux_scan_getnextslot_tidrange, + + .parallelscan_estimate = table_block_parallelscan_estimate, + .parallelscan_initialize = table_block_parallelscan_initialize, + .parallelscan_reinitialize = table_block_parallelscan_reinitialize, + + /* Use minimal index functions */ + .index_scan_begin = flux_index_scan_begin, + .index_scan_reset = flux_index_scan_reset, + .index_scan_end = flux_index_scan_end, + + /* Use minimal tuple functions */ + .tuple_insert = flux_tuple_insert, + .tuple_insert_speculative = flux_tuple_insert_speculative, + .tuple_complete_speculative = flux_tuple_complete_speculative, + .multi_insert = flux_multi_insert, + .tuple_delete = flux_tuple_delete, + .tuple_update = flux_tuple_update, + .tuple_lock = flux_tuple_lock, + + /* UNDO write-buffer activation / deactivation */ + + .fetch_tid = flux_fetch_tid, + .index_fetch_tid = flux_index_fetch_tid, + .tuple_fetch_row_version = flux_tuple_fetch_row_version, + .tuple_get_latest_tid = flux_tuple_get_latest_tid, + .tuple_tid_valid = flux_tuple_tid_valid, + .tuple_satisfies_snapshot = flux_tuple_satisfies_snapshot, + .index_delete_tuples = flux_index_delete_tuples, + .index_tombstone_removable = flux_index_tombstone_removable, + + /* Keep only essential relation functions */ + .relation_set_new_filelocator = flux_relation_set_new_filelocator, + .relation_nontransactional_truncate = flux_relation_nontransactional_truncate, + .relation_copy_data = flux_relation_copy_data, + .relation_copy_for_cluster = flux_relation_copy_for_cluster, + .relation_vacuum = flux_relation_vacuum, + .scan_analyze_next_block = flux_scan_analyze_next_block, + .scan_analyze_next_tuple = flux_scan_analyze_next_tuple, + .index_build_range_scan = flux_index_build_range_scan, + .index_validate_scan = flux_index_validate_scan, + + .relation_size = table_block_relation_size, + .relation_needs_toast_table = flux_relation_needs_toast_table, + .relation_toast_am = flux_relation_toast_am, + .relation_fetch_toast_slice = heap_fetch_toast_slice, + + .relation_estimate_size = flux_relation_estimate_size, + + .scan_bitmap_next_tuple = flux_scan_bitmap_next_tuple, + .scan_sample_next_block = flux_scan_sample_next_block, + .scan_sample_next_tuple = flux_scan_sample_next_tuple, + + /* + * FLUX secondary indexes hold plain 6-byte heap-style TID entries and are + * maintained exactly like heap indexes. A key-changing UPDATE stores the + * new version out of place (new TID) so the old (key, TID) index entry + * becomes dead and is reclaimed by VACUUM, never duplicated -- see + * flux_tuple_update(). + */ +}; + +/* + * Return the FLUX table AM routine + */ +const TableAmRoutine * +GetFluxTableAmRoutine(void) +{ + return &flux_methods; +} + +/* + * Handler function for FLUX table access method + */ +PG_FUNCTION_INFO_V1(flux_tableam_handler); + +Datum +flux_tableam_handler(PG_FUNCTION_ARGS) +{ + PG_RETURN_POINTER(&flux_methods); +} + +/* + * flux_analyze_accumulate_sample + * + * During an ANALYZE scan, capture the decompressed bytes of the relation's + * first varlena column from the just-materialized slot. These samples feed + * FluxMaybeRefreshDict() at scan end so it can train a candidate compression + * dictionary from data ANALYZE already sampled, with no extra table reads. + * + * Sample buffers are allocated lazily in the scan's own memory context and + * are bounded by fixed byte/count caps. Once a cap is hit we stop collecting + * but let the ANALYZE scan continue normally. + */ +static void +flux_analyze_accumulate_sample(FluxScanDesc rscan, TupleTableSlot *slot) +{ + /* + * FLUX does not compress attributes and has no compression dictionary, so + * ANALYZE does not accumulate a training corpus. + */ + (void) rscan; + (void) slot; +} + +/* + * ANALYZE support: select next block to sample + * + * Called by ANALYZE to prepare the next sampled block for tuple extraction. + * The ReadStream provides buffers for blocks selected by the BlockSampler + * in analyze.c -- we do not choose blocks ourselves. + * + * We acquire a buffer pin and shared lock here and hold them until + * flux_scan_analyze_next_tuple() has returned false for this block, + * preventing concurrent activity (e.g. pruning) from removing tuples + * out from under us. + */ +static bool +flux_scan_analyze_next_block(TableScanDesc scan, ReadStream *stream) +{ + FluxScanDesc rscan = (FluxScanDesc) scan; + + /* + * Get the next buffer from the read stream. The stream was set up by + * analyze.c with a BlockSampler callback, so it yields only the randomly + * selected sample blocks. The buffer comes back already pinned. + */ + rscan->rs_cbuf = read_stream_next_buffer(stream, NULL); + + if (!BufferIsValid(rscan->rs_cbuf)) + return false; + + /* + * Don't lock the buffer here; flux_scan_analyze_next_tuple() manages its + * own lock/unlock cycle so it can release the lock before returning a + * sampled tuple, allowing FluxFetchOverflowColumn() to safely lock the + * same buffer for overflow data on this page. + */ + + rscan->rs_cblock = BufferGetBlockNumber(rscan->rs_cbuf); + rscan->rs_cindex = FirstOffsetNumber; + + return true; +} + +/* + * ANALYZE support: get next tuple from current block + * + * Extracts tuples one at a time from the block prepared by + * flux_scan_analyze_next_block(). For each item pointer on the page we + * classify the tuple as live, dead, or not-a-tuple (overflow record, + * unused pointer) and update the caller's counters. + * + * When a live tuple suitable for sampling is found, it is materialized + * into the slot and we return true. When all items on the page have been + * examined, we release the buffer and return false. + * + * The buffer remains pinned and locked for the entire duration of tuple + * iteration on this block, matching the heap AM contract. + */ +static bool +flux_scan_analyze_next_tuple(TableScanDesc scan, + double *liverows, double *deadrows, + TupleTableSlot *slot) +{ + FluxScanDesc rscan = (FluxScanDesc) scan; + Page targpage; + OffsetNumber maxoffset; + + Assert(BufferIsValid(rscan->rs_cbuf)); + + /* + * Re-acquire the buffer content lock. We release it before returning a + * sampled tuple (see below) so that the caller can safely deform the + * tuple -- FluxFetchOverflowColumn() may need to lock the same buffer to + * read overflow data stored on the same page. Share-exclusive: the copy + * below must exclude in-place writers (see FluxSlotStoreTuple). + */ + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_SHARE_EXCLUSIVE); + + targpage = BufferGetPage(rscan->rs_cbuf); + maxoffset = PageGetMaxOffsetNumber(targpage); + + /* Inner loop over items on the selected page */ + for (; rscan->rs_cindex <= maxoffset; rscan->rs_cindex++) + { + ItemId itemid; + FluxTupleHeader *tuple_hdr; + bool sample_it = false; + bool aborted_update; + + itemid = PageGetItemId(targpage, rscan->rs_cindex); + + /* + * Skip unused and dead line pointers. Dead line pointers are counted + * as dead rows because vacuum needs to reclaim them. + */ + if (!ItemIdIsNormal(itemid)) + { + if (ItemIdIsDead(itemid)) + *deadrows += 1; + continue; + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(targpage, itemid); + + /* Skip overflow records -- these are not user-visible tuples */ + if (FluxIsOverflowRecordInline(tuple_hdr, ItemIdGetLength(itemid))) + continue; + + /* + * A row whose version is an aborted transaction's, with the revert + * still pending, is sampled as the revert will leave it (see + * flux_pending_version): an aborted UPDATE's or DELETE's row at its + * committed version, which flux_pending_version stores into slot. An + * aborted INSERT's row is dead, as for heap, and so is the version an + * aborted UPDATE left, while an aborted DELETE leaves none. + */ + aborted_update = !(tuple_hdr->t_flags & FLUX_TUPLE_DELETED); + switch (flux_pending_version(scan->rs_rd, rscan->rs_cbuf, + rscan->rs_cindex, slot)) + { + case FLUX_PENDING_RECONSTRUCTED: + *liverows += 1; + if (aborted_update) + *deadrows += 1; + rscan->rs_cindex++; + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + if (scan->rs_flags & SO_TYPE_ANALYZE) + flux_analyze_accumulate_sample(rscan, slot); + return true; + case FLUX_PENDING_GONE: + *deadrows += 1; + maxoffset = PageGetMaxOffsetNumber(targpage); + continue; + case FLUX_PENDING_NONE: + break; + } + + /* The lock may have been released meanwhile: read the item again. */ + maxoffset = PageGetMaxOffsetNumber(targpage); + itemid = PageGetItemId(targpage, rscan->rs_cindex); + if (!ItemIdIsNormal(itemid)) + continue; + tuple_hdr = (FluxTupleHeader *) PageGetItem(targpage, itemid); + + /* + * Count the row as heap's ANALYZE does (FluxTupleStatsCount): a row + * another transaction is inserting is neither counted nor sampled, + * nor is a speculative insertion not yet confirmed; a row another + * transaction is deleting is live and sampled. + * + * An in-place UPDATE leaves no dead tuple: its before-image is in + * UNDO, which the undo discard worker reclaims without VACUUM. So a + * row with UNDO history is one live row, not also a dead one. + */ + if ((tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE) && + !(tuple_hdr->t_flags & FLUX_TUPLE_DELETED)) + continue; + switch (FluxTupleStatsCount(tuple_hdr)) + { + case FLUX_COUNT_LIVE: + sample_it = true; + *liverows += 1; + break; + case FLUX_COUNT_DEAD: + *deadrows += 1; + break; + case FLUX_COUNT_NONE: + break; + } + + if (sample_it) + { + /* + * Materialize the tuple into palloc'd memory rather than storing + * a buffer-pinned pointer. This is necessary because slot + * deformation may call FluxFetchOverflowColumn(), which acquires + * buffer locks on overflow pages. If the overflow data resides + * on the same page we are scanning, a buffer-pinned slot would + * cause a lock re-entry assertion failure in LockBuffer + * (bufmgr.c). + */ + FluxSlotStoreTuple(slot, tuple_hdr, ItemIdGetLength(itemid), + rscan->rs_cbuf); + slot->tts_tableOid = RelationGetRelid(scan->rs_rd); + ItemPointerSet(&slot->tts_tid, rscan->rs_cblock, rscan->rs_cindex); + rscan->rs_cindex++; + + /* + * Release the content lock so the caller can safely deform the + * materialized tuple. The buffer pin is kept so the page stays + * in the buffer pool. We re-acquire the lock at the top of this + * function when called again. + */ + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + + /* + * Opportunistically feed the dictionary-refresh corpus from the + * same sampled tuple. Done after the unlock so any + * overflow-column fetch can lock this page safely. + */ + if (scan->rs_flags & SO_TYPE_ANALYZE) + flux_analyze_accumulate_sample(rscan, slot); + + return true; + } + } + + /* + * No more tuples on this page. Release the buffer pin and lock that were + * acquired in flux_scan_analyze_next_block(). + */ + UnlockReleaseBuffer(rscan->rs_cbuf); + rscan->rs_cbuf = InvalidBuffer; + + /* Prevent stale slot contents from holding a pin */ + ExecClearTuple(slot); + + return false; +} diff --git a/src/backend/access/flux/flux_lock.c b/src/backend/access/flux/flux_lock.c new file mode 100644 index 0000000000000..4141349dc6cbc --- /dev/null +++ b/src/backend/access/flux/flux_lock.c @@ -0,0 +1,388 @@ +/*------------------------------------------------------------------------- + * + * flux_lock.c + * FLUX locking mechanisms for concurrent access + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_lock.c + * + * NOTES + * This implements proper locking for FLUX operations to ensure + * data consistency under concurrent access. Uses both buffer locks + * and tuple-level locks with deadlock detection. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/flux.h" +#include "access/slog.h" +#include "storage/bufmgr.h" +#include "storage/lmgr.h" +#include "storage/lwlock.h" +#include "storage/proc.h" +#include "utils/rel.h" +#include "miscadmin.h" +#include "access/tableam.h" + +/* + * FluxLockTuple + * + * Acquire a tuple-level lock on the specified tuple using PostgreSQL's + * standard LOCKTAG_TUPLE mechanism. The lock mode is converted from + * LockTupleMode to the corresponding LOCKMODE (ShareLock for read modes, + * ExclusiveLock for write modes). + * + * Parameters: + * rel - open relation containing the tuple + * tid - ItemPointer identifying the tuple (block + offset) + * mode - desired lock strength (LockTupleKeyShare through + * LockTupleExclusive) + * wait - if true, block until the lock is available; if false, + * return false immediately if the lock cannot be acquired + * have_tuple_lock - output: set to true if the lock was successfully acquired + * + * Returns true if the lock was acquired, false if 'wait' was false and the + * lock was not available. + * + * The caller is responsible for calling FluxUnlockTuple() to release the + * lock when done. + */ +bool +FluxLockTuple(Relation rel, ItemPointer tid, LockTupleMode mode, + bool wait, bool *have_tuple_lock) +{ + LOCKTAG tag; + LOCKMODE lockmode; + bool result; + + *have_tuple_lock = false; + + /* + * Convert tuple lock mode to standard lock mode using the same mapping as + * heap (tupleLockExtraInfo in heapam.c). The four modes MUST map to four + * distinct LOCKMODEs: collapsing KeyShare/Share or + * NoKeyExclusive/Exclusive makes an FK key-share lock conflict with a + * concurrent no-key UPDATE, manufacturing deadlocks that heap never + * suffers. + */ + switch (mode) + { + case LockTupleKeyShare: + lockmode = AccessShareLock; + break; + case LockTupleShare: + lockmode = RowShareLock; + break; + case LockTupleNoKeyExclusive: + lockmode = ExclusiveLock; + break; + case LockTupleExclusive: + lockmode = AccessExclusiveLock; + break; + default: + elog(ERROR, "invalid tuple lock mode: %d", mode); + } + + /* Set up lock tag for tuple */ + SET_LOCKTAG_TUPLE(tag, + rel->rd_locator.dbOid, + rel->rd_locator.relNumber, + ItemPointerGetBlockNumber(tid), + ItemPointerGetOffsetNumber(tid)); + + /* Acquire the lock */ + if (wait) + { + LockAcquire(&tag, lockmode, false, false); + result = true; + } + else + { + result = (LockAcquireExtended(&tag, lockmode, false, true, true, NULL, false) != LOCKACQUIRE_NOT_AVAIL); + } + + if (result) + *have_tuple_lock = true; + + return result; +} + +/* + * FluxLockTupleToWait + * + * Take the heavyweight tuple lock a locker, UPDATE or DELETE holds while it + * waits for a conflicting transaction, so that several waiters queue up in + * order (heap_acquire_tuplock), unless it is held already. The caller + * releases it with FluxUnlockTuple. + * + * Like heap, a transaction that already holds a row lock on the tuple (it + * would be a member of heap's multixact) does not queue, and waits for the + * conflicting transaction without the tuple lock: a waiter ahead of it in + * that queue may be waiting for its row lock, which is a deadlock. + * heap_lock_tuple's skip_tuple_lock, and heap_update's and heap_delete's + * current_is_member, implement the same rule (isolation tests + * tuplelock-upgrade-no-deadlock and flux_lock_upgrade_queue). + */ +void +FluxLockTupleToWait(Relation rel, ItemPointer tid, LockTupleMode mode, + bool *have_tuple_lock) +{ + if (*have_tuple_lock) + return; + + if (SLogTupleLockHeldByMe(RelationGetRelid(rel), rel->rd_locator.relNumber, + tid, NULL)) + return; + + FluxLockTuple(rel, tid, mode, true, have_tuple_lock); +} + +/* + * FluxUnlockTuple + * + * Release a tuple-level lock previously acquired by FluxLockTuple(). + * + * Parameters: + * rel - open relation containing the tuple + * tid - ItemPointer identifying the locked tuple + * mode - lock mode that was used when acquiring (must match) + */ +void +FluxUnlockTuple(Relation rel, ItemPointer tid, LockTupleMode mode) +{ + LOCKTAG tag; + LOCKMODE lockmode; + + /* + * Convert tuple lock mode to standard lock mode using the same mapping as + * heap (tupleLockExtraInfo in heapam.c). The four modes MUST map to four + * distinct LOCKMODEs: collapsing KeyShare/Share or + * NoKeyExclusive/Exclusive makes an FK key-share lock conflict with a + * concurrent no-key UPDATE, manufacturing deadlocks that heap never + * suffers. + */ + switch (mode) + { + case LockTupleKeyShare: + lockmode = AccessShareLock; + break; + case LockTupleShare: + lockmode = RowShareLock; + break; + case LockTupleNoKeyExclusive: + lockmode = ExclusiveLock; + break; + case LockTupleExclusive: + lockmode = AccessExclusiveLock; + break; + default: + elog(ERROR, "invalid tuple lock mode: %d", mode); + } + + /* Set up lock tag for tuple */ + SET_LOCKTAG_TUPLE(tag, + rel->rd_locator.dbOid, + rel->rd_locator.relNumber, + ItemPointerGetBlockNumber(tid), + ItemPointerGetOffsetNumber(tid)); + + /* Release the lock */ + LockRelease(&tag, lockmode, false); +} + +/* + * FluxLockPage + * + * Acquire a page-level lock using LOCKTAG_PAGE. This is used for operations + * that need exclusive access to an entire page's structure, such as + * defragmentation or cross-page tuple moves. + * + * Note: This is distinct from buffer-level locking (LockBuffer). Buffer + * locks protect the in-memory page image; page-level locks here protect + * the logical page structure across multiple buffer accesses. + * + * Parameters: + * rel - open relation containing the page + * blkno - block number to lock + * mode - lock mode (typically ShareLock or ExclusiveLock) + */ +void +FluxLockPage(Relation rel, BlockNumber blkno, LOCKMODE mode) +{ + LOCKTAG tag; + + /* Set up lock tag for page */ + SET_LOCKTAG_PAGE(tag, + rel->rd_locator.dbOid, + rel->rd_locator.relNumber, + blkno); + + /* Acquire the lock */ + LockAcquire(&tag, mode, false, false); +} + +/* + * FluxUnlockPage + * + * Release a page-level lock previously acquired by FluxLockPage(). + * + * Parameters: + * rel - open relation containing the page + * blkno - block number to unlock + * mode - lock mode that was used when acquiring (must match) + */ +void +FluxUnlockPage(Relation rel, BlockNumber blkno, LOCKMODE mode) +{ + LOCKTAG tag; + + /* Set up lock tag for page */ + SET_LOCKTAG_PAGE(tag, + rel->rd_locator.dbOid, + rel->rd_locator.relNumber, + blkno); + + /* Release the lock */ + LockRelease(&tag, mode, false); +} + + +/* + * FluxLockMultipleTuples + * + * Acquire tuple-level locks on multiple tuples in a consistent order to + * prevent deadlocks. The TIDs are sorted (using bubble sort, which is + * adequate since N is typically small) before acquiring locks, ensuring + * that all callers acquire locks in the same global order. + * + * If any lock acquisition fails (when wait=false), all previously acquired + * locks are released and the function returns false. + * + * Note: The tids array is sorted in-place, which modifies the caller's array. + * + * Parameters: + * rel - open relation containing the tuples + * tids - array of ItemPointerData identifying tuples to lock (sorted in-place) + * ntids - number of entries in tids array + * mode - desired lock strength for all tuples + * wait - if true, block until all locks are available + * + * Returns true if all locks were acquired, false if any could not be acquired. + */ +bool +FluxLockMultipleTuples(Relation rel, ItemPointerData *tids, int ntids, + LockTupleMode mode, bool wait) +{ + int i, + j; + bool all_locked = true; + bool *locked = palloc0(sizeof(bool) * ntids); + + /* Sort TIDs to ensure consistent lock ordering */ + for (i = 0; i < ntids - 1; i++) + { + for (j = i + 1; j < ntids; j++) + { + if (ItemPointerCompare(&tids[i], &tids[j]) > 0) + { + ItemPointerData temp = tids[i]; + + tids[i] = tids[j]; + tids[j] = temp; + } + } + } + + /* Acquire locks in sorted order */ + for (i = 0; i < ntids; i++) + { + bool have_lock; + + if (!FluxLockTuple(rel, &tids[i], mode, wait, &have_lock)) + { + all_locked = false; + break; + } + locked[i] = have_lock; + } + + /* If we failed to get all locks, release what we got */ + if (!all_locked) + { + for (j = 0; j < i; j++) + { + if (locked[j]) + FluxUnlockTuple(rel, &tids[j], mode); + } + } + + pfree(locked); + return all_locked; +} + +/* + * FluxLockRelationForDDL + * + * Acquire a relation-level lock for DDL operations (e.g., ALTER TABLE, + * DROP TABLE). Delegates to PostgreSQL's standard LockRelationOid(). + * + * Parameters: + * rel - open relation to lock + * lockmode - lock mode (typically AccessExclusiveLock for DDL) + */ +void +FluxLockRelationForDDL(Relation rel, LOCKMODE lockmode) +{ + /* Use standard relation locking */ + LockRelationOid(RelationGetRelid(rel), lockmode); +} + +/* + * FluxHoldsTupleLock + * + * Check whether the current transaction already holds a lock on the + * specified tuple at the given mode. This is useful for avoiding redundant + * lock acquisitions and for assertions in debug builds. + * + * Parameters: + * rel - open relation containing the tuple + * tid - ItemPointer identifying the tuple + * mode - lock mode to check for + * + * Returns true if the current transaction holds the specified lock. + */ +bool +FluxHoldsTupleLock(Relation rel, ItemPointer tid, LockTupleMode mode) +{ + LOCKTAG tag; + LOCKMODE lockmode; + + /* Convert tuple lock mode to standard lock mode */ + switch (mode) + { + case LockTupleKeyShare: + case LockTupleShare: + lockmode = ShareLock; + break; + case LockTupleNoKeyExclusive: + case LockTupleExclusive: + lockmode = ExclusiveLock; + break; + default: + return false; + } + + /* Set up lock tag for tuple */ + SET_LOCKTAG_TUPLE(tag, + rel->rd_locator.dbOid, + rel->rd_locator.relNumber, + ItemPointerGetBlockNumber(tid), + ItemPointerGetOffsetNumber(tid)); + + /* Check if we hold the lock */ + return LockHeldByMe(&tag, lockmode, false); +} diff --git a/src/backend/access/flux/flux_mvcc.c b/src/backend/access/flux/flux_mvcc.c new file mode 100644 index 0000000000000..4412d692905e9 --- /dev/null +++ b/src/backend/access/flux/flux_mvcc.c @@ -0,0 +1,1433 @@ +/*------------------------------------------------------------------------- + * + * flux_mvcc.c + * FLUX heap-compatible xmin/xmax MVCC implementation + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_mvcc.c + * + * NOTES + * FLUX uses ordinary heap-compatible xmin/xmax MVCC visibility, the same + * model as HeapTupleSatisfiesMVCC: a tuple is visible to a snapshot iff its + * inserter (t_xmin) is committed-and-visible-to-the-snapshot AND its + * deleter/updater (t_xmax) is invalid, not committed, or not visible. CLOG + * is the commit-status authority; the snapshot's xmin/xmax/xip decide + * visibility of committed XIDs; subtransaction aborts (ROLLBACK TO + * savepoint) are handled by stamping t_xmin/t_xmax with the current subxid + * so CLOG marks a rolled-back subxact's tuples aborted (plus a transient + * sLog ABORTED marker consulted for the still-in-progress window). + * + * In-place UPDATE keeps the NEWEST version on the page (new t_xmin) and + * pushes the pre-update image to the UNDO log via t_verptr. + * A snapshot that cannot see the updater's xmin reads the + * old version back from the UNDO log with FluxReconstructVisibleVersion() + * (flux_pvs.c), which walks t_verptr and stops at the version whose + * producing xid is visible. The single visibility function is + * FluxTupleSatisfiesMVCC(), reached via FluxTupleVisibleToSnapshotDual() + * from every read site; it and the chain walk decide "is this writer + * visible?" through one rule, FluxWriterVisibleToSnapshot(). + * + * The pre-commit "dirty read" window closes for free: a reader resolves + * t_xmin against CLOG, which reports committed only at the durability point + * (RecordTransactionCommit flushes the commit record). A not-yet-committed + * (hence not-yet-durable) inserter is simply invisible. + * + * The FLUX_TUPLE_UNCOMMITTED flag (0x0080) is set on insert/delete/update + * and cleared (as a hint bit) at commit. It no longer drives read + * visibility (CLOG does); it is retained for the sLog write-conflict and + * defrag paths. + * + * ISOLATION LEVEL SEMANTICS + * + * FLUX integrates with PostgreSQL's Serializable Snapshot Isolation + * (SSI) infrastructure in predicate.c. The scan path acquires SIREAD + * predicate locks via PredicateLockTID(), and the DML paths (INSERT, + * UPDATE, DELETE) call CheckForSerializableConflictIn() to detect + * rw-antidependencies. The FluxCheckForSerializableConflictOut() + * function handles the reverse direction (reader encounters a tuple + * written by a concurrent transaction) by looking up the writer's + * XID via the sLog and delegating to predicate.c. + * + * BEFORE-IMAGE SERVING: + * + * In-place UPDATEs destroy the pre-image on the page. The visible + * prior version is reconstructed by walking the durable UNDO log + * chain (FluxReconstructVisibleVersion), not from any shared sLog DSA + * entry: readers whose snapshot cannot see the committing xid follow the + * on-page head verptr into the UNDO log. This restores correct snapshot + * semantics for concurrent readers under REPEATABLE READ and SERIALIZABLE. + * + * CONCURRENCY: + * + * 1. Same-tuple write-write conflicts serialize correctly: the + * second writer blocks (via XactLockTableWait on the writer xid in + * the tuple header) until the first commits or aborts. + * + * 2. Write Skew (A5B) on disjoint tuples IS detected via predicate + * locking (SIREAD locks on tuples read + conflict-in checks on + * writes). + * + * In summary, FLUX's isolation guarantees are: + * - READ COMMITTED: Correct (no dirty reads, each statement gets + * fresh visibility via a per-statement snapshot) + * - REPEATABLE READ: Full Snapshot Isolation; concurrent committed + * UPDATEs are reconstructed from the UNDO log per reader snapshot + * - SERIALIZABLE: Full SSI via predicate.c integration; write skew + * and phantom anomalies are prevented through predicate locking + * and rw-antidependency cycle detection + * + * References: + * - Berenson et al., "A Critique of ANSI SQL Isolation Levels" (1995) + * - Adya, "Weak Consistency: A Generalized Theory and Optimistic + * Implementations for Distributed Transactions" (2000) + * - Cahill et al., "Serializable Isolation for Snapshot Databases" (2009) + * - Ports & Grittner, "Serializable Snapshot Isolation in PostgreSQL" (2012) + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/atm.h" +#include "access/flux.h" +#include "access/flux_pbu.h" +#include "access/slog.h" +#include "access/subtrans.h" +#include "access/transam.h" +#include "access/twophase.h" +#include "access/xact.h" +#include "utils/snapmgr.h" +#include "miscadmin.h" +#include "port/atomics.h" +#include "utils/memutils.h" +#include "storage/bufmgr.h" +#include "storage/ipc.h" +#include "storage/lwlock.h" +#include "storage/predicate.h" +#include "storage/proc.h" +#include "storage/procarray.h" +#include "storage/shmem.h" +#include "utils/guc.h" +#include "utils/timestamp.h" + +/* + * Total number of PGPROC slots, matching the allProcs array size in proc.c. + * This must cover regular backends, auxiliary procs, and prepared transactions + * since GetNumberFromPGProc() can return indices up to TotalProcs - 1. + */ +#define FLUX_TOTAL_PROCS \ + (MaxBackends + NUM_AUXILIARY_PROCS + max_prepared_xacts) + +/* + * Shared memory structures for MVCC + */ +typedef struct FluxMvccShmemData +{ + LWLock mvcc_lock; /* Protects serializable horizon only */ + uint64 oldest_active_ts; /* Cached oldest active transaction ts; + * backs FluxGetOldestActiveTimestamp(), + * which still feeds the VM all-visible + * check. NOT a vacuum horizon. */ + uint64 serializable_horizon; /* Serializable isolation horizon */ + + pg_atomic_uint32 oldest_active_generation; /* Bumped when cache is + * invalidated */ + pg_atomic_uint32 active_xact_count; /* Number of active transactions + * (atomic) */ + + /* + * Per-backend active transaction start timestamps. Each backend slot + * stores the start timestamp of its current FLUX transaction, or 0 if + * idle. This array is indexed by pgprocno (the offset into + * ProcGlobal->allProcs) and is sized to FLUX_TOTAL_PROCS so that + * auxiliary procs and prepared transactions are covered. + * + * Each slot is written only by its owning backend and read by VACUUM, so + * no lock is needed; just a compiler barrier via volatile access. + */ + int num_xact_slots; /* Number of slots (== FLUX_TOTAL_PROCS) */ + uint64 xact_start_ts_slots[FLEXIBLE_ARRAY_MEMBER]; + +} FluxMvccShmemData; + +static FluxMvccShmemData *FluxMvccShmem = NULL; + + +/* + * Per-transaction MVCC state. + * + * SSI (Serializable Snapshot Isolation) conflict detection is now delegated + * entirely to PostgreSQL's predicate.c infrastructure. FLUX integrates + * with it by calling PredicateLockTID() in the scan path and + * CheckForSerializableConflictIn/Out() in the DML paths. The private + * rw-conflict graph that was previously here has been removed. + * + * Commit visibility uses CLOG (heap-shaped xmin/xmax); the timestamp fields + * below are page-level bookkeeping only. + */ +struct FluxTransactionState +{ + uint64 xact_start_ts; /* Transaction start timestamp */ + uint64 xact_commit_ts; /* Transaction commit timestamp */ + + /* + * READ COMMITTED per-command read point. Under RC the visibility read + * point is "now", refreshed each command so a scan sees concurrent + * commits. Captured once per command (keyed by rc_read_point_cid) and + * reused for every tuple. + */ + CommandId rc_read_point_cid; + uint64 rc_read_point_xcc; /* snapXactCompletionCount at capture */ + bool is_serializable; /* Serializable isolation level */ + bool is_read_only; /* Transaction has not performed writes */ + + /* + * EPQ reconcile identity for the in-place write-write conflict path. + * + * When flux_tuple_update detects a committed concurrent UPDATE (head + * verptr's committer is invisible to snapshot) it returns TM_Updated and + * the executor runs EvalPlanQual: table_tuple_lock re-reads the latest + * on-page value (which, because FLUX updates in place, already reflects + * every commit so far) and the quals/SET expression recompute on top of + * it. But EPQ re-evaluates against the same statement snapshot (same xid + * xip / curcid), so a naive probe would re-detect the same committed + * marker and livelock. Heap escapes this because EPQ advances tupleid to + * a new tuple version with a clean xmax; FLUX has no new version to + * advance to. + * + * EPQ dedup: identity of the (verptr, xid) marker we bounced on to + * EvalPlanQual for this exact (relid, tid, cid). If the next probe on + * the same tuple in the same EPQ retry finds an unchanged head verptr and + * xid, we already reconciled it -- skip to prevent livelock. A + * strictly-newer committer stamps a NEW record with a fresh verptr, so + * dedup only suppresses re-firing on the identical marker. No temporal + * ordering is required: this is a physical identity check. + */ + Oid epq_reconcile_relid; + ItemPointerData epq_reconcile_tid; + CommandId epq_reconcile_cid; + UndoRecPtr epq_reconcile_verptr; + TransactionId epq_reconcile_xid; +}; + +/* Restart reasons */ +#define FLUX_RESTART_NONE 0 +#define FLUX_RESTART_UNCERTAINTY 1 +#define FLUX_RESTART_SERIALIZABLE 2 + +/* + * Per-backend static transaction state. Using a static struct avoids + * a palloc/pfree cycle per transaction. The struct is reset at the start + * of each transaction by FluxInitTransactionState(). + * + * MyFluxXactState points to &MyFluxXactStateData when a transaction is + * active, and is NULL between transactions. This preserves the existing + * NULL-check pattern throughout the codebase. + */ +static FluxTransactionState MyFluxXactStateData; +static FluxTransactionState *MyFluxXactState = NULL; + +/* + * GUC variables (flux_enable_serializable and flux_max_transactions removed; + * SSI is unconditionally provided via predicate.c) + */ + +/* + * Function prototypes + */ +static void FluxInitTransactionState(void); +static void FluxCleanupTransactionState(void); +static void FluxShmemExit(int code, Datum arg); + +/* FluxCheckUncommittedDelete removed -- logic inlined in visibility checks */ + + +/* + * Shared memory size calculation + * + * Per-transaction state (FluxTransactionState) is allocated in + * backend-local TopTransactionContext, NOT in shared memory, so it + * does not appear here. The only shared-memory array is the + * per-backend xact_start_ts_slots[], which scales naturally with + * FLUX_TOTAL_PROCS (and therefore MaxBackends). During bootstrap + * MaxBackends is ~4, keeping this allocation tiny. + */ +Size +FluxMvccShmemSize(void) +{ + Size size; + + /* + * Base struct (includes the flexible array header but not the array + * elements), plus one uint64 slot per PGPROC (regular backends, auxiliary + * procs, prepared transactions) for tracking active transaction start + * timestamps. + */ + size = offsetof(FluxMvccShmemData, xact_start_ts_slots); + size = add_size(size, mul_size(FLUX_TOTAL_PROCS, sizeof(uint64))); + + return size; +} + +/* + * Initialize shared memory for MVCC + */ +void +FluxMvccShmemInit(void) +{ + bool found; + + FluxMvccShmem = (FluxMvccShmemData *) + ShmemInitStruct("FLUX MVCC Data", + FluxMvccShmemSize(), + &found); + + if (!found) + { + int total_procs = FLUX_TOTAL_PROCS; + + /* Initialize shared memory */ + LWLockInitialize(&FluxMvccShmem->mvcc_lock, LWTRANCHE_BUFFER_MAPPING); + FluxMvccShmem->oldest_active_ts = 1; + pg_atomic_init_u32(&FluxMvccShmem->oldest_active_generation, 0); + FluxMvccShmem->serializable_horizon = 1; + pg_atomic_init_u32(&FluxMvccShmem->active_xact_count, 0); + + /* Initialize per-backend active timestamp slots to 0 (idle) */ + FluxMvccShmem->num_xact_slots = total_procs; + memset(FluxMvccShmem->xact_start_ts_slots, 0, + total_procs * sizeof(uint64)); + } + + /* Register cleanup function */ + on_shmem_exit(FluxShmemExit, 0); +} + +/* + * FluxGetDmlTimestamp -- return the transaction's start timestamp for DML + * page-level bookkeeping. + * + * Within a single transaction, all DML operations (INSERT, UPDATE, DELETE) + * stamp the page-level commit-ts word (FluxPageSetCommitTs) with the + * transaction's start timestamp. + * + * This value is NOT a visibility timestamp. Commit visibility comes from + * CLOG via heap-shaped xmin/xmax: while the transaction is in-flight the + * FLUX_TUPLE_UNCOMMITTED flag is set and self-visibility is determined by + * matching the inserter's XID in the sLog; after commit, t_xmin/t_xmax + CLOG + * decide visibility. Intra-transaction ordering (multiple DMLs in the same + * txn) is handled by CID (command ID) from the sLog entry. + */ +uint64 +FluxGetDmlTimestamp(void) +{ + /* + * Callers must have already called FluxGetTransactionTimestamp() or + * equivalent, which initializes the transaction state. We assert rather + * than lazily initializing, keeping this function as lean as possible on + * the hot path. + */ + Assert(MyFluxXactState != NULL); + + return MyFluxXactState->xact_start_ts; +} + +/* + * FluxGetCommitTimestamp + * + * Return a wall-clock timestamp (microseconds since the PostgreSQL epoch) + * used only for page-level commit-ts bookkeeping and the VM all-visible + * hint. Commit visibility comes from CLOG (heap-shaped xmin/xmax); nothing + * reads this value for a correctness/visibility DECISION, so it does not + * need strict cross-backend monotonicity. + * + * Historically this walked an atomic compare-and-swap loop over a single + * shared global_commit_ts counter to keep timestamps strictly increasing. + * That shared RMW was a hot-path serialization point (one cache line CAS'd + * ~2x per write txn) with no surviving consumer of strict monotonicity, so + * it has been replaced with a plain GetCurrentTimestamp() read: no shared + * counter, no CAS, no spin. Wall-clock microseconds are already + * monotonic-enough for a "min over active start-stamps" horizon and a + * ">= oldest" VM hint (both use non-strict comparisons). + */ +uint64 +FluxGetCommitTimestamp(void) +{ + if (FluxMvccShmem == NULL) + elog(ERROR, "FLUX MVCC not initialized"); + + return (uint64) GetCurrentTimestamp(); +} + +/* + * FluxGetTransactionTimestamp + * + * Return the start timestamp of the current transaction. Initializes + * per-transaction MVCC state on first call within a transaction. + * + * Returns the transaction's start timestamp (uint64). + */ +uint64 +FluxGetTransactionTimestamp(void) +{ + if (MyFluxXactState == NULL) + FluxInitTransactionState(); + + return MyFluxXactState->xact_start_ts; +} + +/* + * Subsystem callback wrappers for PG_SHMEM_SUBSYSTEM infrastructure + */ +static void +FluxMvccShmemRequest(void *arg) +{ + ShmemRequestStruct(.name = "FLUX MVCC Data", + .size = FluxMvccShmemSize(), + .ptr = (void **) &FluxMvccShmem); +} + +static void +FluxMvccShmemInit_cb(void *arg) +{ + int total_procs = FLUX_TOTAL_PROCS; + + /* FluxMvccShmem is already set by the ShmemRequestStruct .ptr mechanism */ + Assert(FluxMvccShmem != NULL); + + /* Initialize shared memory fields */ + LWLockInitialize(&FluxMvccShmem->mvcc_lock, LWTRANCHE_BUFFER_MAPPING); + FluxMvccShmem->oldest_active_ts = 1; + pg_atomic_init_u32(&FluxMvccShmem->oldest_active_generation, 0); + FluxMvccShmem->serializable_horizon = 1; + pg_atomic_init_u32(&FluxMvccShmem->active_xact_count, 0); + + /* Initialize per-backend active timestamp slots to 0 (idle) */ + FluxMvccShmem->num_xact_slots = total_procs; + memset(FluxMvccShmem->xact_start_ts_slots, 0, + total_procs * sizeof(uint64)); + + /* Register cleanup function */ + on_shmem_exit(FluxShmemExit, 0); +} + +const ShmemCallbacks FluxMvccShmemCallbacks = { + .request_fn = FluxMvccShmemRequest, + .init_fn = FluxMvccShmemInit_cb, +}; + +/* + * Initialize per-transaction MVCC state + * + * The start timestamp is a plain wall-clock value from + * FluxGetCommitTimestamp() held in the uint64 xact_start_ts field for + * per-backend slot tracking. + */ +/* + * Transaction callback for FLUX MVCC cleanup. + * + * This is registered once per backend via RegisterXactCallback. + * On transaction commit or abort, it calls FluxCommitTransaction() + * or FluxCleanupTransactionState() to reset MyFluxXactState, + * ensuring the next transaction in this backend gets a fresh start + * timestamp from FluxGetCommitTimestamp(). + */ +static bool flux_xact_callback_registered = false; + +static void +FluxXactCallback(XactEvent event, void *arg) +{ + switch (event) + { + case XACT_EVENT_COMMIT: + case XACT_EVENT_PARALLEL_COMMIT: + FluxCommitTransaction(); + break; + + case XACT_EVENT_ABORT: + case XACT_EVENT_PARALLEL_ABORT: + FluxCleanupTransactionState(); + break; + + case XACT_EVENT_PREPARE: + /* Prepared xids pin the pruning horizon through their PGPROC. */ + FluxCleanupTransactionState(); + break; + + default: + /* Pre-commit, pre-prepare -- nothing to do */ + break; + } +} + +static void +FluxInitTransactionState(void) +{ + if (MyFluxXactState != NULL) + return; + + /* Register cleanup callback on first use in this backend */ + if (!flux_xact_callback_registered) + { + RegisterXactCallback(FluxXactCallback, NULL); + flux_xact_callback_registered = true; + } + + /* Use the static per-backend struct; zero it to start fresh */ + memset(&MyFluxXactStateData, 0, sizeof(FluxTransactionState)); + MyFluxXactState = &MyFluxXactStateData; + + /* + * Stamp the transaction start timestamp (monotonic wall-clock via + * FluxGetCommitTimestamp). This is a within-transaction bookkeeping + * value only; commit visibility comes from CLOG (heap-shaped xmin/xmax). + */ + MyFluxXactState->xact_start_ts = FluxGetCommitTimestamp(); + + MyFluxXactState->xact_commit_ts = 0; + + /* No RC per-command read point captured yet this transaction. */ + MyFluxXactState->rc_read_point_cid = InvalidCommandId; + MyFluxXactState->rc_read_point_xcc = 0; + MyFluxXactState->is_serializable = (XactIsoLevel == XACT_SERIALIZABLE); + MyFluxXactState->is_read_only = true; /* Until first write */ + + /* Register in shared memory for oldest-active-timestamp tracking */ + if (FluxMvccShmem != NULL) + { + int my_slot = MyProc ? (int) GetNumberFromPGProc(MyProc) : -1; + + /* + * Write our start timestamp into our per-backend slot. This is a + * single-writer/multi-reader pattern (only we write our slot, VACUUM + * reads it), so no lock is needed; just a write barrier. + */ + if (my_slot >= 0 && my_slot < FluxMvccShmem->num_xact_slots) + { + pg_write_barrier(); + FluxMvccShmem->xact_start_ts_slots[my_slot] = + MyFluxXactState->xact_start_ts; + } + + pg_atomic_fetch_add_u32(&FluxMvccShmem->active_xact_count, 1); + + /* + * If our start timestamp is older than the cached oldest, invalidate + * the cache by bumping the generation counter. + */ + if (MyFluxXactState->xact_start_ts < FluxMvccShmem->oldest_active_ts) + pg_atomic_fetch_add_u32(&FluxMvccShmem->oldest_active_generation, 1); + } +} + +/* + * Cleanup per-transaction MVCC state + */ +static void +FluxCleanupTransactionState(void) +{ + if (MyFluxXactState == NULL) + return; + + /* + * Clear our slot in shared memory. No lock needed: each backend only + * writes its own slot, and the generation counter invalidates the cached + * oldest_active_ts when needed. + */ + if (FluxMvccShmem != NULL) + { + int my_slot = MyProc ? (int) GetNumberFromPGProc(MyProc) : -1; + uint64 my_ts = MyFluxXactState->xact_start_ts; + + /* Clear our per-backend slot */ + if (my_slot >= 0 && my_slot < FluxMvccShmem->num_xact_slots) + { + FluxMvccShmem->xact_start_ts_slots[my_slot] = 0; + pg_write_barrier(); + } + + pg_atomic_fetch_sub_u32(&FluxMvccShmem->active_xact_count, 1); + + /* + * Invalidate the cached oldest_active_ts if we might have been the + * oldest. Bump the generation counter so that + * FluxGetOldestActiveTimestamp() rescans on the next call. If no + * transactions remain, advance the cached value cheaply. + */ + if (pg_atomic_read_u32(&FluxMvccShmem->active_xact_count) == 0) + { + FluxMvccShmem->oldest_active_ts = (uint64) GetCurrentTimestamp(); + pg_atomic_fetch_add_u32(&FluxMvccShmem->oldest_active_generation, 1); + } + else if (my_ts == FluxMvccShmem->oldest_active_ts) + { + /* + * Only invalidate the cache when we were the actual oldest active + * transaction. If my_ts < oldest_active_ts, the cached value was + * already advanced past us by another backend's rescan, so our + * departure cannot change the oldest. Using strict equality + * instead of <= dramatically reduces invalidation frequency under + * high concurrency. + */ + pg_atomic_fetch_add_u32(&FluxMvccShmem->oldest_active_generation, 1); + } + } + + MyFluxXactState = NULL; +} + +/* + * SSI conflict detection is handled by PostgreSQL's predicate.c infrastructure + * via CheckForSerializableConflictIn/Out calls in the DML and scan paths. + * The FluxCheckSerializableConflict compatibility stub has been removed. + */ + +/* + * FluxSerializableConflictOutXid -- report a rw-conflict out to the writer xid + * of a version a serializable reader examined. + * + * The tail of HeapCheckForSerializableConflictOut. The writer either + * superseded the version we read, or wrote one our snapshot cannot see. A + * writer that is still running counts, and so does one that committed after + * our snapshot: SSI needs the edge either way. Skip writers that precede + * TransactionXmin (they cannot be concurrent, and SUBTRANS may no longer + * hold them, so test that before looking them up), our own, and aborted ones + * (heap's HEAPTUPLE_DEAD: an aborted writer's version never existed). + * + * The caller has checked CheckForSerializableConflictOutNeeded. + */ +void +FluxSerializableConflictOutXid(Relation relation, TransactionId xid, + Snapshot snapshot) +{ + if (!TransactionIdIsNormal(xid) || + TransactionIdPrecedes(xid, TransactionXmin) || + TransactionIdIsCurrentTransactionId(xid)) + return; + + /* In progress before committed, as heap: CLOG is set before procarray. */ + if (!TransactionIdIsInProgress(xid) && !TransactionIdDidCommit(xid)) + return; + + xid = SubTransGetTopmostTransaction(xid); + if (TransactionIdPrecedes(xid, TransactionXmin)) + return; + + CheckForSerializableConflictOut(relation, xid, snapshot); +} + +/* + * FluxCheckForSerializableConflictOut -- we are reading the version tuple of + * a row. If it is not visible, there may be a rw-conflict out with the + * transaction that wrote it; if it is visible but has been removed, with the + * one that removed it. + * + * This is HeapCheckForSerializableConflictOut on FLUX's header. The writer + * of an image is t_xmin: the inserter, or for an in-place updated image the + * updater. The remover is t_xmax, when the image is DELETED or UPDATED (the + * same rule FluxTupleSatisfiesMVCC applies). As in heap, a writer counts + * whether it is still running or committed after our snapshot; the commit + * status comes from CLOG, not from the wait-only flux_header_dirty_xid, + * which sees running writers only. + * + * A FLUX image hides the versions before it in UNDO. When a reader walks + * back to one of them, the walk reports each writer it steps over + * (FluxReconstructVisibleVersionEx); this function covers only the image it + * is given. + */ +void +FluxCheckForSerializableConflictOut(bool visible, Relation relation, + FluxTupleHeader *tuple, + Buffer buffer, + Snapshot snapshot) +{ + TransactionId xid; + + if (!CheckForSerializableConflictOutNeeded(relation, snapshot)) + return; + + if (visible) + { + /* We read a version; is someone superseding it? */ + if (!(tuple->t_flags & (FLUX_TUPLE_DELETED | FLUX_TUPLE_UPDATED))) + return; + xid = FluxTupleGetXmax(tuple); + } + else + xid = FluxTupleGetXmin(tuple); + + FluxSerializableConflictOutXid(relation, xid, snapshot); +} + +/* + * Commit the current transaction and assign commit timestamp. + * + * The commit timestamp is a monotonic wall-clock value (FluxGetCommitTimestamp) + * used only for page-level bookkeeping; commit visibility comes from CLOG + * (heap-shaped xmin/xmax MVCC). + */ +void +FluxCommitTransaction(void) +{ + if (MyFluxXactState == NULL) + return; + + MyFluxXactState->xact_commit_ts = FluxGetCommitTimestamp(); + + /* Update serializable horizon (only for serializable transactions) */ + if (FluxMvccShmem != NULL && MyFluxXactState->is_serializable) + { + LWLockAcquire(&FluxMvccShmem->mvcc_lock, LW_EXCLUSIVE); + FluxMvccShmem->serializable_horizon = + Min(FluxMvccShmem->serializable_horizon, + MyFluxXactState->xact_commit_ts); + LWLockRelease(&FluxMvccShmem->mvcc_lock); + } + + FluxCleanupTransactionState(); +} + +/* + * Abort the current transaction + */ +void +FluxAbortTransaction(void) +{ + if (MyFluxXactState == NULL) + return; + + FluxCleanupTransactionState(); +} + +/* + * Get snapshot timestamp for reads + */ +uint64 +FluxGetSnapshotTimestamp(Snapshot snapshot) +{ + if (IsMVCCSnapshot(snapshot)) + { + if (MyFluxXactState == NULL) + FluxInitTransactionState(); + + /* + * REPEATABLE READ / SERIALIZABLE: return transaction-start timestamp + * for a consistent point-in-time snapshot across all statements. + */ + if (IsolationUsesXactSnapshot()) + return MyFluxXactState->xact_start_ts; + + /* + * READ COMMITTED: return current timestamp so each visibility check + * sees the latest committed state. + */ + return (uint64) FluxGetCommitTimestamp(); + } + else + { + /* SnapshotAny or other non-MVCC snapshots */ + return 0; + } +} + +/* + * Invalidate the cached oldest active timestamp, forcing the next call + * to FluxGetOldestActiveTimestamp() to rescan all per-backend slots. + * + * Also callable from VACUUM or any code that needs to force a refresh. + */ +void +FluxUpdateOldestActiveTimestamp(void) +{ + if (FluxMvccShmem == NULL) + return; + + pg_atomic_fetch_add_u32(&FluxMvccShmem->oldest_active_generation, 1); +} + +/* + * Per-backend cache of the oldest-active-timestamp computation. + * Avoids rescanning all per-backend slots on every call; only rescans + * when the global generation counter has been bumped. + */ +static uint32 my_oldest_active_gen = 0; +static uint64 my_oldest_active_cached = 0; + +/* + * FluxGetOldestActiveTimestamp -- return the oldest active transaction's + * start timestamp. + * + * This is the FLUX analog of PostgreSQL's GetOldestNonRemovableTransactionId. + * VACUUM uses this to determine which deleted tuples can be safely removed: + * a deleted tuple whose commit timestamp is older than this value is no + * longer visible to any running transaction and can be reclaimed. + * + * If no transactions are active, returns the current global commit timestamp, + * meaning all committed deletions are eligible for cleanup. + * + * Uses a per-backend cache that is invalidated when the global generation + * counter changes. No LWLock acquisition needed in the common case. + */ +uint64 +FluxGetOldestActiveTimestamp(void) +{ + uint32 current_gen; + + if (FluxMvccShmem == NULL) + elog(ERROR, "FLUX MVCC not initialized"); + + /* Fast path: check if our cached value is still valid */ + current_gen = pg_atomic_read_u32(&FluxMvccShmem->oldest_active_generation); + if (current_gen == my_oldest_active_gen && my_oldest_active_cached != 0) + return my_oldest_active_cached; + + /* Slow path: rescan all per-backend slots (lockless) */ + { + uint64 oldest = 0; + int i; + + pg_read_barrier(); + + for (i = 0; i < FluxMvccShmem->num_xact_slots; i++) + { + uint64 ts = FluxMvccShmem->xact_start_ts_slots[i]; + + if (ts != 0 && (oldest == 0 || ts < oldest)) + oldest = ts; + } + + if (oldest == 0) + oldest = (uint64) GetCurrentTimestamp(); + + /* Update the shared cached value (benign race with other backends) */ + FluxMvccShmem->oldest_active_ts = oldest; + + /* Cache locally */ + my_oldest_active_cached = oldest; + my_oldest_active_gen = current_gen; + + return oldest; + } +} + +/* + * Get MVCC statistics + */ +void +FluxGetMvccStats(uint64 *current_ts, uint64 *oldest_ts, int *active_xacts) +{ + if (FluxMvccShmem == NULL) + { + *current_ts = 0; + *oldest_ts = 0; + *active_xacts = 0; + return; + } + + *current_ts = (uint64) GetCurrentTimestamp(); + *oldest_ts = FluxMvccShmem->oldest_active_ts; + *active_xacts = (int) pg_atomic_read_u32(&FluxMvccShmem->active_xact_count); +} + +/* + * Shared memory exit cleanup + */ +static void +FluxShmemExit(int code, Datum arg) +{ + FluxCleanupTransactionState(); +} + + +/* + * FluxTupleHasCommittedUpdateAfter -- fork-driven lost-update conflict probe. + * + * A FLUX in-place UPDATE stamps a verptr on the new on-page image + * pointing at the UNDO record it just wrote. + * That verptr is the head of the tuple's version chain: the record it + * refers to describes the update that PRODUCED the current on-page image, + * so its urec_xid IS the last committer of this tuple. Older commits have + * already been absorbed into the on-page bytes we are about to overwrite; + * they are not lost-update candidates. A single-step probe is therefore + * sufficient. + * + * Conflict iff: + * 1) the head verptr resolves (FluxPbuFetchXid returns true), and + * 2) urec_xid is invisible to snapshot (XidInMVCCSnapshot returns true), + * and + * 3) urec_xid is not our own xid, and + * 4) urec_xid did commit (guarding against in-progress/aborted xids that + * the writer has yet to reach the wait path for). + * + * If FluxPbuFetchXid returns false the record was discarded. Undo discard + * removes only the records of transactions older than the oldest xmin, so + * the committer then precedes every live snapshot's xmin: it is visible to + * every reader and cannot be a lost-update candidate. Safe terminator. + * + * *out_head_verptr and *out_head_xid receive the observed (verptr, xid) + * identity so the caller can pass them to FluxEpqReconcileMark before + * returning TM_Updated, and to FluxEpqReconcileMatches to skip a + * previously-bounced marker. + */ +bool +FluxTupleHasCommittedUpdateAfter(Relation rel, + const FluxTupleHeader *tuple, + Size tuple_len, + Snapshot snapshot, + TransactionId exclude_xid, + UndoRecPtr *out_head_verptr, + TransactionId *out_head_xid, + bool *out_inprogress) +{ + UndoRecPtr head; + TransactionId head_xid; + + if (out_head_verptr != NULL) + *out_head_verptr = InvalidUndoRecPtr; + if (out_head_xid != NULL) + *out_head_xid = InvalidTransactionId; + if (out_inprogress != NULL) + *out_inprogress = false; + + if (snapshot == NULL || !IsMVCCSnapshot(snapshot)) + return false; + + head = FluxTupleGetVersionPtr(tuple); + if (!UndoRecPtrIsValid(head)) + return false; /* never updated -- no committed conflict */ + + if (!FluxPbuFetchXid(head, &head_xid)) + return false; /* discarded -> visible to every snapshot */ + + if (out_head_verptr != NULL) + *out_head_verptr = head; + if (out_head_xid != NULL) + *out_head_xid = head_xid; + + /* + * Our own update is never a lost-update conflict. Use + * TransactionIdIsCurrentTransactionId, which recognizes the current + * transaction's TOP xid AND every one of its subtransaction xids: the + * version-chain head may have been stamped by an earlier command at a + * different subtransaction nesting level (e.g. an UPDATE before a + * SAVEPOINT, then a re-UPDATE of the same row inside the savepoint), so a + * plain equality test against a single captured exclude_xid can miss it + * and wrongly drive a self-conflict -- which then self-waits in + * XactLockTableWait and trips its no-self-wait assertion. The + * exclude_xid parameter is kept for callers that want to exclude a + * specific xid, but the current-transaction test below is the + * authoritative self-check. + */ + if (TransactionIdIsCurrentTransactionId(head_xid)) + return false; /* our own update, not a conflict */ + + if (TransactionIdIsValid(exclude_xid) && + TransactionIdEquals(head_xid, exclude_xid)) + return false; /* our own update, not a conflict */ + + if (!TransactionIdIsValid(head_xid)) + return false; + + /* + * XidInMVCCSnapshot(xid, snap) returns true iff the xid is NOT visible to + * snap -- i.e. in-progress or later than the snapshot's xmax. A conflict + * requires that the head committer be invisible to the writer's snapshot + * AND actually committed (an in-progress or aborted xid is handled by the + * writer's dirty-xid / abort path, not here). + */ + if (!XidInMVCCSnapshot(head_xid, snapshot)) + return false; + + /* + * The head committer is invisible to our snapshot and is not our own xid. + * Normally it must have committed to be a lost-update conflict. But + * there is a commit-visibility window: the committer's PRE_COMMIT + * callback removes its in-progress sLog marker and clears the on-page + * UNCOMMITTED flag BEFORE RecordTransactionCommit() marks CLOG and BEFORE + * ProcArrayEndTransaction() clears it from the running list. In that + * window TransactionIdDidCommit() is still false while + * TransactionIdIsInProgress() is still true, and the sLog marker the + * writer-wait path keys off is already gone. If we returned "no + * conflict" here, a concurrent updater would clobber the just-committed + * value with no 40001 / EPQ -- a lost update. Signal the caller to wait + * on the in-flight head_xid (heap semantics: see live modifier -> wait -> + * re-check CLOG) rather than treating not-yet-committed as no-conflict. + */ + if (!TransactionIdDidCommit(head_xid)) + { + if (out_inprogress != NULL && + TransactionIdIsInProgress(head_xid)) + *out_inprogress = true; + return false; + } + + return true; +} + +/* + * FluxEpqReconcileMatches -- true iff we already bounced this (relid, tid, + * cid) to EvalPlanQual in the current command. + * + * IMPORTANT (EPQ termination under contention): we deliberately match on + * (relid, tid, cid) ALONE and ignore head_verptr/head_xid. Matching on the + * head marker's physical identity caused an unbounded EPQ livelock on a hot + * row (e.g. TPC-C warehouse/district under high concurrency): every retry + * observed a DIFFERENT, newer committer (fresh head_verptr/xid), so the + * marker never matched, EPQ re-fired forever, and each cycle leaked into + * TopTransactionContext until OOM. Heap does not re-loop when a still-newer + * committer appears mid-EPQ -- it re-evaluates against the latest version + * once and then applies. Matching on (relid, tid, cid) reproduces that: we + * bounce to EPQ at most once per tuple per command, then proceed with the + * update against whatever the current version is. + */ +bool +FluxEpqReconcileMatches(Snapshot snapshot, Oid relid, ItemPointer tid) +{ + return MyFluxXactState != NULL && + TransactionIdIsValid(MyFluxXactState->epq_reconcile_xid) && + MyFluxXactState->epq_reconcile_relid == relid && + MyFluxXactState->epq_reconcile_cid == snapshot->curcid && + ItemPointerEquals(&MyFluxXactState->epq_reconcile_tid, tid); +} + +/* + * FluxEpqReconcileMark -- record the (verptr, xid) marker identity we are + * bouncing to EvalPlanQual so the retry does not re-report it. + */ +void +FluxEpqReconcileMark(Snapshot snapshot, Oid relid, ItemPointer tid, + UndoRecPtr head_verptr, TransactionId head_xid) +{ + if (MyFluxXactState == NULL) + FluxInitTransactionState(); + + MyFluxXactState->epq_reconcile_relid = relid; + ItemPointerCopy(tid, &MyFluxXactState->epq_reconcile_tid); + MyFluxXactState->epq_reconcile_cid = snapshot->curcid; + MyFluxXactState->epq_reconcile_verptr = head_verptr; + MyFluxXactState->epq_reconcile_xid = head_xid; +} + +/* + * FluxTupleSatisfiesMVCC -- heap-compatible xmin/xmax visibility check. + * + * This is the single visibility function for FLUX's heap-shaped MVCC model. + * It mirrors HeapTupleSatisfiesMVCC: a tuple is visible to `snapshot` iff its + * inserter (t_xmin) is committed-and-visible-to-the-snapshot AND its + * deleter/updater (t_xmax) is invalid, not committed, or not visible to the + * snapshot. CLOG is the authoritative commit-status oracle; the snapshot's + * xmin/xmax/xip decide visibility of committed XIDs. The sLog is consulted + * ONLY for the command-id (cid) of the current transaction's own uncommitted + * work, which FLUX stores there instead of in the tuple header. + * + * The pre-commit "dirty read" window closes for free here: a reader resolves + * t_xmin against CLOG, and CLOG marks a transaction committed only at the + * durability point (RecordTransactionCommit flushes the commit record before + * ProcArrayEndTransaction / TransactionIdDidCommit reports true for a + * crash-safe reader path). A not-yet-committed inserter -- which includes a + * not-yet-durable one -- is simply invisible. + * + * `curcid` is the reader's command id for MVCC snapshots (InvalidCommandId + * for SELF/ANY, which see all of the current transaction's work). + */ + +/* + * FluxSetHintBits -- cache a CLOG commit result on the tuple as a hint bit. + * + * Mirrors heap's SetHintBits: once TransactionIdDidCommit() has confirmed a + * tuple's xmin (or xmax) committed, record it in t_flags so subsequent + * visibility checks skip the CLOG SLRU lookup entirely. This is the + * optimization that keeps a hot-set scan off the CLOG buffer LWLock (without + * it, every tuple examined by every scan did a TransactionIdDidCommit -> CLOG + * lookup, serializing on the CLOG buffer lock). Non-WAL hint write: the + * flag is reconstructible from CLOG, so a torn/lost hint is harmless. + */ +static inline void +FluxSetHintBits(FluxTupleHeader *tuple, Buffer buffer, uint16 hint) +{ + /* + * Cache the CLOG result as a hint bit, but ONLY when we hold the buffer + * content lock: BufferSetHintBits16 (like MarkBufferDirtyHint) errors on + * an unlocked buffer, and FluxTupleSatisfiesMVCC is reached from some + * paths (post-VACUUM reads, replication reads, detached-tuple checks) + * where the buffer is pinned but not lock-held. If we can't safely set + * the shared hint, just skip it -- the result is still correct, the CLOG + * lookup simply isn't cached on this call; the next scan holding the lock + * will cache it. Setting the in-memory bit without the lock would race a + * concurrent writer's t_flags update, so we do nothing rather than a bare + * |=. + */ + if (BufferIsValid(buffer) && BufferIsLockedByMe(buffer)) + BufferSetHintBits16(&tuple->t_flags, + tuple->t_flags | hint, buffer); +} + +/* + * FluxWriterVisibleToSnapshot -- is a version written by another + * transaction's xid visible to an MVCC snapshot? + * + * This is the single "is this writer visible?" rule for every FLUX + * visibility path: the on-page xmin/xmax checks in FluxTupleSatisfiesMVCC + * and each step of the UNDO version-chain walk in + * FluxReconstructVisibleVersion. The caller handles the current + * transaction's own xids (those go by command id). + * + * A writer is visible only if it is not in the snapshot's in-progress set + * AND it committed. "Not in the snapshot" is not enough on its own. A + * writer that finished before the snapshot without committing is aborted, + * and its in-place images can still be on the page: a rollback handed to an + * undo apply worker, and every crash loser, is reverted only later. Such a + * writer must be treated as invisible so that readers walk past its versions + * to the last committed one. (OrioleDB gets the same property by marking an + * aborted transaction's CSN ABORTED before its undo is applied.) + * + * known_committed lets a caller that holds a CLOG hint bit for xid skip the + * CLOG lookup. + */ +bool +FluxWriterVisibleToSnapshot(TransactionId xid, Snapshot snapshot, + bool known_committed) +{ + if (TransactionIdEquals(xid, FrozenTransactionId)) + return true; + if (XidInMVCCSnapshot(xid, snapshot)) + return false; + return known_committed || TransactionIdDidCommit(xid); +} + +/* + * Is a version whose writer (t_xmin) is the current transaction visible to + * command curcid? Shared by FluxTupleSatisfiesMVCC and + * FluxTupleSatisfiesLatest. + */ +static bool +flux_own_xmin_visible(FluxTupleHeader *tuple, Oid relid, CommandId curcid, + Buffer buffer, ItemPointer tid) +{ + /* + * Written by our own transaction (t_xmin is the writing subtransaction's + * xid, so a version a rolled-back savepoint wrote is not current and + * never gets here). Visible unless a later command wrote it. A + * committed self-insert from an earlier command is unconditionally + * visible. + */ + if (tuple->t_flags & FLUX_TUPLE_UNCOMMITTED) + { + /* + * Our own LATER command wrote this image: not visible to the current + * command. This is heap's `HeapTupleHeaderGetCmin(tuple) >= + * snapshot->curcid` rule, and it is what stops a statement from + * seeing the rows it is itself producing. Without it FLUX's + * out-of-place UPDATE (delete + insert at a new TID) let the same + * scan re-find its own output and update it twice -- the Halloween + * problem. The plain INSERT path writes no per-tuple sLog entry + * (per-page conflict tracking), so the cid must come off the tuple + * header. + * + * Safe because t_cmin always describes the same writer as t_xmin, and + * we have just established that t_xmin is our own transaction: every + * path that re-stamps t_xmin also re-stamps t_cmin. + */ + if (curcid != InvalidCommandId && + tuple->t_cmin != InvalidCommandId && + tuple->t_cmin >= curcid) + return false; + } + return true; +} + +static bool +FluxTupleSatisfiesMVCC(FluxTupleHeader *tuple, Snapshot snapshot, + Oid relid, CommandId curcid, Buffer buffer, + ItemPointer tid) +{ + TransactionId xmin; + TransactionId xmax; + bool xmin_hint; + bool xmax_hint; + + if (tuple == NULL) + return false; + + xmin = FluxTupleGetXmin(tuple); + xmax = FluxTupleGetXmax(tuple); + + /* + * SnapshotAny / non-MVCC "see everything": show every non-deleted tuple. + * A committed-and-visible xmax still hides it (the row is gone), but for + * SnapshotAny the caller wants raw existence, so only the DELETED flag + * matters. Callers that need SNAPSHOT_DIRTY semantics handle that + * separately in flux_handler.c before reaching here. + */ + if (!IsMVCCSnapshot(snapshot)) + return !(tuple->t_flags & FLUX_TUPLE_DELETED) || + !TransactionIdIsValid(xmax); + + /* ---- xmin side: is the inserter committed-and-visible? ---- */ + if (!TransactionIdIsValid(xmin)) + return false; /* no inserter -- cannot be visible */ + + if (TransactionIdIsCurrentTransactionId(xmin)) + { + if (!flux_own_xmin_visible(tuple, relid, curcid, buffer, tid)) + return false; + /* our insert, visible so far -- fall through to xmax check */ + } + else + { + xmin_hint = (tuple->t_flags & FLUX_TUPLE_XMIN_COMMITTED) != 0; + + /* inserter in progress, aborted, or not yet visible to snapshot */ + if (!FluxWriterVisibleToSnapshot(xmin, snapshot, xmin_hint)) + return false; + + /* + * Inserter committed. Cache it as a hint bit (like heap's + * HEAP_XMIN_COMMITTED) so subsequent visibility checks skip the CLOG + * SLRU lookup -- this is what keeps a hot-set scan off the CLOG + * buffer LWLock. Non-WAL hint write via MarkBufferDirtyHint. + */ + if (!xmin_hint) + FluxSetHintBits(tuple, buffer, FLUX_TUPLE_XMIN_COMMITTED); + } + + /* Inserter is committed-and-visible. Now the xmax (deleter) side. */ + + if (!TransactionIdIsValid(xmax) || + !(tuple->t_flags & (FLUX_TUPLE_DELETED | FLUX_TUPLE_UPDATED))) + { + /* + * No deleter, or the tuple carries no delete/supersede marker: the + * on-page image is live for this snapshot. + * + * NOTE (in-place UPDATE): when this tuple was updated in place, the + * on-page image is the NEWEST version and its xmin is the updater. If + * that updater is invisible to `snapshot`, the xmin check above + * already returned false, and the caller (flux_handler.c) + * reconstructs the older visible version from the UNDO log via + * FluxReconstructVisibleVersion(). So an old snapshot never sees the + * wrong (too-new) bytes: it is either hidden here and reconstructed, + * or the updater is visible and the new image is correct. + */ + return true; + } + + /* There is a deleter/updater xmax; resolve it heap-style. */ + if (TransactionIdIsCurrentTransactionId(xmax)) + { + /* + * Deleted/superseded by our own transaction. Invisible to us unless + * the delete happened in a later command (then we still see the row). + * Consult the sLog for the delete cid while it is uncommitted. + */ + if (curcid != InvalidCommandId && + (tuple->t_flags & FLUX_TUPLE_UNCOMMITTED)) + { + /* + * Our own in-place UPDATE/DELETE command id lives in the + * backend-local cid map (per-page conflict tracking no longer + * writes a per-tuple sLog UPDATE/DELETE entry). If our own LATER + * command superseded this row, it is not visible to the current + * command. + */ + CommandId local_cid = FluxLocalCidGet(relid, tid); + + if (local_cid != InvalidCommandId && local_cid >= curcid) + return true; /* deleted/updated after our scan started */ + } + return false; /* our own delete, visible to this command */ + } + + xmax_hint = (tuple->t_flags & FLUX_TUPLE_XMAX_COMMITTED) != 0; + + /* deleter in progress, aborted, or not yet visible -- row still here */ + if (!FluxWriterVisibleToSnapshot(xmax, snapshot, xmax_hint)) + return true; + + /* deleter committed-and-visible -- gone. Cache the CLOG result. */ + if (!xmax_hint) + FluxSetHintBits(tuple, buffer, FLUX_TUPLE_XMAX_COMMITTED); + return false; +} + +/* + * FluxTupleVisibleToSnapshotDual -- primary visibility entry point. + * + * Forwards to FluxTupleSatisfiesMVCC. + */ +bool +FluxTupleVisibleToSnapshotDual(FluxTupleHeader *tuple, Snapshot snapshot, + Oid relid, Buffer buffer, ItemPointer tid) +{ + /* + * Only apply CID filtering for MVCC snapshots. SNAPSHOT_SELF and + * SNAPSHOT_ANY must see all of the current transaction's work. + */ + return FluxTupleSatisfiesMVCC(tuple, snapshot, relid, + (snapshot != NULL && + snapshot->snapshot_type == SNAPSHOT_MVCC) + ? snapshot->curcid : InvalidCommandId, + buffer, tid); +} + +/* + * FluxTupleSatisfiesLatest -- may a caller without a snapshot lock the version + * on the page? + * + * The NULL-snapshot counterpart of FluxTupleVisibleToSnapshotDual for + * flux_tuple_lock. Heap's tuple_lock never looks at the snapshot: it decides + * with HeapTupleSatisfiesUpdate, against CLOG and the current command. FLUX + * keeps a row's latest version at its TID, so with no snapshot the only + * question is whether that version's writer is committed, or is the current + * transaction in a command before curcid. An in-progress or aborted writer, + * or our own write in curcid or later, makes it false: the caller then waits + * for the writer or reports TM_Invisible, as heap does. The delete mark is + * the caller's to test, as xmax is in heap_lock_tuple. + */ +bool +FluxTupleSatisfiesLatest(FluxTupleHeader *tuple, Oid relid, CommandId curcid, + Buffer buffer, ItemPointer tid) +{ + TransactionId xmin = FluxTupleGetXmin(tuple); + + if (!TransactionIdIsValid(xmin)) + return false; + if (TransactionIdIsCurrentTransactionId(xmin)) + return flux_own_xmin_visible(tuple, relid, curcid, buffer, tid); + return (tuple->t_flags & FLUX_TUPLE_XMIN_COMMITTED) != 0 || + TransactionIdDidCommit(xmin); +} + +/* + * FluxGetOldestXminHorizon -- XID retention horizon for VACUUM/prune. + * + * A committed-deleted tuple whose deleter (t_xmax) precedes this horizon is + * invisible to every current and future snapshot and can be physically + * removed. Uses the standard non-removable-transaction horizon so it tracks + * the cluster's oldest snapshot xmin exactly like heap VACUUM. + */ +TransactionId +FluxGetOldestXminHorizon(Relation rel) +{ + return GetOldestNonRemovableTransactionId(rel); +} + +/* + * FluxTupleAdvanceConflictHorizon -- advance *horizon past a tuple that is + * about to be removed because FluxTupleDeadToAll said so. + * + * The FLUX counterpart of HeapTupleHeaderAdvanceConflictHorizon. A committed + * DELETE was visible to every snapshot that does not see its deleter + * (t_xmax) as committed, so a hot standby must cancel those snapshots before + * it replays the removal. An aborted INSERT was never visible to anyone and + * does not advance the horizon, nor does a row deleted by its own inserter. + */ +void +FluxTupleAdvanceConflictHorizon(FluxTupleHeader *tuple, TransactionId *horizon) +{ + TransactionId xmax = FluxTupleGetXmax(tuple); + + if ((tuple->t_flags & FLUX_TUPLE_DELETED) && + TransactionIdIsNormal(xmax) && + !TransactionIdEquals(xmax, FluxTupleGetXmin(tuple)) && + (!TransactionIdIsValid(*horizon) || + TransactionIdFollows(xmax, *horizon))) + *horizon = xmax; +} + +/* + * FluxTupleDeadToAll -- true iff a tuple can be reclaimed. + * + * Heap-shaped, two cases: + * + * - A committed DELETE (DELETED set, UNCOMMITTED clear) whose deleter + * (t_xmax) committed before the oldest-xmin horizon, so no active or + * future snapshot can see it. + * + * - An aborted INSERT: the inserter (t_xmin) did not commit, is not running, + * and the tuple has no version history (t_verptr invalid). INSERT writes + * no UNDO, so nothing else ever marks such a row dead; heap reclaims it + * the same way (HEAPTUPLE_DEAD for an aborted xmin). A tuple with a valid + * t_verptr is an in-place UPDATE or a DELETE whose writer is t_xmin or + * t_xmax: if that writer aborted, the row is still alive at its + * before-image and must be reverted, not removed. + */ +bool +FluxTupleDeadToAll(FluxTupleHeader *tuple, TransactionId oldest_xmin) +{ + TransactionId xmax; + + if (!UndoRecPtrIsValid(tuple->t_verptr) && + !(tuple->t_flags & FLUX_TUPLE_XMIN_COMMITTED)) + { + TransactionId xmin = FluxTupleGetXmin(tuple); + + if (TransactionIdIsNormal(xmin) && + TransactionIdPrecedes(xmin, oldest_xmin) && + !TransactionIdIsInProgress(xmin) && + !TransactionIdDidCommit(xmin)) + return true; + } + + if (!(tuple->t_flags & FLUX_TUPLE_DELETED)) + return false; + if (tuple->t_flags & FLUX_TUPLE_UNCOMMITTED) + return false; + + xmax = FluxTupleGetXmax(tuple); + if (!TransactionIdIsValid(xmax)) + return false; + if (!TransactionIdDidCommit(xmax)) + return false; /* delete aborted / in flight: keep */ + + return TransactionIdPrecedes(xmax, oldest_xmin); +} + +/* + * FluxTupleStatsCount -- how VACUUM and ANALYZE count a tuple in the + * reltuples and live/dead numbers they report, as heap counts by + * HeapTupleSatisfiesVacuum. + * + * A row that another transaction is inserting is not counted, and one that + * another transaction is deleting is live: that transaction reports the + * insert or delete to the cumulative statistics when it ends, so counting it + * here too would count it twice. A row whose deleter committed, or is the + * current transaction, is dead; one whose deleter aborted is live (its revert + * is pending). An aborted INSERT's row is dead. An in-place UPDATE keeps + * one row whatever its writer's state, so a row with UNDO history is live. + */ +FluxStatsCount +FluxTupleStatsCount(FluxTupleHeader *tuple) +{ + if (tuple->t_flags & FLUX_TUPLE_DELETED) + { + TransactionId xmax = FluxTupleGetXmax(tuple); + + if (tuple->t_flags & FLUX_TUPLE_XMAX_COMMITTED || + !TransactionIdIsNormal(xmax) || + TransactionIdIsCurrentTransactionId(xmax)) + return FLUX_COUNT_DEAD; + if (TransactionIdIsInProgress(xmax)) + return FLUX_COUNT_LIVE; + return TransactionIdDidCommit(xmax) ? FLUX_COUNT_DEAD : FLUX_COUNT_LIVE; + } + + if (!UndoRecPtrIsValid(tuple->t_verptr) && + !(tuple->t_flags & FLUX_TUPLE_XMIN_COMMITTED)) + { + TransactionId xmin = FluxTupleGetXmin(tuple); + + if (TransactionIdIsNormal(xmin) && + !TransactionIdIsCurrentTransactionId(xmin)) + { + if (TransactionIdIsInProgress(xmin)) + return FLUX_COUNT_NONE; + if (!TransactionIdDidCommit(xmin)) + return FLUX_COUNT_DEAD; + } + } + + return FLUX_COUNT_LIVE; +} diff --git a/src/backend/access/flux/flux_operations.c b/src/backend/access/flux/flux_operations.c new file mode 100644 index 0000000000000..70fd59d31724f --- /dev/null +++ b/src/backend/access/flux/flux_operations.c @@ -0,0 +1,9328 @@ +/*------------------------------------------------------------------------- + * + * flux_operations.c + * FLUX table manipulation operations + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_operations.c + * + * NOTES + * This implements the remaining table manipulation operations for + * FLUX storage manager including insert, update, delete, and + * various DDL operations. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/amapi.h" +#include "access/genam.h" +#include "access/heapam.h" +#include "access/multixact.h" +#include "access/nbtree.h" +#include "access/visibilitymap.h" +#include "access/heaptoast.h" +#include "access/flux.h" +#include "access/slog.h" +#include "access/subtrans.h" +#include "access/twophase.h" +#include "access/twophase_rmgr.h" +#include "access/flux_undo.h" +#include "access/flux_pbu.h" +#include "access/flux_xlog.h" +#include "access/tableam.h" +#include "access/xlog.h" +#include "access/tidstore.h" +#include "access/xact.h" +#include "access/xactundo.h" +#include "access/xloginsert.h" +#include "catalog/catalog.h" +#include "catalog/index.h" +#include "catalog/pg_am_d.h" +#include "catalog/storage.h" +#include "commands/progress.h" +#include "commands/vacuum.h" +#include "executor/executor.h" +#include "pgstat.h" +#include "portability/instr_time.h" +#include "storage/bufmgr.h" +#include "storage/freespace.h" +#include "storage/latch.h" +#include "storage/read_stream.h" +#include "storage/lmgr.h" +#include "storage/predicate.h" +#include "storage/procarray.h" +#include "storage/smgr.h" +#include "utils/builtins.h" +#include "utils/datum.h" +#include "utils/injection_point.h" +#include "utils/lsyscache.h" +#include "utils/rel.h" +#include "utils/snapmgr.h" +#include "utils/timestamp.h" +#include "utils/wait_event.h" +#include "storage/bufpage.h" +#include "miscadmin.h" + +/* + * A copied before-image may outlive the CLOG segment containing its xmin. + * Normalize it before full-image logging or delta encoding. No snapshot can + * need its predecessors once its committed writer is globally removable. + * Own/uncommitted writers must retain both their identity and history. + */ +/* The version FLUX_INSERT_REWRITE copies; see FluxSetRewriteVersion. */ +static FluxRewriteVersion flux_rewrite_version; +static bool flux_rewrite_version_set = false; + +/* + * FluxSetRewriteVersion + * Name the writers of the version the next FLUX_INSERT_REWRITE insert + * copies. table_tuple_insert has no argument for them. + */ +void +FluxSetRewriteVersion(const FluxRewriteVersion *v) +{ + flux_rewrite_version = *v; + flux_rewrite_version_set = true; +} + +static TransactionId +flux_prepare_before_image(Relation rel, FluxTupleHeader *tuple) +{ + TransactionId xmin = FluxTupleGetXmin(tuple); + + if (!(tuple->t_flags & (FLUX_TUPLE_DELETED | FLUX_TUPLE_SPECULATIVE)) && + !TransactionIdIsValid(FluxTupleGetXmax(tuple)) && + TransactionIdIsNormal(xmin) && + TransactionIdPrecedes(xmin, GetOldestNonRemovableTransactionId(rel)) && + TransactionIdDidCommit(xmin)) + { + FluxTupleSetXmin(tuple, FrozenTransactionId); + tuple->t_verptr = InvalidUndoRecPtr; + tuple->t_flags &= ~(FLUX_TUPLE_UNCOMMITTED | FLUX_TUPLE_HAS_VERSION_PTR); + tuple->t_flags |= FLUX_TUPLE_XMIN_COMMITTED; + return xmin; + } + return InvalidTransactionId; +} + +/* + * Maximum overflow pointers per tuple for VACUUM overflow cleanup. + * This limits memory usage during VACUUM and is conservative since most + * tuples won't have overflow data. 128 is sufficient for typical workloads. + */ +#define MAX_OVERFLOW_PTRS_PER_TUPLE 128 + +/* Function prototypes for locking */ +extern bool FluxLockTuple(Relation rel, ItemPointer tid, LockTupleMode mode, + bool wait, bool *have_tuple_lock); +extern void FluxUnlockTuple(Relation rel, ItemPointer tid, LockTupleMode mode); +extern void FluxLockPage(Relation rel, BlockNumber blkno, LOCKMODE mode); +extern void FluxUnlockPage(Relation rel, BlockNumber blkno, LOCKMODE mode); + +/* sLog transaction callback prototypes */ +static void FluxSLogXactCallback(XactEvent event, void *arg); +static void FluxSLogSubXactCallback(SubXactEvent event, + SubTransactionId mySubid, + SubTransactionId parentSubid, + void *arg); +static void flux_clear_uncommitted_by_page(TransactionId xid); + +/* + * Backend-local command-id map for the current transaction's own in-place + * UPDATE/DELETE operations. + * + * FLUX removed the on-tuple t_cid field (saving 4 bytes/tuple); the command + * id of an uncommitted UPDATE/DELETE was previously recovered from the + * per-tuple sLog entry. With per-page conflict tracking, FLUX no longer + * writes a per-tuple sLog UPDATE/DELETE entry, so the command id -- needed + * ONLY for the current transaction's own self-visibility ordering + * (curcid/cmax) -- is kept here in a backend-local hash keyed by (relid,tid). + * This is O(tuples this backend modified this transaction) local memory, the + * same information heap keeps on-tuple; it is reset at transaction end. + * + * The cmin side is NOT stored here: the command id of the writer named by + * t_xmin lives on the tuple itself, in FluxTupleHeader.t_cmin (heap's t_cid), + * because the plain insert path writes no per-tuple sLog entry at all under + * per-page conflict tracking. See FluxTupleHeader.t_cmin and + * FluxTupleSatisfiesMVCC. + */ +typedef struct FluxCidKey +{ + Oid relid; + ItemPointerData tid; +} FluxCidKey; + +typedef struct FluxCidEntry +{ + FluxCidKey key; /* hash key -- must be first */ + CommandId cid; +} FluxCidEntry; + +static HTAB *flux_local_cid_map = NULL; + +/* in-place indexed-column UPDATE via delete-marking */ +static bool flux_indexed_attr_changed(Relation relation, ItemPointer otid, + TupleTableSlot *slot, + UndoRecPtr *decided_verptr, + TransactionId *decided_xmin); +static bool flux_index_keys_equalimage(Relation indexRel); +static bool flux_index_change_exact(Relation relation, ItemPointer otid, + FluxTupleHeader *old_hdr, Size old_len, + TupleTableSlot *newslot); +static void flux_inplace_index_maintenance(Relation relation, ItemPointer otid, + FluxTupleHeader *old_hdr, Size old_len, + TupleTableSlot *newslot, UndoRecPtr urp); +static TM_Result flux_tuple_update_inplace(Relation relation, ItemPointer otid, + TupleTableSlot *slot, CommandId cid, + uint32 options, Snapshot snapshot, + Snapshot crosscheck, bool wait, + TM_FailureData *tmfd, + LockTupleMode *lockmode, + TU_UpdateIndexes *update_indexes, + bool key_changed, + bool *did_inplace_key_update, UndoRecPtr *index_urp, + FluxTupleHeader **index_old, Size *index_old_len, + UndoRecPtr decided_verptr, + TransactionId decided_xmin, + bool *redecide, bool *kept_tuplock, + LockTupleMode *kept_mode); + +/* + * FluxLocalCidSet -- record the command id of our own UPDATE/DELETE at (relid, + * tid). Later calls for the same TID overwrite (last writer in the txn wins, + * matching the sLog's previous behavior where the newest op's cid was read). + */ +static void +FluxLocalCidSet(Oid relid, ItemPointer tid, CommandId cid) +{ + FluxCidKey key; + FluxCidEntry *entry; + bool found; + + if (cid == InvalidCommandId) + return; + + if (flux_local_cid_map == NULL) + { + HASHCTL ctl; + + memset(&ctl, 0, sizeof(ctl)); + ctl.keysize = sizeof(FluxCidKey); + ctl.entrysize = sizeof(FluxCidEntry); + ctl.hcxt = TopTransactionContext; + flux_local_cid_map = hash_create("FLUX local cid map", 256, &ctl, + HASH_ELEM | HASH_BLOBS | HASH_CONTEXT); + } + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + entry = (FluxCidEntry *) hash_search(flux_local_cid_map, &key, + HASH_ENTER, &found); + entry->cid = cid; +} + +/* + * FluxLocalCidGet -- fetch the command id our transaction stamped on an + * in-place UPDATE/DELETE of (relid, tid), or InvalidCommandId if none. + */ +CommandId +FluxLocalCidGet(Oid relid, ItemPointer tid) +{ + FluxCidKey key; + FluxCidEntry *entry; + + if (flux_local_cid_map == NULL) + return InvalidCommandId; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + entry = (FluxCidEntry *) hash_search(flux_local_cid_map, &key, + HASH_FIND, NULL); + return entry ? entry->cid : InvalidCommandId; +} + +/* + * FluxLocalCidReset -- drop the backend-local cid map at transaction end. + * The hash lives in TopTransactionContext, so its memory is reclaimed by the + * context reset regardless; nulling the pointer makes the state consistent + * for the next transaction in this backend. + */ +void +FluxLocalCidReset(void) +{ + flux_local_cid_map = NULL; +} + +/* + * FluxCheckSelfModified + * + * Decide whether a tuple we are about to UPDATE or DELETE was already + * modified by OUR OWN transaction in this or a later command, which heap + * reports as TM_SelfModified from HeapTupleSatisfiesUpdate(). + * + * Heap's rule (heapam_visibility.c): if the tuple's xmax is the current + * transaction and its cmax >= the caller's curcid, the row was updated after + * the current command's scan started, so the caller must not silently + * overwrite it. The executor turns that verdict into either "ignore the + * second update of the same row in one command" (cmax == es_output_cid) or + * the "tuple to be updated was already modified by an operation triggered by + * the current command" error raised for BEFORE-trigger self-modification. + * + * FLUX updates in place and keeps no on-tuple cmax, so the command id of our + * own uncommitted UPDATE/DELETE lives in the backend-local cid map + * (FluxLocalCidSet/Get). That gives us exactly heap's inputs: + * + * - the tuple must still be UNCOMMITTED (an in-progress modification), + * - its writer xid must be our own transaction, + * - and we must have recorded a cid for it, which must be >= cid. + * + * Without this check FLUX silently applied the second write: the BEFORE + * trigger's own change was overwritten and lost (no error, wrong data), and a + * MERGE whose BEFORE trigger modifies the target row never terminated. + * + * The caller must hold the tuple's buffer lock so the header read is stable. + * On a true verdict *cmax_out receives the recorded cid for tmfd->cmax, which + * is what the executor compares against es_output_cid. + */ +bool +FluxCheckSelfModified(Relation relation, ItemPointer tid, + FluxTupleHeader *tuple_hdr, CommandId cid, + CommandId *cmax_out) +{ + TransactionId writer; + CommandId local_cid; + + if (cmax_out) + *cmax_out = InvalidCommandId; + + /* Only an in-progress modification can be our own uncommitted write. */ + if (!(tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED)) + return false; + + /* + * Identify the writer the same way flux_header_dirty_xid does, but keep + * OUR xid instead of filtering it out: a DELETE stamps t_xmax, while an + * in-place UPDATE stamps the updater as t_xmin. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + writer = FluxTupleGetXmax(tuple_hdr); + else + writer = FluxTupleGetXmin(tuple_hdr); + + if (!TransactionIdIsValid(writer) || + !TransactionIdIsCurrentTransactionId(writer)) + return false; + + /* + * A recorded cid below the current command id means the change came from + * an EARLIER command, which is visible to us and legitimately updatable + * (heap returns TM_Ok there too). + */ + local_cid = FluxLocalCidGet(RelationGetRelid(relation), tid); + if (local_cid == InvalidCommandId || local_cid < cid) + return false; + + if (cmax_out) + *cmax_out = local_cid; + return true; +} + +/* + * Backend-local set of pages this transaction modified in place + * (UPDATE/DELETE), for per-page commit-time UNCOMMITTED-flag cleanup. + * + * Per-page conflict tracking no longer records a per-tuple sLog UPDATE/DELETE + * entry, so FluxClearUncommittedFlags can no longer enumerate this + * transaction's modified tuples from the shared sLog tracked-key list. This + * backend-local set instead records the DISTINCT (relid, blkno) pages we + * dirtied -- O(pages touched), not O(tuples) -- which is the whole point of + * the per-page scheme. At PRE_COMMIT we visit each page once and clear the + * hint flag on every tuple stamped with our xid. Reset at transaction end. + */ +typedef struct FluxDirtyPageKey +{ + Oid relid; + RelFileNumber relnumber; /* the relation's storage the page is in */ + BlockNumber blkno; +} FluxDirtyPageKey; + +static HTAB *flux_local_dirty_pages = NULL; + +/* + * FluxLocalDirtyPageMark -- record that our transaction modified block blkno + * of rel's current storage in place. Idempotent; called at the in-place + * UPDATE/DELETE sites. + */ +static void +FluxLocalDirtyPageMark(Relation rel, BlockNumber blkno) +{ + FluxDirtyPageKey key; + + if (flux_local_dirty_pages == NULL) + { + HASHCTL ctl; + + memset(&ctl, 0, sizeof(ctl)); + ctl.keysize = sizeof(FluxDirtyPageKey); + ctl.entrysize = sizeof(FluxDirtyPageKey); + ctl.hcxt = TopTransactionContext; + flux_local_dirty_pages = hash_create("FLUX local dirty pages", 128, + &ctl, + HASH_ELEM | HASH_BLOBS | HASH_CONTEXT); + } + + memset(&key, 0, sizeof(key)); + key.relid = RelationGetRelid(rel); + key.relnumber = rel->rd_locator.relNumber; + key.blkno = blkno; + (void) hash_search(flux_local_dirty_pages, &key, HASH_ENTER, NULL); +} + +/* + * FluxLocalDirtyPagesReset -- drop the per-transaction dirty-page set at + * transaction end (memory reclaimed by the TopTransactionContext reset). + */ +static void +FluxLocalDirtyPagesReset(void) +{ + flux_local_dirty_pages = NULL; +} + + +/* + * In-place update statistics counters. + * + * These track the effectiveness of FLUX's in-place update optimization + * across the lifetime of the backend. They are exposed via + * FluxGetUpdateStats() for monitoring. + */ +static int64 flux_stat_in_place_updates = 0; +static int64 flux_stat_out_of_place_updates = 0; +static int64 flux_stat_defrag_triggered_updates = 0; + +/* Whether sLog transaction callbacks have been registered for this backend */ +static bool flux_slog_callbacks_registered = false; + +/* + * GUC: skip commit-time page re-visits (lazy clear of UNCOMMITTED flags). + * + * Default off. Clearing FLUX_TUPLE_UNCOMMITTED is a pure hint (commit + * visibility comes from CLOG via heap-shaped xmin/xmax); the lazy path clears + * the flag at the next visibility check, while the eager path visits each + * modified page at PRE_COMMIT so later readers can skip the sLog fast-path + * lookup. + */ +bool flux_lazy_uncommitted_clear = false; + +/* + * FluxGetUpdateStats - Return in-place update statistics + * + * Fills in the provided counters with the current backend-local statistics. + */ +void +FluxGetUpdateStats(int64 *in_place, int64 *out_of_place, int64 *defrag_triggered) +{ + if (in_place) + *in_place = flux_stat_in_place_updates; + if (out_of_place) + *out_of_place = flux_stat_out_of_place_updates; + if (defrag_triggered) + *defrag_triggered = flux_stat_defrag_triggered_updates; +} + + +/* + * FluxPagePruneOpt -- opportunistic dead-tuple cleanup on a page. + * + * This is the FLUX equivalent of heap_page_prune_opt(). It is called + * during normal DML operations (insert, update) and sequential scans + * when a page looks like it might benefit from cleanup. The goal is to + * reclaim space from deleted tuples without waiting for VACUUM. + * + * The caller must hold a pin on the buffer but must NOT hold a lock on it. + * We attempt a conditional (non-blocking) exclusive lock; if we cannot + * get it, we return immediately -- this is best-effort cleanup. + * + * Returns the number of tuples pruned. + */ +int +FluxPagePruneOpt(Relation relation, Buffer buffer) +{ + Page page; + FluxPageOpaque opaque; + OffsetNumber offnum; + OffsetNumber maxoff; + uint64 oldest_ts; + TransactionId oldest_xmin_prune; + TransactionId conflict_horizon = InvalidTransactionId; + int ndead = 0; + Size minfree; + + /* Cannot write WAL during recovery, so skip */ + if (RecoveryInProgress()) + return 0; + + page = BufferGetPage(buffer); + + /* Skip if page is not initialized */ + if (PageIsNew(page)) + return 0; + + /* + * Validate page header before accessing special space. During recovery or + * after crashes, pages may have invalid headers. Skip pruning if the page + * header looks corrupt. + */ + { + PageHeader phdr = (PageHeader) page; + + if (phdr->pd_special < SizeOfPageHeaderData || + phdr->pd_special > BLCKSZ) + return 0; + } + + /* + * Quick check without lock: does the page look like it needs pruning? The + * FLUX_PAGE_DEFRAG_NEEDED flag is set by delete and update operations + * when a tuple is marked as deleted. If no deletions have occurred on + * this page, there is nothing to clean up. + */ + opaque = FluxPageGetOpaque(page); + if (!(FluxPageGetFlags(opaque) & FLUX_PAGE_DEFRAG_NEEDED)) + return 0; + + /* + * Heuristic: only prune if the page's free space is below a threshold. + * This avoids spending cycles on pages that already have plenty of room. + * We use 10% of BLCKSZ as the minimum, matching heap's approach. Reading + * pd_lower/pd_upper without a lock is slightly racy but acceptable for a + * heuristic. + */ + minfree = BLCKSZ / 10; + if (PageGetFreeSpace(page) >= minfree && + !(FluxPageGetFlags(opaque) & FLUX_PAGE_FULL)) + return 0; + + /* + * Try to get an exclusive lock without blocking. If the page is busy, + * skip it -- we will get another chance later. + */ + if (!ConditionalLockBufferForCleanup(buffer)) + return 0; + + /* + * Re-check under lock: the page state may have changed while we were + * acquiring the lock (or another backend may have pruned it). + */ + page = BufferGetPage(buffer); + opaque = FluxPageGetOpaque(page); + if (!(FluxPageGetFlags(opaque) & FLUX_PAGE_DEFRAG_NEEDED)) + { + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + return 0; + } + + /* + * oldest_xmin_prune is the reclamation gate (XID horizon, + * FluxTupleDeadToAll below). oldest_ts is NOT a decision input here: it + * is carried only into the DEFRAG WAL record's cosmetic page commit-ts + * word (FluxXLogDefrag -> FluxPageSetCommitTs). + */ + oldest_ts = FluxGetOldestActiveTimestamp(); + oldest_xmin_prune = FluxGetOldestXminHorizon(relation); + maxoff = PageGetMaxOffsetNumber(page); + for (offnum = FirstOffsetNumber; offnum <= maxoff; offnum++) + { + ItemId itemid = PageGetItemId(page, offnum); + FluxTupleHeader *tuple_hdr; + + if (!ItemIdIsNormal(itemid)) + continue; + + /* Skip overflow records */ + if (FluxIsOverflowRecordInline(PageGetItem(page, itemid), + ItemIdGetLength(itemid))) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * A deleted tuple can be pruned if: - UNCOMMITTED is NOT set (the + * inserting/deleting xact committed) - commit_ts is older than the + * oldest active snapshot + * + * If UNCOMMITTED is still set, the transaction is still in progress + * (or aborted but not yet cleaned up) -- skip it. + */ + if (FluxTupleDeadToAll(tuple_hdr, oldest_xmin_prune)) + { + ndead++; + } + } + + if (ndead == 0) + { + /* + * No reclaimable dead tuples. Clear the defrag flag so we don't + * recheck this page on every access until a new deletion occurs. + */ + FluxPageClearFlag(opaque, FLUX_PAGE_DEFRAG_NEEDED); + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + return 0; + } + + /* + * We have reclaimable dead tuples. Mark them LP_DEAD (reclaiming their + * storage) and defragment the page to compact free space. + * + * CRITICAL: opportunistic pruning must set LP_DEAD, never LP_UNUSED. A + * deleted FLUX tuple may still be referenced by index entries that only + * VACUUM (after index bulk-delete) is allowed to remove. LP_DEAD + * reclaims the tuple's storage while reserving its line pointer so the + * TID cannot be recycled by a later INSERT (PageAddItemExtended only + * reuses LP_UNUSED slots). Recycling a TID whose index entries still + * exist would let an index scan return the unrelated tuple later placed + * in that slot. Only VACUUM Phase III converts LP_DEAD -> LP_UNUSED, + * after Phase II has removed the corresponding index entries. This + * mirrors heap pruning, which sets LP_DEAD and defers LP_UNUSED to + * post-index-cleanup VACUUM. + */ + START_CRIT_SECTION(); + + maxoff = PageGetMaxOffsetNumber(page); + for (offnum = FirstOffsetNumber; offnum <= maxoff; offnum++) + { + ItemId itemid = PageGetItemId(page, offnum); + FluxTupleHeader *tuple_hdr; + + if (!ItemIdIsNormal(itemid)) + continue; + + /* Skip overflow records */ + if (FluxIsOverflowRecordInline(PageGetItem(page, itemid), + ItemIdGetLength(itemid))) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + if (FluxTupleDeadToAll(tuple_hdr, oldest_xmin_prune)) + { + FluxTupleAdvanceConflictHorizon(tuple_hdr, &conflict_horizon); + ItemIdSetDead(itemid); + } + } + + FluxPageDefragment(page); + + MarkBufferDirty(buffer); + + /* WAL-log the defragmentation using a proper defrag record */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr; + + /* + * Use FluxXLogDefrag (not FluxXLogInitPage). INIT_PAGE with + * REGBUF_WILL_INIT would zero the page during redo, losing all live + * tuples. The defrag record uses REGBUF_STANDARD which stores a Full + * Page Image, preserving the page contents. + */ + recptr = FluxXLogDefrag(relation, buffer, NULL, 0, oldest_ts, + conflict_horizon); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + + /* Update FSM with the reclaimed free space */ + FluxRecordFreeSpace(relation, BufferGetBlockNumber(buffer), + PageGetHeapFreeSpace(page)); + + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + + return true; +} + + +/* + * Insert a tuple into a FLUX table + */ +void +flux_tuple_insert(Relation relation, TupleTableSlot *slot, CommandId cid, + uint32 options, BulkInsertState bistate) +{ + FluxTuple flux_tuple; + Buffer buffer; + Page page; + OffsetNumber offnum; + BlockNumber target_block; + Size tuple_size; + ItemPointer tid = &slot->tts_tid; + uint64 current_ts; + FluxOverflowBuffers overflow_buffers; + FluxLogicalImage insert_logical_img; + Buffer vmbuffer; + Size targetFreeSpace; + int i; + Datum toast_values[MaxTupleAttributeNumber]; + bool toast_isnull[MaxTupleAttributeNumber]; + ToastAttrInfo toast_attr[MaxTupleAttributeNumber]; + ToastTupleContext ttc; + bool toasted = false; + + slot_getallattrs(slot); + + /* + * Get current timestamp for the page-level commit-ts bookkeeping word. + * + * IMPORTANT: Get transaction timestamp here, BEFORE entering critical + * section, because FluxGetTransactionTimestamp() may need to allocate + * memory to initialize transaction state. + * + * CRITICAL FIX: Use FluxGetTransactionTimestamp() for both current_ts and + * xact_ts to ensure consistency. The inserted tuple will be visible + * within the same transaction because the snapshot timestamp will match + * the tuple's commit timestamp. + */ + (void) FluxGetTransactionTimestamp(); + + current_ts = (uint64) FluxGetDmlTimestamp(); + + /* + * Create FLUX tuple from slot. Use the overflow-aware variant which will + * store large attributes (> FLUX_OVERFLOW_THRESHOLD) in overflow records + * on normal data pages, replacing them with compact inline overflow + * pointers. + * + * Overflow buffers are kept pinned for atomic WAL logging inside the + * critical section below. + */ + overflow_buffers.count = 0; + + /* + * FLUX has no on-page overflow: TOAST any wide varlena columns into the + * relation's standard heap TOAST table before forming the tuple, exactly + * like heap. On return toast_values[] holds the (possibly externalized) + * datums that FluxFormTuple stores verbatim. + */ + memcpy(toast_values, slot->tts_values, + RelationGetDescr(relation)->natts * sizeof(Datum)); + memcpy(toast_isnull, slot->tts_isnull, + RelationGetDescr(relation)->natts * sizeof(bool)); + flux_toast_tuple(relation, toast_values, toast_isnull, NULL, NULL, + &ttc, toast_attr, &toasted, 0); + + flux_tuple = FluxFormTuple(RelationGetDescr(relation), + toast_values, + toast_isnull, + relation, + &overflow_buffers); + + /* Set MVCC fields (heap-shaped xmin/xmax) */ + flux_tuple->t_data->t_commit_ts = 0; /* t_xmax = InvalidTransactionId */ + + /* + * Stamp the inserter XID as t_xmin. Visibility resolves t_xmin against + * CLOG + the reader's snapshot (FluxTupleSatisfiesMVCC), so an + * uncommitted insert is automatically invisible to other snapshots. The + * FLUX_TUPLE_UNCOMMITTED flag is kept for the sLog write-conflict and + * defrag paths, but it no longer drives read visibility. + */ + if (options & FLUX_INSERT_REWRITE) + { + const FluxRewriteVersion *v = &flux_rewrite_version; + + /* + * A CLUSTER / VACUUM FULL copy keeps the writers of the version it + * copies, so older snapshots keep seeing it. Only a copy of our own + * transaction's row is still uncommitted. + */ + Assert(flux_rewrite_version_set); + flux_rewrite_version_set = false; + flux_tuple->t_data->t_xmin = v->xmin; + if (v->xmin_committed) + flux_tuple->t_data->t_flags |= FLUX_TUPLE_XMIN_COMMITTED; + else if (TransactionIdIsCurrentTransactionId(v->xmin)) + flux_tuple->t_data->t_flags |= FLUX_TUPLE_UNCOMMITTED; + if (TransactionIdIsValid(v->xmax)) + { + FluxTupleSetXmax(flux_tuple->t_data, v->xmax); + flux_tuple->t_data->t_flags |= FLUX_TUPLE_DELETED; + if (v->xmax_committed) + flux_tuple->t_data->t_flags |= FLUX_TUPLE_XMAX_COMMITTED; + if (v->moved_partition) + flux_tuple->t_data->t_flags |= FLUX_TUPLE_MOVED_PARTITION; + } + cid = v->cmin; + } + else + { + flux_tuple->t_data->t_flags |= FLUX_TUPLE_UNCOMMITTED; + flux_tuple->t_data->t_xmin = GetCurrentTransactionId(); /* subxid: heap-shaped, + * so savepoint rollback + * marks it aborted in + * CLOG */ + } + + /* + * Stamp the inserting command id (heap's t_cid). Self-visibility needs + * it so a statement does not see the rows it is itself producing -- + * without it FLUX's out-of-place UPDATE (delete + insert at a new TID) + * let the same scan re-find and re-update its own output: the Halloween + * problem. Read only while UNCOMMITTED and t_xmin is ours + * (FluxTupleSatisfiesMVCC). + */ + flux_tuple->t_data->t_cmin = cid; + + /* + * The version-chain head lives in the fixed header field t_verptr + * (counted by FLUX_TUPLE_OVERHEAD), so every tuple carries it from birth + * with no on-page growth. Stamp InvalidUndoRecPtr here; the first UPDATE + * replaces it with a real chain head. Readers treat an invalid head as + * "no history" (flux_pvs.c), so a never-updated row is unaffected. + * Because the slot width never changes on UPDATE, the first UPDATE is + * always a same-length overwrite (Strategy 1 / CAS fast path). + */ + flux_tuple->t_data->t_flags |= FLUX_TUPLE_HAS_VERSION_PTR; + FluxTupleSetVersionPtr(flux_tuple->t_data, + InvalidUndoRecPtr); + tuple_size = flux_tuple->t_len; + + /* Ensure relation storage exists */ + RelationGetSmgr(relation); + + /* + * Find a page with enough free space. + * + * Like heap (RelationGetBufferForTuple), we first try the page we last + * inserted into, cached per-relation in the relcache via + * RelationSetTargetBlock(). Only if we have no cached target do we ask + * the FSM. This avoids an fsm_search() + RecordPageWithFreeSpace() on + * every append: on an append-heavy table (e.g. pgbench_history) every + * backend otherwise pounds the same tail FSM pages, saturating the FSM + * buffer-header spinlock (LockBufHdr) at high concurrency. The cache is + * only a hint -- a stale or too-full target just falls through to the FSM + * below and to the have_page free-space recheck. + * + * The cached-target fast path is used only when the tuple has no overflow + * data: with overflow, FluxFormTupleWithOverflow has already chosen and + * locked pages and the target_block/overflow-buffer interplay below + * assumes the FSM was consulted, so that path stays on the FSM. + * + * Account for fill factor: reserve space for future in-place updates. + */ + + /* + * Derive the free-space bar ONCE and use the identical value for the FSM + * request and for every page recheck below. + * + * This must be a single value or the insert path cannot terminate: asking + * the FSM for X but only accepting a page with >= Y > X means a page the + * FSM legitimately returns is rejected by the recheck, which records the + * page as full and asks again -- forever. That is exactly what happened + * when the FSM ask was clamped (to stay inside MaxFSMRequestSize) while + * the recheck still demanded the raw tuple_size + saveFreeSpace: INSERT + * HUNG instead of erroring. FluxTargetFreeSpace applies heap's + * nearlyEmptyFreeSpace rule in FLUX's page geometry and never exceeds + * FLUX_MAX_TUPLE_SIZE, so the bar is always satisfiable by a fresh page. + */ + targetFreeSpace = FluxTargetFreeSpace(relation, tuple_size); + + { + target_block = InvalidBlockNumber; + if (overflow_buffers.count == 0) + target_block = RelationGetTargetBlock(relation); + + if (target_block == InvalidBlockNumber) + target_block = FluxGetPageWithFreeSpace(relation, targetFreeSpace); + } + + if (target_block == InvalidBlockNumber) + { + /* Clean up overflow buffers before throwing error */ + for (i = 0; i < overflow_buffers.count; i++) + { + UnlockReleaseBuffer(overflow_buffers.buffers[i].buffer); + pfree(overflow_buffers.buffers[i].record_data); + } + elog(ERROR, "FLUX failed to allocate page for tuple insertion"); + } + + /* + * Pre-allocate WAL buffer space BEFORE acquiring the data buffer lock. + * XLogEnsureRecordSpace() may allocate memory, so it MUST be called + * outside the critical section. + * + * rdata slots needed: MAX_OVERFLOW_BUFFERS * 2 (header + data per + * overflow record) + 2 (xl_flux_insert header + tuple data) + */ + if (RelationNeedsWAL(relation)) + XLogEnsureRecordSpace(XLR_MAX_BLOCK_ID, 3 + MAX_OVERFLOW_BUFFERS * 2); + + /* + * Check if target_block is already locked in overflow_buffers from + * FluxFormTupleWithOverflow. If FSM returns the same block for both + * overflow storage and main tuple storage, we must reuse that buffer to + * avoid double-locking. + */ + buffer = InvalidBuffer; + for (i = 0; i < overflow_buffers.count; i++) + { + if (BufferGetBlockNumber(overflow_buffers.buffers[i].buffer) == target_block) + { + buffer = overflow_buffers.buffers[i].buffer; + break; + } + } + + /* + * Read and lock the target page only if we don't already have it locked + * from overflow processing. + */ + if (!BufferIsValid(buffer)) + { + buffer = ReadBuffer(relation, target_block); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + } + page = BufferGetPage(buffer); + + /* + * Verify the page has room for the tuple PLUS the fillfactor reservation, + * so in-place UPDATE growth stays possible (heap applies the same + * targetFreeSpace = len + saveFreeSpace bar). A cached target block or a + * stale FSM answer that lacks the slack falls through to FSM retry / + * relation extension below. + */ + if (PageGetHeapFreeSpace(page) < targetFreeSpace) + { + bool buffer_is_from_overflow = false; + + /* + * Check if this buffer is from overflow_buffers. If so, we must NOT + * unlock it for pruning, as overflow_buffers expects all its buffers + * to remain locked until the critical section. + */ + for (i = 0; i < overflow_buffers.count; i++) + { + if (overflow_buffers.buffers[i].buffer == buffer) + { + buffer_is_from_overflow = true; + break; + } + } + + /* + * Page doesn't have enough space. Try opportunistic pruning to + * reclaim space from dead tuples before falling back to the FSM. We + * must release our lock first since FluxPagePruneOpt() takes its own + * conditional lock. + * + * IMPORTANT: Skip pruning if buffer is from overflow_buffers, as we + * must keep those buffers locked. + */ + if (!buffer_is_from_overflow) + { + bool buffer_locked_after_prune = false; + + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + if (FluxPagePruneOpt(relation, buffer)) + { + /* Pruning freed space -- re-lock and check again */ + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + buffer_locked_after_prune = true; + page = BufferGetPage(buffer); + if (PageGetHeapFreeSpace(page) >= targetFreeSpace) + goto have_page; + /* Still not enough after pruning, fall through to FSM retry */ + } + + /* + * FSM information was stale or pruning didn't help. Update and + * retry. Re-acquire the buffer lock only if we do not already + * hold it: FluxPagePruneOpt drops the lock it takes, so after a + * prune that did not free enough (buffer_locked_after_prune) the + * buffer IS still locked from the re-lock above -- locking again + * here would double-lock and trip the buffer content-lock + * assertion under concurrency. + */ + if (!buffer_locked_after_prune) + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + FluxRecordFreeSpace(relation, target_block, PageGetHeapFreeSpace(page)); + UnlockReleaseBuffer(buffer); + } + else + { + /* + * Buffer is from overflow_buffers and can't be pruned or + * released. This means FSM returned an overflow page for the main + * tuple, which doesn't have enough space. This should be rare but + * can happen if overflow pages filled up during tuple formation. + * + * Update FSM for this page, then get a DIFFERENT page. We must + * retry until we find a page that's NOT in overflow_buffers. + */ + FluxRecordFreeSpace(relation, target_block, PageGetHeapFreeSpace(page)); + } + + /* + * Retry with updated FSM, excluding blocks in overflow_buffers. Keep + * trying until we find a suitable page that we don't already have + * locked for overflow storage. + */ + for (;;) + { + target_block = FluxGetPageWithFreeSpace(relation, targetFreeSpace); + if (target_block == InvalidBlockNumber) + { + /* Clean up overflow buffers before throwing error */ + for (i = 0; i < overflow_buffers.count; i++) + { + UnlockReleaseBuffer(overflow_buffers.buffers[i].buffer); + pfree(overflow_buffers.buffers[i].record_data); + } + elog(ERROR, "FLUX failed to allocate page for tuple insertion after retry"); + } + + /* + * Check if target_block is already locked in overflow_buffers. If + * so, skip it and try again - we need a different page. + */ + buffer = InvalidBuffer; + for (i = 0; i < overflow_buffers.count; i++) + { + if (BufferGetBlockNumber(overflow_buffers.buffers[i].buffer) == target_block) + { + /* + * This block is already used for overflow - mark FSM and + * retry + */ + FluxRecordFreeSpace(relation, target_block, 0); + buffer = InvalidBuffer; + break; + } + } + + /* + * If we found a block not in overflow_buffers, check if it has + * space + */ + if (i >= overflow_buffers.count) + { + buffer = ReadBuffer(relation, target_block); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + /* + * Verify the page actually has enough space. If not, update + * FSM and retry. + */ + if (PageGetHeapFreeSpace(page) >= targetFreeSpace) + { + /* Found a suitable page - exit retry loop */ + break; + } + else + { + /* FSM was wrong - update it and retry */ + FluxRecordFreeSpace(relation, target_block, PageGetHeapFreeSpace(page)); + UnlockReleaseBuffer(buffer); + buffer = InvalidBuffer; + /* Continue outer loop to try again */ + } + } + } + } + +have_page: + + /* + * SSI: check for rw-conflict in. An INSERT may conflict with a + * concurrent serializable transaction that holds a relation-level or + * page-level predicate lock (e.g., from a range scan that would have + * included this new tuple). Pass NULL tid since the tuple doesn't exist + * yet; only relation-level and page-level locks are checked. + */ + /* Page SIREAD locks consolidate tuple locks; they do not lock gaps. */ + CheckForSerializableConflictIn(relation, NULL, InvalidBlockNumber); + + /* + * Ensure the current transaction has an XID assigned BEFORE entering the + * critical section. GetCurrentTransactionId() may call + * XactLockTableInsert() which acquires a lock and allocates memory -- + * both forbidden in a critical section. Most inserts already have an XID + * by now (assigned during the unique-index check), but inserting a NULL + * into a UNIQUE column skips that check, so the first assignment can + * otherwise land inside the crit section below. + * + * An assigned XID is also required for correctness: WAL records without + * an attached xid cannot be decoded into a logical replication stream + * (ReorderBuffer groups changes by xid and emits no commit record for + * InvalidTransactionId), and RecordTransactionCommit() would treat the + * transaction as read-only and skip the WAL flush. + */ + (void) GetCurrentTransactionId(); + + /* + * Final free-space check before entering the critical section. + * PageGetFreeSpace may have been optimistic (alignment, line pointer + * overhead). If the page can't actually fit the tuple, release it, + * update FSM, and extend the relation instead. This prevents the PANIC + * that would otherwise fire inside the critical section. + */ + offnum = FluxPageAddTuple(page, flux_tuple, tuple_size); + if (offnum == InvalidOffsetNumber) + { + /* Page too full despite FSM claim; record actual free space */ + FluxRecordFreeSpace(relation, BufferGetBlockNumber(buffer), + PageGetHeapFreeSpace(page)); + UnlockReleaseBuffer(buffer); + + /* Extend the relation to get a guaranteed-empty page */ + buffer = ReadBuffer(relation, P_NEW); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + FluxInitPage(page, BufferGetPageSize(buffer)); + + offnum = FluxPageAddTuple(page, flux_tuple, tuple_size); + if (offnum == InvalidOffsetNumber) + { + /* + * The row does not fit even on a guaranteed-empty page. This is + * a user-facing size limit, not an internal failure, so report it + * the way heap does (hio.c: "row is too big") with a proper + * SQLSTATE instead of an elog that reads as a FLUX bug. + * + * FLUXs ceiling is genuinely lower than heaps: a FLUX tuple + * header is larger than HeapTupleHeaderData, so + * FLUX_MAX_TUPLE_SIZE < MaxHeapTupleSize. A row whose + * UN-TOASTABLE (plain-storage) columns exceed FLUX_MAX_TUPLE_SIZE + * is therefore accepted by heap and rejected here. Columns with + * ordinary storage are pushed out to TOAST/overflow before we get + * here, so this only fires for plain storage or a very wide + * fixed-width row. + */ + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("row is too big: size %zu, maximum size %zu", + (Size) tuple_size, (Size) FLUX_MAX_TUPLE_SIZE))); + } + } + + /* + * Prepare the heap-format logical-decoding image before entering the + * critical section (it calls palloc/heap_form_tuple, which are forbidden + * inside a crit section). No-op unless the relation is logically logged + * and the caller wants the insert decoded. + */ + FluxXLogPrepareLogicalImage(relation, + (options & TABLE_INSERT_NO_LOGICAL) ? + NULL : flux_tuple, + &insert_logical_img); + + /* + * No INSERT UNDO record is emitted. An inserted tuple has no + * before-image; rolling one back only requires it to become invisible, + * which its inserter (t_xmin) never committing already guarantees. The + * DIRTY-snapshot and version-reconstruction paths resolve t_xmin against + * CLOG, so an aborted or crashed inserter's tuple is invisible without + * any undo bookkeeping. + */ + + /* Pin the visibility map page before the critical section (I/O). */ + vmbuffer = FluxVMPinForChange(relation, buffer); + + /* NO EREPORT(ERROR) from here till changes are logged */ + START_CRIT_SECTION(); + + /* Set the tuple's TID */ + ItemPointerSet(tid, BufferGetBlockNumber(buffer), offnum); + flux_tuple->t_self = *tid; + slot->tts_tableOid = RelationGetRelid(relation); + + /* + * Set the on-disk tuple's t_ctid to point to itself, so that update-chain + * code can tell a tuple that is not part of an update chain (t_ctid == + * self). + */ + { + ItemId inserted_itemid = PageGetItemId(page, offnum); + FluxTupleHeader *inserted_hdr = (FluxTupleHeader *) PageGetItem(page, inserted_itemid); + + ItemPointerSet(&inserted_hdr->t_ctid, BufferGetBlockNumber(buffer), offnum); + } + + + /* + * Update page opaque fields BEFORE WAL logging. When + * XLogRegisterBuffer() takes a Full Page Write (FPW), the page image must + * already contain the same opaque values that REDO will set during + * replay. Otherwise WAL consistency checking will detect a mismatch + * between the FPW and the page produced by REDO, causing a FATAL + * "inconsistent page found" error on the standby. + * + * This matches the fix applied to FluxXLogInitPage in flux_fsm.c. + */ + { + FluxPageOpaque phdr = FluxPageGetOpaque(page); + + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), current_ts)); + } + + FluxVMClearForChange(buffer, vmbuffer); + MarkBufferDirty(buffer); + + /* Log the insertion with all overflow buffers atomically */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr = FluxXLogInsert(relation, buffer, offnum, + flux_tuple, current_ts, + &overflow_buffers, + &insert_logical_img, + false); + + PageSetLSN(page, recptr); + } + + FluxVMFinishClear(RelationNeedsWAL(relation) ? + PageGetLSN(page) : InvalidXLogRecPtr); + END_CRIT_SECTION(); + if (BufferIsValid(vmbuffer)) + ReleaseBuffer(vmbuffer); + + FluxXLogReleaseLogicalImage(&insert_logical_img); + + /* + * Release all overflow buffers and free their cached data. + * + * IMPORTANT: Due to spatial locality optimization, an overflow buffer + * might be the SAME as the main buffer or as another overflow buffer + * (when overflow data is placed on the same page). Skip releasing buffers + * that were already released. The main buffer is NOT released yet; only + * overflow buffers that differ from it. + */ + for (i = 0; i < overflow_buffers.count; i++) + { + Buffer ovf_buf = overflow_buffers.buffers[i].buffer; + bool already_released = (ovf_buf == buffer); + int j; + + /* Check if this buffer was already released by a prior overflow entry */ + for (j = 0; j < i && !already_released; j++) + { + if (overflow_buffers.buffers[j].buffer == ovf_buf) + already_released = true; + } + + if (!already_released) + UnlockReleaseBuffer(ovf_buf); + pfree(overflow_buffers.buffers[i].record_data); + } + + + /* + * Save free space while we still have the buffer locked, then release the + * data buffer as soon as possible to reduce contention on hot pages. The + * remaining operations (FSM update, sLog registration) don't need the + * data buffer lock. + */ + { + Size saved_free_space = PageGetHeapFreeSpace(page); + BlockNumber saved_blkno = BufferGetBlockNumber(buffer); + + UnlockReleaseBuffer(buffer); + + /* Update FSM with remaining free space on the page */ + FluxRecordFreeSpace(relation, saved_blkno, saved_free_space); + + /* + * Cache this block as the next insert target (heap's + * RelationSetTargetBlock pattern) so the following append skips the + * FSM search entirely. Only on the no-overflow path -- the overflow + * path deliberately stays on the FSM (see the target-block lookup + * above). If the page is now too full for the next tuple, the + * have_page free-space recheck falls back to the FSM. + */ + if (overflow_buffers.count == 0) + RelationSetTargetBlock(relation, saved_blkno); + } + + /* + * Lightweight subtransaction tracking for savepoint rollback. + * + * Nothing shared: t_xmin and CLOG tell other backends about the insert. + * The tuple is recorded in a backend-local list, which commit uses to + * clear the UNCOMMITTED hint, and which marks it rolled back if its + * savepoint is (SLogTupleRemoveBySubXid). + */ + + /* + * A rewrite's copy of another transaction's version is not our insert. A + * copy of our own is, as the row it copies was: commit clears its + * UNCOMMITTED hint (flux_stamp_tuple_committed) so VACUUM can reclaim it + * once deleted. + */ + if (!(options & FLUX_INSERT_REWRITE) || + TransactionIdIsCurrentTransactionId(flux_rewrite_version.xmin)) + { + FluxEnsureSLogCallbacks(); + SLogTupleTrackLocalOnly(RelationGetRelid(relation), + relation->rd_locator.relNumber, tid, + GetTopTransactionId(), + GetCurrentSubTransactionId()); + } + + FluxFreeTuple(flux_tuple); + + /* Release toasting temporaries now that the tuple is durably stored. */ + if (toasted) + flux_toast_cleanup(&ttc); + + /* The slot now stands for the version just stored (AFTER triggers). */ + FluxSlotSetVersion(slot, (options & FLUX_INSERT_REWRITE) ? + flux_rewrite_version.xmin : GetCurrentTransactionId(), + cid); + + if (!(options & FLUX_DML_NO_STATS)) + pgstat_count_heap_insert(relation, 1); +} + +/* + * flux_help_revert + * Writer-side helping: if the tuple at offnum's current version belongs + * to an aborted transaction whose UNDO has not been applied yet, revert + * it now instead of failing or waiting for the undo apply worker. + * + * buffer is exclusively locked. Returns false, with the lock still held, if + * there was nothing to revert. Returns true after reverting, with the buffer + * released: the caller re-reads the page and starts over, since the tuple may + * have changed length and the index work below ran without the lock. + */ +bool +flux_help_revert(Relation relation, Buffer buffer, OffsetNumber offnum) +{ + Page page = BufferGetPage(buffer); + ItemId lp; + + /* + * A hot standby must not change a page itself: every change has to come + * from the WAL, or the page diverges from the primary's. Its readers do + * not need the revert, as they walk past aborted versions. + */ + if (RecoveryInProgress()) + return false; + + if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) + return false; + lp = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(lp) || + !FluxTupleHasAbortedVersion((FluxTupleHeader *) PageGetItem(page, lp))) + return false; + + if (!FluxRevertAbortedVersions(relation, buffer, offnum)) + return false; + + UnlockReleaseBuffer(buffer); + return true; +} + +/* + * Was this tuple deleted by a committed UPDATE of another transaction that + * moved the row to another partition? See FLUX_TUPLE_MOVED_PARTITION. + */ +bool +FluxTupleMovedPartition(FluxTupleHeader *tuple_hdr) +{ + TransactionId xmax = FluxTupleGetXmax(tuple_hdr); + + return (tuple_hdr->t_flags & (FLUX_TUPLE_DELETED | FLUX_TUPLE_MOVED_PARTITION)) == + (FLUX_TUPLE_DELETED | FLUX_TUPLE_MOVED_PARTITION) && + TransactionIdIsNormal(xmax) && + !TransactionIdIsCurrentTransactionId(xmax) && + TransactionIdDidCommit(xmax); +} + +/* + * Was this tuple deleted by a committed UPDATE of another transaction that + * moved the row to a new TID, which t_ctid holds? See FLUX_TUPLE_SUPERSEDED. + */ +bool +FluxTupleSuperseded(FluxTupleHeader *tuple_hdr) +{ + TransactionId xmax = FluxTupleGetXmax(tuple_hdr); + + return (tuple_hdr->t_flags & (FLUX_TUPLE_DELETED | FLUX_TUPLE_SUPERSEDED)) == + (FLUX_TUPLE_DELETED | FLUX_TUPLE_SUPERSEDED) && + TransactionIdIsNormal(xmax) && + !TransactionIdIsCurrentTransactionId(xmax) && + TransactionIdDidCommit(xmax); +} + +/* + * A write that found its row deleted reports TM_Deleted. If the deleter + * moved the row, report TM_Updated instead, as heap does: with the + * moved-partitions marker in tmfd->ctid if it moved the row to another + * partition (the executor then fails with "tuple to be locked was already + * moved to another partition" or a serialization failure), or with the new + * TID if an UPDATE moved it within the relation (the executor follows it + * under READ COMMITTED, and fails with a serialization failure otherwise). + * The row is re-read because the TM_Deleted paths have released the buffer. + */ +static TM_Result +flux_report_moved(Relation relation, ItemPointer tid, + TM_Result result, TM_FailureData *tmfd) +{ + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + Buffer buffer; + Page page; + bool moved = false; + bool superseded = false; + ItemPointerData new_tid; + + if (result != TM_Deleted) + return result; + + buffer = ReadBuffer(relation, ItemPointerGetBlockNumber(tid)); + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page = BufferGetPage(buffer); + if (offnum >= FirstOffsetNumber && offnum <= PageGetMaxOffsetNumber(page)) + { + ItemId itemid = PageGetItemId(page, offnum); + + if (ItemIdIsNormal(itemid)) + { + FluxTupleHeader *tuple_hdr = (FluxTupleHeader *) + PageGetItem(page, itemid); + + moved = FluxTupleMovedPartition(tuple_hdr); + superseded = FluxTupleSuperseded(tuple_hdr); + new_tid = tuple_hdr->t_ctid; + } + } + UnlockReleaseBuffer(buffer); + + if (moved) + { + if (tmfd) + ItemPointerSetMovedPartitions(&tmfd->ctid); + return TM_Updated; + } + if (superseded) + { + if (tmfd) + tmfd->ctid = new_tid; + return TM_Updated; + } + return result; +} + +/* A palloc'd copy of the tuple stored at tid, which must exist. */ +static FluxTuple +flux_copy_version(Relation relation, ItemPointer tid) +{ + Buffer buffer = ReadBuffer(relation, ItemPointerGetBlockNumber(tid)); + Page page; + ItemId itemid; + FluxTuple tup; + + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page = BufferGetPage(buffer); + itemid = PageGetItemId(page, ItemPointerGetOffsetNumber(tid)); + if (!ItemIdIsNormal(itemid)) + elog(ERROR, "FLUX: version (%u,%u) of a moved row is gone", + ItemPointerGetBlockNumber(tid), ItemPointerGetOffsetNumber(tid)); + tup = palloc0(sizeof(FluxTupleData)); + tup->t_len = ItemIdGetLength(itemid); + tup->t_data = palloc(tup->t_len); + memcpy(tup->t_data, PageGetItem(page, itemid), tup->t_len); + tup->t_self = *tid; + tup->t_tableOid = RelationGetRelid(relation); + UnlockReleaseBuffer(buffer); + return tup; +} + +/* + * Link the old version of a row that flux_update_out_of_place moved to its + * new TID: set FLUX_TUPLE_SUPERSEDED and store the new TID in t_ctid, heap's + * forward link. A writer or locker that waited for the update then follows + * the row to the new version instead of finding it deleted. + * + * The new TID is known only once the insert has placed the new version, so + * the link is a third WAL record after the delete's and the insert's, which + * stay as they are. Until it is written the old version is deleted without a + * link, but only by our own in-progress transaction: every writer that finds + * it waits for us to finish, and readers decide by t_xmax alone. A crash in + * between loses the transaction, whose undo restores the old version. So + * nothing can observe the gap. Rollback needs nothing more either: the + * delete's undo before-image has neither the flag nor the link. + * + * A follower is safe without heap's xmin check of the next version: it holds + * a snapshot that does not see the update (it found the old version), so the + * horizon keeps both the old version and the new one, which only a later + * transaction can delete. + * + * The link record is also the one logical decoding turns into the UPDATE: + * the delete and the insert are logged without logical images, and when + * walLogical this record carries the old replica identity and the new row, + * as XLOG_FLUX_UPDATE_INPLACE does. So the move decodes as one UPDATE, like + * heap's cross-page UPDATE, not as a DELETE and an INSERT. It is written + * after the insert, so the new row's TOAST chunks precede it in WAL, where + * the reorder buffer joins them to it. + */ +static void +flux_link_superseded(Relation relation, ItemPointer otid, ItemPointer new_tid, + bool walLogical, bool key_intact) +{ + OffsetNumber offnum = ItemPointerGetOffsetNumber(otid); + Buffer buffer; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + FluxLogicalImage old_img = {NULL, 0}; + FluxLogicalImage new_img = {NULL, 0}; + + /* + * Form the images from copies of both versions, with no buffer locked: + * deforming may read overflow pages, and palloc is not allowed in the + * critical section. Only our transaction can change either version. + */ + if (walLogical && RelationIsLogicallyLogged(relation)) + { + FluxTuple oldtup = flux_copy_version(relation, otid); + FluxTuple newtup = flux_copy_version(relation, new_tid); + + FluxXLogPrepareReplicaIdentity(relation, oldtup, newtup, &old_img); + FluxXLogPrepareLogicalImage(relation, newtup, &new_img); + FluxFreeTuple(oldtup); + FluxFreeTuple(newtup); + } + + buffer = ReadBuffer(relation, ItemPointerGetBlockNumber(otid)); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + elog(ERROR, "FLUX: old version (%u,%u) of a moved row is gone", + ItemPointerGetBlockNumber(otid), offnum); + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + Assert(tuple_hdr->t_flags & FLUX_TUPLE_DELETED); + Assert(TransactionIdIsCurrentTransactionId(FluxTupleGetXmax(tuple_hdr))); + + START_CRIT_SECTION(); + + tuple_hdr->t_flags |= FLUX_TUPLE_SUPERSEDED; + if (key_intact) + tuple_hdr->t_flags |= FLUX_TUPLE_KEY_INTACT; + tuple_hdr->t_ctid = *new_tid; + MarkBufferDirty(buffer); + + if (RelationNeedsWAL(relation)) + { + xl_flux_supersede xlrec; + XLogRecPtr recptr; + + xlrec.offnum = offnum; + xlrec.new_tid = *new_tid; + xlrec.flags = key_intact ? FLUX_WAL_KEY_INTACT : 0; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_supersede)); + FluxXLogRegisterLogicalImage(&old_img, &xlrec.flags); + FluxXLogRegisterLogicalImage(&new_img, &xlrec.flags); + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_SUPERSEDE); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + + UnlockReleaseBuffer(buffer); + FluxXLogReleaseLogicalImage(&old_img); + FluxXLogReleaseLogicalImage(&new_img); +} + +/* Does the new row change any column that requires an exclusive row lock? */ +static bool +flux_update_changes_key(Relation relation, FluxTupleHeader *header, Size len, + TupleTableSlot *slot) +{ + Bitmapset *keys = RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_KEY); + FluxTupleData old; + TupleDesc desc = RelationGetDescr(relation); + Datum values[MaxTupleAttributeNumber]; + bool nulls[MaxTupleAttributeNumber]; + bool changed = false; + int member = -1; + + if (keys == NULL) + return false; + old.t_data = header; + old.t_len = len; + slot_getallattrs(slot); + FluxDeformTuple(relation, &old, desc, values, nulls); + while ((member = bms_next_member(keys, member)) >= 0) + { + AttrNumber attnum = member + FirstLowInvalidHeapAttributeNumber; + Form_pg_attribute att; + + if (attnum <= 0) + { + if (attnum != TableOidAttributeNumber) + changed = true; + } + else + { + att = TupleDescAttr(desc, attnum - 1); + if (nulls[attnum - 1] != slot->tts_isnull[attnum - 1] || + (!nulls[attnum - 1] && + !datum_image_eq(values[attnum - 1], slot->tts_values[attnum - 1], + att->attbyval, att->attlen))) + changed = true; + } + if (changed) + break; + } + bms_free(keys); + return changed; +} + +static TM_Result flux_tuple_delete_internal(Relation relation, ItemPointer tid, + CommandId cid, uint32 options, + Snapshot snapshot, Snapshot crosscheck, + bool wait, TM_FailureData *tmfd, + bool *have_tuple_lock); + +TM_Result +flux_tuple_delete(Relation relation, ItemPointer tid, CommandId cid, + uint32 options, Snapshot snapshot, Snapshot crosscheck, + bool wait, TM_FailureData *tmfd) +{ + bool have_tuple_lock = false; + TM_Result result; + + result = flux_tuple_delete_internal(relation, tid, cid, options, snapshot, + crosscheck, wait, tmfd, + &have_tuple_lock); + if (have_tuple_lock) + FluxUnlockTuple(relation, tid, LockTupleExclusive); + return flux_report_moved(relation, tid, result, tmfd); +} + +/* + * Delete a tuple from a FLUX table with proper tombstone marking + * + * Like heap_delete, a delete that has to wait for another transaction first + * takes the heavyweight tuple lock (FluxLockTupleToWait), to keep its place + * among the writers and lockers that wait for the row, and when the wait + * ends it starts over: whoever changed the row meanwhile, and whatever the + * waited-for transaction did to it, is classified as on the first pass. The + * tuple lock is kept across the restarts and released by flux_tuple_delete. + */ +static TM_Result +flux_tuple_delete_internal(Relation relation, ItemPointer tid, CommandId cid, + uint32 options, Snapshot snapshot, Snapshot crosscheck, + bool wait, TM_FailureData *tmfd, + bool *have_tuple_lock) +{ + BlockNumber blkno; + OffsetNumber offnum; + Buffer buffer; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + uint64 current_ts; + FluxTuple old_tuple_for_delete_wal; + TransactionId delete_freeze_conflict; + FluxLogicalImage delete_logical_img; + UndoRecPtr del_undo_ptr = InvalidUndoRecPtr; + TransactionId del_hint_xid; + Buffer vmbuffer; + + /* Extract block and offset from TID */ + blkno = ItemPointerGetBlockNumber(tid); + offnum = ItemPointerGetOffsetNumber(tid); + + /* Validate TID range */ + if (blkno >= RelationGetNumberOfBlocks(relation)) + return TM_Invisible; + +restart: + /* Read the page containing the tuple */ + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + /* Helping: see flux_help_revert. */ + if (flux_help_revert(relation, buffer, offnum)) + { + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + } + + /* Validate offset number */ + if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + + /* Get the item */ + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Our own transaction already modified this row in this or a later + * command: report TM_SelfModified so the executor can either ignore the + * duplicate action or raise the BEFORE-trigger self-modification error. + * This must come before the DELETED handling below, which would otherwise + * report our own in-progress delete as a plain TM_Deleted. + */ + { + CommandId self_cmax; + + if (FluxCheckSelfModified(relation, tid, tuple_hdr, cid, &self_cmax)) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) ? + FluxTupleGetXmax(tuple_hdr) : tuple_hdr->t_xmin; + tmfd->cmax = self_cmax; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_SelfModified; + } + } + + /* + * Check if tuple is already deleted (tombstone exists). A DELETED flag + * can mean either a committed delete OR an in-progress delete by a + * concurrent transaction (which now also leaves UNCOMMITTED set). + * Distinguish the two: if another in-progress transaction owns the + * delete, wait for it and retry, matching heap's behavior where the + * second DELETE blocks behind the first. Only report TM_Deleted once the + * delete is genuinely committed (or ours). + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + TransactionId del_xid = InvalidTransactionId; + bool del_is_insert = false; + + if (tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + { + /* + * The concurrent deleter's XID is already on the page (t_xmax); + * read it from the header instead of the per-tuple sLog. The + * header stays authoritative through the deleter's PRE_COMMIT + * window (sLog marker cleared before CLOG commit), so it is the + * more accurate conflict source. + */ + del_xid = flux_header_dirty_xid(tuple_hdr, &del_is_insert); + } + + if (!wait && TransactionIdIsValid(del_xid) && + !TransactionIdIsCurrentTransactionId(del_xid) && + !del_is_insert) + { + /* The deleter is still running: it may yet abort (heap_delete). */ + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = del_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_BeingModified; + } + else if (TransactionIdIsValid(del_xid) && + !TransactionIdIsCurrentTransactionId(del_xid) && + !del_is_insert) + { + /* + * Start over when the deleter is done: a committed delete is + * reported below, an aborted one has had its before-image + * restored, and the row may have another deleter by then. + */ + UnlockReleaseBuffer(buffer); + FluxLockTupleToWait(relation, tid, LockTupleExclusive, + have_tuple_lock); + XactLockTableWait(del_xid, relation, tid, XLTW_Delete); + goto restart; + } + else + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = GetCurrentTransactionId(); + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Deleted; + } + } + + /* + * Handle LOCKED flag: same logic as the UPDATE path; clear our own lock + * before proceeding with the delete. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_LOCKED) + { + LockTupleMode own_mode; + + if (SLogTupleLockHeldByMe(RelationGetRelid(relation), + relation->rd_locator.relNumber, tid, + &own_mode)) + { + tuple_hdr->t_flags &= ~FLUX_TUPLE_LOCKED; + } + } + + /* + * Check tuple visibility against snapshot and handle concurrent + * modifications. Same logic as the UPDATE path: distinguish truly + * invisible tuples from concurrent modifications. + */ + if (snapshot) + { + bool visible; + + visible = FluxTupleVisibleToSnapshotDual(tuple_hdr, snapshot, + RelationGetRelid(relation), + buffer, tid); + + if (!visible) + { + TransactionId dirty_xid; + bool is_insert_entry; + + /* + * The concurrent writer's XID is on the page (t_xmin/t_xmax); + * read it from the header, which stays authoritative through the + * writer's PRE_COMMIT window (sLog marker cleared before CLOG). + */ + dirty_xid = flux_header_dirty_xid(tuple_hdr, &is_insert_entry); + + /* Check if tuple was deleted by another transaction */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = TransactionIdIsValid(dirty_xid) ? + dirty_xid : GetCurrentTransactionId(); + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Deleted; + } + + /* + * Buffer lock was never released (wait-free sLog read), so the + * tuple cannot have changed. Proceed with dirty_xid. + */ + { + if (TransactionIdIsValid(dirty_xid) && is_insert_entry) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = dirty_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + + if (TransactionIdIsValid(dirty_xid) && !is_insert_entry) + { + if (wait) + { + UnlockReleaseBuffer(buffer); + FluxLockTupleToWait(relation, tid, LockTupleExclusive, + have_tuple_lock); + XactLockTableWait(dirty_xid, relation, tid, + XLTW_Delete); + goto restart; + } + else + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = dirty_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_BeingModified; + } + } + else + { + /* + * No in-progress sLog entry for another txn. Same + * EPQ-loop fix as the UPDATE path: check if our + * transaction already has a sLog entry (from + * table_tuple_lock during EPQ). If so, fall through; + * otherwise trigger EPQ. + */ + TransactionId myxid_chk = + GetCurrentTransactionIdIfAny(); + + if (TransactionIdIsValid(myxid_chk)) + { + if (SLogTupleLockHeldByMe(RelationGetRelid(relation), + relation->rd_locator.relNumber, tid, NULL)) + { + /* EPQ already done; proceed. */ + } + else + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = + InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Updated; + } + } + else + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Updated; + } + } + } + /* If now visible, fall through to perform the delete */ + } + } + + /* + * Same as the UPDATE path: even when visibility returned "true", check + * for in-progress modifications by another transaction. Block if found. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + { + TransactionId dirty_xid; + bool is_insert_entry; + + /* + * Concurrent writer XID is on the page (authoritative through + * PRE_COMMIT). + */ + dirty_xid = flux_header_dirty_xid(tuple_hdr, &is_insert_entry); + + if (!TransactionIdIsValid(dirty_xid)) + { + /* + * No in-flight transaction is modifying this tuple. The + * UNCOMMITTED flag is stale (left over from a committed + * transaction whose cleanup callback already ran). Clear it + * opportunistically to prevent future visibility re-checks. + */ + tuple_hdr->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; + MarkBufferDirty(buffer); + } + else if (TransactionIdIsValid(dirty_xid) && + !TransactionIdIsCurrentTransactionId(dirty_xid) && + !is_insert_entry) + { + if (wait) + { + TransactionId wait_xid = dirty_xid; + + UnlockReleaseBuffer(buffer); + FluxLockTupleToWait(relation, tid, LockTupleExclusive, + have_tuple_lock); + XactLockTableWait(wait_xid, relation, tid, XLTW_Delete); + + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + if (offnum < FirstOffsetNumber || + offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + tuple_hdr = (FluxTupleHeader *) + PageGetItem(page, itemid); + + /* + * A delete mark is a delete only once its deleter commits, + * and the row may have another deleter by now: start over. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + UnlockReleaseBuffer(buffer); + goto restart; + } + + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Updated; + } + else + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = dirty_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_BeingModified; + } + } + } + + /* + * Authoritative write-write / lock-conflict gate (mirrors the UPDATE + * path's final gate). The three checks above read the writer xids in the + * tuple header, which only detect in-progress WRITERS: a SELECT ... FOR + * UPDATE/FOR SHARE locker of a committed, non-deleted tuple leaves no xid + * there, so it would sail straight through and DELETE the row the locker + * is protecting. Ask the tuple sLog (SLogTupleLockConflictXid), which + * reports a locker whose recorded LockTupleMode conflicts with ours under + * the real heavyweight matrix. + * + * LockTupleMode: LockTupleExclusive (AccessExclusiveLock), matching heap + * DELETE ("we need the strongest one"). A DELETE destroys the key, so it + * must conflict with FOR KEY SHARE FK lockers too; unlike UPDATE (which + * uses NoKeyExclusive precisely to stay compatible with a KeyShare FK + * lock), waiting on a KeyShare locker here is CORRECT, not spurious -- + * the FK machinery guarantees that locker releases before the referenced + * row can go away. + * + * Self-wait / EPQ safety: SLogTupleLockConflictXid skips our own locks, + * so XactLockTableWait never receives GetTopTransactionId() (no lmgr + * self-wait assertion). A lock this transaction took during EPQ + * re-evaluation is our own and is likewise skipped. Before sleeping we + * acquire the heavyweight LOCKTAG_TUPLE lock so two writers racing the + * same tuple queue FIFO instead of mutually XactLockTableWait deadlocking + * (matches heap_delete's "establish our priority"). + */ + { + TransactionId conflict_xid; + + conflict_xid = SLogTupleLockConflictXid(RelationGetRelid(relation), + relation->rd_locator.relNumber, + tid, LockTupleExclusive); + + if (TransactionIdIsValid(conflict_xid) && + !TransactionIdIsCurrentTransactionId(conflict_xid)) + { + if (!wait) + { + /* Not waiting: report the locker, as heap_delete does. */ + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = conflict_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_BeingModified; + } + + /* + * Start over when the locker is done: it may have updated or + * deleted the row before it finished, and another transaction may + * hold a lock on it by then. Several transactions can hold FOR + * KEY SHARE on the row (one per foreign-key check), so this may + * wait for each in turn, as heap waits for the MultiXact of all + * lockers. A locker that arrives after us queues behind us on + * the tuple lock, which flux_tuple_lock takes before it records + * its lock, unless it holds a lock on the row already. + */ + UnlockReleaseBuffer(buffer); + FluxLockTupleToWait(relation, tid, LockTupleExclusive, + have_tuple_lock); + XactLockTableWait(conflict_xid, relation, tid, XLTW_Delete); + goto restart; + } + } + + /* + * Transaction-snapshot RI action: the row must also be visible to the + * crosscheck snapshot, as heap_delete requires, or the RI query found a + * row (one inserted after our snapshot) that the transaction cannot see. + */ + if (crosscheck != InvalidSnapshot && + !FluxTupleVisibleToSnapshotDual(tuple_hdr, crosscheck, + RelationGetRelid(relation), buffer, tid)) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = FluxTupleGetXmin(tuple_hdr); + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Updated; + } + + /* + * Get transaction timestamp BEFORE critical section. Use xact_ts as + * commit timestamp for within-transaction visibility. + */ + (void) FluxGetTransactionTimestamp(); + current_ts = (uint64) FluxGetDmlTimestamp(); + + /* + * Allocate old_tuple structure and save a copy of the old tuple data + * BEFORE entering the critical section. The tuple header will be + * modified below (DELETED flag, commit_ts, etc.), and the WAL record + * needs the unmodified before-image for UNDO support. + */ + { + uint32 del_old_len = ItemIdGetLength(itemid); + + old_tuple_for_delete_wal = palloc(sizeof(FluxTupleData)); + old_tuple_for_delete_wal->t_len = del_old_len; + old_tuple_for_delete_wal->t_data = (FluxTupleHeader *) palloc(del_old_len); + memcpy(old_tuple_for_delete_wal->t_data, tuple_hdr, del_old_len); + delete_freeze_conflict = flux_prepare_before_image(relation, old_tuple_for_delete_wal->t_data); + } + + /* + * Prepare the heap-format logical-decoding image of the deleted tuple + * before the critical section (palloc/heap_form_tuple are forbidden + * inside). No-op unless the relation is logically logged and the caller + * wants the delete decoded. + */ + if (options & TABLE_DELETE_NO_LOGICAL) + FluxXLogPrepareLogicalImage(relation, NULL, &delete_logical_img); + else + FluxXLogPrepareReplicaIdentity(relation, old_tuple_for_delete_wal, NULL, + &delete_logical_img); + + /* + * SSI: check for rw-conflict in. If a concurrent serializable + * transaction read this tuple (holds a SIREAD lock on it), our delete + * creates an rw-antidependency that may form a dangerous structure. + */ + CheckForSerializableConflictIn(relation, tid, BufferGetBlockNumber(buffer)); + + /* + * Pre-allocate WAL buffer space BEFORE entering critical section. DELETE + * operations only need the main buffer (no overflow). + * + * CRITICAL: XLogEnsureRecordSpace() may allocate memory, so it MUST be + * called outside the critical section. + */ + if (RelationNeedsWAL(relation)) + XLogEnsureRecordSpace(0, 2); + + /* + * Per-backend UNDO: prepare a DELETE UNDO record carrying the full old + * tuple so a top-level ROLLBACK can restore the deleted tuple in place. + * Must happen before the critical section (PrepareUndoInsert may extend + * the undo log and ereport). Its UndoRecPtr is stamped into t_verptr, + * like an UPDATE's: that is the idempotency key of the UNDO apply, and + * the before-image it restores carries the previous t_verptr. Readers + * never step through a DELETE record: a deleted row's visibility is + * decided by t_xmax. + */ + del_undo_ptr = FluxPbuPrepareInsert(FLUX_UNDO_DELETE, + relation, tid, + (const char *) old_tuple_for_delete_wal->t_data, + old_tuple_for_delete_wal->t_len, + relation->rd_rel->relpersistence, false, delete_freeze_conflict, + cid); + + /* + * Assign our top-level XID for the t_xmin stamp BEFORE the critical + * section: GetTopTransactionId() allocates a fresh XID on first use, + * which calls XactLockTableInsert -> LockAcquire -> palloc, forbidden + * inside a critical section. Precompute here and write only the bare + * field below, matching the surrounding "compute before crit, write + * inside" pattern (del_undo_ptr, XLogEnsureRecordSpace, logical image). + */ + del_hint_xid = GetCurrentTransactionId(); /* subxid: heap-shaped t_xmax */ + + vmbuffer = FluxVMPinForChange(relation, buffer); + + /* Start critical section for WAL logging */ + START_CRIT_SECTION(); + + /* + * Mark tuple as deleted with tombstone - this is the key FLUX feature. + * Set UNCOMMITTED while the delete is in flight: a second DELETE or + * UPDATE on this TID must detect the in-progress delete (t_xmax, via + * flux_header_dirty_xid) and block on it, rather than racing ahead. The + * flag is cleared at commit by flux_stamp_tuple_committed; VACUUM sees a + * clean committed delete via the xmax XID + CLOG. + * + * Heap-shaped: set t_xmax to the deleter XID (leaving t_xmin, the + * inserter, untouched). Visibility resolves t_xmax against CLOG + the + * reader's snapshot: an old snapshot that predates the delete's commit + * still sees the row; a snapshot after it does not. + */ + tuple_hdr->t_flags |= FLUX_TUPLE_DELETED; + tuple_hdr->t_flags |= FLUX_TUPLE_UNCOMMITTED; + /* Fresh xmax being stamped: drop any stale XMAX_COMMITTED CLOG hint. */ + tuple_hdr->t_flags &= ~FLUX_TUPLE_XMAX_COMMITTED; + FluxTupleSetXmax(tuple_hdr, del_hint_xid); + + /* + * A row moved to another partition: heap's moved-partitions t_ctid. + * + * SUPERSEDED is normally gone by now: it is set only with DELETED, and a + * writer reverts an aborted out-of-place UPDATE first (flux_help_revert). + * But a dirty-snapshot fetch can clear the DELETED hint of a deleter that + * aborted after FluxTupleHasAbortedVersion found it running, which leaves + * SUPERSEDED and its forward t_ctid without anything that makes a writer + * revert. So clear it, and reset t_ctid; the redo does the same. + */ + tuple_hdr->t_flags &= ~(FLUX_TUPLE_MOVED_PARTITION | FLUX_TUPLE_SUPERSEDED | + FLUX_TUPLE_KEY_INTACT); + if (options & TABLE_DELETE_CHANGING_PARTITION) + tuple_hdr->t_flags |= FLUX_TUPLE_MOVED_PARTITION; + if (UndoRecPtrIsValid(del_undo_ptr)) + tuple_hdr->t_verptr = del_undo_ptr; + /* Keep the original t_ctid for potential update chains */ + ItemPointerCopy(tid, &tuple_hdr->t_ctid); + + /* Update page header to match what redo does */ + { + FluxPageOpaque phdr = FluxPageGetOpaque(page); + + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), current_ts)); + FluxPageSetFlag(phdr, FLUX_PAGE_DEFRAG_NEEDED); + } + + /* + * The page holds a version not every snapshot sees. Clearing BOTH bits + * matters: VACUUM's first pass skips an all-frozen page, so a deleted + * tuple and its index entries left there would never be removed. + */ + FluxVMClearForChange(buffer, vmbuffer); + MarkBufferDirty(buffer); + + /* WAL log the deletion using the pre-saved old tuple copy */ + + /* + * Write the per-backend DELETE UNDO record into its pinned undo buffers + * BEFORE the forward-op WAL record so FluxXLogDelete can fold the undo + * pages onto its record as full-page images. + */ + if (UndoRecPtrIsValid(del_undo_ptr)) + FluxPbuInsertUndo(); + + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr; + + recptr = FluxXLogDelete(relation, buffer, offnum, + old_tuple_for_delete_wal, + (uint64) del_hint_xid, + &delete_logical_img); + PageSetLSN(page, recptr); + + if (UndoRecPtrIsValid(del_undo_ptr)) + FluxPbuFinishUndo(recptr); + } + else if (UndoRecPtrIsValid(del_undo_ptr)) + FluxPbuFinishUndo(InvalidXLogRecPtr); + + FluxVMFinishClear(RelationNeedsWAL(relation) ? + PageGetLSN(page) : InvalidXLogRecPtr); + END_CRIT_SECTION(); + if (BufferIsValid(vmbuffer)) + ReleaseBuffer(vmbuffer); + + FluxXLogReleaseLogicalImage(&delete_logical_img); + + /* + * The per-backend DELETE UNDO record was written and folded into the + * forward WAL record inside the critical section above. + */ + + + /* + * Clean up overflow chains if this tuple has overflow attributes. This + * must happen outside the critical section since it performs its own + * buffer I/O. We check the flag and capture the free space before + * releasing the buffer so we can read the tuple header and page state. + */ + { + bool has_overflow = (tuple_hdr->t_flags & FLUX_TUPLE_HAS_OVERFLOW) != 0; + Size free_space = PageGetHeapFreeSpace(page); + + UnlockReleaseBuffer(buffer); + + /* + * Delete the row's external TOAST values, as heap_delete does: an + * MVCC delete in the TOAST table by this transaction, which commits + * or rolls back with the DELETE. A snapshot that still reads the + * deleted version (from UNDO) still sees the values, and TOAST VACUUM + * removes them only once no snapshot can. VACUUM of this table then + * has no TOAST work: when it removes the row, its values are already + * deleted (committed DELETE) or were never live (aborted INSERT). + * old_tuple_for_delete_wal is the version as it was before this + * DELETE; the buffer lock is released, as toast deletion reads and + * writes other relations. + */ + if ((old_tuple_for_delete_wal->t_data->t_infomask & FLUX_INFOMASK_HASEXTERNAL) && + (relation->rd_rel->relkind == RELKIND_RELATION || + relation->rd_rel->relkind == RELKIND_MATVIEW)) + { + TupleDesc tupdesc = RelationGetDescr(relation); + Datum *values = palloc_array(Datum, tupdesc->natts); + bool *isnull = palloc_array(bool, tupdesc->natts); + + FluxDeformTuple(relation, old_tuple_for_delete_wal, tupdesc, + values, isnull); + flux_toast_delete(relation, values, isnull, false); + pfree(values); + pfree(isnull); + } + + /* + * Ensure xact/subxact callbacks are registered before any sLog + * operation. This is critical for savepoint rollback: without the + * SubXactCallback, ROLLBACK TO SAVEPOINT won't restore tuples. + */ + FluxEnsureSLogCallbacks(); + + /* + * BUG 5 fix: track this DELETE with the bounded backend-local tracker + * (no shared per-tuple entry, no unbounded slog_tracked_keys list + * node). Per-page conflict tracking means a concurrent observer + * reads the conflicting xid from the on-page t_xmax + * (flux_header_dirty_xid), not from a per-tuple sLog UPDATE/DELETE + * entry; commit-time flag cleanup comes from the per-page dirty set; + * and savepoint rollback restores from the durable UNDO log. So the + * shared per-tuple entry is redundant and its O(N-rows) memory is the + * cause of the backend OOM under write-heavy load. + */ + + /* + * BUG 5: UPDATE/DELETE keep NO per-tuple tracking at all. Commit-time + * UNCOMMITTED-flag cleanup comes from the per-page dirty set + * (flux_clear_uncommitted_by_page), savepoint rollback restores from + * the durable UNDO log, and conflict detection reads the on-page + * header -- so neither the bounded INSERT hash (O(N tuples/txn)) nor + * the subtransaction list is needed. This keeps per-transaction + * backend memory O(pages), not O(rows), which is what heap does. + */ + + /* Record our own delete's command id for self-visibility (cmax). */ + FluxLocalCidSet(RelationGetRelid(relation), tid, cid); + + FluxFreeTuple(old_tuple_for_delete_wal); + + FluxLocalDirtyPageMark(relation, blkno); + + /* + * NOTE: We do NOT immediately clean up overflow chains here. + * Immediate cleanup was: 1. Buggy (collected wrong overflow pointers + * after modification) 2. Expensive on hot paths (extra buffer I/O + + * locking) 3. Complex to WAL-log correctly + * + * Instead, overflow cleanup is deferred to VACUUM. When VACUUM + * prunes deleted tuples, it will also reclaim orphaned overflow + * pages. + * + * Future enhancement: Log overflow block/offset in WAL DELETE record + * so UNDO log pruning can also clean up overflow chains. + */ + (void) has_overflow; /* Suppress unused variable warning */ + + /* Update free space map - deleted tuple creates more free space */ + FluxRecordFreeSpace(relation, blkno, free_space); + } + + /* Return success - tuple was successfully marked as deleted */ + if (!(options & FLUX_DML_NO_STATS)) + pgstat_count_heap_delete(relation); + return TM_Ok; +} + +/* + * Release a set of overflow buffers collected during a force-shrink update. + * + * FluxStoreOverflowColumn leaves each overflow page locked, dirty, and + * unlogged, handing the buffers back to the caller for atomic WAL logging on + * the success path. On any error path before that logging happens, the caller + * must release them here so no dirty unlogged page is pinned into transaction + * abort. Skips main_buffer (released separately by the caller) and any buffer + * that appears more than once (two overflow columns can land on one page). + */ +static void +flux_release_update_overflow_buffers(FluxOverflowBuffers *bufs, + Buffer main_buffer) +{ + int i; + + for (i = 0; i < bufs->count; i++) + { + Buffer ovf_buf = bufs->buffers[i].buffer; + bool already_released = (ovf_buf == main_buffer); + int j; + + for (j = 0; j < i && !already_released; j++) + { + if (bufs->buffers[j].buffer == ovf_buf) + already_released = true; + } + if (!already_released) + UnlockReleaseBuffer(ovf_buf); + pfree(bufs->buffers[i].record_data); + } + bufs->count = 0; +} + +/* + * flux_update_conflict_xid + * + * The in-progress transaction an UPDATE of this tuple must wait for, or + * InvalidTransactionId if there is none. The caller holds the buffer lock. + * + * The row's writer is read from the on-page header first + * (flux_header_dirty_xid: t_xmin of an in-place updated image, t_xmax of a + * delete). UPDATE and DELETE keep no per-tuple sLog entry, so the sLog + * cannot see an in-progress updater; the header can, and it stays + * authoritative through the writer's PRE_COMMIT window, when the + * UNCOMMITTED hint is already gone but CLOG does not know the outcome yet. + * The xid there is the writing subtransaction's, which XactLockTableWait + * resolves to its top-level transaction. + * + * Then the tuple sLog, for what only it records: row locks whose mode + * conflicts with our update. A key change also conflicts with FOR KEY + * SHARE; a non-key change does not. + * + * "No in-progress writer" is not "safe to overwrite": a writer that has + * committed since our snapshot was taken is the committed-update gates' job + * (FluxTupleHasCommittedUpdateAfter), which every caller still runs. + */ +static TransactionId +flux_update_conflict_xid(Relation relation, ItemPointer otid, + FluxTupleHeader *tuple_hdr, bool key_changed, + bool *is_insert) +{ + TransactionId xid; + + xid = flux_header_dirty_xid(tuple_hdr, is_insert); + if (TransactionIdIsValid(xid)) + return xid; + + return SLogTupleLockConflictXid(RelationGetRelid(relation), + relation->rd_locator.relNumber, otid, + key_changed ? LockTupleExclusive : + LockTupleNoKeyExclusive); +} + +/* + * An UPDATE that waited for a writer found the row delete-marked. It is + * deleted only if the deleter committed: that deleter may have come after the + * transaction we waited for and still be in progress, or have aborted. Then + * report TM_Updated, as after any wait for a writer (see below), and the + * executor locks the row, which waits for the deleter, before it rechecks it. + */ +static TM_Result +flux_deleted_after_wait(FluxTupleHeader *tuple_hdr) +{ + TransactionId xmax = FluxTupleGetXmax(tuple_hdr); + + if (TransactionIdIsValid(xmax) && TransactionIdDidCommit(xmax)) + return TM_Deleted; + return TM_Updated; +} + +/* + * Update a tuple in a FLUX table with versioning support + * + * FLUX_RELEASE_TUPLOCK releases the heavyweight tuple lock that competing + * updaters acquire before XactLockTableWait (see the wait sites below). The + * lock is held continuously from acquisition through the recheck/update so it + * serializes racing updaters into a FIFO queue (matching heap_update); it must + * be released on every function-exit path so it never leaks past commit. The + * have_tuple_lock guard makes the macro a no-op on paths that never acquired. + */ +#define FLUX_RELEASE_TUPLOCK() \ + do { \ + if (have_tuple_lock) \ + { \ + FluxUnlockTuple(relation, otid, update_lockmode); \ + have_tuple_lock = false; \ + } \ + } while (0) + +/* + * flux_header_dirty_xid + * + * Derive the in-progress, non-current writer XID for a tuple directly from its + * ON-PAGE header, without consulting the per-tuple sLog. This is the common + * write-conflict question (who is concurrently modifying this tuple?) and the + * answer is already on the page: the inserter is t_xmin and the + * updater/deleter is t_xmax (low 32 bits of t_commit_ts). + * + * The caller must hold the tuple's buffer lock (shared or exclusive) so the + * header read is stable. Returns the conflicting XID and sets *is_insert to + * whether that XID is an in-progress INSERT (t_xmin, tuple not yet + * DELETED/UPDATED) rather than an in-progress UPDATE/DELETE (t_xmax). + * + * Returns InvalidTransactionId when there is no in-progress non-current writer + * (the tuple is plain-committed, or the only writer is our own transaction). + * A returned XID is advisory: the caller must re-validate after any wait + * because the xid may commit/abort concurrently. We filter + * TransactionIdIsCurrentTransactionId and require TransactionIdIsInProgress. + */ +TransactionId +flux_header_dirty_xid(FluxTupleHeader *tuple_hdr, bool *is_insert) +{ + TransactionId xmax = FluxTupleGetXmax(tuple_hdr); + TransactionId xmin = FluxTupleGetXmin(tuple_hdr); + + if (is_insert) + *is_insert = false; + + /* + * DELETED: the concurrent writer is the deleter (t_xmax). + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (TransactionIdIsValid(xmax) && + !TransactionIdIsCurrentTransactionId(xmax) && + TransactionIdIsInProgress(xmax)) + return xmax; + return InvalidTransactionId; + } + + /* + * In-place UPDATED (but not deleted): FLUX rewrites the newest version in + * place, stamping the updater as the on-page t_xmin and leaving t_xmax + * Invalid (the row is still live). So the concurrent writer to wait on + * is the UPDATER = t_xmin, and it is NOT an insert -- a concurrent DELETE + * or UPDATE must block behind it, exactly as heap blocks behind an + * in-progress updater. (Reporting is_insert here would wrongly let a + * deleter/updater skip the wait, since those never wait on inserts.) + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) + { + if (TransactionIdIsValid(xmin) && + !TransactionIdIsCurrentTransactionId(xmin) && + TransactionIdIsInProgress(xmin)) + return xmin; /* in-progress in-place updater; + * is_insert=false */ + return InvalidTransactionId; + } + + /* + * Otherwise the only possible in-progress writer is the inserter + * (t_xmin): a live, not-yet-superseded tuple that is still UNCOMMITTED is + * being inserted. + */ + if (TransactionIdIsValid(xmin) && + !TransactionIdIsCurrentTransactionId(xmin) && + TransactionIdIsInProgress(xmin)) + { + if (is_insert) + *is_insert = true; + return xmin; + } + + return InvalidTransactionId; +} + +/* + * flux_inplace_index_maint_possible + * + * Can FLUX's in-place (stable-TID) index maintenance correctly and completely + * maintain EVERY index on this relation for a key-changing UPDATE? If not, + * the caller must fall back to the out-of-place (new-TID) path and let the + * executor do all index work (TU_All). + * + * flux_inplace_index_maintenance is the SINGLE authority for index work on the + * in-place path -- the executor is told TU_None and does none. That is only + * sound for indexes where FLUX can reproduce everything the executor would + * have done. Four cases where it cannot: + * + * 1. The index AM has no delete-marking support (amdeletemark == NULL). Only + * nbtree implements it; gist, gin, spgist and brin do not. For those, + * index_delete_mark() returns false and the OLD (k_old, TID) entry silently + * stays LIVE while the row at TID now holds k_new. The scan-side safety + * net does not catch it either: the key recheck in flux_index_getnext_slot + * needs scan->xs_itup, and xs_want_itup_recheck is honoured only by nbtree. + * Measured symptom: after UPDATE gs SET r = WHERE id = 7, a gist + * Index Scan for the OLD range still returned id=7 with its NEW range, + * while a seq scan of the same predicate correctly did not -- an index scan + * returning a row that does not satisfy the qual. (A Bitmap Heap Scan hid + * it, because the bitmap recheck re-evaluates the qual against the row.) + * + * 2. The index enforces an EXCLUSION constraint (indisexclusion). The check + * lives in the executor (ExecInsertIndexTuples -> + * check_exclusion_or_unique_constraint), not in the index AM, so reporting + * TU_None skips it entirely. Measured symptom: a WITHOUT OVERLAPS primary + * key accepted an UPDATE that produced two overlapping rows for the same + * scalar key -- silent constraint violation, no error. Note this is NOT + * covered by case 1 even though such indexes are usually gist: EXCLUDE USING + * btree is legal, and btree does have delete-marking. + * + * 3. The constraint is DEFERRABLE (!indimmediate). The executor inserts with + * UNIQUE_CHECK_PARTIAL and returns the index OID in its recheckIndexes list, + * which ExecUpdateEpilogue hands to ExecARUpdateTriggers to queue the + * end-of-statement/transaction recheck. FLUX's in-place maintenance passes + * UNIQUE_CHECK_PARTIAL too but DISCARDS the result and has no way to queue + * the after-trigger event, so a deferred violation would never be reported. + * + * 4. The index is not valid yet (!indisvalid): CREATE INDEX CONCURRENTLY or + * REINDEX CONCURRENTLY is building it, or such a build failed and left it. + * The build indexes each TID once, at the version its snapshot sees, and + * its validation (validate_index) only adds the TIDs the index does not + * have yet; neither looks at a TID again. So a key that changes in place + * while the index is not ready is never indexed (maintenance skips a + * not-ready index, and the validation finds the TID present), and one that + * changes in place after it is ready leaves the index without the version + * the validation's snapshot sees. Measured symptom: after CREATE INDEX + * CONCURRENTLY returned, an index scan for the new key found no row, a + * seqscan found it, and amcheck reported "lacks matching index tuple"; a + * UNIQUE build accepted a duplicate. A new TID is what heap gives such an + * UPDATE (it is never HOT while the index is listed): the build or the + * validation then finds the new version missing and indexes it, and the + * old entry points at the old version. This holds whichever indexed + * column changed -- key, INCLUDE, expression or predicate column -- as + * flux_indexed_attr_changed already compares all of them. + * + * A SUMMARIZING index (BRIN) is exempt from rule 1: it has no per-row (key,TID) + * entry to delete-mark, and a range scan rechecks the heap tuple, so a stale + * summary can never make a scan return a wrong row. Rules 2, 3 and 4 still + * apply to it. See the per-index comment in the loop. + * + * Cheap: the index list and every rd_index/rd_indam field read here are + * relcache-resident, and this only runs on the already-rare key-changing + * UPDATE path, which opens every index again a moment later anyway. + */ +static bool +flux_inplace_index_maint_possible(Relation relation) +{ + List *indexoidlist; + ListCell *l; + bool ok = true; + + indexoidlist = RelationGetIndexList(relation); + + foreach(l, indexoidlist) + { + Relation indexRel = index_open(lfirst_oid(l), AccessShareLock); + + /* + * A SUMMARIZING index (BRIN) is exempt from the delete-marking + * requirement. It stores a per-block-range summary, not one entry + * per row, so there is no (key, TID) entry to delete-mark and no way + * for a scan to return a row under a stale key: a range scan always + * rechecks the heap tuple itself. Demanding amdeletemark here forced + * every BRIN table's key UPDATE out of place, which moved rows to + * fresh pages and left new unsummarized ranges behind -- + * brin/brin_bloom/brin_multi then saw brin_summarize_new_values() + * return 2 where 0 was expected. It is also exactly the distinction + * the executor draws with TU_Summarizing / EIIT_ONLY_SUMMARIZING and + * ii_Summarizing. + * + * Summarizing indexes are still maintained: flux_indexed_attr_changed + * unions INDEX_ATTR_BITMAP_SUMMARIZED into the indexed-attribute set, + * so a summarized-column change is detected, and the in-place + * maintenance loop below reinserts into every index whose key + * changed, BRIN included. + */ + if (!indexRel->rd_indam->amsummarizing && + (indexRel->rd_indam->amdeletemark == NULL || + indexRel->rd_index->indisexclusion || + !indexRel->rd_index->indimmediate)) + ok = false; + + /* + * An exclusion or deferred constraint is a correctness gate + * regardless of summarization (the check lives in the executor, not + * the AM), so it applies even to a summarizing index. No in-tree + * summarizing AM can back either kind today; keep the rule explicit + * rather than implied. + */ + if (indexRel->rd_index->indisexclusion || + !indexRel->rd_index->indimmediate) + ok = false; + + /* + * Rule 4. Like heap's HOT test this is per relation, not per index: + * while a build runs, a change to any indexed column moves the row, + * even one the index being built does not use. Comparing each + * invalid index's own columns would keep those in place. + */ + if (!indexRel->rd_index->indisvalid) + ok = false; + + index_close(indexRel, AccessShareLock); + + if (!ok) + break; + } + + list_free(indexoidlist); + return ok; +} + +/* + * flux_index_key_changed + * + * Compare the OLD and NEW index key datums for one index and report whether + * the key actually changed. Shared by flux_inplace_index_maintenance (do we + * need to insert a new entry and delete-mark the old one?) and + * flux_inplace_index_undo (do we need to reverse that?), which MUST agree + * exactly or rollback leaves the index in a half-reversed state. + * + * Only the KEY attributes participate: ii_NumIndexKeyAttrs, not + * ii_NumIndexAttrs. INCLUDE'd columns are not part of the key, they are not + * covered by rd_opcintype, and changing one does not move the entry. + * + * TYPE METADATA, the subtle part. The datums here come from FormIndexDatum, + * which returns values in the opclass INPUT type (rd_opcintype) -- not in the + * index tuple descriptor's type. For most opclasses those coincide, but any + * opclass with a STORAGE type (pg_opclass.opckeytype <> 0) makes them differ, + * and then reading typlen/typbyval off RelationGetDescr(index) describes the + * wrong type entirely. Concretely, gist_int4_ops has input int4 (4 bytes, + * BY VALUE) and storage gbtreekey8 (8 bytes, BY REFERENCE): taking the index + * tupdesc's attbyval=false/attlen=8 sent datum_image_eq down its memcmp() + * branch, which dereferenced the int4 VALUE 7 as a pointer and SEGFAULTED the + * backend on any in-place UPDATE of a gist-indexed int column. In the tree + * as shipped, 15 of 179 opclasses have a storage type (all 72 brin, all 4 + * gin, 6 of 9 gist, 1 btree = name_ops, 1 spgist). + * + * So resolve the type from the opclass input type, falling back to the index + * tupdesc only for an INCLUDE-style column with no opclass entry. Pseudo + * types reachable here (anyrange, anymultirange, anyarray, anyenum, record) + * all have correct typlen/typbyval in pg_type, so get_typlenbyval is safe. + */ +static bool +flux_index_key_changed(Relation indexRel, IndexInfo *indexInfo, + const Datum *old_values, const bool *old_isnull, + const Datum *new_values, const bool *new_isnull) +{ + int i; + + for (i = 0; i < indexInfo->ii_NumIndexKeyAttrs; i++) + { + int16 typlen; + bool typbyval; + Oid keytype; + + if (old_isnull[i] != new_isnull[i]) + return true; + if (new_isnull[i]) + continue; + + /* Type of the datum FormIndexDatum produced: the opclass input type. */ + keytype = (indexRel->rd_opcintype != NULL) ? + indexRel->rd_opcintype[i] : InvalidOid; + + if (OidIsValid(keytype)) + get_typlenbyval(keytype, &typlen, &typbyval); + else + { + Form_pg_attribute att = TupleDescAttr(RelationGetDescr(indexRel), i); + + typlen = att->attlen; + typbyval = att->attbyval; + } + + if (!datum_image_eq(old_values[i], new_values[i], typbyval, typlen)) + return true; + } + + return false; +} + +/* + * Do equal keys always have equal images in this btree index? The test + * _bt_allequalimage makes for deduplication, but over the key columns only: + * an INCLUDE column is not compared, so it cannot make two keys equal. + */ +static bool +flux_index_keys_equalimage(Relation indexRel) +{ + for (int i = 0; i < IndexRelationGetNumberOfKeyAttributes(indexRel); i++) + { + Oid opcintype = indexRel->rd_opcintype[i]; + Oid proc = get_opfamily_proc(indexRel->rd_opfamily[i], + opcintype, opcintype, + BTEQUALIMAGE_PROC); + + if (!OidIsValid(proc) || + !DatumGetBool(OidFunctionCall1Coll(proc, + indexRel->rd_indcollation[i], + ObjectIdGetDatum(opcintype)))) + return false; + } + return true; +} + +/* + * flux_index_change_exact + * + * Can delete-marking describe this UPDATE in every index? An in-place key + * UPDATE leaves the row at its TID under two entries, the delete-marked + * (k_old, TID) for snapshots that see the old version and the live + * (k_new, TID) for the others, and each entry stands for the version whose + * key it carries. That needs k_old and k_new to be different keys: they are + * told apart, and found again (index_delete_mark, index_undo_mark), only by + * the index's comparison. It fails in two cases, each found as an index that + * amcheck reports corrupt: + * + * - an INCLUDE column changed. If the key is the same, no entry is + * inserted or marked, and the one entry keeps the old INCLUDE values: + * bt_index_check(heapallindexed) found the table tuple lacking its entry. + * If the key changed too, a later change back to the old key revives its + * tombstone re-formed with the new INCLUDE values, and the version the + * tombstone stood for loses its entry the same way. + * + * - the index has an opclass that can call two different images equal + * (numeric 3 and 3.00, float8 0 and -0, a nondeterministic collation). A + * change to an equal key with another image left two entries that compare + * equal with the same TID, which breaks nbtree's (key, TID) order ("item + * order invariant violated"). A change to a different key is no safer: + * 3 -> 4 -> 3.0 revives the tombstone of 3 as 3.0, which hid the row from + * an index scan of a snapshot that sees the version holding 3. So any key + * change in such an index is refused. + * + * - the row enters or leaves a partial index. A reader rechecks an entry by + * its key only, and the planner drops the quals the predicate implies, so + * a delete-marked entry of the same key returned the row to snapshots that + * see it outside the predicate, and a partial unique index still counted + * it. Coming back revived the entry it left, re-formed from the new + * version, which may have another INCLUDE value or key image. Heap's + * UPDATE is not HOT here either. + * + * With these refused, every version at a TID is a member of a partial index + * or none is, and all of them carry the same INCLUDE values, so a tombstone + * an UPDATE revives is the entry the new version would get. + * + * Whether equal keys always have equal images is the opclasses' answer + * (BTEQUALIMAGE_PROC), which nbtree keeps in the metapage for deduplication + * (btm_allequalimage, see _bt_allequalimage). An index with INCLUDE columns + * is never allequalimage there, but its INCLUDE columns do not take part in + * the comparison, so for it the key columns' opclasses are asked + * (flux_index_keys_equalimage), and its INCLUDE columns are compared image + * by image when the key stays the same. + * + * old_hdr (old_len bytes) is the version the UPDATE replaces. Returns false + * when some btree index's entry for this row would change in a way + * delete-marking cannot describe: the caller then moves the row to a new + * TID, as heap's non-HOT UPDATE does, and the executor maintains every index. + * Other index AMs are not delete-marked (flux_inplace_index_maint_possible + * refuses them), and summarizing ones keep no per-row entry. + */ +static bool +flux_index_change_exact(Relation relation, ItemPointer otid, + FluxTupleHeader *old_hdr, Size old_len, + TupleTableSlot *newslot) +{ + List *indexoidlist; + ListCell *l; + EState *estate = NULL; + TupleTableSlot *oldslot = NULL; + ExprContext *econtext = NULL; + bool exact = true; + + indexoidlist = RelationGetIndexList(relation); + + foreach(l, indexoidlist) + { + Relation indexRel = index_open(lfirst_oid(l), AccessShareLock); + IndexInfo *indexInfo; + bool heapkeyspace; + bool allequalimage; + bool old_member = true; + bool new_member = true; + Datum old_values[INDEX_MAX_KEYS]; + bool old_isnull[INDEX_MAX_KEYS]; + Datum new_values[INDEX_MAX_KEYS]; + bool new_isnull[INDEX_MAX_KEYS]; + bool key_changed; + TupleDesc itupdesc = RelationGetDescr(indexRel); + + if (indexRel->rd_rel->relam != BTREE_AM_OID || + !indexRel->rd_index->indisready) + { + index_close(indexRel, AccessShareLock); + continue; + } + + _bt_metaversion(indexRel, &heapkeyspace, &allequalimage); + if (allequalimage && + IndexRelationGetNumberOfAttributes(indexRel) == + IndexRelationGetNumberOfKeyAttributes(indexRel) && + heap_attisnull(indexRel->rd_indextuple, Anum_pg_index_indpred, + NULL)) + { + /* + * Equal keys have equal images and there is nothing else: no + * INCLUDE columns, no predicate. + */ + index_close(indexRel, AccessShareLock); + continue; + } + + if (estate == NULL) + { + estate = CreateExecutorState(); + econtext = GetPerTupleExprContext(estate); + oldslot = MakeSingleTupleTableSlot(RelationGetDescr(relation), + table_slot_callbacks(relation)); + FluxSlotStoreTuple(oldslot, old_hdr, old_len, InvalidBuffer); + oldslot->tts_tid = *otid; + slot_getallattrs(oldslot); + slot_getallattrs(newslot); + } + + indexInfo = BuildIndexInfo(indexRel); + if (indexInfo->ii_Predicate != NIL) + { + ExprState *pred = ExecPrepareQual(indexInfo->ii_Predicate, estate); + + econtext->ecxt_scantuple = newslot; + new_member = ExecQual(pred, econtext); + econtext->ecxt_scantuple = oldslot; + old_member = ExecQual(pred, econtext); + } + + if (old_member != new_member) + exact = false; + else if (old_member) + { + econtext->ecxt_scantuple = oldslot; + FormIndexDatum(indexInfo, oldslot, estate, old_values, old_isnull); + econtext->ecxt_scantuple = newslot; + FormIndexDatum(indexInfo, newslot, estate, new_values, new_isnull); + + key_changed = flux_index_key_changed(indexRel, indexInfo, + old_values, old_isnull, + new_values, new_isnull); + + /* + * Even a change to a different key is refused if equal keys can + * have different images: the entry it revives or later + * delete-marks may hold another image of an equal key (3 -> 4 -> + * 3.0 revives the tombstone of 3). + */ + if (key_changed && !allequalimage && + !flux_index_keys_equalimage(indexRel)) + exact = false; + + /* Every INCLUDE column must be unchanged, key change or not. */ + for (int i = indexInfo->ii_NumIndexKeyAttrs; + exact && i < indexInfo->ii_NumIndexAttrs; i++) + { + Form_pg_attribute att = TupleDescAttr(itupdesc, i); + + if (old_isnull[i] != new_isnull[i] || + (!old_isnull[i] && + !datum_image_eq(old_values[i], new_values[i], + att->attbyval, att->attlen))) + exact = false; + } + ResetExprContext(econtext); + } + + index_close(indexRel, AccessShareLock); + if (!exact) + break; + } + + if (estate != NULL) + { + ExecDropSingleTupleTableSlot(oldslot); + FreeExecutorState(estate); + } + list_free(indexoidlist); + return exact; +} + +/* + * flux_indexed_attr_changed + * + * Self-compute whether any INDEXED attribute changed value between the old + * on-page tuple at 'otid' and the new row in 'slot'. Stock PostgreSQL no + * longer passes the modified-attrs set into tuple_update, so FLUX derives it + * here exactly like heap does internally (HeapDetermineColumnsInfo): deform + * the old on-page tuple, deform the new slot, and value-compare each indexed + * attribute with datum_image_eq. + * + * Returns true if any indexed attribute differs, forcing the OUT-OF-PLACE + * (delete + insert at new TID) update path so secondary indexes are rebuilt. + * Whole-row (attr 0) and non-tableOID system attributes in the indexed set + * are treated as changed (force out-of-place), matching heap's conservative + * direction. If the old tuple can't be read (gone/abnormal), we conservatively + * return true so index maintenance still happens. + * + * The image is read without waiting, so it may be a concurrent writer's + * uncommitted one. The version compared is returned in *decided_verptr and + * *decided_xmin (see flux_same_version) for the caller to check once the row + * is its to write; *decided_xmin stays invalid if no image was compared. + */ +static bool +flux_indexed_attr_changed(Relation relation, ItemPointer otid, + TupleTableSlot *slot, UndoRecPtr *decided_verptr, + TransactionId *decided_xmin) +{ + Bitmapset *indexed; + Bitmapset *summarized; + TupleDesc tupdesc = RelationGetDescr(relation); + BlockNumber blkno = ItemPointerGetBlockNumber(otid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(otid); + Buffer buffer; + Page page; + ItemId itemid; + FluxTupleData old_tup; + Datum old_values[MaxTupleAttributeNumber]; + bool old_isnull[MaxTupleAttributeNumber]; + bool changed = false; + int attidx = -1; + + /* + * The full set of attributes referenced by ANY index on the relation: + * HOT_BLOCKING (ordinary/key indexes) plus SUMMARIZED (BRIN and friends). + * Heap's HeapDetermineColumnsInfo uses the same bitmaps; taking their + * union means "any indexed column changed" and lets FLUX force the + * out-of-place TU_All path, which rebuilds every index (summarizing + * included) at the new TID -- so FLUX never needs TU_Summarizing. + */ + indexed = RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_HOT_BLOCKING); + summarized = RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_SUMMARIZED); + indexed = bms_add_members(indexed, summarized); + bms_free(summarized); + if (indexed == NULL) + return false; /* no indexed columns -> always in place */ + + /* New values live in the slot; make sure they are all deformed. */ + slot_getallattrs(slot); + + /* Read the old on-page tuple. */ + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page = BufferGetPage(buffer); + if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + bms_free(indexed); + return true; /* can't read old tuple -> be conservative */ + } + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + bms_free(indexed); + return true; + } + + old_tup.t_len = ItemIdGetLength(itemid); + old_tup.t_data = (FluxTupleHeader *) PageGetItem(page, itemid); + ItemPointerSet(&old_tup.t_self, blkno, offnum); + *decided_verptr = FluxTupleGetVersionPtr(old_tup.t_data); + *decided_xmin = FluxTupleGetXmin(old_tup.t_data); + + /* + * Deform only as far as the highest ordinary indexed column. Indexed + * columns are usually the leading attributes, so on a wide table this + * avoids deforming (walking + fetching) every trailing non-indexed column + * just to compare a few keys -- one of the two per-update overheads + * called out in the write-gap diagnosis. Whole-row (attrnum 0) and + * system (attrnum < 0) references are handled in the loop below without + * reading old_values[], so they do not raise the bound. + */ + { + int max_indexed_natts = 0; + int probe = -1; + + while ((probe = bms_next_member(indexed, probe)) >= 0) + { + AttrNumber probenum = probe + FirstLowInvalidHeapAttributeNumber; + + if (probenum > max_indexed_natts) + max_indexed_natts = probenum; + } + FluxDeformTupleUpTo(relation, &old_tup, tupdesc, old_values, old_isnull, + max_indexed_natts); + } + + while (!changed && (attidx = bms_next_member(indexed, attidx)) >= 0) + { + AttrNumber attrnum = attidx + FirstLowInvalidHeapAttributeNumber; + Form_pg_attribute att; + Datum old_val, + new_val; + bool old_null, + new_null; + + /* Whole-row reference: treat as changed (force all-index). */ + if (attrnum == 0) + { + changed = true; + break; + } + + /* + * System attributes other than tableOID cannot be compared + * meaningfully for an updated row: treat as changed. tableOID never + * changes. + */ + if (attrnum < 0) + { + if (attrnum != TableOidAttributeNumber) + changed = true; + continue; + } + + old_val = old_values[attrnum - 1]; + old_null = old_isnull[attrnum - 1]; + new_val = slot->tts_values[attrnum - 1]; + new_null = slot->tts_isnull[attrnum - 1]; + + /* Null-ness differs -> changed. */ + if (old_null != new_null) + { + changed = true; + break; + } + /* Both null -> unchanged. */ + if (new_null) + continue; + + att = TupleDescAttr(tupdesc, attrnum - 1); + if (!datum_image_eq(old_val, new_val, att->attbyval, att->attlen)) + { + changed = true; + break; + } + } + + UnlockReleaseBuffer(buffer); + bms_free(indexed); + return changed; +} + +/* + * Is the image at tuple_hdr the version an UPDATE decided its index work + * from? Every in-place UPDATE and DELETE stamps a new t_verptr and its xid, + * and their rollback restores the version before, so an unchanged + * (t_verptr, t_xmin) pair means an unchanged row. Freezing changes the pair + * but not the row, which only costs a second decision. + */ +static inline bool +flux_same_version(const FluxTupleHeader *tuple_hdr, UndoRecPtr verptr, + TransactionId xmin) +{ + return FluxTupleGetVersionPtr(tuple_hdr) == verptr && + TransactionIdEquals(FluxTupleGetXmin(tuple_hdr), xmin); +} + +/* + * Count an in-place UPDATE in the cumulative statistics by its physical + * effect. pgstat turns every counted update into one dead tuple when its + * transaction ends (the old version after a commit, the new one after an + * abort), which is what a heap UPDATE leaves. An in-place UPDATE that + * changes no indexed column leaves none either way: its before-image goes to + * UNDO, which the undo discard worker reclaims without VACUUM, and an abort + * restores the row in place. So that dead tuple is taken back, and the + * update is HOT in heap's sense: no index got a new entry. An in-place + * UPDATE that changes a key inserts index entries and delete-marks the old + * ones, which only VACUUM removes: it is not HOT, and its dead tuple stays + * counted so that autovacuum comes to remove them. + * + * ponytail: pgstat drops the dead-tuple delta of a transaction that + * truncated the relation, correction included, so an in-place UPDATE after a + * TRUNCATE in the same transaction counts one dead tuple, as heap's would. + */ +static void +flux_count_inplace_update(Relation relation, bool key_changed) +{ + pgstat_count_heap_update(relation, !key_changed, false); + if (!key_changed) + pgstat_update_heap_dead_tuples(relation, 1); +} + +/* + * flux_update_out_of_place + * + * Perform an out-of-place (new-TID) UPDATE: DELETE the old version at otid and + * INSERT the new version from slot, which lands at a fresh TID. Used in two + * cases: + * 1. an indexed attribute changed (secondary indexes carry plain heap-style + * TIDs, so the row must move and every index entry be rebuilt), and + * 2. as a last-resort fallback when an in-place UPDATE of a NON-indexed + * column cannot fit in its slot on a full page (FLUX TIDs are stable, so + * the tuple cannot grow in place and cannot relocate within the page). + * + * In BOTH cases the executor must rebuild every index entry at the new TID, so + * this always reports TU_All: even for a non-indexed-column update, the row's + * unchanged indexed columns still need fresh (key, newTID) entries because the + * old (key, oldTID) entries die with the old TID. Reporting TU_None here would + * leave indexes pointing at the dead old TID with nothing at the new one -- + * silent index corruption. The old version stays visible to older snapshots + * via the DELETE's UNDO before-image; ROLLBACK undoes both the delete and the + * insert. The old version links to the new one (flux_link_superseded), so + * a concurrent writer or locker follows the row as it does in heap. + */ +static TM_Result +flux_update_out_of_place(Relation relation, ItemPointer otid, + TupleTableSlot *slot, CommandId cid, uint32 options, + Snapshot snapshot, Snapshot crosscheck, bool wait, + TM_FailureData *tmfd, LockTupleMode *lockmode, + TU_UpdateIndexes *update_indexes) +{ + TM_Result del_res; + bool key_intact; + FluxTuple old; + + /* + * Report the lock mode before anything can fail, as heap_update does: on + * TM_Updated the executor locks the row with it to recheck it, and it + * must be the exclusive lock an UPDATE that moves the row takes. Left at + * zero it would be LockTupleKeyShare, and FLUX's key-share lock path + * serves the snapshot's pre-update image, so EvalPlanQual recomputed the + * new row from the version the concurrent update had replaced: a lost + * update. + */ + if (lockmode != NULL) + *lockmode = LockTupleExclusive; + + /* + * The halves are not decoded: the link record carries the logical UPDATE. + * The delete half takes delete options, not the update's: bit 0 is + * TABLE_UPDATE_NO_LOGICAL there but TABLE_DELETE_CHANGING_PARTITION here. + */ + del_res = flux_tuple_delete(relation, otid, cid, + TABLE_DELETE_NO_LOGICAL | FLUX_DML_NO_STATS, + snapshot, crosscheck, wait, tmfd); + if (del_res != TM_Ok) + return del_res; + + /* The deleted version is now ours and cannot change under the comparison. */ + old = flux_copy_version(relation, otid); + key_intact = !flux_update_changes_key(relation, old->t_data, old->t_len, slot); + FluxFreeTuple(old); + + flux_tuple_insert(relation, slot, cid, + TABLE_INSERT_NO_LOGICAL | FLUX_DML_NO_STATS, NULL); + flux_link_superseded(relation, otid, &slot->tts_tid, + (options & TABLE_UPDATE_NO_LOGICAL) == 0, + key_intact); + + /* + * One UPDATE, counted as heap counts a non-HOT one: the deleted version + * is a dead tuple for VACUUM, and every index got a new entry. + */ + pgstat_count_heap_update(relation, false, + ItemPointerGetBlockNumber(otid) != + ItemPointerGetBlockNumber(&slot->tts_tid)); + + if (update_indexes != NULL) + *update_indexes = TU_All; + return TM_Ok; +} + +/* + * flux_inplace_index_maintenance + * + * After an in-place UPDATE that kept the row at its stable TID, + * bring the secondary indexes up to date WITHOUT moving the row: for every + * index whose key value for this row changed, INSERT the new (k_new, TID) + * entry and DELETE-MARK the old (k_old, TID) entry (index_delete_mark, Phase + * 5). Indexes whose key did not change are left untouched -- their (k, TID) + * entry is still valid because the TID is stable. + * + * This is the SINGLE authority for which indexes changed and what each needs, + * so there is no cross-component agreement hazard: the executor is told + * TU_None and does no index work for a FLUX indexed-column UPDATE. + * + * 'old_hdr'/'old_len' is the BEFORE-image on-page tuple (captured by the + * caller before the in-place overwrite); 'newslot' holds the new row (its + * tts_tid is the stable TID). Partial and expression indexes are handled the + * same way the executor's ExecInsertIndexTuples does: FormIndexDatum builds + * expression keys, and a partial index's predicate is evaluated against both + * the old and the new row so an entry is inserted only when the new row is a + * member and delete-marked only when the old row was a member. + * + * On ROLLBACK the FLUX table UNDO (flux_undo_apply, FLUX_UNDO_UPDATE) reverses + * exactly this: it clears the delete-mark on (k_old, TID) and delete-marks + * (k_new, TID) (flux_inplace_index_undo), so each key leads to the row as it + * did before the update. So no + * separate index UNDO is written for a FLUX-parented index (nbtree/hash + * suppress it via RelationSupportsDeleteMarking). + */ +static void +flux_inplace_index_maintenance(Relation relation, ItemPointer otid, + FluxTupleHeader *old_hdr, Size old_len, + TupleTableSlot *newslot, UndoRecPtr urp) +{ + List *indexoidlist; + ListCell *l; + EState *estate; + TupleTableSlot *oldslot; + ExprContext *econtext; + Oid *revived; + uint16 nrevived = 0; + + (void) old_len; /* FluxTupleToSlotWithOverflow reads t_len + * itself */ + + indexoidlist = RelationGetIndexList(relation); + if (indexoidlist == NIL) + return; + + revived = palloc(list_length(indexoidlist) * sizeof(Oid)); + estate = CreateExecutorState(); + oldslot = MakeSingleTupleTableSlot(RelationGetDescr(relation), + table_slot_callbacks(relation)); + + /* Materialize the before-image into oldslot to form the old keys. */ + FluxTupleToSlotWithOverflow(old_hdr, oldslot, relation); + oldslot->tts_tid = *otid; + + slot_getallattrs(newslot); + slot_getallattrs(oldslot); + + econtext = GetPerTupleExprContext(estate); + + foreach(l, indexoidlist) + { + Oid indexoid = lfirst_oid(l); + Relation indexRel; + IndexInfo *indexInfo; + Datum new_values[INDEX_MAX_KEYS]; + bool new_isnull[INDEX_MAX_KEYS]; + Datum old_values[INDEX_MAX_KEYS]; + bool old_isnull[INDEX_MAX_KEYS]; + bool old_member = true; + bool new_member = true; + bool key_changed; + + indexRel = index_open(indexoid, RowExclusiveLock); + + /* Not-yet-ready indexes are maintained by the build, not here. */ + if (!indexRel->rd_index->indisready) + { + index_close(indexRel, RowExclusiveLock); + continue; + } + + indexInfo = BuildIndexInfo(indexRel); + + /* Partial index: evaluate the predicate against old and new rows. */ + if (indexInfo->ii_Predicate != NIL) + { + ExprState *pred = ExecPrepareQual(indexInfo->ii_Predicate, estate); + + econtext->ecxt_scantuple = newslot; + new_member = ExecQual(pred, econtext); + econtext->ecxt_scantuple = oldslot; + old_member = ExecQual(pred, econtext); + } + + if (new_member) + { + econtext->ecxt_scantuple = newslot; + FormIndexDatum(indexInfo, newslot, estate, new_values, new_isnull); + } + if (old_member) + { + econtext->ecxt_scantuple = oldslot; + FormIndexDatum(indexInfo, oldslot, estate, old_values, old_isnull); + } + + /* + * Decide whether the index key actually changed for this row. When + * both versions are index members we compare the formed key datums; + * differing predicate membership is itself a change (an entry appears + * or disappears). + */ + if (old_member != new_member) + key_changed = true; + else if (!old_member) /* neither indexed -> nothing to do */ + key_changed = false; + else + key_changed = flux_index_key_changed(indexRel, indexInfo, + old_values, old_isnull, + new_values, new_isnull); + + if (key_changed) + { + /* + * Insert the new (k_new, TID) entry so new-snapshot readers reach + * the row under its new key. A->B->A recurrence: if a + * delete-marked (k_new, TID) tombstone already exists (a previous + * UPDATE moved the key away and back), REVIVE it (clear the mark) + * instead of inserting a duplicate (key, TID) -- two entries with + * the same key AND heap TID would violate the heapkeyspace + * ordering invariant (amcheck "item order invariant violated"). + * Only insert a fresh entry when no such tombstone exists. + * Uniqueness is enforced on the insert exactly as the executor + * would (immediate unique indexes throw on a real duplicate; + * delete-marked tombstones are skipped). + */ + if (new_member) + { + bool revive = index_probe_delete_mark(indexRel, new_values, + new_isnull, &newslot->tts_tid, relation); + + if (revive) + { + revived[nrevived++] = indexoid; + FluxPbuAmendRevivedList(urp, revived, nrevived); + INJECTION_POINT("flux-index-after-amend", NULL); + /* Never insert after logging a revive intent. */ + if (!index_undo_mark(indexRel, new_values, new_isnull, + &newslot->tts_tid, relation, true)) + elog(ERROR, "FLUX index tombstone disappeared before revival"); + } + if (!revive) + { + IndexUniqueCheck checkUnique; + + if (!indexRel->rd_index->indisunique) + checkUnique = UNIQUE_CHECK_NO; + else if (indexRel->rd_index->indimmediate) + checkUnique = UNIQUE_CHECK_YES; + else + checkUnique = UNIQUE_CHECK_PARTIAL; + + index_insert(indexRel, new_values, new_isnull, + &newslot->tts_tid, relation, + checkUnique, false, indexInfo); + } + else if (indexRel->rd_index->indisunique) + { + bool anynull = false; + + /* + * A revived entry skipped the uniqueness check an insert + * runs. The row left this key, and another row may have + * taken it since. Check the entry now in place, as a + * deferred unique constraint's recheck does: wait for an + * in-progress holder, raise unique_violation for a live + * one. A key with a null is no duplicate unless NULLS NOT + * DISTINCT, and nbtree does not check it. + */ + for (int keyno = 0; keyno < indexInfo->ii_NumIndexKeyAttrs; keyno++) + anynull |= new_isnull[keyno]; + if (!anynull || indexInfo->ii_NullsNotDistinct) + index_insert(indexRel, new_values, new_isnull, + &newslot->tts_tid, relation, + UNIQUE_CHECK_EXISTING, false, indexInfo); + } + } + + /* + * Delete-mark the old (k_old, TID) entry so old-snapshot readers + * still reach the before-image under the old key, and a recheck + * keeps new-snapshot readers from seeing it under the old key. + * Missing entry is not an error (btdeletemark). + */ + if (old_member) + (void) index_delete_mark(indexRel, old_values, old_isnull, + (ItemPointer) otid, relation); + } + + /* + * Every index_insert() must be paired with index_insert_cleanup() + * before the index is closed, exactly as the executor does + * (execIndexing.c ExecCloseIndices) and as index.c/constraint.c do. + * Some AMs cache per-insert state in indexInfo->ii_AmCache that owns + * pinned buffers -- BRIN keeps its revmap buffer there -- and only + * the aminsertcleanup callback releases them. Omitting it leaked one + * pin per inserted row per BRIN index page, which surfaced as a flood + * of "WARNING: resource was not closed: ... refcount=1" at + * transaction end (regression tests brin, brin_bloom, brin_multi, + * stats under default_table_access_method=flux). + */ + index_insert_cleanup(indexRel, indexInfo); + + index_close(indexRel, RowExclusiveLock); + } + + pfree(revived); + list_free(indexoidlist); + ExecDropSingleTupleTableSlot(oldslot); + FreeExecutorState(estate); +} + +/* + * flux_inplace_index_undo + * + * ROLLBACK reversal of flux_inplace_index_maintenance. Before the + * table UNDO restores the before-image tuple in place (see + * flux_undo_index_first), bring the secondary indexes back to their + * pre-update state for every index whose key changed: delete-mark a revived + * new entry, physically remove an inserted one, and clear the mark on the old + * entry so it is a live entry again. Both steps are + * idempotent, so a retry after a failure is harmless. + * + * 'new_hdr' is the current on-page (post-update, k_new) tuple; 'old_hdr' is + * the before-image (k_old) about to be restored. 'indexes' is the record's + * list of nindexes FluxUndoIndex entries: the indexes maintenance may have + * changed, and the files it changed. + * Partial/expression indexes are handled exactly as the forward path does: + * an entry only needs delete-marking where the new row was a member, and only needs + * unmarking where the old row was a member. + * + * This is why FLUX writes NO separate index UNDO: this function IS the index + * UNDO, driven by the table's own undo apply (decision (a)). + * + * The relcache must show each listed index with the file the record names. + * While the transaction xid is aborting, it may not: the transaction itself + * dropped the index, or gave it a new file (REINDEX, SET TABLESPACE), and + * the abort has not rolled that back yet. Then return false, changing + * nothing more, and the caller leaves the record for later: for after the + * abort (PbuApplyPendingUndo), or, for ROLLBACK TO SAVEPOINT, for the retry + * as the parent (PbuRetrySubxactUndo). By then an index that is missing or + * has another file was created after the record's file, by the rolled-back + * transaction itself or a later one, and built from the table: there is + * nothing to reverse in it. + */ +bool +flux_inplace_index_undo(Relation relation, ItemPointer tid, + FluxTupleHeader *new_hdr, Size new_len, + FluxTupleHeader *old_hdr, Size old_len, + const char *indexes, uint16 nindexes, + TransactionId xid) +{ + List *indexoidlist; + EState *estate; + TupleTableSlot *oldslot; + TupleTableSlot *newslot; + ExprContext *econtext; + bool pushed_snapshot = false; + + bool aborting = TransactionIdEquals(xid, GetTopTransactionIdIfAny()) && + !PbuInSubxactRetry(); + bool done = true; + + (void) new_len; + (void) old_len; + + indexoidlist = RelationGetIndexList(relation); + + /* + * Index expressions and predicates may run SQL (a PL/pgSQL function), + * which needs an active snapshot. An undo apply worker has none. + */ + if (!ActiveSnapshotSet()) + { + PushActiveSnapshot(GetTransactionSnapshot()); + pushed_snapshot = true; + } + + estate = CreateExecutorState(); + oldslot = MakeSingleTupleTableSlot(RelationGetDescr(relation), + table_slot_callbacks(relation)); + newslot = MakeSingleTupleTableSlot(RelationGetDescr(relation), + table_slot_callbacks(relation)); + + FluxTupleToSlotWithOverflow(old_hdr, oldslot, relation); + oldslot->tts_tid = *tid; + + FluxTupleToSlotWithOverflow(new_hdr, newslot, relation); + newslot->tts_tid = *tid; + + slot_getallattrs(oldslot); + slot_getallattrs(newslot); + + econtext = GetPerTupleExprContext(estate); + + for (int i = 0; i < nindexes; i++) + { + Relation indexRel; + IndexInfo *indexInfo; + Datum new_values[INDEX_MAX_KEYS]; + bool new_isnull[INDEX_MAX_KEYS]; + Datum old_values[INDEX_MAX_KEYS]; + bool old_isnull[INDEX_MAX_KEYS]; + bool old_member = true; + bool new_member = true; + bool key_changed; + FluxUndoIndex entry; + + memcpy(&entry, indexes + i * sizeof(entry), sizeof(entry)); + if (!list_member_oid(indexoidlist, entry.indexoid)) + { + if (aborting) + { + done = false; + break; + } + continue; + } + + indexRel = index_open(entry.indexoid, RowExclusiveLock); + if (indexRel->rd_locator.relNumber != entry.relnumber || + indexRel->rd_locator.spcOid != entry.spcoid) + { + index_close(indexRel, RowExclusiveLock); + if (aborting) + { + done = false; + break; + } + continue; + } + if (!indexRel->rd_index->indisready) + { + index_close(indexRel, RowExclusiveLock); + continue; + } + + indexInfo = BuildIndexInfo(indexRel); + + if (indexInfo->ii_Predicate != NIL) + { + ExprState *pred = ExecPrepareQual(indexInfo->ii_Predicate, estate); + + econtext->ecxt_scantuple = newslot; + new_member = ExecQual(pred, econtext); + econtext->ecxt_scantuple = oldslot; + old_member = ExecQual(pred, econtext); + } + + if (new_member) + { + econtext->ecxt_scantuple = newslot; + FormIndexDatum(indexInfo, newslot, estate, new_values, new_isnull); + } + if (old_member) + { + econtext->ecxt_scantuple = oldslot; + FormIndexDatum(indexInfo, oldslot, estate, old_values, old_isnull); + } + + if (old_member != new_member) + key_changed = true; + else if (!old_member) + key_changed = false; + else + key_changed = flux_index_key_changed(indexRel, indexInfo, + old_values, old_isnull, + new_values, new_isnull); + + if (key_changed) + { + if (new_member) + { + if (entry.flags & FLUX_UNDO_INDEX_REVIVED) + (void) index_delete_mark(indexRel, new_values, new_isnull, + tid, relation); + else + (void) index_undo_mark(indexRel, new_values, new_isnull, + tid, relation, false); + } + /* Clear the delete-mark on the old (k_old, TID) entry. */ + if (old_member) + (void) index_undo_mark(indexRel, old_values, old_isnull, + tid, relation, true); + } + + index_close(indexRel, RowExclusiveLock); + } + + list_free(indexoidlist); + ExecDropSingleTupleTableSlot(oldslot); + ExecDropSingleTupleTableSlot(newslot); + FreeExecutorState(estate); + if (pushed_snapshot) + PopActiveSnapshot(); + return done; +} + +/* + * flux_tuple_update -- table AM entry point. + * + * Wrapper around the in-place update machinery. An indexed-column + * UPDATE stays IN PLACE (stable TID); this wrapper captures the old row's + * before-image, runs the in-place update, and -- if any indexed column changed + * and the update really stayed in place -- drives the secondary-index + * maintenance at the stable TID (insert new (k_new,TID) + delete-mark old + * (k_old,TID) per changed index). It reports TU_None so the executor does no + * further index work; on abort the FLUX table UNDO reverses both the tuple and + * the index changes. + * + * The rare genuine no-room case still falls back to an out-of-place update + * (inner returns with did_inplace_key_update = false and TU_All); there we do + * NOT run in-place maintenance -- the executor rebuilds every index at the new + * TID as before. + */ +TM_Result +flux_tuple_update(Relation relation, ItemPointer otid, TupleTableSlot *slot, + CommandId cid, uint32 options, + Snapshot snapshot, Snapshot crosscheck, + bool wait, TM_FailureData *tmfd, + LockTupleMode *lockmode, TU_UpdateIndexes *update_indexes) +{ + bool key_changed; + bool did_inplace; + UndoRecPtr index_urp; + FluxTupleHeader *old_copy; + Size old_copy_len; + UndoRecPtr decided_verptr; + TransactionId decided_xmin; + bool redecide; + bool kept_tuplock = false; + LockTupleMode kept_mode = LockTupleExclusive; + TM_Result res; + + /* + * Decide up front whether any indexed column changed, and if so grab a + * private copy of the old on-page tuple BEFORE the in-place overwrite so + * we can form the old index keys (k_old) for delete-marking after the + * update. We only need this when the relation has indexes and an indexed + * attribute actually changed; the common non-key UPDATE skips all of it. + * + * The version compared may be a concurrent writer's uncommitted one, + * which its rollback replaces while we wait for it. Then + * flux_tuple_update_inplace finds another version once the row is ours, + * and we decide again, as heap_update starts over at l2 after a wait. + */ +decide: + did_inplace = false; + index_urp = InvalidUndoRecPtr; + old_copy = NULL; + old_copy_len = 0; + decided_verptr = InvalidUndoRecPtr; + decided_xmin = InvalidTransactionId; + key_changed = (relation->rd_rel->relhasindex && + flux_indexed_attr_changed(relation, otid, slot, + &decided_verptr, &decided_xmin)); + /* Tests change the row here, after the decision was made from it. */ + INJECTION_POINT("flux-update-after-decision", NULL); + + /* + * In-place index maintenance is only correct when FLUX can fully + * reproduce the executor's index work for every index on the relation + * (see flux_inplace_index_maint_possible: delete-marking capability, + * exclusion constraints, deferred constraints). When it cannot, take the + * out-of-place (new-TID) path, which reports TU_All so the executor does + * ALL index work including the constraint checks -- correctness over the + * stable-TID optimisation. + */ + if (key_changed && !flux_inplace_index_maint_possible(relation)) + { + res = flux_update_out_of_place(relation, otid, slot, cid, options, + snapshot, crosscheck, wait, tmfd, + lockmode, update_indexes); + goto done; + } + + if (key_changed) + { + BlockNumber blkno = ItemPointerGetBlockNumber(otid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(otid); + Buffer buffer; + Page page; + ItemId itemid; + + if (blkno < RelationGetNumberOfBlocks(relation)) + { + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page = BufferGetPage(buffer); + if (offnum >= FirstOffsetNumber && + offnum <= PageGetMaxOffsetNumber(page)) + { + itemid = PageGetItemId(page, offnum); + if (ItemIdIsNormal(itemid)) + { + old_copy_len = ItemIdGetLength(itemid); + old_copy = (FluxTupleHeader *) palloc(old_copy_len); + memcpy(old_copy, PageGetItem(page, itemid), old_copy_len); + } + } + UnlockReleaseBuffer(buffer); + } + /* Old tuple unreadable: fall back to treating as no in-place maint. */ + if (old_copy == NULL) + key_changed = false; + + /* + * flux_index_change_exact below reads this copy: it must be the + * version key_changed was computed from. + */ + else if (!flux_same_version(old_copy, decided_verptr, decided_xmin)) + { + pfree(old_copy); + goto decide; + } + } + + /* + * A change that delete-marking cannot describe moves the row instead, see + * flux_index_change_exact. + */ + if (key_changed && + !flux_index_change_exact(relation, otid, old_copy, old_copy_len, + slot)) + { + pfree(old_copy); + res = flux_update_out_of_place(relation, otid, slot, cid, options, + snapshot, crosscheck, wait, tmfd, + lockmode, update_indexes); + goto done; + } + + res = flux_tuple_update_inplace(relation, otid, slot, cid, options, + snapshot, crosscheck, wait, tmfd, + lockmode, update_indexes, key_changed, + &did_inplace, &index_urp, &old_copy, &old_copy_len, + decided_verptr, decided_xmin, &redecide, + &kept_tuplock, &kept_mode); + if (redecide) + { + if (old_copy != NULL) + pfree(old_copy); + INJECTION_POINT("flux-update-redecide", NULL); + goto decide; + } + res = flux_report_moved(relation, otid, res, tmfd); + + /* + * The slot now stands for the version just stored, stamped with our xid + * and cid by every update path (flux_tuple_insert does the same for an + * out-of-place one): the AFTER triggers' NEW row. + */ + if (res == TM_Ok) + FluxSlotSetVersion(slot, GetCurrentTransactionId(), cid); + + if (res == TM_Ok && key_changed && did_inplace) + { + /* + * The row stayed at its TID and an indexed column changed: maintain + * the secondary indexes in place. slot->tts_tid was set to the + * stable TID by the in-place path. + */ + flux_inplace_index_maintenance(relation, otid, old_copy, old_copy_len, + slot, index_urp); + if (update_indexes != NULL) + *update_indexes = TU_None; + } + + if (old_copy != NULL) + pfree(old_copy); + +done: + if (kept_tuplock) + FluxUnlockTuple(relation, otid, kept_mode); + return res; +} + +/* + * flux_tuple_update_inplace + * + * The in-place UPDATE machinery: overwrite the tuple at 'otid' in place + * (stable TID), writing the before-image to per-backend UNDO. Never moves the + * row for an indexed-column change; it only falls back to + * out-of-place when the new tuple genuinely cannot fit on the page even after + * defrag/shrink (the heap cross-page analogue). On a successful in-place + * update of an indexed-column change *did_inplace_key_update is set true so the + * caller (flux_tuple_update) drives the secondary-index maintenance + * (insert new + delete-mark old) at the stable TID. + * + * key_changed was computed from the version (decided_verptr, decided_xmin), + * see flux_indexed_attr_changed; an invalid decided_xmin means no version was + * compared. If the row holds another version once it is ours to write, + * nothing is changed: *redecide is set, and the caller decides again. The + * tuple lock this call took to wait is then handed to the caller, which sets + * *kept_tuplock and *kept_mode and releases it once it is done, so that the + * UPDATE keeps its place among the row's waiters while it decides again. + */ +static TM_Result +flux_tuple_update_inplace(Relation relation, ItemPointer otid, TupleTableSlot *slot, + CommandId cid, uint32 options, + Snapshot snapshot, Snapshot crosscheck, + bool wait, TM_FailureData *tmfd, + LockTupleMode *lockmode, TU_UpdateIndexes *update_indexes, + bool key_changed, + bool *did_inplace_key_update, UndoRecPtr *index_urp, + FluxTupleHeader **index_old, Size *index_old_len, + UndoRecPtr decided_verptr, TransactionId decided_xmin, + bool *redecide, bool *kept_tuplock, + LockTupleMode *kept_mode) +{ + BlockNumber blkno; + OffsetNumber offnum; + Buffer buffer; + Page page; + ItemId itemid; + FluxTupleHeader *old_tuple_hdr; + FluxTuple new_tuple; + Size new_tuple_size; + uint64 current_ts; + bool old_has_overflow = false; + bool have_tuple_lock = false; + LockTupleMode update_lockmode = key_changed ? + LockTupleExclusive : LockTupleNoKeyExclusive; + bool locked_by_me = false; + FluxTuple old_tuple_for_inplace_wal; + TransactionId update_freeze_conflict; + bool walLogical = (options & TABLE_UPDATE_NO_LOGICAL) == 0; + FluxLogicalImage update_old_img; + FluxLogicalImage update_new_img; + FluxOverflowBuffers update_overflow_buffers; + int upd_i; + uint64 defrag_oldest_ts = 0; + TransactionId defrag_oldest_xmin = InvalidTransactionId; + bool force_shrink_attempted = false; + UndoRecPtr upd_undo_ptr = InvalidUndoRecPtr; + Datum upd_toast_values[MaxTupleAttributeNumber]; + bool upd_toast_isnull[MaxTupleAttributeNumber]; + ToastAttrInfo upd_toast_attr[MaxTupleAttributeNumber]; + ToastTupleContext upd_ttc; + bool upd_toasted = false; + Datum upd_old_values[MaxTupleAttributeNumber]; + bool upd_old_isnull[MaxTupleAttributeNumber]; + FluxTupleHeader *upd_old_copy = NULL; + Buffer vmbuffer; + + *redecide = false; + + /* Extract block and offset from old TID */ + blkno = ItemPointerGetBlockNumber(otid); + offnum = ItemPointerGetOffsetNumber(otid); + + + /* Validate TID range */ + if (blkno >= RelationGetNumberOfBlocks(relation)) + return TM_Invisible; + + /* + * Concurrent updaters of the same row are serialized the way heap_update + * serializes them: by the identity of the row's latest writer, which FLUX + * keeps in the tuple itself. An in-place UPDATE stamps the new image's + * t_xmin with the updater's xid and marks it FLUX_TUPLE_UPDATED | + * FLUX_TUPLE_UNCOMMITTED (flux_header_dirty_xid reads it back); a DELETE + * stamps t_xmax. Both stay on the page until the writer's commit or + * rollback is complete. So an updater that finds an in-progress foreign + * writer on the row waits for it (XactLockTableWait), holding the + * heavyweight tuple lock only for the duration of that wait so that + * several waiters queue up behind each other, and then reports TM_Updated + * so the executor re-evaluates via EvalPlanQual. A writer that already + * committed but is invisible to our snapshot is caught by the + * committed-update gates (FluxTupleHasCommittedUpdateAfter) and also + * returns TM_Updated. + * + * No tuple lock is held to commit: one per updated row exhausted the + * shared lock table on any UPDATE of more than a few thousand rows. + */ + + /* + * Update in place, even when an indexed column changes. + * + * With nbtree delete-marking the row keeps its TID: the in-place + * machinery below overwrites the tuple at 'otid', and the caller + * (flux_tuple_update) inserts the new (k_new, TID) entry and delete-marks + * the old (k_old, TID) entry for each index whose key changed. Unchanged + * indexes are untouched (their (k, TID) entry is still valid). On abort + * the FLUX table UNDO reverses both the tuple and the index changes -- no + * separate index UNDO. + * + * The only remaining out-of-place case is a genuine no-room fallback: an + * in-place-grown tuple that cannot fit on its page even after + * defrag/shrink (the heap cross-page analogue), handled at the ERROR + * sites far below. That path sets *did_inplace_key_update = false so the + * caller does not also run in-place index maintenance (the out-of-place + * path already sets TU_All to rebuild every index at the new TID). + */ + if (did_inplace_key_update != NULL) + *did_inplace_key_update = true; + + /* + * Report the tuple-lock strength this UPDATE effectively holds, exactly + * as heap_update does (heapam.c: LockTupleNoKeyExclusive when no key + * column changed, LockTupleExclusive otherwise). The executor keeps this + * in UpdateContext.lockmode -- an UNINITIALIZED stack field -- and passes + * it straight back to table_tuple_lock() when it has to re-lock the + * latest version for EvalPlanQual (nodeModifyTable.c, the TM_Updated -> + * TUPLE_LOCK_FLAG_FIND_LAST_VERSION path). + * + * Leaving it unset was a live lost-update bug: the garbage value happened + * to be 0 == LockTupleKeyShare, so an UPDATE that lost the race re-locked + * the row with a KEY-SHARE lock instead of an exclusive one. That is + * compatible with the winner's in-progress non-key UPDATE, so the loser + * never waited, re-read the pre-update image, and overwrote the winner's + * committed value ('' -> 'A' -> 'B' instead of 'AB'). + */ + if (lockmode != NULL) + *lockmode = update_lockmode; + + /* + * In-place UPDATE default: the row keeps its TID. The caller overrides + * *update_indexes to TU_None after driving any delete-marking + * maintenance, and for a non-key UPDATE this TU_None is already correct + * (no index work). + */ + if (update_indexes != NULL) + *update_indexes = TU_None; + + /* + * --------------------------------------------------------------- FAST + * PATH: Same-size CAS update under SHARE_EXCLUSIVE buffer lock. + * + * For simple same-size updates (e.g. balance += delta in TPC-B), we can + * avoid the fully exclusive buffer lock by using a share-exclusive lock. + * This still allows concurrent plain-SHARE readers while serializing + * writers on the same page: BUFFER_LOCK_SHARE_EXCLUSIVE conflicts with + * itself and with BUFFER_LOCK_EXCLUSIVE (bufmgr.c BufferLockAttempt), so + * while we hold it no other backend can modify this page, and the buffer + * lock alone makes us the tuple's only writer. + * + * NOTE on hot-row contention (investigated, no change made): at most one + * CAS writer per buffer runs at a time; additional hot-row writers + * queue+sleep on the buffer content lock. There is no spin storm to + * convert to a queued lock: the hot-row path ALREADY degrades to fair + * queued locks (BUFFER_LOCK_SHARE_EXCLUSIVE, then LOCKTAG_TUPLE + + * XactLockTableWait in the slow path). The measured hot-row throughput + * decline (peak ~c8 then fall) is dominated by "Lock : tuple" heavyweight + * waits + per-update UNDO/sLog/WAL work under that lock -- the same + * serialization heap incurs on a hot row -- NOT by spinning. Switching + * the fast path to BUFFER_LOCK_EXCLUSIVE would not change this (both + * modes serialize one writer per buffer) and would regress the scattered + * case by blocking concurrent plain-SHARE readers. + * + * Hot-row throughput is limited by per-update UNDO/sLog/WAL work under + * the serialized lock, not by the writer-lock mode. + * + * Eligibility requirements: - New tuple must be the same on-disk size as + * the old tuple - Old tuple must not have overflow data - Old tuple must + * not be deleted, locked, or uncommitted - No speculative insertion - + * Must be a simple UPDATE (not HOT-chain following) - Snapshot visibility + * must be trivially true (committed tuple) - Relation must need WAL (for + * crash safety) + * + * If any condition fails, we fall through to the exclusive-lock path. + * + * A logically-logged relation is excluded: the CAS record only WAL-logs a + * byte-diff (offset+length+bytes), not a full tuple, so a logical decoder + * cannot reconstruct the new row from it. Such relations take the + * regular exclusive-lock UPDATE path below, whose xl_flux_update record + * carries the full old and new heap-format images that DecodeFluxUpdate() + * reads. --------------------------------------------------------------- + */ + if (key_changed || RelationIsLogicallyLogged(relation) || + crosscheck != InvalidSnapshot) + goto flux_update_slow_path; + + { + FluxTuple cas_new_tuple; + Size cas_new_size; + + /* Form the new tuple speculatively (no overflow handling) */ + slot_getallattrs(slot); + cas_new_tuple = FluxFormTuple(RelationGetDescr(relation), + slot->tts_values, + slot->tts_isnull, + NULL, /* no overflow */ + NULL); + + cas_new_size = cas_new_tuple->t_len; + + Assert(!key_changed); + + /* Attempt the CAS fast path */ + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_SHARE_EXCLUSIVE); + page = BufferGetPage(buffer); + + if (offnum >= FirstOffsetNumber && + offnum <= PageGetMaxOffsetNumber(page)) + { + itemid = PageGetItemId(page, offnum); + + if (ItemIdIsNormal(itemid) && + cas_new_size <= ItemIdGetLength(itemid)) + { + Size cas_target_size; + + old_tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * The version-chain head lives in the fixed header field + * t_verptr, so both the on-page tuple and the freshly formed + * new tuple carry it inside their header with no trailing + * growth. Same-column-width updates are therefore naturally + * the same length, so this stays on the CAS fast path with no + * first-time growth dispatch. + */ + cas_target_size = cas_new_size; + + /* + * Cheap in-page eligibility gate: committed, not deleted, not + * locked, no overflow, same size for direct memcpy. This is + * a pure buffer-domain check (no sLog probe). + * + * We deliberately do NOT probe the sLog for write-write + * conflicts here. The flag gate is not a reliable conflict + * signal -- a committed in-place UPDATE rewinds t_commit_ts + * and clears FLUX_TUPLE_UNCOMMITTED, and a reader/third + * writer may clear a still-live flag as "stale" -- so any + * cheaper pre-check here would have to be repeated + * authoritatively below anyway. The committed-update and + * in-progress-writer probes therefore run once, below; no WAL + * or page change happens until they pass, and a failed probe + * just falls to the exclusive path. + */ + if (!(old_tuple_hdr->t_flags & (FLUX_TUPLE_DELETED | + FLUX_TUPLE_LOCKED | + FLUX_TUPLE_UNCOMMITTED | + FLUX_TUPLE_HAS_OVERFLOW | + FLUX_TUPLE_SPECULATIVE)) && + !((old_tuple_hdr->t_infomask | + cas_new_tuple->t_data->t_infomask) & + FLUX_INFOMASK_HASEXTERNAL) && /* TOAST: slow path */ + cas_new_size <= TOAST_TUPLE_THRESHOLD && /* wide: slow path + * toasts it */ + cas_target_size == ItemIdGetLength(itemid) && + + /* + * The CAS record cannot carry a visibility map clear: let the + * exclusive path update a page that is or may be marked + * all-visible. + * + * ponytail: so the first UPDATE of a page after VACUUM marked + * it takes the exclusive path; give the CAS record the clear + * (as the other change records have it) if that shows in a + * profile. + */ + !FluxVMNeedsClear(relation, buffer)) + { + /* + * The buffer lock is what makes us the tuple's only + * writer from here to the page write: SHARE_EXCLUSIVE + * conflicts with itself and with EXCLUSIVE. + */ + Assert(BufferIsLockedByMeInMode(buffer, BUFFER_LOCK_SHARE_EXCLUSIVE) || + BufferIsLockedByMeInMode(buffer, BUFFER_LOCK_EXCLUSIVE)); + + { + /* + * A competing writer may have completed its own + * in-place overwrite (it stamps + * FLUX_TUPLE_UNCOMMITTED into the new page image), or + * committed and left a conflict marker, before we got + * the buffer lock. Acting on a stale decision would + * silently clobber that update (lost update). + * Re-validate the flags and run the write-write + * probes under the lock; if the tuple is + * disqualified, fall through to the exclusive path, + * which blocks on the in-progress writer or returns + * TM_Updated for EPQ. + */ + bool cas_revalidated; + + cas_revalidated = + !(old_tuple_hdr->t_flags & (FLUX_TUPLE_DELETED | + FLUX_TUPLE_LOCKED | + FLUX_TUPLE_UNCOMMITTED | + FLUX_TUPLE_HAS_OVERFLOW | + FLUX_TUPLE_SPECULATIVE)) && + cas_target_size == ItemIdGetLength(itemid) && + (!TransactionIdIsValid(decided_xmin) || + flux_same_version(old_tuple_hdr, decided_verptr, + decided_xmin)); + if (cas_revalidated && + snapshot != NULL && IsMVCCSnapshot(snapshot)) + { + UndoRecPtr head_verptr; + TransactionId head_xid; + bool head_inprogress = false; + + if (FluxTupleHasCommittedUpdateAfter(relation, + old_tuple_hdr, + ItemIdGetLength(itemid), + snapshot, + GetCurrentTransactionIdIfAny(), + &head_verptr, + &head_xid, + &head_inprogress) && + !FluxEpqReconcileMatches(snapshot, + RelationGetRelid(relation), + otid)) + cas_revalidated = false; + + /* + * Commit-window conflict: the head committer is + * between PRE_COMMIT (marker + UNCOMMITTED + * cleared) and CLOG commit. Its sLog marker is + * gone, so the dirty-xid probe below cannot see + * it. Abandon the CAS fast path and fall to the + * slow path, which waits on the in-flight writer; + * otherwise the just-committed update is lost. + */ + else if (head_inprogress) + cas_revalidated = false; + + /* + * The row's writer per its header, which names it + * whether or not the UNCOMMITTED hint is still + * set (it is cleared at PRE_COMMIT). Only a + * writer invisible to our snapshot can still be + * in progress, which keeps the proc-array probe + * off the common path. + */ + else if (!(old_tuple_hdr->t_flags & FLUX_TUPLE_XMIN_COMMITTED) && + XidInMVCCSnapshot(FluxTupleGetXmin(old_tuple_hdr), + snapshot) && + TransactionIdIsValid(flux_header_dirty_xid(old_tuple_hdr, + NULL))) + cas_revalidated = false; + } + + /* + * Re-check for a concurrent in-progress writer (see + * above) + */ + if (cas_revalidated) + { + bool reval_is_insert = false; + TransactionId reval_dirty_xid = + flux_update_conflict_xid(relation, otid, old_tuple_hdr, + key_changed, &reval_is_insert); + + if (TransactionIdIsValid(reval_dirty_xid) && + !reval_is_insert) + cas_revalidated = false; + } + + if (cas_revalidated) + { + /* + * Fairness gate: funnel the CAS writer through + * LOCKTAG_TUPLE so lockers (flux_tuple_lock) and + * slow-path writers already queued on the same + * tag get FIFO service. Without this, a stream + * of CAS writers can lap a locker sleeping in + * XactLockTableWait under FluxLockTuple, starving + * it past statement_timeout. Must be dontWait: + * the buffer lock is held here, and blocking on a + * heavyweight lock under it could deadlock. If + * not granted, drop the buffer lock and fall + * through to the exclusive path, which takes + * LOCKTAG_TUPLE with wait=true and queues FIFO + * behind the waiter that beat us here. + */ + bool cas_have_tuple_lock = false; + + if (!FluxLockTuple(relation, otid, + update_lockmode, + false, &cas_have_tuple_lock)) + { + /* + * CANDIDATE A (surgical fell-through): a + * locker or updater is queued on + * LOCKTAG_TUPLE for this tid. Drop the page + * lock, then BLOCK on LOCKTAG_TUPLE so this + * writer queues FIFO behind the existing + * waiter. Only after we acquire the tag (the + * locker ahead of us has been served) do we + * re-enter the update path via the slow + * route, which re-reads the page and + * re-classifies. We must release the buffer + * content lock BEFORE the blocking acquire: + * holding it across a heavyweight sleep would + * deadlock. Set the OUTER have_tuple_lock so + * the slow path's dirty_xid-gated LockTuple + * sites (guarded by !have_tuple_lock) do not + * double-acquire and FLUX_RELEASE_TUPLOCK + * frees it on every exit. + */ + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + ReleaseBuffer(buffer); + FluxFreeTuple(cas_new_tuple); + FluxLockTupleToWait(relation, otid, + update_lockmode, &have_tuple_lock); + goto flux_update_slow_path; + } + { + /* + * We own this tuple exclusively under + * share-exclusive page lock. No other writer + * can modify it until we release that. + * + * Compute timestamps outside critical section + * to avoid memory allocation issues. + */ + uint64 cas_current_ts; + uint16 cas_data_offset; + uint16 cas_data_len; + char *cas_old_bytes; + char *cas_new_bytes; + Size cas_tuple_len; + + (void) FluxGetTransactionTimestamp(); + cas_current_ts = (uint64) FluxGetDmlTimestamp(); + + /* Ensure we have an XID for WAL flush */ + (void) GetCurrentTransactionId(); + + /* + * Heap-shaped: the in-place image is a FRESH + * version. Stamp t_xmax = Invalid (this is + * the newest, live version) and t_xmin = the + * updater below. The pre-update image is + * preserved in the UNDO log (the CAS path + * writes an undo record), so an older + * snapshot that cannot see the updater's xmin + * reads the before-image back via + * FluxReconstructVisible- Version. + * + * Torn-read note: the CAS path holds only + * BUFFER_LOCK_SHARE_EXCLUSIVE, so a + * concurrent SHARE reader can observe this + * image mid-overwrite. It is marked + * UNCOMMITTED and its xmin is the still- + * in-progress updater, so + * FluxTupleSatisfiesMVCC hides it + * (XidInMVCCSnapshot(updater) == true) and + * the reader reconstructs the prior version + * -- the row never blinks out. + */ + cas_new_tuple->t_data->t_commit_ts = 0; + cas_new_tuple->t_data->t_flags |= FLUX_TUPLE_UPDATED; + + /* + * Record whether this update changes an + * indexed column, so a concurrent KeyShare + * (FK) probe knows whether it may proceed + * without waiting. See + * FLUX_TUPLE_KEYS_UPDATED in flux.h. + */ + if (key_changed) + cas_new_tuple->t_data->t_flags |= + FLUX_TUPLE_KEYS_UPDATED; + + /* + * Mark the in-place image UNCOMMITTED, + * mirroring the regular update path. A + * second writer that hits this row sees + * UNCOMMITTED in the CAS eligibility gate, + * bails to the regular path, and blocks on + * the in-progress writer via + * XactLockTableWait -- without this flag the + * second CAS would silently overwrite an + * uncommitted update (lost update / no + * write-write blocking). PRE_COMMIT clears + * the flag and stamps the real commit + * timestamp; readers self-heal a stale flag + * via the visibility path after a crash. + */ + cas_new_tuple->t_data->t_flags |= FLUX_TUPLE_UNCOMMITTED; + + /* + * Clear inherited CLOG hint bits: this image + * copied the old tuple's flags, but we are + * stamping a NEW t_xmin (the updater). A + * stale XMIN_COMMITTED from the old inserter + * would wrongly assert the new xmin is + * committed. + */ + cas_new_tuple->t_data->t_flags &= + ~(FLUX_TUPLE_XMIN_COMMITTED | FLUX_TUPLE_XMAX_COMMITTED); + cas_new_tuple->t_data->t_xmin = GetCurrentTransactionId(); /* subxid: heap-shaped, + * so savepoint rollback + * marks it aborted in + * CLOG */ + ItemPointerSet(&cas_new_tuple->t_data->t_ctid, blkno, offnum); + + /* + * t_cmin must agree with the t_xmin stamped + * just above (ours), so use OUR cid, not the + * original inserter's. See the longer note + * on the regular in-place path. + */ + cas_new_tuple->t_data->t_cmin = cid; + + /* + * Build the new-image buffer of + * cas_target_size (== cas_new_size; the + * version pointer is a header field, not a + * trailer). We reserve the per-rel UNDO + * record first so its UndoRecPtr can be + * stamped into the new image BEFORE both the + * diff scan (so the diff includes the + * version-pointer change) and the page + * memcpy. + * + * PrepareUndoInsert may ereport -- neither is + * safe inside the critical section. Lock + * order: data buffer then UNDO buffer. + */ + { + uint32 cas_old_len = ItemIdGetLength(itemid); + char *cas_old_copy = palloc(cas_old_len); + char *cas_full_image; + UndoRecPtr cas_undo_ptr = InvalidUndoRecPtr; + bool cas_undo_prepared = false; + TransactionId cas_freeze_conflict; + + memcpy(cas_old_copy, old_tuple_hdr, cas_old_len); + cas_freeze_conflict = flux_prepare_before_image(relation, (FluxTupleHeader *) cas_old_copy); + + /* + * Assemble the on-page image: header+data + * from the freshly formed new tuple plus + * the trailing version-pointer. Stamp a + * placeholder verptr for now; the real + * reserved cas_undo_ptr is stamped after + * the delta-vs-full decision below. The + * diff region and the delta both force + * t_verptr into their changed span, so + * re-stamping it never moves those + * boundaries. We prepare the UNDO record + * exactly once, choosing delta or full up + * front, so there is no cancel + + * re-prepare (that would double-advance + * the undo log's insert pointer). + */ + cas_full_image = palloc(cas_target_size); + memcpy(cas_full_image, cas_new_tuple->t_data, cas_new_size); + { + FluxTupleHeader *cas_full_hdr = + (FluxTupleHeader *) cas_full_image; + + cas_full_hdr->t_flags |= FLUX_TUPLE_HAS_VERSION_PTR; + FluxTupleSetVersionPtr(cas_full_hdr, + InvalidUndoRecPtr); + } + + /* + * Compute the diff region for WAL + * logging. We log only the bytes that + * actually changed within the unified + * image. Read the old side from + * cas_old_copy (already memcpy'd from the + * page, identical bytes) rather than the + * live locked page: the scan is pure CPU + * work with no I/O, and reading a + * palloc'd copy instead of old_tuple_hdr + * means this loop no longer needs the + * data page to stay locked -- + * cas_target_size == cas_old_len is + * guaranteed by the eligibility gate + * above (same-size CAS only), so + * cas_old_copy has exactly cas_tuple_len + * bytes available. + */ + cas_tuple_len = cas_target_size; + cas_old_bytes = cas_old_copy; + cas_new_bytes = cas_full_image; + + /* Find first differing byte */ + cas_data_offset = 0; + while (cas_data_offset < cas_tuple_len && + cas_old_bytes[cas_data_offset] == cas_new_bytes[cas_data_offset]) + cas_data_offset++; + + if (cas_data_offset < cas_tuple_len) + { + uint16 cas_end = (uint16) cas_tuple_len; + + /* Find last differing byte */ + while (cas_end > cas_data_offset && + cas_old_bytes[cas_end - 1] == cas_new_bytes[cas_end - 1]) + cas_end--; + + cas_data_len = cas_end - cas_data_offset; + } + else + { + /* + * No actual data change -- release + * and return OK. No UNDO record was + * prepared yet (the single prepare + * happens after this branch), so + * nothing to cancel. + */ + pfree(cas_full_image); + pfree(cas_old_copy); + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + ReleaseBuffer(buffer); + FluxFreeTuple(cas_new_tuple); + + /* Set output TID */ + ItemPointerSet(&slot->tts_tid, blkno, offnum); + slot->tts_tableOid = RelationGetRelid(relation); + if (update_indexes) + *update_indexes = TU_None; + flux_count_inplace_update(relation, key_changed); + FluxUnlockTuple(relation, otid, + update_lockmode); + return TM_Ok; + } + + /* + * The real version pointer is stamped + * into t_verptr only below, after this + * scan. The placeholder equals the old + * t_verptr on a row's first in-place + * update (both invalid), so the scan can + * leave t_verptr out of the logged + * region; redo would then drop the + * version-chain head. Always log it. + */ + { + uint16 vp_start = offsetof(FluxTupleHeader, t_verptr); + uint16 vp_end = vp_start + sizeof(UndoRecPtr); + uint16 cas_end = cas_data_offset + cas_data_len; + + cas_data_offset = Min(cas_data_offset, vp_start); + cas_data_len = Max(cas_end, vp_end) - cas_data_offset; + } + + /* SSI conflict check */ + CheckForSerializableConflictIn(relation, otid, + BufferGetBlockNumber(buffer)); + + /* + * Choose delta vs full at emit time and + * prepare the UNDO record exactly once. + * The diff region (cas_data_offset/len) + * covers t_verptr (forced above), and + * FluxUndoDeltaCompute forces it into the + * delta too. If a delta record is + * strictly smaller than the full + * before-image, prepare + * FLUX_UNDO_UPDATE_DELTA; else the full + * FLUX_UNDO_UPDATE with the OLD image. + * Then stamp the returned UndoRecPtr + * (this update version-chain head) into + * the image at offset 8 -- the only + * new-image mutation after the diff scan, + * inside the diff/delta region under any + * verptr. cas_new_bytes aliases + * cas_full_image, so the page memcpy and + * the WAL redo byte-diff pick up the + * final verptr. Preparing once (no + * cancel + re-prepare) advances the undo + * log insert pointer exactly once. + */ + { + ItemPointerData cas_tid; + char *cas_delta_buf = + (char *) palloc(SizeOfFluxUndoDelta + cas_old_len); + Size cas_delta_total = + FluxUndoDeltaCompute(cas_old_bytes, cas_old_len, + cas_new_bytes, (uint32) cas_tuple_len, + offsetof(FluxTupleHeader, t_verptr), + cas_delta_buf, + SizeOfFluxUndoDelta + cas_old_len); + + ItemPointerSet(&cas_tid, blkno, offnum); + + if (cas_delta_total > 0) + cas_undo_ptr = + FluxPbuPrepareInsert(FLUX_UNDO_UPDATE_DELTA, + relation, + &cas_tid, + cas_delta_buf, + (uint32) cas_delta_total, + relation->rd_rel->relpersistence, false, cas_freeze_conflict, + cid); + else + cas_undo_ptr = + FluxPbuPrepareInsert(FLUX_UNDO_UPDATE, + relation, + &cas_tid, + cas_old_copy, cas_old_len, + relation->rd_rel->relpersistence, false, cas_freeze_conflict, + cid); + cas_undo_prepared = true; + pfree(cas_delta_buf); + + FluxTupleSetVersionPtr((FluxTupleHeader *) cas_full_image, cas_undo_ptr); + } + + /* + * FOLD variant: the per-backend UNDO + * record was already PREPARED up front + * (its old-image payload set, its + * UndoRecPtr stamped into the new image). + * Inside the critical section we write it + * into its pinned undo buffers + * (FluxPbuInsertUndo) and then fold those + * undo pages onto the ONE combined WAL + * record (block 0 = data page redo + * byte-diff, block 1+ = undo page + * full-page images), so the undo bytes + * ride to disk on the same record as the + * forward mutation (crash-safe) in one + * XLogInsert. + * + * The tid the record references was set + * at prepare; set the output slot TID + * here too. + */ + ItemPointerSet(&slot->tts_tid, blkno, offnum); + slot->tts_tableOid = RelationGetRelid(relation); + + /* Critical section: modify page + WAL */ + START_CRIT_SECTION(); + + memcpy(old_tuple_hdr, cas_full_image, cas_target_size); + + /* + * Update page-level commit timestamp + * atomically + */ + { + FluxPageOpaque cas_opaque = FluxPageGetOpaque(page); + uint64 cas_old_ts_flags; + uint64 cas_new_ts_flags; + uint64 cur_ts; + + do + { + cas_old_ts_flags = cas_opaque->pd_commit_ts_and_flags; + cur_ts = cas_old_ts_flags & FLUX_PAGE_TS_MASK; + + if (cas_current_ts <= cur_ts) + break; + cas_new_ts_flags = (cas_old_ts_flags & FLUX_PAGE_FLAG_MASK) | + (cas_current_ts & FLUX_PAGE_TS_MASK); + } while (!pg_atomic_compare_exchange_u64( + (pg_atomic_uint64 *) &cas_opaque->pd_commit_ts_and_flags, + &cas_old_ts_flags, cas_new_ts_flags)); + } + + MarkBufferDirtyShared(buffer); + + /* + * Write the prepared per-backend UNDO + * record into its pinned undo buffers + * before the combined WAL record folds + * them in. + */ + if (cas_undo_prepared) + FluxPbuInsertUndo(); + + /* + * WAL log. When a per-backend UNDO + * record was prepared, emit the combined + * fold record carrying both the main-fork + * redo diff (block 0) and the undo page + * full-page images (block 1+), then stamp + * the record LSN onto the undo pages. + * Otherwise (unlogged/temp: no WAL) emit + * the plain redo record. + */ + if (cas_undo_prepared && RelationNeedsWAL(relation)) + { + XLogRecPtr cas_recptr; + + cas_recptr = + FluxXLogCasUpdateUndo(relation, buffer, offnum, + cas_data_offset, cas_data_len, + cas_new_bytes + cas_data_offset, + 0); /* new version: t_xmax = + * Invalid */ + FluxPbuFinishUndo(cas_recptr); + } + else if (RelationNeedsWAL(relation)) + { + FluxXLogCasUpdate(relation, buffer, offnum, + cas_data_offset, cas_data_len, + cas_new_bytes + cas_data_offset, + 0); /* new version: t_xmax = + * Invalid */ + } + else if (cas_undo_prepared) + { + /* Unlogged/temp: no WAL LSN to stamp. */ + FluxPbuFinishUndo(InvalidXLogRecPtr); + } + + END_CRIT_SECTION(); + + /* + * The per-backend UNDO record was written + * and folded into the combined WAL record + * above; FluxPbuFinishUndo already + * released its undo buffers, and + * InsertPreparedUndo chained it into the + * xact for rollback discovery. Nothing + * further to do here. + */ + + /* + * sLog registration BEFORE buffer + * release. Eliminates the race window + * where another backend reads the + * modified tuple but finds no sLog entry + * (causing visibility failures at high + * concurrency). Safe: seqlock reads are + * wait-free, no deadlock with buffer + * lock. + */ + FluxEnsureSLogCallbacks(); + + /* + * BUG 5 fix: bounded local-only tracking + * (see flux_tuple_delete). + */ + + /* + * BUG 5: no per-tuple tracking for UPDATE + * (see flux_tuple_delete). + */ + + /* + * Record our own update's command id + * (cmax). + */ + FluxLocalCidSet(RelationGetRelid(relation), + &slot->tts_tid, cid); + + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + + /* + * No index maintenance for the CAS + * in-place path. A CAS update is + * same-size and only reaches here when no + * indexed column changed (key changes are + * routed out of place at the top of + * flux_tuple_update, TU_All). The TID is + * unchanged, so every (key, TID) entry + * still points at the live tuple; + * *update_indexes is set TU_None below. + */ + + pfree(cas_old_copy); + pfree(cas_full_image); + + + ReleaseBuffer(buffer); + + /* Track in-place update */ + flux_stat_in_place_updates++; + + FluxLocalDirtyPageMark(relation, blkno); + + FluxFreeTuple(cas_new_tuple); + + /* + * The per-backend engine's discard worker + * reclaims stale undo; there is nothing + * to discard here. + */ + flux_count_inplace_update(relation, key_changed); + FluxUnlockTuple(relation, otid, + update_lockmode); + return TM_Ok; + } /* end block (tuple-lock granted) */ + } /* end else (revalidated) */ + } + } + } + /* Not eligible for CAS fast path */ + } + /* ItemId not normal or new tuple too large */ + } + /* Offset out of range */ + + /* Release shared lock, fall through to exclusive path */ + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + ReleaseBuffer(buffer); + FluxFreeTuple(cas_new_tuple); + } + + /* + * Lock the buffer exclusively. The exclusive buffer lock prevents + * concurrent modification while we hold it, but it is dropped while we + * XactLockTableWait on a conflicting in-progress writer (the wait sites + * below). To stop two backends that each see the other's in-progress + * write from mutually waiting and deadlocking, an updater first takes a + * heavyweight tuple lock (have_tuple_lock) that serializes them into a + * FIFO queue, held continuously through the recheck/update and released + * on every exit via FLUX_RELEASE_TUPLOCK(). This matches heap_update. + * + * CANDIDATE A: a CAS writer that found a locker queued on the tag jumps + * here (flux_update_slow_path) already holding have_tuple_lock, so do NOT + * reset it -- clobbering it would drop the lock we just queued for. The + * buffer was released before the goto, so the re-read below re-pins it; + * the label must precede ReadBuffer. + */ +flux_update_slow_path: + /* Read the page containing the old tuple */ + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + /* + * Helping: revert an aborted transaction's not-yet-reverted versions of + * this row before working on it (see FluxRevertAbortedVersions). + */ + if (flux_help_revert(relation, buffer, offnum)) + goto flux_update_slow_path; + + /* Validate offset number */ + if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + + /* Get the old tuple */ + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + + old_tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Our own transaction already modified this row in this or a later + * command (typically a BEFORE trigger, or a volatile function, that + * updated the very row we are updating): report TM_SelfModified. The + * executor either ignores the duplicate action (same command id) or + * raises "tuple to be updated was already modified by an operation + * triggered by the current command". Silently applying our second write + * would discard the trigger's change. + */ + { + CommandId self_cmax; + + if (FluxCheckSelfModified(relation, otid, old_tuple_hdr, cid, + &self_cmax)) + { + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = (old_tuple_hdr->t_flags & FLUX_TUPLE_DELETED) ? + FluxTupleGetXmax(old_tuple_hdr) : old_tuple_hdr->t_xmin; + tmfd->cmax = self_cmax; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_SelfModified; + } + } + + /* Check if old tuple has overflow chains to clean up later */ + old_has_overflow = (old_tuple_hdr->t_flags & FLUX_TUPLE_HAS_OVERFLOW) != 0; + + /* + * Check if tuple is already deleted. As in the delete path, a DELETED + * flag may reflect an in-progress delete by a concurrent transaction + * (which also sets UNCOMMITTED). If so, wait for that transaction and + * retry rather than reporting TM_Deleted immediately -- this matches + * heap, where an UPDATE blocks behind a concurrent in-progress DELETE. + */ + if (old_tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + TransactionId del_xid = InvalidTransactionId; + bool del_is_insert = false; + + if (old_tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + { + /* + * The concurrent deleter's XID is on the page (t_xmax). Read it + * from the header: per-page conflict tracking no longer writes a + * shared per-tuple sLog DELETE entry, and the header stays + * authoritative through the deleter's PRE_COMMIT window. An + * UPDATE must block behind a concurrent in-progress DELETE of the + * same row (matches heap), which requires seeing the deleter + * here. + */ + del_xid = flux_header_dirty_xid(old_tuple_hdr, &del_is_insert); + } + + if (wait && TransactionIdIsValid(del_xid) && + !TransactionIdIsCurrentTransactionId(del_xid) && + !del_is_insert) + { + TransactionId wait_xid = del_xid; + + /* + * Acquire a heavyweight tuple lock before sleeping on the + * conflicting xid. This serializes competing updaters of the + * same tuple into a FIFO queue; without it, two backends that + * each see the other's in-progress write would mutually + * XactLockTableWait and deadlock. Matches heap_update. + */ + UnlockReleaseBuffer(buffer); + FluxLockTupleToWait(relation, otid, + update_lockmode, &have_tuple_lock); + XactLockTableWait(wait_xid, relation, otid, XLTW_Update); + + /* + * Start over: a committed delete is reported then, an aborted one + * has had its before-image restored, and the row may have another + * deleter by now. + */ + goto flux_update_slow_path; + } + else + { + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = GetCurrentTransactionId(); + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Deleted; + } + } + + /* + * Handle LOCKED flag: if this tuple is locked by the current transaction + * (FOR SHARE/FOR KEY SHARE/FOR UPDATE), the lock is compatible with + * UPDATE (self-lock). Clear the LOCKED flag since we're about to modify + * the tuple. The row lock itself stays in the tuple sLog until commit. + */ + if (old_tuple_hdr->t_flags & FLUX_TUPLE_LOCKED) + { + LockTupleMode own_mode; + + if (SLogTupleLockHeldByMe(RelationGetRelid(relation), + relation->rd_locator.relNumber, otid, + &own_mode)) + { + /* Our own lock - clear flag and proceed with update */ + old_tuple_hdr->t_flags &= ~FLUX_TUPLE_LOCKED; + + /* + * Only an UPDATE-strength self-lock earns the conflict-gate + * exemption below. The arbiter/EPQ re-entries this exists for + * take LockTupleExclusive / LockTupleNoKeyExclusive and have + * genuinely re-validated the row under that lock. A KeyShare or + * Share marker has not: our own FK check on this row + * (ri_LockPKTuple) leaves a KeyShare marker behind, and treating + * that as "already validated" disabled the committed-update and + * committed-after-snapshot gates for the rest of the statement -- + * so an UPDATE in a transaction that had FK-checked the same row + * silently overwrote a concurrent committed update ('' -> 'A' -> + * 'B' yielded 'B', not 'AB'). The weaker marker still clears + * FLUX_TUPLE_LOCKED above (it is ours, so it must not block us); + * it just does not suppress the lost-update gates. + */ + if (own_mode != LockTupleNoKeyExclusive && + own_mode != LockTupleExclusive) + goto self_lock_done; + + /* + * LOST-UPDATE / ON CONFLICT fix (bug 4): we already hold our own + * LOCK marker on this exact tuple. The ON CONFLICT DO UPDATE + * arbiter took that lock (ExecOnConflictLockRow -> + * table_tuple_lock), re-checked the row under it, and only then + * routed here to apply the update. Heap's own-lock short-circuit + * (heap_update: if we hold the lock, the committed-after-snapshot + * xmax is not a conflict) then lets the update proceed. FLUX + * must do the same: the committed-update / + * committed-after-snapshot gates below would otherwise return + * TM_Updated, and on the ON CONFLICT path the executor has no + * ri_oldTupleSlot to EPQ-refetch into -> + * flux_tuple_fetch_row_version dereferences a NULL slot and + * crashes (and even absent the crash it loops forever bouncing to + * EPQ). Record locked_by_me so those gates are exempted; the + * invariant that makes this safe is bug 5's: the LOCK marker is + * registered under the buffer lock, so "we hold the lock" is + * authoritative here under this same buffer lock. + */ + locked_by_me = true; + } + +self_lock_done: + + /* + * If it's another transaction's lock, the conflict probe below + * (flux_update_conflict_xid) waits for it. + */ + ; + } + + /* + * Check tuple visibility against snapshot and handle concurrent + * modifications. Unlike a simple scan visibility check, UPDATE must + * distinguish between: - Truly invisible (another txn's uncommitted + * insert) -> TM_Invisible - Concurrent update committed after our + * snapshot -> TM_Updated - In-progress modification by another txn -> + * wait, retry + */ + if (snapshot) + { + bool visible; + + visible = FluxTupleVisibleToSnapshotDual(old_tuple_hdr, snapshot, + RelationGetRelid(relation), + buffer, otid); + + if (!visible) + { + TransactionId dirty_xid; + bool is_insert_entry; + + /* + * The in-progress writer (header) or conflicting locker (sLog) to + * wait for. A KeyShare locker is compatible only when no key + * changes. For a non-key update, waiting on its xid while it is + * queued behind us would form the mutual wait cycle heap avoids + * via HEAP_XMAX_IS_LOCKED_ONLY. + */ + dirty_xid = flux_update_conflict_xid(relation, otid, + old_tuple_hdr, key_changed, + &is_insert_entry); + + /* Check if tuple was deleted by another transaction */ + if (old_tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = TransactionIdIsValid(dirty_xid) ? + dirty_xid : GetCurrentTransactionId(); + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Deleted; + } + + /* + * Buffer lock was never released (wait-free sLog read), so the + * tuple cannot have changed. Proceed with dirty_xid. + */ + { + if (TransactionIdIsValid(dirty_xid) && is_insert_entry) + { + /* + * Another txn's in-progress INSERT. The tuple truly + * doesn't exist in our snapshot. + */ + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = dirty_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + + if (TransactionIdIsValid(dirty_xid) && !is_insert_entry && + !TransactionIdIsCurrentTransactionId(dirty_xid)) + { + /* + * Another txn's in-progress UPDATE/DELETE. Wait for it + * to finish and then retry (the tuple may be gone or + * changed). + * + * The !TransactionIdIsCurrentTransactionId guard covers a + * row already modified in place by an earlier command of + * OUR OWN transaction (possibly at a shallower + * subtransaction level): its conflict xid resolves to us, + * and waiting on ourselves trips the self-wait assertion + * in XactLockTableWait and would self-deadlock. Our own + * prior write is not a conflict; fall through to perform + * the update. + */ + if (wait) + { + TransactionId wait_xid = dirty_xid; + + /* + * Serialize competing updaters via a heavyweight + * tuple lock before sleeping on the conflicting xid, + * so two backends racing on the same tuple queue + * instead of deadlocking. Matches heap_update. + */ + UnlockReleaseBuffer(buffer); + FluxLockTupleToWait(relation, otid, + update_lockmode, &have_tuple_lock); + XactLockTableWait(wait_xid, relation, + otid, XLTW_Update); + + /* Re-read the page and re-check after waking */ + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + if (offnum < FirstOffsetNumber || + offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + old_tuple_hdr = (FluxTupleHeader *) + PageGetItem(page, itemid); + + /* If it got deleted while we waited, report that */ + if (old_tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + TM_Result res = flux_deleted_after_wait(old_tuple_hdr); + + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return res; + } + + /* + * Re-check visibility. The tuple was modified by the + * now-committed txn; its commit_ts is now later than + * our snapshot -> TM_Updated so the executor can EPQ. + */ + visible = FluxTupleVisibleToSnapshotDual(old_tuple_hdr, snapshot, + RelationGetRelid(relation), + buffer, otid); + + if (!visible) + { + /* + * Still not visible after the waited-on txn + * committed. Before returning TM_Updated (which + * triggers another EPQ cycle), check whether we + * already hold a LOCK entry from a previous EPQ + * iteration. If so, we've already re-evaluated + * the quals and should proceed with the update + * instead of looping forever. + * + * Without this check, the following livelock + * occurs with hot-row contention: + * + * 1. We return TM_Updated -> executor EPQ 2. + * table_tuple_lock takes a row lock 3. Retry -> + * another txn is in-progress -> wait 4. Waited + * txn commits -> still not visible 5. Return + * TM_Updated -> goto 2 (infinite) + * + * Each iteration leaks per-query memory in the + * executor, eventually causing OOM. + */ + TransactionId myxid_postw = + GetCurrentTransactionIdIfAny(); + + if (TransactionIdIsValid(myxid_postw)) + { + if (SLogTupleLockHeldByMe(RelationGetRelid(relation), + relation->rd_locator.relNumber, otid, NULL)) + { + /* + * Our LOCK entry from a prior EPQ cycle + * exists. Fall through to perform the + * update. + */ + } + else + { + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + } + else + { + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + } + /* Now visible; fall through to perform the update */ + } + else + { + /* NOWAIT mode */ + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = dirty_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_WouldBlock; + } + } + else + { + /* + * No in-progress writer or locker in another transaction: + * the modification has already committed. + * + * Bounce to EvalPlanQual at most ONCE per (tid, cid). The + * previous termination check looked for our own per-tuple + * sLog entry, but per-page conflict tracking does not + * create one for UPDATE/DELETE, so it never terminated -> + * unbounded EPQ retry leaking into TopTransactionContext + * (the c>=16 PAYMENT OOM). Use the reconcile marker + * instead: first encounter marks+bounces, a repeat for + * the same tuple this command proceeds. + * + * locked_by_me short-circuit (bug 4): if we already hold + * our own LOCK marker on this tuple (the ON CONFLICT + * arbiter path), a commit that landed after our snapshot + * is NOT a conflict -- we re-evaluated under the lock + * already, exactly as heap's own-lock short-circuit. Fall + * through and apply the update; returning TM_Updated here + * would EPQ-bounce into a NULL ri_oldTupleSlot on the ON + * CONFLICT path and crash. + */ + if (locked_by_me) + { + /* We hold the lock: proceed with the update. */ + } + else if (FluxEpqReconcileMatches(snapshot, + RelationGetRelid(relation), + otid)) + { + /* Already bounced to EPQ this command: proceed. */ + } + else + { + FluxEpqReconcileMark(snapshot, + RelationGetRelid(relation), + otid, InvalidUndoRecPtr, + GetCurrentTransactionIdIfAny()); + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + } + } + /* If now visible, fall through to perform the update */ + } + + /* + * Write-write conflict against an ALREADY-COMMITTED concurrent + * update. + * + * The visibility check above always returns "visible" for a committed + * in-place update, because at commit time t_commit_ts is rewound to + * the original insert timestamp (so mid-life readers still see the + * row). That rewind erases the "updated since you read it" signal + * heap keeps in xmax, so visibility alone can never detect a lost + * update. Instead, the on-page tuple's verptr points at the UNDO + * record produced by the last committed update; if that committer is + * invisible to our snapshot, taking the in-place UPDATE now would + * silently lose it. Return TM_Updated so the executor re-evaluates + * via EvalPlanQual, matching heap. + * + * Converge like heap: bounce to EPQ once per DISTINCT committed + * update, not once per probe. FluxEpqReconcileMatches suppresses + * re-firing on the identical (verptr, xid) marker we already bounced + * on this statement; FluxEpqReconcileMark records the marker before + * we return. A strictly-newer committer stamps a fresh verptr, so + * identity dedup only suppresses the same marker and never masks a + * genuine new conflict. + */ + if (!locked_by_me && IsMVCCSnapshot(snapshot)) + { + UndoRecPtr head_verptr; + TransactionId head_xid; + bool head_inprogress = false; + + if (FluxTupleHasCommittedUpdateAfter(relation, + old_tuple_hdr, + ItemIdGetLength(itemid), + snapshot, + GetCurrentTransactionIdIfAny(), + &head_verptr, + &head_xid, + &head_inprogress) && + !FluxEpqReconcileMatches(snapshot, + RelationGetRelid(relation), + otid)) + { + FluxEpqReconcileMark(snapshot, + RelationGetRelid(relation), otid, + head_verptr, head_xid); + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + else if (head_inprogress && + !TransactionIdIsCurrentTransactionId(head_xid)) + { + /* + * Commit-window conflict (see + * FluxTupleHasCommittedUpdateAfter): the head committer + * cleared its sLog marker + UNCOMMITTED flag at PRE_COMMIT + * but has not yet reached CLOG. Neither this gate nor the + * sLog dirty-xid probe sees it, so we would clobber a + * just-committed update. Wait on the in-flight writer, then + * retry from the top of the slow path where CLOG now resolves + * it to a committed conflict -> TM_Updated -> EPQ. + * Heap-identical. + */ + if (wait) + { + TransactionId wait_xid = head_xid; + + /* + * Wait for the in-flight committer to reach CLOG or + * abort, then report TM_Updated so the executor + * re-fetches through EvalPlanQual. EPQ's SnapshotAny + * fetch reconstructs the correct visible version (the + * committed value if head_xid committed, or the restored + * before-image if it aborted) and re-projects the update + * on top. A blind update retry here would instead apply + * over the raw on-page bytes, which on abort are the + * not-yet-reverted value. + */ + UnlockReleaseBuffer(buffer); + FluxLockTupleToWait(relation, otid, + update_lockmode, &have_tuple_lock); + XactLockTableWait(wait_xid, relation, otid, XLTW_Update); + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + } + } + } + + /* + * Even when visibility returned "true", the tuple may have an in-progress + * modification by another transaction. This happens when + * FluxTupleVisibleToSnapshotDual returns true for in-progress + * UPDATE/DELETE entries (to preserve tuple existence in scans). We must + * still detect the write-write conflict and block. + * + * The in-progress writer is detected from the writer xids in the header + * and TransactionIdIsInProgress, and a conflicting row lock from the + * tuple sLog, NOT via the on-page FLUX_TUPLE_UNCOMMITTED flag, which is a + * hint cleared at PRE_COMMIT, before the writer's commit is in CLOG. + * Gating this wait on the flag (as we once did) let such a writer slip + * past, silently clobbering its in-progress update. Probe + * unconditionally; only use the flag to opportunistically clear stale + * state when no writer is present. + */ + { + TransactionId dirty_xid; + bool is_insert_entry; + + /* + * An in-progress writer (from the header) always conflicts, and a row + * lock conflicts iff its recorded LockTupleMode does. A KeyShare FK + * locker conflicts only with a key change. A pure writer-only probe + * would sail past a FOR UPDATE locker, which writes no xid in the + * header, and clobber the row it protects. We then acquire the + * heavyweight LOCKTAG_TUPLE lock and XactLockTableWait on the + * conflicting xid, which queues us behind other waiters rather than + * deadlocking. + */ + dirty_xid = flux_update_conflict_xid(relation, otid, old_tuple_hdr, + key_changed, &is_insert_entry); + + if (!TransactionIdIsValid(dirty_xid)) + { + /* + * No active writer. If the page still carries a stale + * UNCOMMITTED flag, clear it opportunistically. + */ + if (old_tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + { + old_tuple_hdr->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; + MarkBufferDirty(buffer); + } + } + else if (TransactionIdIsValid(dirty_xid) && + !TransactionIdIsCurrentTransactionId(dirty_xid) && + !is_insert_entry) + { + /* + * Another transaction has an in-progress UPDATE/DELETE. Block + * until it finishes, then re-check. + */ + if (wait) + { + TransactionId wait_xid = dirty_xid; + + /* + * Serialize competing updaters via a heavyweight tuple lock + * before sleeping on the conflicting xid, so two backends + * racing on the same tuple queue instead of deadlocking. + * Matches heap_update. + */ + UnlockReleaseBuffer(buffer); + FluxLockTupleToWait(relation, otid, + update_lockmode, &have_tuple_lock); + XactLockTableWait(wait_xid, relation, otid, XLTW_Update); + + /* Re-read page after waking */ + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + if (offnum < FirstOffsetNumber || + offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + old_tuple_hdr = (FluxTupleHeader *) + PageGetItem(page, itemid); + + /* If deleted while we waited, report that */ + if (old_tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + TM_Result res = flux_deleted_after_wait(old_tuple_hdr); + + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return res; + } + + /* + * The waited-on txn finished (committed or aborted). Return + * TM_Updated to force the executor to re-evaluate via EPQ + * against the now-stable page. + * + * This is required even on ABORT, and is where FLUX differs + * from heap. FLUX updates in place, so a READ COMMITTED scan + * that ran while the other writer was in progress dirty-read + * that writer's uncommitted value as the base for the new + * tuple's expression (e.g. counter = counter + 10 computed + * off the in-flight value). Heap never sees this because its + * scan reads the prior committed version. By the time we get + * here: - COMMIT: the page holds the other writer's committed + * value; EPQ recomputes on top of it. - ABORT: the + * per-backend UNDO reverse-apply has already restored the + * pre-update image inline (before the conflicting XID left + * the proc array), so the page holds the original committed + * value; EPQ recomputes on top of that. Either way EPQ + * re-reads the correct base and recomputes, matching heap's + * final result. + */ + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + else + { + /* NOWAIT mode */ + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = dirty_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_WouldBlock; + } + } + /* If dirty_xid is our own or invalid, proceed with update */ + } + + /* + * Final committed-update gate (authoritative). + * + * STAGE 2 above runs the committed-update detector once, before the + * in-progress-writer wait. That check races: while the head writer W is + * still in progress, the detector declines (W's undo record is not yet + * committed), so control falls through to the writer-wait stage. The + * buffer exclusive lock does NOT block W's commit, and because FLUX + * updates in place, the on-page value is already W's. If W commits in + * the window between STAGE 2's read and the writer-wait probe, the wait + * stage sees NO in-progress writer and would fall through to apply our + * update over W's now-committed value -- silently losing W's update. + * + * Re-run the detector here, after the wait stage has resolved, as the + * last action before the update proper. Every path that reaches the + * update passes through this point with the buffer exclusively locked, so + * a committer that landed anywhere upstream is caught. Reconcile dedup + * makes this converge exactly like STAGE 2: we bounce to EvalPlanQual + * once per distinct committed marker; a repeat of the identical (verptr, + * xid) marker we already reconciled falls through and applies. + * + * locked_by_me short-circuit (bug 4): skip this gate entirely when we + * already hold our own LOCK marker on the tuple. The ON CONFLICT arbiter + * locked and re-evaluated the row; heap's own-lock short-circuit then + * applies the update without an EPQ bounce. Returning TM_Updated here on + * that path would EPQ-refetch into a NULL ri_oldTupleSlot and crash. + */ + if (!locked_by_me && snapshot && IsMVCCSnapshot(snapshot)) + { + UndoRecPtr head_verptr; + TransactionId head_xid; + bool head_inprogress = false; + + if (FluxTupleHasCommittedUpdateAfter(relation, + old_tuple_hdr, + ItemIdGetLength(itemid), + snapshot, + GetCurrentTransactionIdIfAny(), + &head_verptr, + &head_xid, + &head_inprogress)) + { + if (!FluxEpqReconcileMatches(snapshot, + RelationGetRelid(relation), + otid)) + { + FluxEpqReconcileMark(snapshot, + RelationGetRelid(relation), otid, + head_verptr, head_xid); + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + } + else if (head_inprogress) + { + /* + * Commit-window conflict (see FluxTupleHasCommittedUpdateAfter): + * the head committer cleared its sLog marker + UNCOMMITTED flag + * at PRE_COMMIT but has not yet reached CLOG. Neither this gate + * nor the sLog dirty-xid probe sees it, so we would clobber a + * just-committed update. Wait on the in-flight writer, then + * retry from the top of the slow path where CLOG now resolves it + * to a committed conflict -> TM_Updated -> EPQ. Heap-identical. + */ + if (wait) + { + TransactionId wait_xid = head_xid; + + /* + * Wait for the in-flight committer to reach CLOG or abort, + * then report TM_Updated so the executor re-fetches through + * EvalPlanQual. EPQ's SnapshotAny fetch reconstructs the + * correct visible version (the committed value if head_xid + * committed, or the restored before-image if it aborted) and + * re-projects the update on top. A blind update retry here + * would instead apply over the raw on-page bytes, which on + * abort are the not-yet-reverted value. + */ + UnlockReleaseBuffer(buffer); + FluxLockTupleToWait(relation, otid, + update_lockmode, &have_tuple_lock); + XactLockTableWait(wait_xid, relation, otid, XLTW_Update); + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + } + } + + /* Transaction-snapshot RI action: see flux_tuple_delete_internal. */ + if (crosscheck != InvalidSnapshot && + !FluxTupleVisibleToSnapshotDual(old_tuple_hdr, crosscheck, + RelationGetRelid(relation), buffer, otid)) + { + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = FluxTupleGetXmin(old_tuple_hdr); + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + + /* + * Every gate has passed, and the buffer stays locked until the row is + * written. The caller decided whether an indexed column changes, and + * which index entries change, from the version it read before any wait. A + * writer we waited for may have rolled back since, restoring the version + * before its own, or have committed: the row is then not the one the + * decision describes, and keeping it would overwrite the row with no + * index work or the wrong one. Ask the caller to decide again, and hand + * it the tuple lock: as heap_update keeps it across its goto l2, the + * UPDATE keeps its place ahead of the transactions queued behind it, or a + * DELETE queued behind it would delete the row first. + */ + if (TransactionIdIsValid(decided_xmin) && + !flux_same_version(old_tuple_hdr, decided_verptr, decided_xmin)) + { + UnlockReleaseBuffer(buffer); + if (have_tuple_lock && !*kept_tuplock) + { + *kept_tuplock = true; + *kept_mode = update_lockmode; + have_tuple_lock = false; + } + FLUX_RELEASE_TUPLOCK(); + *redecide = true; + return TM_Ok; + } + + /* + * Get transaction timestamp BEFORE critical section (initializes the + * per-transaction MVCC state FluxGetDmlTimestamp relies on). Commit + * visibility comes from CLOG (heap-shaped xmin/xmax MVCC). + */ + (void) FluxGetTransactionTimestamp(); + current_ts = (uint64) FluxGetDmlTimestamp(); + + /* + * Ensure the current transaction has an XID assigned BEFORE entering the + * critical section. GetCurrentTransactionId() may call + * XactLockTableInsert() which acquires a lock and allocates memory -- + * both forbidden in a critical section. + * + * Without an assigned XID, RecordTransactionCommit() considers the + * transaction read-only and skips the WAL flush, even though we write WAL + * records for the data change. This would cause the update to be lost on + * crash recovery. + */ + (void) GetCurrentTransactionId(); + + /* + * Form the new tuple from the slot. + * + * Fast path: If the old tuple is small (no overflow potential), keep the + * buffer locked and form the tuple without overflow handling. This + * avoids the expensive unlock/relock cycle and the re-validation that + * follows. + * + * Slow path: For large tuples or those with existing overflow data, + * release the buffer lock first. FluxFormTuple may call + * FluxStoreOverflowColumn which acquires buffer locks on overflow pages. + * If overflow data lands on the same page we're updating, that would + * cause a buffer lock re-entry assertion failure. + */ + update_overflow_buffers.count = 0; + { + bool buffer_unlocked = false; + + /* + * This in-place UPDATE path runs only when no indexed column changed + * (a key-changing UPDATE is routed to the out-of-place path), so the + * varlena columns are typically unchanged. FLUX has no on-page + * overflow: TOAST any oversized varlena into the relation's standard + * heap TOAST table before forming, exactly like the insert path, then + * form the tuple from the (possibly externalized) datums. + */ + slot_getallattrs(slot); + { + int upd_natts = RelationGetDescr(relation)->natts; + + /* + * Like heap, hand the toaster the old version: an unchanged + * external value is reused, and a superseded one is deleted by + * flux_toast_cleanup() once the new version is in place. That + * delete is an ordinary MVCC delete in the TOAST table, so an + * abort keeps the value for the restored before-image, and VACUUM + * reclaims it only once no snapshot that could read the + * before-image remains. Deform a private copy: the page image is + * overwritten before the cleanup runs. + */ + if (old_tuple_hdr->t_infomask & FLUX_INFOMASK_HASEXTERNAL) + { + FluxTupleData old_tup; + + old_tup.t_len = ItemIdGetLength(itemid); + upd_old_copy = palloc(old_tup.t_len); + memcpy(upd_old_copy, old_tuple_hdr, old_tup.t_len); + old_tup.t_data = upd_old_copy; + ItemPointerSet(&old_tup.t_self, blkno, offnum); + FluxDeformTuple(relation, &old_tup, RelationGetDescr(relation), + upd_old_values, upd_old_isnull); + } + + memcpy(upd_toast_values, slot->tts_values, upd_natts * sizeof(Datum)); + memcpy(upd_toast_isnull, slot->tts_isnull, upd_natts * sizeof(bool)); + + /* + * Without logical decoding of the UPDATE, as for heap, none of + * its TOAST chunks either. + */ + flux_toast_tuple(relation, upd_toast_values, upd_toast_isnull, + upd_old_copy ? upd_old_values : NULL, + upd_old_copy ? upd_old_isnull : NULL, + &upd_ttc, upd_toast_attr, + &upd_toasted, + walLogical ? 0 : HEAP_INSERT_NO_LOGICAL); + + new_tuple = FluxFormTuple(RelationGetDescr(relation), + upd_toast_values, + upd_toast_isnull, + NULL, /* FLUX has no on-page overflow */ + NULL); + } + + /* Set MVCC fields for new tuple (heap-shaped: fresh version) */ + new_tuple->t_data->t_commit_ts = 0; /* t_xmax = InvalidTransactionId */ + + /* + * The new on-page image is the NEWEST version; stamp its inserter + * (t_xmin) with our XID. Older snapshots that cannot see this XID + * read the pre-update image back from the UNDO log via t_verptr. + */ + new_tuple->t_data->t_flags |= FLUX_TUPLE_UNCOMMITTED; + new_tuple->t_data->t_xmin = GetCurrentTransactionId(); /* subxid: heap-shaped, + * so savepoint rollback + * marks it aborted in + * CLOG */ + new_tuple_size = new_tuple->t_len; + + /* + * The version-chain head lives in the fixed header field t_verptr, so + * new_tuple already carries it inside its header with no trailing + * growth. The real urec_ptr is stamped in below after the + * per-backend UNDO insert returns; until then t_verptr holds + * InvalidUndoRecPtr (consistent with the unlogged/temp path that + * never reserves). + */ + new_tuple->t_data->t_flags |= FLUX_TUPLE_HAS_VERSION_PTR; + FluxTupleSetVersionPtr(new_tuple->t_data, + InvalidUndoRecPtr); + + /* + * Pre-compute the oldest active timestamp before (re-)acquiring the + * buffer lock. This avoids an O(MaxBackends) shared-memory scan + * while holding a page-level exclusive lock. The value is used by + * the defrag estimation/execution path below. + */ + defrag_oldest_ts = FluxGetOldestActiveTimestamp(); + defrag_oldest_xmin = FluxGetOldestXminHorizon(relation); + + if (buffer_unlocked) + { + /* + * Re-acquire the buffer lock for the in-place update decision. + * Check if the main buffer is already locked as part of the + * overflow buffers to avoid double-lock assertion failure. + */ + bool buffer_already_locked = false; + + for (upd_i = 0; upd_i < update_overflow_buffers.count; upd_i++) + { + if (update_overflow_buffers.buffers[upd_i].buffer == buffer) + { + buffer_already_locked = true; + break; + } + } + + if (!buffer_already_locked) + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + + page = BufferGetPage(buffer); + + /* + * Re-validate the tuple after re-locking. Another backend may + * have reorganized the page while we didn't hold the lock. + */ + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + FluxFreeTuple(new_tuple); + flux_toast_abandon(&upd_ttc); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + old_tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + } + } + + /* + * Determine if we can do an in-place update. + * + * In-place update is FLUX's primary advantage over heap: it avoids + * creating dead tuple versions and the associated index maintenance. We + * try several strategies in order of increasing cost: + * + * 1. Direct fit: new tuple fits within the old tuple's slot. 2. Page + * space fit: new tuple is larger but the extra bytes fit in the page's + * available free space. 3. Defrag fit: page defragmentation frees enough + * space for the new tuple to fit in-place. + * + */ +retry_fit: + if (new_tuple_size <= ItemIdGetLength(itemid)) + { + /* Strategy 1: new tuple fits within old tuple's slot */ + } + else if (new_tuple_size <= ItemIdGetLength(itemid) + PageGetFreeSpace(page)) + { + /* + * Strategy 2: new tuple is larger but the difference fits in the + * page's free space. We need to relocate the tuple data within the + * page, which PageRepairFragmentation can handle. + */ + } + else + { + /* + * Strategy 3: try page defragmentation to reclaim dead tuple space. + * If the page has the defrag-needed flag and defragmentation would + * free enough space, do it now. + */ + FluxPageOpaque upd_opaque = FluxPageGetOpaque(page); + Size potential_free; + + (void) upd_opaque; /* flag is only an optimization hint; see + * below */ + + { + /* + * Strategy 3: reclaim space occupied by dead-to-all superseded + * versions via defragmentation. + * + * We DO NOT gate this on FLUX_PAGE_DEFRAG_NEEDED. That flag is + * an optimization hint set by the pruning paths, but in-place + * UPDATEs accumulate dead-to-all versions on a hot page (e.g. + * TPC-C district) without reliably setting it. Trusting the flag + * as authoritative made a 2-byte tuple growth spuriously fail + * with "does not fit" on a page that was actually full of + * reclaimable dead versions (measured: ~34k such aborts/run, + * contigfree=0, maxoff 47-69 on a 500-row table). Always scan; + * only error if a full defrag genuinely cannot free enough space. + */ + /* + * Estimate how much space defragmentation could free by scanning + * for dead tuples. This is a quick scan without actually + * defragmenting yet. + */ + potential_free = PageGetFreeSpace(page); + { + OffsetNumber df_off; + OffsetNumber df_maxoff = PageGetMaxOffsetNumber(page); + + for (df_off = FirstOffsetNumber; df_off <= df_maxoff; df_off++) + { + ItemId df_itemid = PageGetItemId(page, df_off); + FluxTupleHeader *df_hdr; + + if (!ItemIdIsNormal(df_itemid)) + { + if (ItemIdIsDead(df_itemid)) + potential_free += ItemIdGetLength(df_itemid) + sizeof(ItemIdData); + continue; + } + + if (FluxIsOverflowRecordInline(PageGetItem(page, df_itemid), + ItemIdGetLength(df_itemid))) + continue; + + df_hdr = (FluxTupleHeader *) PageGetItem(page, df_itemid); + + if (FluxTupleDeadToAll(df_hdr, defrag_oldest_xmin)) + { + potential_free += ItemIdGetLength(df_itemid) + sizeof(ItemIdData); + } + } + } + + if (new_tuple_size <= ItemIdGetLength(itemid) + potential_free) + { + /* + * Defragmentation should free enough space. Do it now. We + * are already holding an exclusive lock on the buffer. First + * mark dead tuples LP_DEAD, then defragment. + * + * As in FluxPagePruneOpt, opportunistic pruning sets LP_DEAD + * (reclaiming storage) rather than LP_UNUSED. The deleted + * tuples may still have index entries; reserving the line + * pointer until VACUUM removes those entries prevents the TID + * from being recycled and returning wrong index-scan results. + * + * defrag_oldest_ts was pre-computed before acquiring the + * buffer lock to avoid shared-memory scans while holding + * page-level exclusive locks. + */ + TransactionId df_horizon = InvalidTransactionId; + + /* + * This compacts under our exclusive lock alone, where VACUUM + * and FluxPagePruneOpt take a cleanup lock. That is enough + * on the primary: every FLUX reader copies the tuple it + * returns while it holds the content lock, so a pin alone + * keeps no pointer into the page. Replay of the record takes + * a cleanup lock, for standby readers. + */ + START_CRIT_SECTION(); + { + OffsetNumber prune_off; + OffsetNumber prune_maxoff = PageGetMaxOffsetNumber(page); + + for (prune_off = FirstOffsetNumber; prune_off <= prune_maxoff; prune_off++) + { + ItemId prune_itemid = PageGetItemId(page, prune_off); + FluxTupleHeader *prune_hdr; + + if (!ItemIdIsNormal(prune_itemid)) + continue; + + if (FluxIsOverflowRecordInline(PageGetItem(page, prune_itemid), + ItemIdGetLength(prune_itemid))) + continue; + + prune_hdr = (FluxTupleHeader *) PageGetItem(page, prune_itemid); + + if (FluxTupleDeadToAll(prune_hdr, defrag_oldest_xmin)) + { + FluxTupleAdvanceConflictHorizon(prune_hdr, + &df_horizon); + ItemIdSetDead(prune_itemid); + } + } + } + FluxPageDefragment(page); + MarkBufferDirty(buffer); + + if (RelationNeedsWAL(relation)) + { + XLogRecPtr df_lsn; + + df_lsn = FluxXLogDefrag(relation, buffer, NULL, 0, + defrag_oldest_ts, df_horizon); + PageSetLSN(page, df_lsn); + } + END_CRIT_SECTION(); + + FluxRecordFreeSpace(relation, blkno, PageGetHeapFreeSpace(page)); + + /* + * Re-fetch the item after defragmentation since line pointers + * may have been reorganized. The offset number should still + * be valid for surviving tuples. + */ + itemid = PageGetItemId(page, offnum); + old_tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Check again if in-place update now fits */ + if (new_tuple_size <= ItemIdGetLength(itemid) + PageGetFreeSpace(page)) + { + flux_stat_defrag_triggered_updates++; + } + else + { + if (!force_shrink_attempted && + update_overflow_buffers.count == 0) + goto force_shrink_retry; + flux_release_update_overflow_buffers(&update_overflow_buffers, + buffer); + UnlockReleaseBuffer(buffer); + FluxFreeTuple(new_tuple); + flux_toast_abandon(&upd_ttc); + + /* + * BUG 2 fallback: the row cannot grow in place on this + * full page even after defragmentation, and FLUX TIDs are + * stable so it cannot relocate within the page. Rather + * than error, re-drive as an out-of-place (new-TID) + * update, which moves the row to a page with room and + * rebuilds all index entries (TU_All). We have released + * the buffer and freed the speculatively formed tuple, + * and no UNDO for this update has been reserved yet, so + * the state is clean to restart from. + */ + FLUX_RELEASE_TUPLOCK(); + if (did_inplace_key_update != NULL) + *did_inplace_key_update = false; + return flux_update_out_of_place(relation, otid, slot, cid, + options, snapshot, + crosscheck, wait, tmfd, + lockmode, update_indexes); + } + } + else + { + /* Defrag wouldn't free enough space */ + if (!force_shrink_attempted && + update_overflow_buffers.count == 0) + goto force_shrink_retry; + flux_release_update_overflow_buffers(&update_overflow_buffers, + buffer); + UnlockReleaseBuffer(buffer); + FluxFreeTuple(new_tuple); + flux_toast_abandon(&upd_ttc); + + /* BUG 2 fallback (see above): out-of-place instead of error. */ + FLUX_RELEASE_TUPLOCK(); + if (did_inplace_key_update != NULL) + *did_inplace_key_update = false; + return flux_update_out_of_place(relation, otid, slot, cid, + options, snapshot, crosscheck, + wait, tmfd, lockmode, + update_indexes); + } + } + } + + if (false) + { + /* + * Recovery path for an in-place-grown tuple that no longer fits in + * its slot, even after page defragmentation. FLUX TIDs are stable, + * so the tuple cannot move to another page; the only way to shrink it + * is to push variable-length columns off-page. Re-form the new tuple + * forcing every eligible varlena column into overflow with a zero + * inline prefix, which collapses the main tuple to its minimum + * footprint, then retry the in-place fit (it should now satisfy + * Strategy 1). + * + * This branch is only entered from the ERROR sites above, and only + * when the first form attempt took the no-overflow fast path (the + * main buffer is still locked and no overflow buffers were + * collected). + */ +force_shrink_retry: + { + bool relock_already_locked = false; + UndoRecPtr shrink_verptr = FluxTupleGetVersionPtr(old_tuple_hdr); + TransactionId shrink_xmin = FluxTupleGetXmin(old_tuple_hdr); + + FluxFreeTuple(new_tuple); + + /* Release the main buffer lock before re-forming with overflow. */ + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + + new_tuple = FluxFormTupleForceShrink(RelationGetDescr(relation), + upd_toast_values, + upd_toast_isnull, + relation, + &update_overflow_buffers); + + new_tuple->t_data->t_commit_ts = 0; /* t_xmax = + * InvalidTransactionId */ + new_tuple->t_data->t_flags |= FLUX_TUPLE_UNCOMMITTED; + new_tuple->t_data->t_xmin = GetCurrentTransactionId(); /* subxid: heap-shaped, + * so savepoint rollback + * marks it aborted in + * CLOG */ + new_tuple_size = new_tuple->t_len; + + /* + * Stamp InvalidUndoRecPtr into t_verptr, mirroring the main + * exclusive path, regardless of whether we reached this branch + * via fit cascade or force-shrink retry. + */ + new_tuple->t_data->t_flags |= FLUX_TUPLE_HAS_VERSION_PTR; + FluxTupleSetVersionPtr(new_tuple->t_data, + InvalidUndoRecPtr); + + /* + * Re-acquire the main buffer lock. An overflow column may have + * landed on the page we are updating, in which case it is already + * locked as part of update_overflow_buffers. + */ + for (upd_i = 0; upd_i < update_overflow_buffers.count; upd_i++) + { + if (update_overflow_buffers.buffers[upd_i].buffer == buffer) + { + relock_already_locked = true; + break; + } + } + if (!relock_already_locked) + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + + page = BufferGetPage(buffer); + + /* Re-validate the line pointer after re-locking. */ + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + flux_release_update_overflow_buffers(&update_overflow_buffers, + buffer); + UnlockReleaseBuffer(buffer); + FluxFreeTuple(new_tuple); + flux_toast_abandon(&upd_ttc); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + old_tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Re-validate visibility after the unlock/relock window. The fit + * cascade ran conflict detection before forming the tuple, but + * re-forming with overflow drops the buffer lock again. A + * concurrent committed delete that landed in that window must be + * reported as TM_Deleted rather than silently overwritten. + */ + if ((old_tuple_hdr->t_flags & FLUX_TUPLE_DELETED) && + !(old_tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED)) + { + flux_release_update_overflow_buffers(&update_overflow_buffers, + buffer); + UnlockReleaseBuffer(buffer); + FluxFreeTuple(new_tuple); + flux_toast_abandon(&upd_ttc); + FLUX_RELEASE_TUPLOCK(); + return TM_Deleted; + } + + /* + * Likewise a concurrent writer that got in while the buffer was + * unlocked, or one that came and went: either the header names a + * foreign writer we did not see at the conflict gates, or the + * version-chain head moved. Do not overwrite it; report + * TM_Updated so the executor waits and re-evaluates via EPQ, as + * the gates would have. + */ + if (FluxTupleGetVersionPtr(old_tuple_hdr) != shrink_verptr || + !TransactionIdEquals(FluxTupleGetXmin(old_tuple_hdr), + shrink_xmin)) + { + flux_release_update_overflow_buffers(&update_overflow_buffers, + buffer); + UnlockReleaseBuffer(buffer); + FluxFreeTuple(new_tuple); + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + + force_shrink_attempted = true; + goto retry_fit; + } + } + + /* + * Save a copy of the old tuple data BEFORE entering the critical section + * and BEFORE modifying the page. palloc is not allowed inside critical + * sections, and in-place updates overwrite the on-page data, so we must + * preserve the original tuple for WAL logging (the before-image). + * + * For small tuples (common case in pgbench-style OLTP), use stack buffers + * to avoid palloc overhead in the hot path. + */ + { + uint32 old_len = ItemIdGetLength(itemid); + char *old_copy; + + old_tuple_for_inplace_wal = palloc0(sizeof(FluxTupleData)); + old_copy = palloc(old_len); + + memcpy(old_copy, old_tuple_hdr, old_len); + update_freeze_conflict = flux_prepare_before_image(relation, (FluxTupleHeader *) old_copy); + old_tuple_for_inplace_wal->t_len = old_len; + old_tuple_for_inplace_wal->t_data = (FluxTupleHeader *) old_copy; + } + + /* + * NOTE: The early UNDO record (pre-modification) was removed to avoid + * double UNDO records. The deferred UNDO path below (upd_undo_ptr) + * handles UNDO recording for all in-place updates. The deferred approach + * is safe because the WAL record includes both old and new tuple data, + * enabling crash recovery regardless of UNDO write order. + */ + + + /* + * Pre-allocate WAL buffer space BEFORE entering critical section. We may + * need to register the main buffer plus overflow buffers. + * + * rdata slots needed for UPDATE: MAX_OVERFLOW_BUFFERS * 2 (header + data + * per overflow record) + 3 (xl_flux_update header + old tuple data + new + * tuple data) + * + * CRITICAL: XLogEnsureRecordSpace() may allocate memory, so it MUST be + * called outside the critical section. + */ + if (RelationNeedsWAL(relation)) + XLogEnsureRecordSpace(XLR_MAX_BLOCK_ID, 10 + MAX_OVERFLOW_BUFFERS * 2); + + /* + * Per-backend UNDO: prepare a full-tuple UPDATE record before the + * critical section. The record stores the old tuple so a top-level + * ROLLBACK can restore it in place. PrepareUndoInsert may ereport, so it + * must run outside the crit section. Lock order: data buffer then UNDO + * buffer. + * + * If a force-shrink retry pushed columns off-page, + * update_overflow_buffers holds buffers that are pinned, content-locked, + * and marked dirty but not yet WAL-logged (overflow WAL is deferred to + * the atomic record below). PrepareUndoInsert and + * CheckForSerializableConflictIn can both ereport here, before the + * critical section makes the change durable. Resource-owner cleanup + * would still release the pins and locks on abort, leaving only + * VACUUM-reclaimable dead overflow space, but release them explicitly so + * the abort path leaves no dirtied-yet-orphaned overflow pages behind. + */ + PG_TRY(); + { + /* + * Build the heap-format logical-decoding images (old and new) BEFORE + * the critical section, since heap_form_tuple()/palloc() are + * forbidden inside it. When the relation is not logically logged, or + * the caller asked for no decoding (TABLE_UPDATE_NO_LOGICAL, as + * REPACK (CONCURRENTLY) does for its transient table), these leave + * data == NULL, and the record carries no logical images, which + * decoding then skips. These calls palloc and can ereport on OOM, so + * they stay inside the PG_TRY: a throw here must also release the + * force-shrink overflow buffers, exactly like the reservation above. + */ + FluxXLogPrepareReplicaIdentity(relation, + walLogical ? old_tuple_for_inplace_wal : NULL, + new_tuple, &update_old_img); + FluxXLogPrepareLogicalImage(relation, walLogical ? new_tuple : NULL, + &update_new_img); + + /* + * Per-backend UNDO: FLUX always produces a version-chain UNDO record + * for an in-place UPDATE. The record is PREPARED after the new + * on-page image is finalized below (widen fixup, flags, t_gen, + * t_ctid), so the emit-time delta scan compares the old image against + * the exact bytes that will land on the page. Only the version + * pointer at offset 8 is stamped afterwards (from the returned + * UndoRecPtr), and the delta always covers offset 8. + */ + + /* + * Widen-then-narrow fixup. The version pointer lives in the fixed + * header field t_verptr, so FluxTupleGetVersionPtr/SetVersionPtr read + * and write it independently of the slot length -- widening the image + * to the old (larger) slot length no longer moves the pointer. The + * in-place overwrite path below keeps the old line-pointer length + * when the new image is smaller; deform's self-describing t_natts + * lets the trailing slack be ignored by ordinary readers. We still + * widen + zero-pad here so the memcpy below fills the whole slot (no + * stale trailing bytes). + * + * Done here, before the critical section, because repalloc()/pfree() + * are forbidden inside it. + */ + if (new_tuple_size <= ItemIdGetLength(itemid)) + { + Size slot_len = ItemIdGetLength(itemid); + + if (slot_len > new_tuple_size && + (new_tuple->t_data->t_flags & FLUX_TUPLE_HAS_VERSION_PTR)) + { + UndoRecPtr verptr = FluxTupleGetVersionPtr(new_tuple->t_data); + + new_tuple->t_data = (FluxTupleHeader *) + repalloc(new_tuple->t_data, slot_len); + MemSet((char *) new_tuple->t_data + new_tuple_size, 0, + slot_len - new_tuple_size); + new_tuple->t_len = (uint32) slot_len; + new_tuple_size = slot_len; + FluxTupleSetVersionPtr(new_tuple->t_data, verptr); + } + } + + /* + * transaction read this tuple (holds a SIREAD lock on it), our update + * creates an rw-antidependency that may form a dangerous structure. + */ + CheckForSerializableConflictIn(relation, otid, BufferGetBlockNumber(buffer)); + + /* + * Always set UNCOMMITTED so that visibility checks consult the sLog. + * Even though the tuple position hasn't moved (in-place update), the + * DATA has changed and other transactions must see the old data until + * this update commits. The flag will be lazily cleared on the first + * visibility check after the updating transaction commits (since the + * sLog entry will have been removed at commit time). + * + * Also set FLUX_TUPLE_UPDATED to mark that this tuple has been + * updated in-place. After commit, this flag persists and indicates + * that the tuple's t_commit_ts reflects the original INSERT commit + * time (not the UPDATE commit time). This preserves visibility for + * readers whose snapshots predate the update. + */ + new_tuple->t_data->t_flags |= FLUX_TUPLE_UPDATED; + + /* + * Record whether this update changes an indexed column. This covers + * every non-CAS in-place image: same-size, growing, shrinking and the + * key-changing (delete-marking) UPDATE alike, because they all land + * here. A concurrent LockTupleKeyShare probe reads this bit to + * decide whether it may skip waiting on us. See + * FLUX_TUPLE_KEYS_UPDATED. + */ + if (key_changed) + new_tuple->t_data->t_flags |= FLUX_TUPLE_KEYS_UPDATED; + + /* + * Stamp t_cmin to match the t_xmin this image carries. + * + * The in-place image's t_xmin was just re-stamped to OUR xid above + * (the on-page image is the newest version and we are its author), so + * t_cmin must be OUR command id: the pair (t_xmin, t_cmin) has to + * describe one writer. Carrying the original INSERT's cid forward + * here was WRONG and broke six isolation tests -- it paired a NEW + * xmin with an OLD cid, so FluxTupleSatisfiesMVCC's "inserted by a + * later command of my own transaction" test compared our own curcid + * against a stranger's cid and hid rows we had legitimately written + * (freeze-the-dead reported "unexpected table_tuple_lock status: 1" + * == TM_Invisible; total-cash read a stale SUM). + * + * Using cid here is also exactly what self-visibility needs on this + * path: our own LATER command must not see a row this command wrote. + */ + new_tuple->t_data->t_cmin = cid; + + /* + * Finalize t_ctid to the (unchanged) in-place position so the emit + * delta scan sees the exact on-page bytes. It is re-set identically + * inside the critical section. + */ + ItemPointerSet(&new_tuple->t_data->t_ctid, blkno, offnum); + + /* + * Choose delta vs full at emit time. Compute the prefix/suffix diff + * of the OLD image relative to the FINAL new on-page image, forcing + * the t_verptr offset (8) into the recorded middle so the version + * chain link is always carried. FluxUndoDeltaCompute returns 0 when + * the delta is not strictly smaller than the full image, in which + * case we emit the full before-image exactly as before. DELETE keeps + * the full image; INSERT writes no undo. PrepareUndoInsert returns + * the UndoRecPtr that becomes this new version's t_verptr (the head + * of the tuple's version chain); the before-image (full or delta) + * carries the prior chain link, so the chain continues verbatim + * through reverse- apply. + */ + { + uint32 upd_old_len = old_tuple_for_inplace_wal->t_len; + Size upd_delta_total; + char *upd_delta_buf = + (char *) palloc(SizeOfFluxUndoDelta + upd_old_len); + + /* + * A waiter may have observed a now-aborted key before acquiring + * the row. Index maintenance must use the same authoritative + * before-image as this undo record, not that earlier observation. + */ + if (key_changed) + { + if (*index_old != NULL) + pfree(*index_old); + *index_old = palloc(upd_old_len); + *index_old_len = upd_old_len; + memcpy(*index_old, old_tuple_for_inplace_wal->t_data, upd_old_len); + } + + upd_delta_total = + FluxUndoDeltaCompute((const char *) old_tuple_for_inplace_wal->t_data, + upd_old_len, + (const char *) new_tuple->t_data, + new_tuple_size, + offsetof(FluxTupleHeader, t_verptr), + upd_delta_buf, + SizeOfFluxUndoDelta + upd_old_len); + + if (upd_delta_total > 0) + upd_undo_ptr = FluxPbuPrepareInsert(FLUX_UNDO_UPDATE_DELTA, + relation, + otid, + upd_delta_buf, + (uint32) upd_delta_total, + relation->rd_rel->relpersistence, key_changed, update_freeze_conflict, + cid); + else + upd_undo_ptr = FluxPbuPrepareInsert(FLUX_UNDO_UPDATE, + relation, + otid, + (const char *) old_tuple_for_inplace_wal->t_data, + upd_old_len, + relation->rd_rel->relpersistence, key_changed, update_freeze_conflict, + cid); + + pfree(upd_delta_buf); + + /* + * Stamp the freshly reserved urec_ptr (never preserve a prior + * pointer). This is the only new-image mutation after the delta + * scan and it lands at offset 8, inside the delta's recorded + * middle. + */ + FluxTupleSetVersionPtr(new_tuple->t_data, + upd_undo_ptr); + *index_urp = upd_undo_ptr; + } + + + } + PG_CATCH(); + { + flux_release_update_overflow_buffers(&update_overflow_buffers, buffer); + PG_RE_THROW(); + } + PG_END_TRY(); + + vmbuffer = FluxVMPinForChange(relation, buffer); + + /* Start critical section for WAL logging */ + START_CRIT_SECTION(); + + /* + * Set t_ctid on the in-memory new tuple BEFORE copying to the page. This + * ensures the WAL record's new_tuple image includes the correct t_ctid, + * so redo produces a page identical to the primary. (We use blkno/offnum + * which is correct for both the "fits in existing slot" and + * "delete+re-add" strategies since we update offnum below if it changes.) + */ + ItemPointerSet(&new_tuple->t_data->t_ctid, blkno, offnum); + + if (new_tuple_size <= ItemIdGetLength(itemid)) + { + /* + * New tuple fits within the old tuple's allocated space. Overwrite + * directly -- safe because we don't exceed the existing allocation. + * When the kept slot is larger than the new image, new_tuple was + * already widened to the full slot length before the critical section + * (see the widen block above), so new_tuple_size == slot_len here and + * the memcpy fills the whole slot, keeping any trailing slack zeroed. + * The version pointer lives in the header field t_verptr, so it is + * copied by the memcpy regardless of slot width. + */ + memcpy(old_tuple_hdr, new_tuple->t_data, new_tuple_size); + } + else + { + /* + * New tuple is larger than the old one but fits on the page (Strategy + * 2 or 3). We cannot memcpy in place because that would overwrite + * adjacent data. Use PageIndexTupleOverwrite, which grows the slot + * in place at the SAME offnum: it shifts only the tuple data that + * physically precedes this tuple and fixes the affected line-pointer + * offsets, keyed purely by (offnum, new size). This is the identical + * primitive the redo handler replays (see + * flux_xlog_update_inplace_redo), so primary and recovery converge + * deterministically regardless of where the other tuples' data + * happens to sit on the page -- unlike the old + * FluxPageIndexTupleDelete + PageAddItem, whose page compaction and + * line-pointer renumbering were not reproducible under crash-recovery + * replay ordering. offnum is unchanged. + */ + if (!PageIndexTupleOverwrite(page, offnum, + (const char *) new_tuple->t_data, + new_tuple_size)) + elog(PANIC, "failed to overwrite-grow FLUX tuple in place"); + + /* Re-fetch itemid and header from the (same) location */ + itemid = PageGetItemId(page, offnum); + old_tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + ItemPointerSet(&new_tuple->t_data->t_ctid, blkno, offnum); + } + + /* Set new TID to same location */ + ItemPointerSet(&slot->tts_tid, blkno, offnum); + new_tuple->t_self = slot->tts_tid; + + /* + * t_ctid on the on-disk tuple is already correct from the memcpy or + * PageAddItem above, since we set it on new_tuple->t_data before copying. + */ + + /* Track in-place update success */ + flux_stat_in_place_updates++; + + slot->tts_tableOid = RelationGetRelid(relation); + + /* + * Update page opaque header to track the latest commit timestamp and + * current free space. This must happen before MarkBufferDirty and WAL + * logging so that full-page images capture the correct opaque state. The + * redo function performs the same updates so WAL consistency checking + * passes. + */ + { + FluxPageOpaque upd_phdr = FluxPageGetOpaque(page); + + FluxPageSetCommitTs(upd_phdr, Max(FluxPageGetCommitTs(upd_phdr), current_ts)); + } + + FluxVMClearForChange(buffer, vmbuffer); + MarkBufferDirty(buffer); + + /* + * Write the per-backend UPDATE UNDO record into its pinned undo buffers + * BEFORE the forward-op WAL record so FluxXLogUpdate folds the undo pages + * onto its record as full-page images (crash-safe). + */ + if (UndoRecPtrIsValid(upd_undo_ptr)) + FluxPbuInsertUndo(); + + /* + * WAL log the update with all overflow buffers (and folded undo) + * atomically + */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr upd_recptr; + + /* + * old_tuple_for_inplace_wal was populated with a copy of the old + * tuple data BEFORE we modified the page. Use its saved + * old_commit_ts for the WAL record so the before-image is correct. + */ + upd_recptr = FluxXLogUpdate(relation, buffer, offnum, + old_tuple_for_inplace_wal, new_tuple, + old_tuple_for_inplace_wal->t_data->t_commit_ts, + 0, /* new version: t_xmax = Invalid + * (heap-shaped) */ + &update_overflow_buffers, + InvalidBuffer, + &update_old_img, &update_new_img, + !walLogical); + + if (UndoRecPtrIsValid(upd_undo_ptr)) + FluxPbuFinishUndo(upd_recptr); + } + else if (UndoRecPtrIsValid(upd_undo_ptr)) + FluxPbuFinishUndo(InvalidXLogRecPtr); + + FluxVMFinishClear(RelationNeedsWAL(relation) ? + PageGetLSN(page) : InvalidXLogRecPtr); + END_CRIT_SECTION(); + if (BufferIsValid(vmbuffer)) + ReleaseBuffer(vmbuffer); + + FluxXLogReleaseLogicalImage(&update_old_img); + FluxXLogReleaseLogicalImage(&update_new_img); + + /* + * Release all overflow buffers first; they were WAL-logged atomically + * above so they're safe to unlock now. + */ + for (upd_i = 0; upd_i < update_overflow_buffers.count; upd_i++) + { + Buffer ovf_buf = update_overflow_buffers.buffers[upd_i].buffer; + bool already_released = (ovf_buf == buffer); + int dup_j; + + for (dup_j = 0; dup_j < upd_i && !already_released; dup_j++) + { + if (update_overflow_buffers.buffers[dup_j].buffer == ovf_buf) + already_released = true; + } + + if (!already_released) + UnlockReleaseBuffer(ovf_buf); + pfree(update_overflow_buffers.buffers[upd_i].record_data); + } + + /* Capture free space while we still hold the main buffer lock. */ + { + Size update_free_space = PageGetHeapFreeSpace(page); + + /* + * sLog registration BEFORE buffer release; eliminates the race window + * where another backend reads the modified tuple but finds no sLog + * entry. Safe: seqlock reads take no lock, no deadlock. + */ + FluxEnsureSLogCallbacks(); + /* BUG 5 fix: bounded local-only tracking (see flux_tuple_delete). */ + /* BUG 5: no per-tuple tracking for UPDATE (see flux_tuple_delete). */ + + /* Record our own update's command id (cmax). */ + FluxLocalCidSet(RelationGetRelid(relation), &slot->tts_tid, cid); + + /* Release the main buffer lock; sLog registered, no race. */ + UnlockReleaseBuffer(buffer); + + FluxRecordFreeSpace(relation, blkno, update_free_space); + } + + /* + * The per-backend UPDATE UNDO record was written and folded into the + * forward WAL record inside the critical section above (FluxPbuInsertUndo + * / FluxPbuFinishUndo). Unlike the fork engine, the per-backend insert + * must happen inside the crit section so its pages ride the same WAL + * record; the record is chained into the xact automatically by + * InsertPreparedUndo (SetCurrentUndoLocation), so no explicit + * registration is needed. + */ + + /* + * No index maintenance for this path. Key-changing UPDATEs are routed + * out of place at the top of flux_tuple_update (delete + insert at a new + * TID, *update_indexes = TU_All); this in-place path runs only for + * non-indexed changes, so the TID is unchanged and every secondary (key, + * TID) entry still points at the live tuple. *update_indexes was + * defaulted to TU_None at the top of the function, so no index re-insert + * happens. + */ + { + FluxFreeTuple(old_tuple_for_inplace_wal); + + FluxLocalDirtyPageMark(relation, blkno); + + /* + * NOTE: We do NOT immediately clean up overflow chains here. + * Immediate cleanup was: 1. Buggy (collected wrong overflow pointers + * after in-place modification) 2. Expensive on hot paths (extra + * buffer I/O + locking during UPDATE) 3. Complex to WAL-log correctly + * + * Instead, overflow cleanup is deferred to VACUUM. When VACUUM + * prunes deleted tuples, it will also reclaim orphaned overflow + * pages. + * + * Future enhancement: Log overflow block/offset in WAL UPDATE record + * so UNDO log pruning can also clean up overflow chains. + */ + (void) old_has_overflow; /* Suppress unused variable warning */ + } + + FluxFreeTuple(new_tuple); + + /* Delete the superseded external values, free toasting temporaries. */ + flux_toast_cleanup(&upd_ttc); + if (upd_old_copy) + pfree(upd_old_copy); + + FLUX_RELEASE_TUPLOCK(); + + flux_count_inplace_update(relation, key_changed); + return TM_Ok; +} + +#undef FLUX_RELEASE_TUPLOCK + + + +/* + * Multi-insert operation for bulk loading (batched page-at-a-time) + * + * Pre-forms all tuples, then inserts them page-at-a-time to minimize + * per-tuple overhead: one FSM lookup, one buffer lock, one WAL record, + * and one UNDO reservation per page batch instead of per tuple. + * + * Tuples that are too large for batch handling (need overflow) are + * inserted individually via the single-insert path. + */ +void +flux_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, + CommandId cid, uint32 options, BulkInsertState bistate) +{ + FluxTuple *formed_tuples; + bool *needs_single_insert; + uint64 current_ts; + TransactionId my_xid; + int i; + int ndone; + + if (ntuples <= 0) + return; + + /* + * Get timestamps and XID outside the loop; these are per-transaction + * cached values, but calling them once avoids function call overhead. + */ + (void) FluxGetTransactionTimestamp(); + current_ts = (uint64) FluxGetDmlTimestamp(); + my_xid = GetCurrentTransactionId(); /* subxid: heap-shaped xmin */ + /* Ensure relation storage exists */ + RelationGetSmgr(relation); + + /* + * Phase 1: Pre-form all tuples without overflow handling. Passing NULL + * for rel and overflow_buffers skips the overflow path, keeping the tuple + * inline. Tuples that exceed the page size will be detected below and + * routed to single-insert. + */ + formed_tuples = (FluxTuple *) palloc(ntuples * sizeof(FluxTuple)); + needs_single_insert = (bool *) palloc0(ntuples * sizeof(bool)); + + for (i = 0; i < ntuples; i++) + { + slot_getallattrs(slots[i]); + formed_tuples[i] = FluxFormTuple(RelationGetDescr(relation), + slots[i]->tts_values, + slots[i]->tts_isnull, + NULL, /* no overflow in batch */ + NULL); + + /* Set MVCC fields (heap-shaped xmin/xmax) */ + formed_tuples[i]->t_data->t_commit_ts = 0; /* t_xmax = Invalid */ + formed_tuples[i]->t_data->t_flags |= FLUX_TUPLE_UNCOMMITTED; + formed_tuples[i]->t_data->t_xmin = my_xid; + formed_tuples[i]->t_data->t_cmin = cid; /* see flux_tuple_insert */ + + /* + * Reserve the version-pointer slot at INSERT time so the first UPDATE + * is a same-length overwrite (see flux_tuple_insert for the + * rationale). Done before the size check below so oversize routing + * accounts for the base+8 on-page footprint. + */ + formed_tuples[i]->t_data->t_flags |= FLUX_TUPLE_HAS_VERSION_PTR; + FluxTupleSetVersionPtr(formed_tuples[i]->t_data, + InvalidUndoRecPtr); + + /* + * Route a tuple that needs TOAST to the single-insert path, which + * toasts it through flux_toast_tuple: one holding an external value + * (possibly a pointer into another relation's TOAST table, e.g. from + * a column default) or one wider than heap's toasting threshold + * (heap_prepare_insert's test). Tuples too large for the page go + * there too. + * + * ponytail: wide rows lose page batching; toast here and keep them in + * the batch if COPY of wide rows into FLUX matters. + */ + if ((formed_tuples[i]->t_data->t_infomask & FLUX_INFOMASK_HASEXTERNAL) || + formed_tuples[i]->t_len > TOAST_TUPLE_THRESHOLD || + formed_tuples[i]->t_len > FLUX_MAX_TUPLE_SIZE) + needs_single_insert[i] = true; + } + + /* + * Phase 2: Batch insert page-at-a-time. + * + * For each page: lock once, insert all fitting tuples, WAL-log once, + * unlock. This is much faster than per-tuple buffer operations. + * + * We register each inserted tuple in the backend-local tracked-key list + * (via SLogTupleTrackLocalOnly) so that FluxClearUncommittedFlags() can + * find and stamp them at commit time. Without this, COPY-inserted tuples + * retain FLUX_TUPLE_UNCOMMITTED permanently and are invisible to all + * snapshots. + */ + FluxEnsureSLogCallbacks(); + ndone = 0; + while (ndone < ntuples) + { + Buffer buffer; + Page page; + BlockNumber target_block; + int batch_start; + int batch_count; + int nfit; + Size saveFreeSpace; + Size avail; + OffsetNumber *offnums = NULL; + FluxLogicalImage *logical_imgs = NULL; + FluxLogicalImage combined_image = {NULL, 0}; + bool need_logical = RelationIsLogicallyLogged(relation); + Buffer vmbuffer; + + /* Skip tuples that need single-insert (overflow) */ + if (needs_single_insert[ndone]) + { + flux_tuple_insert(relation, slots[ndone], cid, options, bistate); + FluxFreeTuple(formed_tuples[ndone]); + ndone++; + continue; + } + + /* Find a page with space for at least one tuple (fill-factor aware) */ + saveFreeSpace = RelationGetTargetPageFreeSpace(relation, + FLUX_DEFAULT_FILLFACTOR); + /* Clamped request -- see FluxTargetFreeSpace in flux_fsm.c. */ + target_block = FluxGetPageWithFreeSpace(relation, + FluxTargetFreeSpace(relation, + formed_tuples[ndone]->t_len)); + if (target_block == InvalidBlockNumber) + { + /* Fall back to single insert */ + flux_tuple_insert(relation, slots[ndone], cid, options, bistate); + FluxFreeTuple(formed_tuples[ndone]); + ndone++; + continue; + } + + buffer = ReadBuffer(relation, target_block); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + /* + * Determine how many tuples will fit on this page BEFORE entering the + * critical section. Logical-decoding images and the offnums array + * must be allocated outside the crit section, so we need the count up + * front. This mirrors heap_multi_insert(): walk the pending tuples, + * subtracting each tuple's (line-pointer + aligned body) cost from + * the page's free space until the next one would not fit, honouring + * the fill-factor reserve and stopping at the first overflow tuple. + */ + avail = PageGetHeapFreeSpace(page); + + /* + * Honour the fill-factor reserve while PACKING, not just while + * choosing the target block. FLUX has stable TIDs and updates rows + * in place, so a page packed 100%% full leaves no room for later + * in-place growth and a growing UPDATE (e.g. an accumulating numeric) + * aborts with "does not fit". Reserve saveFreeSpace bytes here so + * bulk load (COPY / HammerDB) leaves the same headroom the + * single-insert path does. The "always take at least one tuple" rule + * below still guarantees forward progress even when a single tuple + * exceeds the post-reserve budget. + */ + if (avail > saveFreeSpace) + avail -= saveFreeSpace; + nfit = 0; + while (ndone + nfit < ntuples && + !needs_single_insert[ndone + nfit]) + { + Size need = sizeof(ItemIdData) + + MAXALIGN(formed_tuples[ndone + nfit]->t_len); + + /* Always take at least one tuple; stop when the next won't fit */ + if (nfit > 0 && need > avail) + break; + avail -= need; + nfit++; + } + Assert(nfit > 0); + + offnums = (OffsetNumber *) palloc(nfit * sizeof(OffsetNumber)); + if (need_logical) + { + Size total = 0; + char *p; + + logical_imgs = (FluxLogicalImage *) + palloc(nfit * sizeof(FluxLogicalImage)); + for (i = 0; i < nfit; i++) + FluxXLogPrepareLogicalImage(relation, + formed_tuples[ndone + i], + &logical_imgs[i]); + + /* + * Serialize every image into one contiguous blob, framed exactly + * as FluxXLogRegisterLogicalImage emits a single image: each + * frame is "[heap bytes][uint32 len]" in tuple order. The + * multi-insert emitter registers this blob as a single rdata + * chunk, keeping the registered-data slot count constant + * regardless of batch size (the old per-tuple registration + * overflowed XLR_NORMAL_RDATAS on dense pages). We size for all + * nfit images but register only the prefix covering the tuples + * actually inserted (see below), matching the ntuples trailing + * frames the redo/decode readers expect. + */ + for (i = 0; i < nfit; i++) + if (logical_imgs[i].data != NULL) + total += logical_imgs[i].len + sizeof(uint32); + if (total > 0) + { + combined_image.data = (char *) palloc(total); + p = combined_image.data; + for (i = 0; i < nfit; i++) + { + if (logical_imgs[i].data == NULL) + continue; + memcpy(p, logical_imgs[i].data, logical_imgs[i].len); + p += logical_imgs[i].len; + memcpy(p, &logical_imgs[i].len, sizeof(uint32)); + p += sizeof(uint32); + } + } + } + + /* + * Ensure the current transaction has an XID assigned BEFORE entering + * the critical section (GetCurrentTransactionId may lock and + * allocate, both forbidden in a crit section). An assigned XID is + * required for correctness: a multi-insert WAL record without an + * attached xid cannot be decoded into a logical replication stream + * (ReorderBuffer groups changes by xid and asserts the xid is valid), + * and a WAL-emitting transaction would otherwise be treated as + * read-only. This mirrors flux_tuple_insert() and + * heap_multi_insert(). + */ + if (RelationNeedsWAL(relation)) + (void) GetCurrentTransactionId(); + + /* Pre-allocate WAL space outside critical section */ + if (RelationNeedsWAL(relation)) + XLogEnsureRecordSpace(XLR_MAX_BLOCK_ID, 4); + + /* As for a single insert, only relation-level readers conflict. */ + CheckForSerializableConflictIn(relation, NULL, InvalidBlockNumber); + + vmbuffer = FluxVMPinForChange(relation, buffer); + + START_CRIT_SECTION(); + + batch_start = ndone; + batch_count = 0; + + /* Insert the tuples we sized for above */ + while (batch_count < nfit) + { + OffsetNumber offnum; + ItemId inserted_itemid; + FluxTupleHeader *inserted_hdr; + + offnum = FluxPageAddTuple(page, formed_tuples[ndone], + formed_tuples[ndone]->t_len); + if (offnum == InvalidOffsetNumber) + break; /* page is full, stop batching */ + + /* Set TID in slot */ + ItemPointerSet(&slots[ndone]->tts_tid, target_block, offnum); + formed_tuples[ndone]->t_self = slots[ndone]->tts_tid; + slots[ndone]->tts_tableOid = RelationGetRelid(relation); + /* Set t_ctid to self */ + inserted_itemid = PageGetItemId(page, offnum); + inserted_hdr = (FluxTupleHeader *) PageGetItem(page, inserted_itemid); + ItemPointerSet(&inserted_hdr->t_ctid, target_block, offnum); + + offnums[batch_count] = offnum; + batch_count++; + ndone++; + } + + if (batch_count > 0) + { + /* Update page opaque fields */ + FluxPageOpaque phdr = FluxPageGetOpaque(page); + + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), current_ts)); + + FluxVMClearForChange(buffer, vmbuffer); + MarkBufferDirty(buffer); + + /* + * WAL-log the whole batch in one record. Every tuple body is + * logged individually (no forced full-page image), so recovery is + * crash-safe even with full_page_writes=off, and one logical- + * decoding image per tuple is appended for logically-logged + * relations so COPY'd rows replicate. + */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr; + FluxLogicalImage *emit_image = NULL; + + /* + * Register only the framed-image prefix covering the tuples + * we actually inserted. batch_count can be < nfit if the + * page filled mid-loop, and the redo/decode readers walk + * exactly ntuples (== batch_count) trailing frames, so a + * longer blob would misalign their backward walk. + */ + if (combined_image.data != NULL) + { + Size prefix = 0; + + for (i = 0; i < batch_count; i++) + if (logical_imgs[i].data != NULL) + prefix += logical_imgs[i].len + sizeof(uint32); + combined_image.len = (uint32) prefix; + if (prefix > 0) + emit_image = &combined_image; + } + + recptr = FluxXLogMultiInsert(relation, buffer, + offnums, + &formed_tuples[batch_start], + batch_count, current_ts, + emit_image); + PageSetLSN(page, recptr); + } + } + + FluxVMFinishClear(batch_count > 0 && RelationNeedsWAL(relation) ? + PageGetLSN(page) : InvalidXLogRecPtr); + END_CRIT_SECTION(); + if (BufferIsValid(vmbuffer)) + ReleaseBuffer(vmbuffer); + + if (logical_imgs != NULL) + { + for (i = 0; i < nfit; i++) + FluxXLogReleaseLogicalImage(&logical_imgs[i]); + pfree(logical_imgs); + } + if (combined_image.data != NULL) + pfree(combined_image.data); + pfree(offnums); + + /* + * Register each inserted tuple in the backend-local tracked-key list. + * This is lightweight (no shared hash entry, no LWLock); just a + * palloc'd linked-list node per tuple. FluxClearUncommittedFlags() + * iterates this list at PRE_COMMIT to clear the UNCOMMITTED flag, + * making the tuples visible. + * + * We skip full sLog registration (shared hash) because COPY doesn't + * use SNAPSHOT_DIRTY or ON CONFLICT, and the local tracking is + * sufficient for commit-time processing. + */ + { + TransactionId xid = GetTopTransactionId(); + TransactionId subxid = GetCurrentSubTransactionId(); + + for (i = batch_start; i < batch_start + batch_count; i++) + { + SLogTupleTrackLocalOnly(RelationGetRelid(relation), + relation->rd_locator.relNumber, + &slots[i]->tts_tid, + xid, subxid); + FluxSlotSetVersion(slots[i], my_xid, cid); + } + } + + /* Update FSM with remaining free space */ + FluxRecordFreeSpace(relation, target_block, PageGetHeapFreeSpace(page)); + + + UnlockReleaseBuffer(buffer); + + /* + * Count the batched inserts here rather than once for all ntuples at + * the end: tuples routed to the single-insert fallback are already + * counted by their own flux_tuple_insert call, so counting the whole + * ntuples at the tail would double-count them. + */ + pgstat_count_heap_insert(relation, batch_count); + + /* Free formed tuples in this batch */ + for (i = batch_start; i < batch_start + batch_count; i++) + FluxFreeTuple(formed_tuples[i]); + } + + pfree(formed_tuples); + pfree(needs_single_insert); +} + +/* + * Per-stream state for the Phase I VACUUM scan callback. + */ +typedef struct FluxVacScanState +{ + Relation rel; + BlockNumber current_block; /* last block handed out (InvalidBlockNumber + * before the first call) */ + BlockNumber nblocks; + bool skip_all_frozen; /* honor the VM ALL_FROZEN skip? */ +} FluxVacScanState; + +/* + * Read stream callback for VACUUM Phase I. + * + * Hands the read stream the next block that actually needs scanning so the + * upcoming pages are prefetched under AIO, matching heapam's + * heap_vac_scan_next_block(). Pages marked ALL_FROZEN in the visibility map + * are skipped here (unless DISABLE_PAGE_SKIPPING was requested), so the + * stream never issues I/O for pages VACUUM would immediately discard. + */ +static BlockNumber +flux_vac_scan_next_block(ReadStream *stream, + void *callback_private_data, + void *per_buffer_data) +{ + FluxVacScanState *state = callback_private_data; + BlockNumber next_block = state->current_block + 1; + + for (; next_block < state->nblocks; next_block++) + { + CHECK_FOR_INTERRUPTS(); + + if (state->skip_all_frozen && + FluxVMCheck(state->rel, next_block, FLUX_VM_ALL_FROZEN)) + continue; + + state->current_block = next_block; + return next_block; + } + + state->current_block = state->nblocks; + return InvalidBlockNumber; +} + +/* Truncation lock-acquisition tuning, mirroring heap's lazy_truncate_heap. */ +#define FLUX_TRUNCATE_LOCK_WAIT_INTERVAL 50 /* ms */ +#define FLUX_TRUNCATE_LOCK_TIMEOUT 5000 /* ms */ + +/* + * FluxPageIsEmpty + * + * A FLUX page is truncatable-empty when every line pointer is LP_UNUSED. + * Pruning leaves removed ItemIds LP_UNUSED without resetting pd_lower, so + * PageGetMaxOffsetNumber() alone would call such a page non-empty. An + * LP_DEAD slot keeps the page: an index may still name its TID. + */ +static bool +FluxPageIsEmpty(Page page) +{ + OffsetNumber maxoff; + + if (PageIsNew(page)) + return true; + + maxoff = PageGetMaxOffsetNumber(page); + for (OffsetNumber offnum = FirstOffsetNumber; offnum <= maxoff; + offnum = OffsetNumberNext(offnum)) + { + /* an LP_DEAD slot keeps the page: index entries may still name it */ + if (ItemIdIsUsed(PageGetItemId(page, offnum))) + return false; + } + + return true; +} + +/* Truncation thresholds and lock-waiter check interval, as in vacuumlazy.c */ +#define FLUX_REL_TRUNCATE_MINIMUM 1000 +#define FLUX_REL_TRUNCATE_FRACTION 16 +#define FLUX_TRUNCATE_LOCK_CHECK_INTERVAL 20 /* ms */ + +/* + * FluxShouldAttemptTruncation + * + * Truncate only if allowed (VACUUM (TRUNCATE), the vacuum_truncate reloption + * and GUC, resolved into params->truncate) and if the empty tail is worth an + * AccessExclusiveLock, which a hot standby must replay: at least + * FLUX_REL_TRUNCATE_MINIMUM pages or 1/FLUX_REL_TRUNCATE_FRACTION of the + * relation, as heap's should_attempt_truncation does. + */ +static bool +FluxShouldAttemptTruncation(const VacuumParams *params, BlockNumber nblocks, + BlockNumber empty_end_pages) +{ + if (params->truncate == VACOPTVALUE_DISABLED) + return false; + + return empty_end_pages > 0 && + (empty_end_pages >= FLUX_REL_TRUNCATE_MINIMUM || + empty_end_pages >= nblocks / FLUX_REL_TRUNCATE_FRACTION); +} + +/* + * FluxCountNondeletablePages + * + * Scan backwards from nblocks, but not below desired_nblocks, and return one + * past the last page that is not empty: the size to truncate to. The caller + * holds AccessExclusiveLock, so no backend adds a row behind the scan. The + * scan that found the empty tail ran under ShareUpdateExclusiveLock, which + * INSERT does not conflict with, so this recount is necessary. + * + * As heap's count_nondeletable_pages does, check every 32 blocks, at most + * once per FLUX_TRUNCATE_LOCK_CHECK_INTERVAL ms, whether another backend waits + * for the lock. If one does, set *lock_waiter_detected and return the size + * verified so far, so that the caller truncates that much and lets it in. + */ +static BlockNumber +FluxCountNondeletablePages(Relation onerel, BlockNumber nblocks, + BlockNumber desired_nblocks, + bool *lock_waiter_detected) +{ + BlockNumber blkno = nblocks; + instr_time starttime; + + INSTR_TIME_SET_CURRENT(starttime); + + while (blkno > desired_nblocks) + { + Buffer buf; + bool empty; + + if ((blkno % 32) == 0) + { + instr_time currenttime; + instr_time elapsed; + + INSTR_TIME_SET_CURRENT(currenttime); + elapsed = currenttime; + INSTR_TIME_SUBTRACT(elapsed, starttime); + if ((INSTR_TIME_GET_MICROSEC(elapsed) / 1000) + >= FLUX_TRUNCATE_LOCK_CHECK_INTERVAL) + { + if (LockHasWaitersRelation(onerel, AccessExclusiveLock)) + { + ereport(DEBUG2, + (errmsg("table \"%s\": suspending truncate due to conflicting lock request", + RelationGetRelationName(onerel)))); + *lock_waiter_detected = true; + return blkno; + } + starttime = currenttime; + } + } + + CHECK_FOR_INTERRUPTS(); + + buf = ReadBuffer(onerel, blkno - 1); + LockBuffer(buf, BUFFER_LOCK_SHARE); + empty = FluxPageIsEmpty(BufferGetPage(buf)); + UnlockReleaseBuffer(buf); + + if (!empty) + break; + + blkno--; + } + + return blkno; +} + +/* + * FluxTruncateRelation + * + * Truncate the empty tail VACUUM found, to no fewer than desired_nblocks + * pages, with heap's lazy_truncate_heap protocol: take AccessExclusiveLock + * without waiting for it (retrying every FLUX_TRUNCATE_LOCK_WAIT_INTERVAL ms + * for up to FLUX_TRUNCATE_LOCK_TIMEOUT ms, then giving up), give up if the + * relation grew, and recount the empty tail under the lock. If a backend + * starts waiting for the lock during the recount, truncate what was + * verified, release the lock and start over. + */ +static BlockNumber +FluxTruncateRelation(Relation onerel, BlockNumber orig_nblocks, + BlockNumber desired_nblocks, bool verbose) +{ + BlockNumber start_nblocks = orig_nblocks; + BlockNumber new_nblocks = orig_nblocks; + bool lock_waiter_detected; + + do + { + int lock_retry = 0; + + lock_waiter_detected = false; + while (!ConditionalLockRelation(onerel, AccessExclusiveLock)) + { + CHECK_FOR_INTERRUPTS(); + + if (++lock_retry > (FLUX_TRUNCATE_LOCK_TIMEOUT / + FLUX_TRUNCATE_LOCK_WAIT_INTERVAL)) + { + ereport(verbose ? INFO : DEBUG2, + (errmsg("\"%s\": stopping truncate due to conflicting lock request", + RelationGetRelationName(onerel)))); + return start_nblocks - orig_nblocks; + } + + (void) WaitLatch(MyLatch, + WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH, + FLUX_TRUNCATE_LOCK_WAIT_INTERVAL, + WAIT_EVENT_VACUUM_TRUNCATE); + ResetLatch(MyLatch); + } + + /* New pages presumably hold rows: give up, as heap does */ + if (RelationGetNumberOfBlocks(onerel) != orig_nblocks) + { + UnlockRelation(onerel, AccessExclusiveLock); + return start_nblocks - orig_nblocks; + } + + new_nblocks = FluxCountNondeletablePages(onerel, orig_nblocks, + desired_nblocks, + &lock_waiter_detected); + if (new_nblocks >= orig_nblocks) + { + UnlockRelation(onerel, AccessExclusiveLock); + return start_nblocks - orig_nblocks; + } + + RelationTruncate(onerel, new_nblocks); + + /* + * Other backends process the smgr invalidation smgrtruncate sent when + * they next lock the relation, so the lock can go now. + */ + UnlockRelation(onerel, AccessExclusiveLock); + + ereport(verbose ? INFO : DEBUG2, + (errmsg("table \"%s\": truncated %u to %u pages", + RelationGetRelationName(onerel), + orig_nblocks, new_nblocks))); + orig_nblocks = new_nblocks; + } while (new_nblocks > desired_nblocks && lock_waiter_detected); + + return start_nblocks - new_nblocks; +} + +/* + * May VACUUM free this line pointer? Only once Phase II has removed the + * index entries naming it, that is, if Phase I recorded it. A row that died + * or was pruned after Phase I stays LP_DEAD for the next VACUUM. Without + * indexes (no dead_items) nothing names it. + */ +static bool +FluxVacuumMayFree(TidStore *dead_items, BlockNumber blkno, OffsetNumber offnum) +{ + ItemPointerData tid; + + if (dead_items == NULL) + return true; + ItemPointerSet(&tid, blkno, offnum); + return TidStoreIsMember(dead_items, &tid); +} + +/* + * Reuse writer helping to clear aborted UPDATE/DELETE heads before freezing. + * A pending worker's later apply is idempotent. Finish outside the content + * lock, retaining our pin, and recheck the page after any lock release. + */ +static void +flux_vacuum_revert_page(Relation rel, Buffer buf) +{ + Page page = BufferGetPage(buf); + + for (OffsetNumber off = FirstOffsetNumber; off <= PageGetMaxOffsetNumber(page); off++) + { + ItemId item = PageGetItemId(page, off); + + if (!ItemIdIsNormal(item) || + FluxIsOverflowRecordInline(PageGetItem(page, item), ItemIdGetLength(item))) + continue; + if (FluxRevertAbortedVersions(rel, buf, off)) + off = InvalidOffsetNumber; + } +} + +/* Freeze a page under its exclusive content lock, before publishing horizons. */ +static void +flux_vacuum_freeze_page(Relation rel, Buffer buf, const VacuumCutoffs *cutoffs, + TransactionId *new_frozen) +{ + Page page = BufferGetPage(buf); + OffsetNumber maxoff = PageGetMaxOffsetNumber(page); + xl_flux_freeze_tuple plans[MaxOffsetNumber]; + int nplans = 0; + TransactionId conflict_xid = InvalidTransactionId; + + memset(plans, 0, sizeof(plans)); + for (OffsetNumber off = FirstOffsetNumber; off <= maxoff; off++) + { + ItemId item = PageGetItemId(page, off); + FluxTupleHeader *tuple; + TransactionId xmin; + TransactionId xmax; + uint8 actions = 0; + + if (!ItemIdIsNormal(item)) + continue; + tuple = (FluxTupleHeader *) PageGetItem(page, item); + if (FluxIsOverflowRecordInline(tuple, ItemIdGetLength(item))) + continue; + xmin = FluxTupleGetXmin(tuple); + xmax = FluxTupleGetXmax(tuple); + /* A pending DELETE rollback owns its chain: leave it to helping. */ + if (TransactionIdIsNormal(xmax) && + !(tuple->t_flags & FLUX_TUPLE_DELETED) && + !TransactionIdIsInProgress(xmax) && !TransactionIdDidCommit(xmax)) + actions |= FLUX_FREEZE_XMAX; + if (TransactionIdIsNormal(xmin) && + TransactionIdPrecedes(xmin, cutoffs->FreezeLimit) && + !(tuple->t_flags & FLUX_TUPLE_SPECULATIVE) && + TransactionIdDidCommit(xmin)) + { + actions |= FLUX_FREEZE_XMIN; + if (!TransactionIdIsValid(conflict_xid) || + TransactionIdFollows(xmin, conflict_xid)) + conflict_xid = xmin; + } + /* Only a universally visible live head can discard all history. */ + if ((actions & FLUX_FREEZE_XMIN || xmin == FrozenTransactionId) && + !(tuple->t_flags & FLUX_TUPLE_DELETED) && + (!TransactionIdIsValid(xmax) || actions & FLUX_FREEZE_XMAX)) + actions |= FLUX_FREEZE_DETACH; + if (actions != 0 && + (actions != FLUX_FREEZE_DETACH || UndoRecPtrIsValid(tuple->t_verptr))) + { + plans[nplans].offset = off; + plans[nplans++].actions = actions; + } + } + if (nplans > 0) + { + START_CRIT_SECTION(); + FluxExecuteFreeze(buf, plans, nplans); + MarkBufferDirty(buf); + if (RelationNeedsWAL(rel)) + PageSetLSN(page, FluxXLogFreeze(rel, buf, plans, nplans, conflict_xid)); + END_CRIT_SECTION(); + } + /* Include legacy and own-transaction before-images, not only the head. */ + for (OffsetNumber off = FirstOffsetNumber; off <= maxoff; off++) + { + ItemId item = PageGetItemId(page, off); + ItemPointerData tid; + + if (!ItemIdIsNormal(item) || + FluxIsOverflowRecordInline(PageGetItem(page, item), ItemIdGetLength(item))) + continue; + ItemPointerSet(&tid, BufferGetBlockNumber(buf), off); + *new_frozen = FluxVersionChainOldestXid(rel, &tid, + (const char *) PageGetItem(page, item), ItemIdGetLength(item), + *new_frozen); + } +} + +/* + * Run one index's ambulkdelete (dead_items != NULL) or amvacuumcleanup, + * reporting the index to pg_stat_progress_vacuum as heap's VACUUM does. + */ +static IndexBulkDeleteResult * +flux_vacuum_one_index(Relation indrel, Relation heaprel, + IndexBulkDeleteResult *istat, double num_heap_tuples, + bool estimated_count, bool verbose, + BufferAccessStrategy bstrategy, TidStore *dead_items, + VacDeadItemsInfo *dead_items_info) +{ + IndexVacuumInfo ivinfo; + const int reset_index[] = { + PROGRESS_VACUUM_CURRENT_INDEX_RELID, + PROGRESS_SCAN_BLOCKS_TOTAL, + PROGRESS_SCAN_BLOCKS_DONE + }; + const int64 reset_val[] = {(int64) InvalidOid, 0, 0}; + + ivinfo.index = indrel; + ivinfo.heaprel = heaprel; + ivinfo.analyze_only = false; + ivinfo.is_autovacuum = AmAutoVacuumWorkerProcess(); + ivinfo.report_progress = true; + ivinfo.estimated_count = estimated_count; + ivinfo.message_level = verbose ? INFO : DEBUG2; + ivinfo.num_heap_tuples = num_heap_tuples; + ivinfo.strategy = bstrategy; + + pgstat_progress_update_param(PROGRESS_VACUUM_CURRENT_INDEX_RELID, + (int64) RelationGetRelid(indrel)); + if (dead_items != NULL) + istat = vac_bulkdel_one_index(&ivinfo, istat, dead_items, + dead_items_info); + else + istat = vac_cleanup_one_index(&ivinfo, istat); + pgstat_progress_update_multi_param(3, reset_index, reset_val); + + return istat; +} + +/* + * Run ambulkdelete (dead_items != NULL) or amvacuumcleanup on all indexes + * with parallel workers, as heap's VACUUM does. Heap keeps its parallel + * context for the whole VACUUM; FLUX creates one per index pass, because + * its table phases write in ways parallel mode forbids (Phase III deletes + * dead rows' TOAST values, and reverting an aborted writer's versions can + * write). indstats carries the index statistics across passes. The dead + * TIDs are copied into the shared TidStore the workers read. + * + * ponytail: the copy holds the round's TIDs twice during the pass; collect + * into the shared TidStore directly if that memory matters. + * + * Returns false, doing nothing, if no worker would be used. + */ +static bool +flux_vacuum_indexes_parallel(Relation onerel, Relation *indrels, int nindexes, + IndexBulkDeleteResult **indstats, int nworkers, + bool verbose, BufferAccessStrategy bstrategy, + TidStore *dead_items, + VacDeadItemsInfo *dead_items_info, + double num_heap_tuples, bool estimated_count, + int num_index_scans) +{ + ParallelVacuumState *pvs; + + pvs = parallel_vacuum_init(onerel, indrels, nindexes, nworkers, + maintenance_work_mem, verbose ? INFO : DEBUG2, + bstrategy); + if (pvs == NULL) + return false; + parallel_vacuum_set_index_stats(pvs, indstats); + + if (dead_items != NULL) + { + VacDeadItemsInfo *shared_info; + TidStore *shared_items = parallel_vacuum_get_dead_items(pvs, + &shared_info); + TidStoreIter *iter = TidStoreBeginIterate(dead_items); + TidStoreIterResult *res; + OffsetNumber offsets[MaxOffsetNumber]; + + while ((res = TidStoreIterateNext(iter)) != NULL) + { + int n = TidStoreGetBlockOffsets(res, offsets, + lengthof(offsets)); + + Assert(n <= lengthof(offsets)); + TidStoreSetBlockOffsets(shared_items, res->blkno, offsets, n); + } + TidStoreEndIterate(iter); + shared_info->num_items = dead_items_info->num_items; + + parallel_vacuum_bulkdel_all_indexes(pvs, (long) num_heap_tuples, + num_index_scans, NULL); + } + else + parallel_vacuum_cleanup_all_indexes(pvs, (long) num_heap_tuples, + num_index_scans, estimated_count, + NULL); + + /* Copies the statistics back and leaves parallel mode. */ + parallel_vacuum_end(pvs, indstats); + return true; +} + +/* + * Vacuum a FLUX relation + * + * This performs garbage collection on a FLUX table in multiple phases: + * + * Phase I: Scan all pages, identify dead tuples, collect their TIDs + * Phase II: Remove dead index entries using the collected TIDs + * Phase III: Defragment data pages to reclaim space (must happen AFTER + * index cleanup to avoid dangling index pointers) + * Phase IV: Post-vacuum index cleanup (amvacuumcleanup) + * Phase V: Truncate trailing empty pages, update FSM + */ +void +flux_relation_vacuum(Relation onerel, const VacuumParams *params, + BufferAccessStrategy bstrategy) +{ + BlockNumber nblocks; + BlockNumber blkno; + Buffer buf; + Page page; + uint64 oldest_ts; + TransactionId oldest_xmin_vac; + VacuumCutoffs cutoffs; + bool aggressive; + TransactionId new_frozen; + TimestampTz starttime = GetCurrentTimestamp(); + BlockNumber all_visible_pages; + BlockNumber all_frozen_pages; + int64 remaining_dead = 0; + int64 num_tuples = 0; + int64 dead_tuples = 0; + int64 recently_dead_tuples = 0; + int64 live_tuples = 0; + int64 pages_vacuumed = 0; + BlockNumber empty_end_pages = 0; + BlockNumber pages_truncated = 0; + bool verbose = (params->options & VACOPT_VERBOSE) != 0; + + /* Index cleanup state */ + Relation *indrels = NULL; + int nindexes = 0; + IndexBulkDeleteResult **indstats = NULL; + TidStore *dead_items = NULL; + VacDeadItemsInfo *dead_items_info = NULL; + bool do_index_cleanup; + int num_index_scans = 0; + int nworkers = -1; /* parallel index vacuum: < 0 is off */ + + /* Rounds: Phases I-III over blocks round_start .. round_end - 1 */ + FluxVacScanState scan_state; + BlockNumber round_start = 0; + BlockNumber round_end; + bool round_full; + int64 scan_live_tuples = 0; /* Phase I's count, for Phase II */ + + /* + * Report to pg_stat_progress_vacuum at the points heap's VACUUM does. + * Phase I is "scanning heap", Phase II "vacuuming indexes", Phase III + * "vacuuming heap" (it visits every page, so heap_blks_vacuumed advances + * page by page), Phase IV "cleaning up indexes", Phase V "truncating + * heap". + */ + pgstat_progress_start_command(PROGRESS_COMMAND_VACUUM, + RelationGetRelid(onerel)); + if (AmAutoVacuumWorkerProcess()) + pgstat_progress_update_param(PROGRESS_VACUUM_STARTED_BY, + params->is_wraparound + ? PROGRESS_VACUUM_STARTED_BY_AUTOVACUUM_WRAPAROUND + : PROGRESS_VACUUM_STARTED_BY_AUTOVACUUM); + else + pgstat_progress_update_param(PROGRESS_VACUUM_STARTED_BY, + PROGRESS_VACUUM_STARTED_BY_MANUAL); + + /* + * Initialize FLUX transaction state so that VACUUM's own start timestamp + * is registered in xact_start_ts_slots. Without this, + * FluxGetOldestActiveTimestamp() would see no active transactions and + * fall back to the current wall clock (GetCurrentTimestamp()), which for + * the VM all-visible hint would over-eagerly mark tuples all-visible. + * (The reclamation gate itself is the XID horizon below, not this ts.) + */ + (void) FluxGetTransactionTimestamp(); + + /* + * Get the oldest active transaction's start timestamp. Deleted tuples + * whose commit timestamp is older than this are no longer visible to any + * running transaction and can safely be removed. + * + * Previously this called FluxGetCommitTimestamp() which returns (and + * advances) the current wall-clock time. That was wrong: it made VACUUM + * consider almost all deleted tuples as reclaimable, even those still + * needed by long-running concurrent transactions. + */ + oldest_ts = FluxGetOldestActiveTimestamp(); + oldest_xmin_vac = FluxGetOldestXminHorizon(onerel); + aggressive = vacuum_get_cutoffs(onerel, params, &cutoffs); + if (params->options & VACOPT_DISABLE_PAGE_SKIPPING) + aggressive = true; + new_frozen = cutoffs.OldestXmin; + nblocks = RelationGetNumberOfBlocks(onerel); + pgstat_progress_update_param(PROGRESS_VACUUM_MODE, + aggressive + ? PROGRESS_VACUUM_MODE_AGGRESSIVE + : PROGRESS_VACUUM_MODE_NORMAL); + + if (verbose) + ereport(INFO, (errmsg("vacuuming \"%s\": scanning %u pages", + RelationGetRelationName(onerel), nblocks))); + + /* + * Open all indexes on the relation. We need RowExclusiveLock to prevent + * concurrent index modifications during vacuum. + */ + vac_open_indexes(onerel, RowExclusiveLock, &nindexes, &indrels); + do_index_cleanup = (nindexes > 0); + + /* + * Vacuum the indexes in parallel when heap would: VACUUM (PARALLEL n) + * with n > 0 asks for up to n workers, a default VACUUM lets + * parallel_vacuum_init choose, and PARALLEL 0 sets params->nworkers to + * -1. Each index is vacuumed by one process, so it takes two indexes. + */ + if (params->nworkers >= 0 && nindexes > 1) + { + /* Parallel workers cannot access temporary tables. */ + if (RelationUsesLocalBuffers(onerel)) + { + if (params->nworkers > 0) + ereport(WARNING, + (errmsg("disabling parallel option of vacuum on \"%s\" --- cannot vacuum temporary tables in parallel", + RelationGetRelationName(onerel)))); + } + else + nworkers = params->nworkers; + } + + /* + * Allocate TidStore for collecting dead tuple TIDs, and per-index stats + * array. We use maintenance_work_mem as the budget for the TidStore. + */ + if (do_index_cleanup) + { + dead_items_info = (VacDeadItemsInfo *) palloc0(sizeof(VacDeadItemsInfo)); + dead_items_info->max_bytes = (size_t) maintenance_work_mem * 1024; + dead_items_info->num_items = 0; + + dead_items = TidStoreCreateLocal(dead_items_info->max_bytes, true); + + indstats = (IndexBulkDeleteResult **) + palloc0(nindexes * sizeof(IndexBulkDeleteResult *)); + } + + { + const int prog_index[] = { + PROGRESS_VACUUM_PHASE, + PROGRESS_VACUUM_TOTAL_HEAP_BLKS, + PROGRESS_VACUUM_MAX_DEAD_TUPLE_BYTES + }; + int64 prog_val[3]; + + prog_val[0] = PROGRESS_VACUUM_PHASE_SCAN_HEAP; + prog_val[1] = nblocks; + prog_val[2] = dead_items_info ? dead_items_info->max_bytes : 0; + pgstat_progress_update_multi_param(3, prog_index, prog_val); + } + + /* + * ----------------------------------------------------------------------- + * Phase I: Scan all pages, identify dead tuples, collect TIDs + * + * We scan every page and classify each tuple as live, dead (removable), + * or recently dead (not yet removable). Dead tuple TIDs are recorded in + * the TidStore for later index cleanup. We do NOT defragment pages yet + * -- that must wait until after index entries pointing to dead tuples + * have been removed (Phase II), to avoid dangling index pointers. + * ----------------------------------------------------------------------- + */ + scan_state.rel = onerel; + scan_state.current_block = InvalidBlockNumber; + scan_state.nblocks = nblocks; + scan_state.skip_all_frozen = + !(params->options & VACOPT_DISABLE_PAGE_SKIPPING); + + /* + * When dead_items outgrows maintenance_work_mem, Phase I stops, Phases II + * and III run for the blocks scanned so far, and the next round scans on + * from there with an empty dead_items, as heap's VACUUM does. Phase III + * frees only TIDs of the current round (FluxVacuumMayFree), whose index + * entries that round's Phase II removed. + */ +next_round: + round_full = false; + blkno = InvalidBlockNumber; + { + ReadStream *scan_stream; + + scan_stream = read_stream_begin_relation(READ_STREAM_MAINTENANCE | + READ_STREAM_USE_BATCHING, + bstrategy, + onerel, + MAIN_FORKNUM, + flux_vac_scan_next_block, + &scan_state, + 0); + + for (;;) + { + OffsetNumber offnum, + maxoffnum; + ItemId itemid; + OffsetNumber dead_offsets[MaxOffsetNumber]; + int ndead_on_page = 0; + + /* Cost-based delay, before pinning the next page. */ + vacuum_delay_point(false); + + /* Out of room for dead TIDs: end the round here. */ + if (dead_items != NULL && dead_items_info->num_items > 0 && + TidStoreMemoryUsage(dead_items) > dead_items_info->max_bytes) + { + round_full = true; + break; + } + buf = read_stream_next_buffer(scan_stream, NULL); + if (!BufferIsValid(buf)) + break; + + blkno = BufferGetBlockNumber(buf); + pgstat_progress_update_param(PROGRESS_VACUUM_HEAP_BLKS_SCANNED, + blkno); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + + /* Skip if page is new/uninitialized */ + if (PageIsNew(page)) + { + UnlockReleaseBuffer(buf); + continue; + } + + flux_vacuum_revert_page(onerel, buf); + maxoffnum = PageGetMaxOffsetNumber(page); + + /* Scan all tuples on the page */ + for (offnum = FirstOffsetNumber; offnum <= maxoffnum; offnum++) + { + FluxTupleHeader *tuple_hdr; + + itemid = PageGetItemId(page, offnum); + + /* + * LP_DEAD line pointers were produced by opportunistic + * pruning (FluxPagePruneOpt / UPDATE defrag-fit), which + * reclaims a committed-deleted tuple's storage but reserves + * its TID so it is not recycled while index entries still + * reference it. VACUUM must record these TIDs for index + * cleanup (Phase II) before Phase III converts them to + * LP_UNUSED. Their storage is already gone, so there is no + * tuple header to inspect. + */ + if (ItemIdIsDead(itemid)) + { + if (do_index_cleanup) + { + dead_offsets[ndead_on_page++] = offnum; + dead_tuples++; + } + continue; + } + + /* Skip if not a normal tuple */ + if (!ItemIdIsNormal(itemid)) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Skip overflow records - they are managed by tuple lifecycle */ + if (FluxIsOverflowRecordInline(tuple_hdr, ItemIdGetLength(itemid))) + continue; + + num_tuples++; + + /* + * Check if tuple is deleted and old enough to be removed. + * + * With the sLog-based MVCC model, a deleted tuple can be + * vacuumed if: - FLUX_TUPLE_DELETED is set - + * FLUX_TUPLE_UNCOMMITTED is NOT set (committed delete) - + * commit_ts is older than the oldest active snapshot + * + * If UNCOMMITTED is still set, the deleting transaction is + * still in progress (or aborted but not yet cleaned up by the + * sLog callback). Skip it. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + { + /* Transaction still in progress -- skip */ + scan_live_tuples++; + } + else if (FluxTupleDeadToAll(tuple_hdr, oldest_xmin_vac)) + { + /* Tuple is dead and can be removed */ + dead_offsets[ndead_on_page++] = offnum; + dead_tuples++; + } + else + { + /* Recently dead -- not yet reclaimable but still dead */ + dead_tuples++; + recently_dead_tuples++; + } + } + else if (FluxTupleDeadToAll(tuple_hdr, oldest_xmin_vac)) + { + /* Aborted INSERT: never visible to anyone, remove it. */ + dead_offsets[ndead_on_page++] = offnum; + dead_tuples++; + } + else + { + /* Tuple is live */ + scan_live_tuples++; + } + } + + /* + * Record dead tuple TIDs for this page in the TidStore. This is + * needed for index cleanup in Phase II. + */ + if (ndead_on_page > 0 && do_index_cleanup) + { + const int prog_index[] = { + PROGRESS_VACUUM_NUM_DEAD_ITEM_IDS, + PROGRESS_VACUUM_DEAD_TUPLE_BYTES + }; + int64 prog_val[2]; + + TidStoreSetBlockOffsets(dead_items, blkno, + dead_offsets, ndead_on_page); + dead_items_info->num_items += ndead_on_page; + prog_val[0] = dead_items_info->num_items; + prog_val[1] = TidStoreMemoryUsage(dead_items); + pgstat_progress_update_multi_param(2, prog_index, prog_val); + } + + UnlockReleaseBuffer(buf); + } + + read_stream_end(scan_stream); + if (round_full) + { + /* The next round resumes after the last block read. */ + Assert(BlockNumberIsValid(blkno)); + scan_state.current_block = blkno; + round_end = blkno + 1; + } + else + { + pgstat_progress_update_param(PROGRESS_VACUUM_HEAP_BLKS_SCANNED, + nblocks); + round_end = nblocks; + } + } + + /* + * ----------------------------------------------------------------------- + * Phase II: Index vacuum -- remove dead index entries + * + * For each index on the relation, call the index AM's bulk delete routine + * to remove entries pointing to dead tuples. This MUST happen before we + * defragment data pages (Phase III) to ensure no index entry points to a + * TID that has been recycled. + * ----------------------------------------------------------------------- + */ + if (do_index_cleanup) + { + int idx; + bool scanned = false; + const int start_index[] = { + PROGRESS_VACUUM_PHASE, + PROGRESS_VACUUM_INDEXES_TOTAL + }; + const int end_index[] = { + PROGRESS_VACUUM_INDEXES_TOTAL, + PROGRESS_VACUUM_INDEXES_PROCESSED, + PROGRESS_VACUUM_NUM_INDEX_VACUUMS + }; + int64 start_val[2]; + int64 end_val[3]; + + if (verbose && dead_items_info->num_items > 0) + ereport(INFO, + (errmsg("vacuuming \"%s\": removing %lld dead index entries across %d indexes", + RelationGetRelationName(onerel), + (long long) dead_items_info->num_items, + nindexes))); + + start_val[0] = PROGRESS_VACUUM_PHASE_VACUUM_INDEX; + start_val[1] = nindexes; + pgstat_progress_update_multi_param(2, start_index, start_val); + + /* + * The workers report indexes_processed. A pass with no dead TIDs + * only scans delete-marking indexes, and stays serial. + */ + if (nworkers >= 0 && dead_items_info->num_items > 0 && + flux_vacuum_indexes_parallel(onerel, indrels, nindexes, indstats, + nworkers, verbose, bstrategy, + dead_items, dead_items_info, + (double) scan_live_tuples, true, + num_index_scans)) + scanned = true; + else + { + for (idx = 0; idx < nindexes; idx++) + { + /* + * With no dead TIDs, only a delete-marking index has anything + * to remove: the delete-marked entries an in-place key UPDATE + * left for a live row (table_index_tombstone_removable): a + * scan of such an index on every VACUUM. Counting key + * UPDATEs per relation would let us skip it when there were + * none. The first round's scan has already removed those. + */ + if (dead_items_info->num_items > 0 || + (num_index_scans == 0 && + indrels[idx]->rd_indam->amdeletemark != NULL)) + { + indstats[idx] = flux_vacuum_one_index(indrels[idx], onerel, + indstats[idx], + (double) scan_live_tuples, + true, verbose, bstrategy, + dead_items, + dead_items_info); + scanned = true; + } + pgstat_progress_update_param(PROGRESS_VACUUM_INDEXES_PROCESSED, + idx + 1); + } + } + + /* A pass that scanned an index counts as a round, as in heap. */ + if (scanned) + num_index_scans++; + end_val[0] = 0; + end_val[1] = 0; + end_val[2] = num_index_scans; + pgstat_progress_update_multi_param(3, end_index, end_val); + } + + /* + * ----------------------------------------------------------------------- + * Phase III: Defragment data pages -- remove dead tuples from heap + * + * Now that index entries pointing to dead tuples have been removed, we + * can safely defragment data pages. This reclaims the space occupied by + * dead tuples and makes it available for reuse. + * ----------------------------------------------------------------------- + */ + /* Tests stop here, between Phase II and Phase III. */ + INJECTION_POINT("flux-vacuum-before-phase3", NULL); + /* Phase III visits every page of the round, also those Phase I skipped. */ + pgstat_progress_update_param(PROGRESS_VACUUM_PHASE, + PROGRESS_VACUUM_PHASE_VACUUM_HEAP); + + { + ReadStream *stream; + BlockRangeReadStreamPrivate stream_private; + + stream_private.current_blocknum = round_start; + stream_private.last_exclusive = round_end; + + stream = read_stream_begin_relation(READ_STREAM_MAINTENANCE | + READ_STREAM_USE_BATCHING, + bstrategy, + onerel, + MAIN_FORKNUM, + block_range_read_stream_cb, + &stream_private, + 0); + + for (;;) + { + OffsetNumber offnum, + maxoffnum; + ItemId itemid; + bool page_has_dead_tuples = false; + bool page_modified = false; + + vacuum_delay_point(false); + buf = read_stream_next_buffer(stream, NULL); + if (!BufferIsValid(buf)) + break; + + blkno = BufferGetBlockNumber(buf); + pgstat_progress_update_param(PROGRESS_VACUUM_HEAP_BLKS_VACUUMED, + blkno); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + + if (PageIsNew(page)) + { + empty_end_pages++; /* empty too, see below */ + UnlockReleaseBuffer(buf); + continue; + } + + flux_vacuum_revert_page(onerel, buf); + maxoffnum = PageGetMaxOffsetNumber(page); + + /* + * First pass: clean overflow chains for dead tuples. We must do + * this BEFORE defragmenting, because FluxPageDefragment removes + * dead item pointers and after that we can no longer identify + * which tuples had overflow data. We temporarily drop the + * exclusive lock since overflow chain deletion may need to read + * and lock other pages. + */ + for (offnum = FirstOffsetNumber; offnum <= maxoffnum; offnum++) + { + FluxTupleHeader *tuple_hdr; + + itemid = PageGetItemId(page, offnum); + + /* + * LP_DEAD line pointers were already pruned (storage + * reclaimed) by opportunistic pruning and their TIDs recorded + * in Phase I for index cleanup, which has now run. They + * carry no storage and no overflow chain to clean; just flag + * the page so the defragment pass below converts them to + * LP_UNUSED. + */ + if (ItemIdIsDead(itemid)) + { + if (FluxVacuumMayFree(dead_items, blkno, offnum)) + page_has_dead_tuples = true; + continue; + } + + if (!ItemIdIsNormal(itemid)) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + if (FluxIsOverflowRecordInline(tuple_hdr, ItemIdGetLength(itemid))) + continue; + + /* + * A dead row's TOAST values need nothing from VACUUM, as in + * heap: its DELETE deleted them, and an aborted INSERT's + * values died with it. TOAST VACUUM removes them. + */ + if (FluxTupleDeadToAll(tuple_hdr, oldest_xmin_vac)) + page_has_dead_tuples = true; + } + + /* + * If page has dead tuples, defragment it to consolidate space. + */ + if (page_has_dead_tuples) + { + TransactionId vac_horizon = InvalidTransactionId; + + /* + * Compaction moves tuples, so it needs a cleanup lock, as + * heap pruning does. The exclusive lock we hold may already + * be one. Otherwise an aggressive VACUUM waits for the other + * pins to go away and looks at the page again (the pass below + * decides again which tuples to remove); a non-aggressive one + * leaves the page for a later VACUUM, as heap's + * lazy_scan_noprune does, rather than wait behind a cursor + * that may hold its pin for a long time. Freezing and the + * visibility map still see the page below. + */ + if (!IsBufferCleanupOK(buf)) + { + if (!aggressive) + goto page_not_compacted; + LockBuffer(buf, BUFFER_LOCK_UNLOCK); + LockBufferForCleanup(buf); + page = BufferGetPage(buf); + maxoffnum = PageGetMaxOffsetNumber(page); + } + + START_CRIT_SECTION(); + + /* + * Mark dead tuples as unused before defragmenting. The scan + * above already identified them; now set LP_UNUSED. Index + * cleanup (Phase II) has removed every index entry pointing + * at these TIDs, so it is now safe to free the line pointers + * for recycling -- both the still-materialized DELETED tuples + * and the already-pruned LP_DEAD placeholders. + */ + for (offnum = FirstOffsetNumber; offnum <= maxoffnum; offnum++) + { + FluxTupleHeader *vac_hdr; + + itemid = PageGetItemId(page, offnum); + + if (ItemIdIsDead(itemid)) + { + if (FluxVacuumMayFree(dead_items, blkno, offnum)) + ItemIdSetUnused(itemid); + continue; + } + + if (!ItemIdIsNormal(itemid)) + continue; + + if (FluxIsOverflowRecordInline(PageGetItem(page, itemid), + ItemIdGetLength(itemid))) + continue; + + vac_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + if (FluxTupleDeadToAll(vac_hdr, oldest_xmin_vac)) + { + FluxTupleAdvanceConflictHorizon(vac_hdr, &vac_horizon); + /* unrecorded: its index entries survived Phase II */ + if (FluxVacuumMayFree(dead_items, blkno, offnum)) + ItemIdSetUnused(itemid); + else + ItemIdSetDead(itemid); + } + } + + FluxPageDefragment(page); + page_modified = true; + pages_vacuumed++; + + MarkBufferDirty(buf); + + if (RelationNeedsWAL(onerel)) + { + XLogRecPtr recptr; + + recptr = FluxXLogDefrag(onerel, buf, NULL, 0, oldest_ts, + vac_horizon); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + } + + page_not_compacted: + flux_vacuum_freeze_page(onerel, buf, &cutoffs, &new_frozen); + + for (offnum = FirstOffsetNumber; offnum <= PageGetMaxOffsetNumber(page); offnum++) + { + FluxTupleHeader *hdr; + + itemid = PageGetItemId(page, offnum); + if (ItemIdIsDead(itemid)) + { + remaining_dead++; + continue; + } + if (!ItemIdIsNormal(itemid)) + continue; + hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + if (FluxIsOverflowRecordInline(hdr, ItemIdGetLength(itemid))) + continue; + switch (FluxTupleStatsCount(hdr)) + { + case FLUX_COUNT_LIVE: + live_tuples++; + break; + case FLUX_COUNT_DEAD: + remaining_dead++; + break; + case FLUX_COUNT_NONE: + break; + } + } + + /* Update FSM with accurate free space information */ + if (page_modified || PageGetFreeSpace(page) > 0) + { + FluxRecordFreeSpace(onerel, blkno, PageGetHeapFreeSpace(page)); + } + + /* + * Update the Visibility Map for this page. + * + * After defragmentation, check whether all remaining tuples on + * the page are visible to all transactions and/or frozen. If so, + * set the appropriate VM bits. This enables index-only scans to + * skip heap fetches and future VACUUMs to skip this page + * entirely. + */ + { + bool all_visible = true; + bool all_frozen = true; + TransactionId vm_conflict = InvalidTransactionId; + OffsetNumber vm_offnum; + OffsetNumber vm_maxoff; + + vm_maxoff = PageGetMaxOffsetNumber(page); + + for (vm_offnum = FirstOffsetNumber; vm_offnum <= vm_maxoff; vm_offnum++) + { + ItemId vm_itemid; + FluxTupleHeader *vm_tuple_hdr; + + vm_itemid = PageGetItemId(page, vm_offnum); + + /* + * an LP_DEAD slot awaits a VACUUM, which must not skip + * the page + */ + if (ItemIdIsDead(vm_itemid)) + { + all_visible = false; + all_frozen = false; + break; + } + if (!ItemIdIsNormal(vm_itemid)) + continue; + + /* Skip overflow records */ + if (FluxIsOverflowRecordInline(PageGetItem(page, vm_itemid), + ItemIdGetLength(vm_itemid))) + continue; + + vm_tuple_hdr = (FluxTupleHeader *) PageGetItem(page, vm_itemid); + + /* + * Dead tuples (deleted) that survived defrag are recently + * dead + */ + if (vm_tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + all_visible = false; + all_frozen = false; + break; + } + + /* Speculative tuples are not visible to all */ + if (vm_tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE) + { + all_visible = false; + all_frozen = false; + break; + } + + if (vm_tuple_hdr->t_xmin != FrozenTransactionId) + { + all_frozen = false; + if (!TransactionIdIsValid(vm_conflict) || + TransactionIdFollows(vm_tuple_hdr->t_xmin, vm_conflict)) + vm_conflict = vm_tuple_hdr->t_xmin; + if (!TransactionIdIsNormal(vm_tuple_hdr->t_xmin) || + !TransactionIdPrecedes(vm_tuple_hdr->t_xmin, oldest_xmin_vac) || + !TransactionIdDidCommit(vm_tuple_hdr->t_xmin)) + all_visible = false; + } + if (TransactionIdIsNormal(FluxTupleGetXmax(vm_tuple_hdr))) + { + all_frozen = false; + all_visible = false; + } + } + + /* Empty pages are trivially all-visible and all-frozen */ + if (vm_maxoff < FirstOffsetNumber) + { + all_visible = true; + all_frozen = true; + } + + FluxVMVacuumPage(onerel, buf, all_visible, all_frozen, vm_conflict); + } + + /* + * Count the run of empty pages ending at this block. Pages are + * read in ascending order, so the run that reaches the last block + * is the one Phase V truncates. + */ + if (PageGetMaxOffsetNumber(page) < FirstOffsetNumber) + empty_end_pages++; + else + empty_end_pages = 0; + + UnlockReleaseBuffer(buf); + } + + pgstat_progress_update_param(PROGRESS_VACUUM_HEAP_BLKS_VACUUMED, + round_end); + read_stream_end(stream); + } + + if (round_full) + { + /* Forget the TIDs this round vacuumed, and scan on. */ + TidStoreDestroy(dead_items); + dead_items = TidStoreCreateLocal(dead_items_info->max_bytes, true); + dead_items_info->num_items = 0; + round_start = round_end; + pgstat_progress_update_param(PROGRESS_VACUUM_PHASE, + PROGRESS_VACUUM_PHASE_SCAN_HEAP); + goto next_round; + } + + /* + * ----------------------------------------------------------------------- + * Phase IV: Index cleanup (amvacuumcleanup) + * + * Call each index AM's vacuum cleanup routine. This lets the index AM do + * any post-vacuum maintenance such as reclaiming empty pages, updating + * statistics, etc. This is called even if no dead tuples were found, + * since some index AMs use this to update internal metadata. + * ----------------------------------------------------------------------- + */ + if (do_index_cleanup) + { + int idx; + const int start_index[] = { + PROGRESS_VACUUM_PHASE, + PROGRESS_VACUUM_INDEXES_TOTAL + }; + const int end_index[] = { + PROGRESS_VACUUM_INDEXES_TOTAL, + PROGRESS_VACUUM_INDEXES_PROCESSED + }; + int64 start_val[2]; + int64 end_val[2] = {0, 0}; + + start_val[0] = PROGRESS_VACUUM_PHASE_INDEX_CLEANUP; + start_val[1] = nindexes; + pgstat_progress_update_multi_param(2, start_index, start_val); + + if (nworkers < 0 || + !flux_vacuum_indexes_parallel(onerel, indrels, nindexes, indstats, + nworkers, verbose, bstrategy, + NULL, NULL, (double) live_tuples, + false, num_index_scans)) + { + for (idx = 0; idx < nindexes; idx++) + { + indstats[idx] = flux_vacuum_one_index(indrels[idx], onerel, + indstats[idx], + (double) live_tuples, + false, verbose, + bstrategy, NULL, NULL); + pgstat_progress_update_param(PROGRESS_VACUUM_INDEXES_PROCESSED, + idx + 1); + } + } + + /* Out of parallel mode now: catalog updates are allowed. */ + for (idx = 0; idx < nindexes; idx++) + { + if (indstats[idx] != NULL && !indstats[idx]->estimated_count) + vac_update_relstats(indrels[idx], indstats[idx]->num_pages, + indstats[idx]->num_index_tuples, 0, 0, false, + InvalidTransactionId, InvalidMultiXactId, + NULL, NULL, false); + } + pgstat_progress_update_multi_param(2, end_index, end_val); + } + + /* + * ----------------------------------------------------------------------- + * Phase IV-C: Orphan overflow cleanup + * + * Run the two-pass orphan detection algorithm to find and remove overflow + * records that are not referenced by any live tuple. This catches + * overflow records that were orphaned by crashes, aborted transactions, + * or bugs in the eager cleanup path. + * ----------------------------------------------------------------------- + */ + /* FLUX has no on-page overflow records to vacuum (wide values use TOAST). */ + + /* + * ----------------------------------------------------------------------- + * Phase IV-D: per-backend UNDO discard + * + * FLUX's DML UNDO lives in the per-backend engine, which reclaims its own + * log segments via its discard worker. There is no per-relation UNDO log + * for VACUUM to discard here. + * ----------------------------------------------------------------------- + */ + + /* + * ----------------------------------------------------------------------- + * Phase V: Truncation and final cleanup + * ----------------------------------------------------------------------- + */ + + /* Truncate empty pages at the end of the relation */ + if (FluxShouldAttemptTruncation(params, nblocks, empty_end_pages)) + { + BlockNumber new_nblocks = nblocks - empty_end_pages; + + pgstat_progress_update_param(PROGRESS_VACUUM_PHASE, + PROGRESS_VACUUM_PHASE_TRUNCATE); + + /* Tests insert into the empty tail here, before the lock is taken. */ + INJECTION_POINT("flux-vacuum-before-truncate", NULL); + + /* + * Truncate under AccessExclusiveLock with a tail re-verification, so + * we never remove a block a concurrent INSERT is targeting. The + * helper may truncate to fewer pages than requested (never more) or + * decline entirely if it can't get the lock or the tail is no longer + * empty. + */ + pages_truncated = FluxTruncateRelation(onerel, nblocks, new_nblocks, + verbose); + } + + /* Update FSM for the entire relation using the real post-truncate size */ + FluxVacuumFSM(onerel, RelationGetNumberOfBlocks(onerel)); + + pgstat_progress_update_param(PROGRESS_VACUUM_PHASE, + PROGRESS_VACUUM_PHASE_FINAL_CLEANUP); + + /* The complete Phase III scan bounds every on-page and historical XID. */ + nblocks -= pages_truncated; + visibilitymap_count(onerel, &all_visible_pages, &all_frozen_pages); + all_visible_pages = Min(all_visible_pages, nblocks); + all_frozen_pages = Min(all_frozen_pages, all_visible_pages); + vac_update_relstats(onerel, nblocks, live_tuples, + all_visible_pages, all_frozen_pages, nindexes > 0, + new_frozen, cutoffs.OldestMxact, NULL, NULL, false); + pgstat_report_vacuum(onerel, live_tuples, remaining_dead, starttime); + pgstat_progress_end_command(); + + /* Clean up index resources */ + if (dead_items != NULL) + TidStoreDestroy(dead_items); + if (dead_items_info != NULL) + pfree(dead_items_info); + if (indstats != NULL) + pfree(indstats); + vac_close_indexes(nindexes, indrels, RowExclusiveLock); + + /* Report statistics */ + if (verbose || params->options & VACOPT_VERBOSE) + { + ereport(INFO, + (errmsg("FLUX vacuum \"%s\": found %lld tuples (%lld live, %lld dead), " + "%lld are dead but not yet removable, " + "vacuumed %lld pages, truncated %u pages, " + "cleaned %d indexes", + RelationGetRelationName(onerel), + (long long) num_tuples, + (long long) live_tuples, + (long long) dead_tuples, + (long long) recently_dead_tuples, + (long long) pages_vacuumed, + pages_truncated, + nindexes))); + } +} + +/* ================================================================ + * sLog transaction callbacks for FLUX + * + * These handle FLUX-specific page operations (clearing UNCOMMITTED flags, + * marking aborted tuples as DELETED) at transaction boundaries. They call + * the generic SLogTuple* functions for shared-hash cleanup. + * ================================================================ + */ + +/* One record per row lock held by a prepared transaction. */ +typedef struct FluxTwoPhaseRecord +{ + RelFileLocator rlocator; + Oid relid; + ItemPointerData tid; + TransactionId xid; + LockTupleMode mode; +} FluxTwoPhaseRecord; + +#define FLUX_2PC_ROWLOCK 1 + +/* Allocated in TopTransactionContext at PRE_PREPARE. */ +static List *flux_prepared_locks = NIL; + +/* + * FluxEnsureSLogCallbacks -- register xact/subxact callbacks once per backend. + */ +void +FluxEnsureSLogCallbacks(void) +{ + if (!flux_slog_callbacks_registered) + { + RegisterXactCallback(FluxSLogXactCallback, NULL); + RegisterSubXactCallback(FluxSLogSubXactCallback, NULL); + flux_slog_callbacks_registered = true; + } +} + +/* + * Was this image written by one of top's own subtransactions that rolled + * back? t_xmin is the writing (sub)transaction's xid, so that is exactly: + * a later xid than top, no longer current (a committed child still is), and + * top is its topmost parent. Every xid that follows top also follows + * TransactionXmin, as pg_subtrans requires. + */ +static bool +flux_xmin_is_own_aborted_subxact(TransactionId xmin, TransactionId top) +{ + return TransactionIdIsNormal(xmin) && + TransactionIdFollows(xmin, top) && + !TransactionIdIsCurrentTransactionId(xmin) && + TransactionIdEquals(SubTransGetTopmostTransaction(xmin), top); +} + +/* + * FluxProcessAbortedEntries -- at PRE_COMMIT and PRE_PREPARE, mark the tuples + * this transaction's rolled-back subtransactions inserted as DELETED on their + * pages. + * + * SLogTupleRemoveBySubXid flips a tracked key's own op_type to + * SLOG_OP_ABORTED when its subtransaction rolls back, so that is what says the + * key's op was rolled back. + * + * A key made in storage the relation no longer uses (rewritten or truncated + * since) has no page to fix: that storage goes away at commit. A key in the + * current storage can still name a block past its end (TRUNCATE of a table + * created in this transaction keeps the storage), and that tuple is gone too. + */ +static void +flux_process_aborted_key(Relation rel, BlockNumber nblocks, + const SLogTrackedKeyInfo *tk) +{ + ItemPointer tid = (ItemPointer) &tk->key.tid; + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + + if (tk->relnumber == rel->rd_locator.relNumber && blkno < nblocks) + { + Buffer buf; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + + buf = ReadBuffer(rel, blkno); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + + if (offnum <= PageGetMaxOffsetNumber(page)) + { + itemid = PageGetItemId(page, offnum); + if (ItemIdIsNormal(itemid)) + { + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Only an image one of our rolled-back subtransactions wrote + * is ours to discard. The ABORTED op says only that we did + * something to this tid in a rolled-back subtransaction -- an + * INSERT, but also an UPDATE, a DELETE or a row lock -- and + * the UNCOMMITTED hint may belong to an image someone else + * wrote after that rollback (committed or still running), or + * to our own top-level UPDATE. Marking such an image DELETED + * left a committed row that a plain SELECT still returned but + * UPDATE, DELETE and row locks no longer found, and did so + * with no WAL, on the primary only. + */ + if ((tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) && + flux_xmin_is_own_aborted_subxact(FluxTupleGetXmin(tuple_hdr), + tk->xid)) + { + tuple_hdr->t_flags |= FLUX_TUPLE_DELETED; + tuple_hdr->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; + MarkBufferDirty(buf); + } + } + } + + UnlockReleaseBuffer(buf); + } +} + +static void +FluxProcessAbortedEntries(TransactionId xid) +{ + SLogTrackedKeyInfo *keys; + int nkeys; + Oid cur_relid = InvalidOid; + Relation rel = NULL; + BlockNumber nblocks = 0; + + nkeys = SLogTupleCollectTrackedKeys(xid, &keys); + for (int i = 0; i < nkeys; i++) + { + SLogTrackedKeyInfo *tk = &keys[i]; + + if (tk->op_type != SLOG_OP_ABORTED) + continue; + + if (tk->key.relid != cur_relid) + { + if (rel != NULL) + relation_close(rel, AccessShareLock); + cur_relid = tk->key.relid; + rel = try_relation_open(cur_relid, AccessShareLock); + if (rel != NULL) + nblocks = RelationGetNumberOfBlocks(rel); + } + + if (rel != NULL) + flux_process_aborted_key(rel, nblocks, tk); + } + + if (rel != NULL) + relation_close(rel, AccessShareLock); + pfree(keys); +} + +/* ---------------------------------------------------------------- + * Batched commit-time UNCOMMITTED-flag clearing for + * FluxClearUncommittedFlags. + * + * Commit visibility comes from CLOG (heap-shaped xmin/xmax MVCC); commit + * does NOT stamp any timestamp. This path only clears the on-page + * FLUX_TUPLE_UNCOMMITTED hint flag so later readers skip the sLog + * fast-path lookup. t_xmin/t_xmax written by the DML stay untouched. + * + * The batched approach collects tracked keys, sorts by (relid, blockno), + * and processes them with minimal buffer I/O: + * - One try_relation_open() per distinct relid + * - One ReadBuffer() per distinct block + * - Local-only INSERTs skip the shared sLog lookup entirely + * ---------------------------------------------------------------- + */ +/* + * flux_cmp_tracked_key_by_block -- qsort comparator for batch commit stamping. + * + * Sorts by (relid, blockno, offnum) to enable sequential I/O: one + * try_relation_open per relation, one ReadBuffer per distinct block. + */ +static int +flux_cmp_tracked_key_by_block(const void *a, const void *b) +{ + const SLogTrackedKeyInfo *ka = (const SLogTrackedKeyInfo *) a; + const SLogTrackedKeyInfo *kb = (const SLogTrackedKeyInfo *) b; + + if (ka->key.relid < kb->key.relid) + return -1; + if (ka->key.relid > kb->key.relid) + return 1; + + { + BlockNumber ba = ItemPointerGetBlockNumber((ItemPointer) &ka->key.tid); + BlockNumber bb = ItemPointerGetBlockNumber((ItemPointer) &kb->key.tid); + + if (ba < bb) + return -1; + if (ba > bb) + return 1; + } + + { + OffsetNumber oa = ItemPointerGetOffsetNumber((ItemPointer) &ka->key.tid); + OffsetNumber ob = ItemPointerGetOffsetNumber((ItemPointer) &kb->key.tid); + + if (oa < ob) + return -1; + if (oa > ob) + return 1; + } + + return 0; +} + +/* + * flux_stamp_tuple_committed -- clear the UNCOMMITTED hint of one tuple we + * inserted, at commit time. Called from the batched commit path with the + * buffer already locked exclusive. + * + * A key whose savepoint rolled back (op_type flipped to SLOG_OP_ABORTED by + * SLogTupleRemoveBySubXid) is skipped: FluxProcessAbortedEntries has marked + * its tuple DELETED. Commit stamps nothing else: t_xmin and t_xmax were + * written by the DML, CLOG is the commit oracle, and the flag is a hint. + */ +static void +flux_stamp_tuple_committed(Buffer buf, OffsetNumber offnum, + const SLogTrackedKeyInfo *tk, + TransactionId xid) +{ + Page page = BufferGetPage(buf); + ItemId itemid; + FluxTupleHeader *tuple_hdr; + + if (tk->op_type == SLOG_OP_ABORTED) + return; + if (offnum > PageGetMaxOffsetNumber(page)) + return; + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + return; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + if (tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + tuple_hdr->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; +} + +/* + * flux_batch_clear_uncommitted -- batch-process tracked keys at commit time. + * + * Processes the pre-sorted array of tracked keys with sequential I/O: + * - One try_relation_open() per distinct relid + * - One ReadBuffer() per distinct block within each relation + * - All tuples on the same page are stamped while holding one buffer lock + * + * This replaces the per-tuple callback pattern which did O(n) ReadBuffer + * calls even when multiple tuples shared the same page. + */ +static void +flux_batch_clear_uncommitted(SLogTrackedKeyInfo *keys, int nkeys, + TransactionId xid) +{ + Oid cur_relid = InvalidOid; + BlockNumber cur_blkno = InvalidBlockNumber; + BlockNumber nblocks = 0; + Relation rel = NULL; + Buffer buf = InvalidBuffer; + int i; + + /* + * Prefetch: issue advisory read-ahead for the first few distinct blocks. + * Since keys are sorted by (relid, blockno), we can scan ahead cheaply. + * This overlaps I/O with the kernel readahead path. + */ + if (nkeys > 0) + { + Oid pf_relid = keys[0].key.relid; + Relation pf_rel; + BlockNumber pf_prev = InvalidBlockNumber; + int pf_count = 0; + + pf_rel = try_relation_open(pf_relid, AccessShareLock); + if (pf_rel != NULL) + { + for (int j = 0; j < nkeys && pf_count < 8; j++) + { + BlockNumber pf_blk; + + /* Stop prefetching if we cross into a different relation */ + if (keys[j].key.relid != pf_relid) + break; + if (keys[j].relnumber != pf_rel->rd_locator.relNumber) + continue; + + pf_blk = ItemPointerGetBlockNumber((ItemPointer) &keys[j].key.tid); + if (pf_blk != pf_prev) + { + PrefetchBuffer(pf_rel, MAIN_FORKNUM, pf_blk); + pf_prev = pf_blk; + pf_count++; + } + } + relation_close(pf_rel, AccessShareLock); + } + } + + for (i = 0; i < nkeys; i++) + { + SLogTrackedKeyInfo *tk = &keys[i]; + Oid relid = tk->key.relid; + BlockNumber blkno = ItemPointerGetBlockNumber((ItemPointer) &tk->key.tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber((ItemPointer) &tk->key.tid); + + /* Switch relation if needed (sorted order minimizes switches) */ + if (relid != cur_relid) + { + /* Mark outgoing page dirty (once per page, not per tuple) */ + if (BufferIsValid(buf)) + { + MarkBufferDirtyHint(buf, true); + UnlockReleaseBuffer(buf); + buf = InvalidBuffer; + } + if (rel != NULL) + { + relation_close(rel, AccessShareLock); + rel = NULL; + } + + rel = try_relation_open(relid, AccessShareLock); + if (rel == NULL) + { + cur_relid = relid; + cur_blkno = InvalidBlockNumber; + continue; + } + cur_relid = relid; + cur_blkno = InvalidBlockNumber; + nblocks = RelationGetNumberOfBlocks(rel); + } + + if (rel == NULL) + continue; + + /* + * The row went to storage this relation no longer uses: a rewrite or + * TRUNCATE later in this transaction replaced it, and it goes away at + * commit. The same TID in the current storage is another row, or a + * heap tuple, or past its end. A row in the current storage can + * still be past its end, after a TRUNCATE of a table created in this + * transaction (which keeps the storage). + */ + if (tk->relnumber != rel->rd_locator.relNumber || blkno >= nblocks) + continue; + + /* + * Switch block if needed; amortizes ReadBuffer across same-page + * tuples + */ + if (blkno != cur_blkno) + { + /* Mark outgoing page dirty (once per page, not per tuple) */ + if (BufferIsValid(buf)) + { + MarkBufferDirtyHint(buf, true); + UnlockReleaseBuffer(buf); + buf = InvalidBuffer; + } + + buf = ReadBuffer(rel, blkno); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + cur_blkno = blkno; + } + + /* Stamp this tuple */ + flux_stamp_tuple_committed(buf, offnum, tk, xid); + } + + /* Mark final page dirty and release */ + if (BufferIsValid(buf)) + { + MarkBufferDirtyHint(buf, true); + UnlockReleaseBuffer(buf); + } + if (rel != NULL) + relation_close(rel, AccessShareLock); +} + +/* + * FluxClearUncommittedFlags -- proactively clear FLUX_TUPLE_UNCOMMITTED on + * all tuples modified by the current transaction at PRE_COMMIT time. + * + * Uses a batched approach: collects all tracked keys into an array, sorts + * by (relid, blockno) for sequential I/O, then processes them with at most + * one ReadBuffer per distinct block and one try_relation_open per relation. + * + * A tracked INSERT whose savepoint did not roll back (op_type still + * SLOG_OP_INSERT) is a live INSERT of this transaction. + * + * Clearing the flag is a pure hint: commit visibility comes from CLOG + * (heap-shaped xmin/xmax MVCC), not from any stamped timestamp. + */ +static void +FluxClearUncommittedFlags(TransactionId xid) +{ + SLogTrackedKeyInfo *keys; + int nkeys; + + /* + * When lazy clear is enabled, skip the expensive batch page-visit loop. + * The UNCOMMITTED flags will be cleared lazily by readers via the + * visibility functions when they next access these tuples. + */ + if (flux_lazy_uncommitted_clear) + return; + + /* + * Option B (per-page conflict tracking): clear the hint by visiting each + * page we dirtied in place once, rather than walking a per-tuple + * tracked-key list. This is O(pages touched), not O(tuples), which is + * what keeps a large UPDATE/DELETE from exhausting backend memory. The + * per-tuple INSERT tracked keys (local-only, bounded) are still cleared + * by the batch path below; only in-place UPDATE/DELETE flag cleanup moved + * to the page set. + */ + flux_clear_uncommitted_by_page(xid); + + /* Collect tracked keys into a sortable array */ + nkeys = SLogTupleCollectTrackedKeys(xid, &keys); + if (nkeys == 0) + { + pfree(keys); + return; + } + + /* Sort by (relid, blockno, offnum) for sequential I/O */ + qsort(keys, nkeys, sizeof(SLogTrackedKeyInfo), flux_cmp_tracked_key_by_block); + + /* Batch-process: one ReadBuffer per distinct block */ + flux_batch_clear_uncommitted(keys, nkeys, xid); + + pfree(keys); +} + +/* + * flux_page_clear_uncommitted -- clear FLUX_TUPLE_UNCOMMITTED on every tuple + * of our committing xid on one page. + * + * Per-page commit-time flag cleanup (option B): instead of visiting a + * per-tuple tracked-key list, we visit each page this transaction dirtied + * (from the backend-local dirty-page set) exactly once and scan its line + * pointers, clearing the hint on tuples stamped with our xid. + * + * Correctness vs. the per-tuple path: + * - We clear only tuples whose inserter (t_xmin) or updater/deleter (t_xmax) + * is our committing xid, so we never touch another transaction's tuples + * that happen to share the page. + * - Savepoint-aborted tuples must NOT be un-hinted (that would resurrect + * them). At PRE_COMMIT, FluxProcessAbortedEntries has already run and + * marked such tuples' shared ABORTED state; we additionally skip any + * tuple that still carries a shared ABORTED sLog entry. Clearing the + * flag is only ever a hint, and CLOG remains the commit oracle, so a + * conservative skip here is always safe. + */ +static void +flux_page_clear_uncommitted(Relation rel, BlockNumber blkno, TransactionId xid) +{ + Buffer buf; + Page page; + OffsetNumber off, + maxoff; + bool dirtied = false; + + buf = ReadBuffer(rel, blkno); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + maxoff = PageGetMaxOffsetNumber(page); + + for (off = FirstOffsetNumber; off <= maxoff; off++) + { + ItemId itemid = PageGetItemId(page, off); + FluxTupleHeader *hdr; + + if (!ItemIdIsNormal(itemid)) + continue; + if (FluxIsOverflowRecordInline(PageGetItem(page, itemid), + ItemIdGetLength(itemid))) + continue; + + hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + if (!(hdr->t_flags & FLUX_TUPLE_UNCOMMITTED)) + continue; + + /* + * Only our own top-level writes. A rolled-back savepoint's carry its + * own, aborted, xid. + */ + if (!TransactionIdEquals(FluxTupleGetXmin(hdr), xid) && + !TransactionIdEquals(FluxTupleGetXmax(hdr), xid)) + continue; + + hdr->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; + dirtied = true; + } + + if (dirtied) + MarkBufferDirtyHint(buf, true); + UnlockReleaseBuffer(buf); +} + +/* + * flux_clear_uncommitted_by_page -- option B driver. Visit each page this + * transaction modified in place (from the backend-local dirty-page set) once + * and clear the UNCOMMITTED hint on our tuples. O(pages), not O(tuples). + */ +static void +flux_clear_uncommitted_by_page(TransactionId xid) +{ + HASH_SEQ_STATUS seq; + FluxDirtyPageKey *pk; + Oid cur_relid = InvalidOid; + Relation rel = NULL; + BlockNumber nblocks = 0; + + if (flux_local_dirty_pages == NULL) + return; + + hash_seq_init(&seq, flux_local_dirty_pages); + while ((pk = (FluxDirtyPageKey *) hash_seq_search(&seq)) != NULL) + { + if (pk->relid != cur_relid) + { + if (rel != NULL) + relation_close(rel, AccessShareLock); + rel = try_relation_open(pk->relid, AccessShareLock); + cur_relid = pk->relid; + if (rel != NULL) + nblocks = RelationGetNumberOfBlocks(rel); + } + + /* + * Skip a page of storage the relation no longer uses (rewritten or + * truncated later in this transaction), or past the current storage's + * end, as flux_batch_clear_uncommitted does. + */ + if (rel != NULL && pk->relnumber == rel->rd_locator.relNumber && + pk->blkno < nblocks) + flux_page_clear_uncommitted(rel, pk->blkno, xid); + } + + if (rel != NULL) + relation_close(rel, AccessShareLock); +} + +/* + * Resolve relation locators while relcache access is still legal. AtPrepare + * runs in TRANS_PREPARE, too late to open a relation. + */ +static void +flux_collect_prepared_lock(Oid relid, RelFileNumber relnumber, + ItemPointer tid, LockTupleMode mode, void *arg) +{ + FluxTwoPhaseRecord *rec; + Relation rel; + MemoryContext oldcontext; + + rel = relation_open(relid, NoLock); + oldcontext = MemoryContextSwitchTo(TopTransactionContext); + rec = palloc0(sizeof(*rec)); + rec->rlocator = rel->rd_locator; + rec->rlocator.relNumber = relnumber; + rec->relid = relid; + rec->tid = *tid; + rec->xid = *(TransactionId *) arg; + rec->mode = mode; + flux_prepared_locks = lappend(flux_prepared_locks, rec); + MemoryContextSwitchTo(oldcontext); + relation_close(rel, NoLock); +} + +static void +FluxCollectPreparedLocks(TransactionId xid) +{ + SLogTupleLockForEachMine(flux_collect_prepared_lock, &xid); +} + +void +AtPrepare_Flux(void) +{ + ListCell *lc; + + foreach(lc, flux_prepared_locks) + RegisterTwoPhaseRecord(TWOPHASE_RM_FLUX_ID, FLUX_2PC_ROWLOCK, + lfirst(lc), sizeof(FluxTwoPhaseRecord)); +} + +static void +FluxSLogXactCallback(XactEvent event, void *arg) +{ + switch (event) + { + case XACT_EVENT_PRE_COMMIT: + { + TransactionId xid = GetCurrentTransactionIdIfAny(); + + if (TransactionIdIsValid(xid)) + { + /* + * 1. FluxProcessAbortedEntries -- mark DELETED the tuples + * our rolled-back subtransactions inserted. + * + * 2. FluxClearUncommittedFlags -- clear the on-page + * UNCOMMITTED hint flag. Commit visibility itself comes + * from CLOG (heap-shaped xmin/xmax MVCC); clearing the + * flag is a pure hint. + * + * Both run at PRE_COMMIT, before the point of no return, + * where an ERROR still aborts the transaction. + */ + FluxProcessAbortedEntries(xid); + FluxClearUncommittedFlags(xid); + } + } + break; + + case XACT_EVENT_PRE_PREPARE: + { + TransactionId xid = GetCurrentTransactionIdIfAny(); + + if (TransactionIdIsValid(xid)) + { + /* + * At PREPARE, we must NOT clear UNCOMMITTED flags. The + * transaction is not yet committed and another backend + * might ROLLBACK PREPARED. + * + * We still need to process any subtransaction-aborted + * entries (mark them DELETED on page) since those are + * definitively aborted regardless of PREPARE outcome. + * + * Two-phase record registration happens in + * AtPrepare_Flux(), called from PrepareTransaction() + * after StartPrepare(). + */ + FluxProcessAbortedEntries(xid); + FluxCollectPreparedLocks(xid); + } + } + break; + + case XACT_EVENT_COMMIT: + case XACT_EVENT_PARALLEL_COMMIT: + { + /* + * The UNCOMMITTED-flag clear and before-image handling + * already happened at XACT_EVENT_PRE_COMMIT (see above). Here + * we only release backend-local tracking state, which is safe + * in the post-commit no-abort region. Commit visibility + * comes from CLOG (heap-shaped xmin/xmax MVCC); nothing + * further is written at COMMIT. + */ + flux_prepared_locks = NIL; + SLogTupleLockReleaseAll(); + SLogTupleResetTracking(); + FluxLocalCidReset(); + FluxLocalDirtyPagesReset(); + } + break; + + case XACT_EVENT_PREPARE: + { + /* + * At PREPARE completion, do NOT remove sLog entries. The + * sLog entries must persist so that visibility checks can see + * the transaction is still in-progress (prepared). Only + * discard the backend-local tracking list since this backend + * is done. + * + * The two-phase records registered by AtPrepare_Flux will be + * used by COMMIT PREPARED / ROLLBACK PREPARED to perform the + * actual cleanup. The row locks move to the prepared + * transaction's dummy proc. + */ + SLogTupleLockAtPrepare(GetTopFullTransactionIdIfAny(), + flux_prepared_locks != NIL); + flux_prepared_locks = NIL; + SLogTupleResetTracking(); + FluxLocalCidReset(); + FluxLocalDirtyPagesReset(); + } + break; + + case XACT_EVENT_ABORT: + case XACT_EVENT_PARALLEL_ABORT: + { + /* + * CLOG says the inserts aborted; the UNDO records restore + * what we updated or deleted. + */ + flux_prepared_locks = NIL; + SLogTupleLockReleaseAll(); + SLogTupleResetTracking(); + FluxLocalCidReset(); + FluxLocalDirtyPagesReset(); + } + break; + + default: + break; + } +} + +/* + * FluxSLogSubXactCallback -- handle subtransaction events. + */ +static void +FluxSLogSubXactCallback(SubXactEvent event, + SubTransactionId mySubid, + SubTransactionId parentSubid, + void *arg) +{ + TransactionId xid; + + switch (event) + { + case SUBXACT_EVENT_ABORT_SUB: + xid = GetTopTransactionIdIfAny(); + if (TransactionIdIsValid(xid)) + SLogTupleRemoveBySubXid(xid, mySubid); + break; + + case SUBXACT_EVENT_COMMIT_SUB: + xid = GetTopTransactionIdIfAny(); + if (TransactionIdIsValid(xid)) + SLogTupleUpdateSubXid(xid, mySubid, parentSubid); + break; + + default: + break; + } +} + +/* ================================================================ + * Two-phase commit callbacks for FLUX + * + * These are invoked by FinishPreparedTransaction() in the backend that + * runs COMMIT PREPARED or ROLLBACK PREPARED. They perform the tuple-level + * cleanup that would normally happen at XACT_EVENT_PRE_COMMIT / COMMIT + * or ABORT in the originating backend. + * ================================================================ + */ + +/* Release only this prepared transaction's row lock, not other holders. */ +void +flux_twophase_postcommit(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len) +{ + FluxTwoPhaseRecord *rec PG_USED_FOR_ASSERTS_ONLY = (FluxTwoPhaseRecord *) recdata; + + Assert(info == FLUX_2PC_ROWLOCK); + Assert(len == sizeof(*rec)); + Assert(TransactionIdEquals(rec->xid, XidFromFullTransactionId(fxid))); + SLogTupleLockReleasePrepared(fxid); +} + +void +flux_twophase_postabort(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len) +{ + flux_twophase_postcommit(fxid, info, recdata, len); +} + +/* Restore the lock owners lost with shared memory at restart or promotion. */ +void +flux_twophase_recover(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len) +{ + FluxTwoPhaseRecord *rec = (FluxTwoPhaseRecord *) recdata; + SMgrRelation smgr; + + Assert(info == FLUX_2PC_ROWLOCK); + Assert(len == sizeof(*rec)); + Assert(TransactionIdEquals(rec->xid, XidFromFullTransactionId(fxid))); + smgr = smgropen(rec->rlocator, INVALID_PROC_NUMBER); + if (!smgrexists(smgr, MAIN_FORKNUM)) + return; + + SLogTupleLockRestore(fxid, rec->relid, rec->rlocator.relNumber, &rec->tid, + rec->mode); +} diff --git a/src/backend/access/flux/flux_pbu.c b/src/backend/access/flux/flux_pbu.c new file mode 100644 index 0000000000000..3c29ce0904a0f --- /dev/null +++ b/src/backend/access/flux/flux_pbu.c @@ -0,0 +1,927 @@ +/*------------------------------------------------------------------------- + * + * flux_pbu.c + * FLUX per-backend UNDO shim (insert / fetch / redo side). + * + * FLUX writes its DML before-images to the per-backend UNDO engine + * (access/undo/perbackend, PrepareUndoInsert/UndoFetchRecord). This + * translation unit is the small "pbu shim" (mirroring fileops_pbu.c): it is + * the ONLY FLUX file that CAN include the per-backend engine's record/insert + * headers, because those headers redefine UndoRecordHeader / UndoLogControl + * and still use the pre-RelFileLocator RelFileNode type, and so conflict with + * the other UNDO headers that the rest of the FLUX access method pulls in. + * The rest of FLUX reaches this engine only through the by-prototype entry + * points declared in access/flux.h. + * + * WRITE PROTOCOL (one FLUX UNDO record per INSERT/UPDATE/DELETE before-image, + * mirroring the FILEOPS crash-safe choreography): + * 1. FluxPbuPrepareInsert() -- BEFORE the critical section. Builds the + * UnpackedUndoRecord and calls PrepareUndoInsert(), reserving+pinning the + * undo buffers; may palloc. Returns the UndoRecPtr that becomes the + * tuple's version-chain head (t_verptr). + * 2. FluxPbuInsertUndo() -- INSIDE the crit section, before the FLUX + * forward-op WAL record: WAL-logs undo-log meta then writes the record + * into the pinned buffers (InsertPreparedUndo also chains the xact via + * SetCurrentUndoLocation). + * 3. FluxPbuRegisterUndoBuffers() -- INSIDE the crit section, after the FLUX + * forward-op XLogRegisterBuffer()/XLogRegisterData() and before its + * XLogInsert(): attaches each pinned undo page to the FLUX forward WAL + * record. No full-page image is forced: the forward record also carries + * the undo record's own bytes (xl_flux_undo, attached as block data on the + * first undo block), and flux_redo() RE-RUNS the undo insert (FluxPbuRedoUndo) to rebuild the + * undo page from those bytes. Registering the buffers still matters -- + * it makes the undo page a block reference of this record, so recovery + * restores it from an ordinary FPI when one was taken for other reasons + * (a page's first touch after a checkpoint, or wal_consistency_checking). + * THIS is what makes per-backend FLUX undo crash-safe: the undo bytes ride + * to disk on the same WAL record as the forward FLUX page mutation. + * 4. FluxPbuFinishUndo() -- after XLogInsert(): stamps the record + * LSN onto the undo pages, unlocks/releases them, resets state. + * FluxPbuCancelUndo() -- release the pinned undo buffers if the + * DML aborts before it commits the record (e.g. a no-op CAS). + * + * The record is tagged uur_rmid = UNDO_RMID_FLUX and uur_type = the FLUX undo + * subtype (FLUX_UNDO_UPDATE/DELETE/UPDATE_DELTA), so on abort execute_undo_actions() + * -> GetUndoRmgr(UNDO_RMID_FLUX) -> rm_undo reaches flux_undo_apply() + * (flux_undo.c) with the subtype in its "info" argument. The whole + * before-image payload -- FluxUndoPayloadHeader followed by the before-image + * tuple bytes -- is carried in uur_payload so that (a) flux_undo_apply reads + * it unchanged (execute_undo_actions_page passes rec->uur_payload to rm_undo), + * and (b) FluxReconstructVisibleVersion reads the trailing tuple bytes back + * exactly as before. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_pbu.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" +#include "storage/standby.h" + +#include "access/perbackend/pbu_compat.h" +#include "access/perbackend/pbu_undoinsert.h" +#include "access/perbackend/pbu_undolog.h" +#include "access/perbackend/pbu_undorecord.h" +#include "access/flux_undo.h" +#include "access/genam.h" +#include "access/amapi.h" +#include "utils/rel.h" +#include "access/flux_pbu.h" +#include "access/flux_xlog.h" +#include "access/undormgr.h" +#include "access/xact.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "access/xlogutils.h" +#include "storage/bufmgr.h" +#include "storage/itemptr.h" + +/* + * One FLUX UNDO record is prepared/inserted at a time: each DML op emits + * exactly one, entirely within its own critical section, before returning + * (prepare -> insert -> register -> set-lsn -> release runs to completion with + * no nesting), so a single static suffices and there is no re-entrancy. + */ +static UnpackedUndoRecord flux_pbu_uur; +static xl_undolog_meta flux_pbu_undometa; +static UndoRecPtr flux_pbu_urp = InvalidUndoRecPtr; +static xl_flux_undo flux_pbu_walhdr; +static bool flux_pbu_prepared = false; + +/* + * FluxPbuPrepareInsert - reserve undo space for one FLUX UNDO record and + * return the UndoRecPtr that will be stamped into the tuple's t_verptr. + * + * Must be called BEFORE the caller's critical section (PrepareUndoInsert may + * palloc / extend the undo log / ereport). subtype is a FLUX_UNDO_* code; + * tid is the target tuple's TID; tuple_bytes/tuple_len is the before-image (or + * NULL/0 for INSERT records, which carry no image). relpersistence is the + * relation's relpersistence ('p'/'u'/'t'), mapped to the undo persistence; + * see flux_undo_persistence() for a permanent relation that skips WAL. + * + * The uur_payload holds a FluxUndoPayloadHeader immediately followed by the + * before-image tuple bytes -- the exact layout flux_undo_apply() and + * FluxReconstructVisibleVersion() expect. + */ +/* + * The undo log a change to rel writes its undo to. + * + * Undo goes to the log of the relation's persistence, except for a permanent + * relation that skips WAL: one created or rewritten in the current + * transaction at wal_level = minimal, which is synchronized at commit + * instead. Its changes write no WAL, so neither may its undo, and undo + * written to a permanent undo log without WAL would leave that log in a + * state crash recovery cannot rebuild: the next WAL-logged undo in the log + * would land at an offset replay never reached, and a WAL-logged discard of + * it would move the replayed discard pointer past the replayed insert + * pointer. Use the unlogged undo log, which is never replayed and is + * emptied after a crash. Nothing needs that undo after a crash either: if + * the transaction committed, its relation was synchronized and no snapshot + * from before the crash survives to read an older version; if it did not, + * the relation is dropped. Rollback and the version-chain readers find the + * records through the undo record pointer, whichever log it names. + */ +static UndoPersistence +flux_undo_persistence(Relation rel, char relpersistence) +{ + if (relpersistence == RELPERSISTENCE_PERMANENT && !RelationNeedsWAL(rel)) + return UNDO_UNLOGGED; + return UndoPersistenceForRelPersistence(relpersistence); +} + +UndoRecPtr +FluxPbuPrepareInsert(uint16 subtype, Relation rel, ItemPointer tid, + const char *tuple_bytes, uint32 tuple_len, + char relpersistence, bool reserve_indexes, + TransactionId freeze_conflict, CommandId cid) +{ + FullTransactionId fxid = GetTopFullTransactionId(); + Oid reloid = RelationGetRelid(rel); + FluxUndoPayloadHeader hdr; + UndoRecPtr urp; + Size index_area = 0; + FluxUndoIndex *index_files = NULL; + uint16 nindexes = 0; + Size row_limit; + + Assert(!flux_pbu_prepared); + flux_pbu_walhdr.freeze_conflict = freeze_conflict; + + /* + * Always false for a FLUX relation: FLUX refuses user_catalog_table and + * is never a system catalog, so RelationIsAccessibleInLogicalDecoding() + * cannot be true here. The field and the catalog branch it drives in + * ResolveRecoveryConflictWithSnapshot() at redo are therefore unreachable + * for FLUX records; they are kept so the WAL format and the conflict + * resolution stay correct for a genuine catalog relation and so the test + * tracks the one place the rule could change. + */ + flux_pbu_walhdr.freeze_catalog_rel = + RelationIsAccessibleInLogicalDecoding(rel); + + MemSet(&flux_pbu_uur, 0, sizeof(flux_pbu_uur)); + flux_pbu_uur.uur_rmid = UNDO_RMID_FLUX; + flux_pbu_uur.uur_type = (uint8) subtype; + flux_pbu_uur.uur_info = 0; + flux_pbu_uur.uur_reloid = reloid; + flux_pbu_uur.uur_fork = MAIN_FORKNUM; + flux_pbu_uur.uur_block = ItemPointerGetBlockNumber(tid); + flux_pbu_uur.uur_offset = ItemPointerGetOffsetNumber(tid); + + /* + * The record's xid/epoch identify the owning transaction; the abort path + * asserts the fetched record's (uur_xidepoch,uur_xid) equals the aborting + * full xid. UndoRecordAllocate fills uur_xidepoch for the + * transaction-header record, but the caller must supply uur_xid. This is + * also the xid FluxReconstructVisibleVersion reads back (the producer of + * the CURRENT candidate image) for its snapshot visibility test. + */ + flux_pbu_uur.uur_xid = XidFromFullTransactionId(fxid); + flux_pbu_uur.uur_xidepoch = EpochFromFullTransactionId(fxid); + + /* + * Record the command id of the writer that produced the CURRENT on-page + * image (the command performing this UPDATE/DELETE). The version-chain + * walker uses it, together with uur_xid, so that a reader in the same + * transaction whose snapshot predates this command reconstructs the prior + * image instead of seeing the row this command produced -- an in-place + * UPDATE overwrites the intermediate version on the page, so it is only + * recoverable from here. + * + * This is the operation's own command id (the executor's es_output_cid, + * which heap stores as cmin/cmax), not GetCurrentCommandId(): a BEFORE + * STATEMENT trigger running SQL advances the command counter before the + * row is modified, and with the current command id the next command of + * the transaction took the change for a later command's, stepped back + * over it, and served the deleted or overwritten row again. + */ + flux_pbu_uur.uur_cid = cid; + + /* Build the FLUX before-image payload: fixed header + tuple bytes. */ + MemSet(&hdr, 0, sizeof(hdr)); + ItemPointerCopy(tid, &hdr.tid); + hdr.tuple_len = tuple_len; + hdr.relnumber = rel->rd_locator.relNumber; + hdr.flags = (tuple_bytes != NULL && tuple_len > 0) ? + FLUX_UNDO_FLAG_HAS_TUPLE : 0; + + /* + * writer_xid is the xid that produced this record's host image -- the + * CURRENT (possibly SUBXACT) xid, NOT the top xid. The version-chain + * reader and the lost-update probe test THIS against the snapshot so that + * a SAVEPOINT rollback (which aborts a subxact while the top xact keeps + * running) resolves visibility correctly. uur_xid below stays the owning + * TOP xid because the per-backend engine's abort path asserts the fetched + * record's uur_xid equals the aborting full xid. + */ + hdr.writer_xid = GetCurrentTransactionId(); + if (reserve_indexes) + { + List *indexes = RelationGetIndexList(rel); + ListCell *lc; + + index_files = palloc0(list_length(indexes) * sizeof(FluxUndoIndex)); + + /* + * Only delete-marking indexes can revive an entry. List them all, an + * upper bound on maintenance's changed set: the old key may change + * while an UPDATE waits, and predicates and expressions must not be + * evaluated while holding a data buffer content lock. The relation + * lock keeps this index list stable through maintenance. Each entry + * names the index file maintenance changes; see FluxUndoIndex. + */ + foreach(lc, indexes) + { + Relation index = index_open(lfirst_oid(lc), AccessShareLock); + + if (index->rd_indam->amdeletemark != NULL) + { + index_files[nindexes].indexoid = RelationGetRelid(index); + index_files[nindexes].spcoid = index->rd_locator.spcOid; + index_files[nindexes].relnumber = index->rd_locator.relNumber; + nindexes++; + } + index_close(index, AccessShareLock); + } + list_free(indexes); + if (nindexes > 0) + index_area = sizeof(uint16) + (Size) nindexes * sizeof(FluxUndoIndex); + } + + /* Include the transaction header even if this record will not need it. */ + row_limit = Min(UndoRecordMaxSize, UINT16_MAX) - + SizeOfUndoRecordHeader - SizeOfUndoRecordBlock - + SizeOfUndoRecordTransaction - SizeOfUndoRecordPayload - + sizeof(uint16) - SizeOfFluxUndoPayloadHeader; + if (index_area > row_limit || tuple_len > row_limit - index_area) + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("FLUX undo row size %u exceeds maximum %zu", + tuple_len, index_area > row_limit ? (Size) 0 : + row_limit - index_area), + errdetail("Index reservations require %zu bytes.", index_area))); + if (index_area > 0) + { + hdr.pad = index_area; + hdr.flags |= FLUX_UNDO_FLAG_INDEX_LIST; + } + + initStringInfo(&flux_pbu_uur.uur_payload); + appendBinaryStringInfo(&flux_pbu_uur.uur_payload, + (char *) &hdr, SizeOfFluxUndoPayloadHeader); + if (tuple_bytes != NULL && tuple_len > 0) + appendBinaryStringInfo(&flux_pbu_uur.uur_payload, + tuple_bytes, tuple_len); + + if (hdr.pad > 0) + { + char *area = palloc0(hdr.pad); + + memcpy(area, &nindexes, sizeof(nindexes)); + memcpy(area + sizeof(nindexes), index_files, + nindexes * sizeof(FluxUndoIndex)); + appendBinaryStringInfo(&flux_pbu_uur.uur_payload, area, hdr.pad); + pfree(area); + } + + if (index_files != NULL) + pfree(index_files); + + MemSet(&flux_pbu_undometa, 0, sizeof(flux_pbu_undometa)); + + urp = PrepareUndoInsert(&flux_pbu_uur, fxid, + flux_undo_persistence(rel, relpersistence), + NULL, &flux_pbu_undometa); + + flux_pbu_prepared = true; + flux_pbu_urp = urp; + return urp; +} + +/* Persist the complete revive list before any newly listed entry is revived. */ +void +FluxPbuAmendRevivedList(UndoRecPtr urp, const Oid *oids, uint16 n) +{ + UnpackedUndoRecord *urec; + FluxUndoPayloadHeader hdr; + uint16 nindexes; + char *area; + + urec = UndoFetchRecord(urp, InvalidBlockNumber, InvalidOffsetNumber, + InvalidTransactionId, NULL, NULL); + if (urec == NULL || urec->uur_payload.len < SizeOfFluxUndoPayloadHeader) + elog(ERROR, "FLUX undo record missing during amendment"); + memcpy(&hdr, urec->uur_payload.data, sizeof(hdr)); + if (!(hdr.flags & FLUX_UNDO_FLAG_INDEX_LIST) || hdr.pad < sizeof(uint16) || + SizeOfFluxUndoPayloadHeader + hdr.tuple_len + hdr.pad != urec->uur_payload.len) + elog(ERROR, "invalid FLUX undo index list reservation"); + area = palloc(hdr.pad); + memcpy(area, urec->uur_payload.data + SizeOfFluxUndoPayloadHeader + + hdr.tuple_len, hdr.pad); + UndoRecordRelease(urec); + memcpy(&nindexes, area, sizeof(nindexes)); + if (sizeof(uint16) + nindexes * sizeof(FluxUndoIndex) > hdr.pad) + elog(ERROR, "invalid FLUX undo index list"); + for (int i = 0; i < nindexes; i++) + { + FluxUndoIndex entry; + char *ptr = area + sizeof(uint16) + i * sizeof(entry); + + memcpy(&entry, ptr, sizeof(entry)); + for (int j = 0; j < n; j++) + if (entry.indexoid == oids[j]) + entry.flags |= FLUX_UNDO_INDEX_REVIVED; + memcpy(ptr, &entry, sizeof(entry)); + } + PbuAmendPayload(urp, SizeOfFluxUndoPayloadHeader + hdr.tuple_len, + area, hdr.pad); + pfree(area); +} + +/* + * FluxPbuInsertUndo - WAL-log the undo-log meta and write the prepared record + * into the undo buffers. Call INSIDE the crit section, before the FLUX + * forward-op WAL record. + * + * LogUndoMetaData() emits the xid->logno attach/meta record when this is the + * first undo insert after a checkpoint, which is what lets + * UndoLogAllocateInRecovery() resolve this xid's log during redo. Because + * flux_redo() re-runs this same insert (FluxPbuRedoUndo), replaying the record + * re-derives the advanced insert pointer itself -- no post-insert meta record + * is needed, unlike an FPI-based producer such as fileops_pbu.c. + * + * The undo of a temporary, unlogged or WAL-skipping relation goes to a log + * that is never replayed (flux_undo_persistence), so its meta is not logged. + */ +void +FluxPbuInsertUndo(void) +{ + Assert(flux_pbu_prepared); + + if (flux_pbu_undometa.meta.persistence == UNDO_PERMANENT) + LogUndoMetaData(&flux_pbu_undometa); + + InsertPreparedUndo(); +} + +/* + * FluxPbuRegisterUndoBuffers - attach the pinned undo buffers, and the undo + * record's own description, to the caller's forward-op WAL record. + * + * Call INSIDE the crit section, after the caller's XLogRegisterBuffer()/ + * XLogRegisterData() and before its XLogInsert(). first_block_id is the WAL + * block id at which to start registering the undo buffers -- it must be past + * any block id the FLUX forward-op record already uses (block 0 = data page, + * plus any overflow / cross-page destination blocks). Returns the number of + * undo blocks registered. + * + * No full-page image is forced. Instead an xl_flux_undo header plus the undo + * record's payload is attached as block data on the first undo block, and + * flux_redo() re-runs the undo insert from it (FluxPbuRedoUndo). That removes + * the 8 KB undo-page image per undo record which dominated FLUX's UPDATE WAL + * volume. Registering the buffers is still required: it makes each undo page a + * block reference of this record, so the page gets an ordinary full-page image + * when it needs one (first touch after a checkpoint, i.e. torn-page protection) + * and the replay is verified under wal_consistency_checking. + */ +int +FluxPbuRegisterUndoBuffers(uint8 first_block_id) +{ + int nblocks; + + Assert(flux_pbu_prepared); + + /* + * The header must outlive this call: XLogRegisterBufData only records a + * pointer and the bytes are read at XLogInsert(), so it lives in a static + * that stays valid until FluxPbuFinishUndo(), not on the stack. + */ + flux_pbu_walhdr.urec_ptr = (uint64) flux_pbu_urp; + flux_pbu_walhdr.reloid = flux_pbu_uur.uur_reloid; + flux_pbu_walhdr.subtype = flux_pbu_uur.uur_type; + flux_pbu_walhdr.payload_len = (uint16) flux_pbu_uur.uur_payload.len; + flux_pbu_walhdr.top_xid = flux_pbu_uur.uur_xid; + flux_pbu_walhdr.top_xid_epoch = flux_pbu_uur.uur_xidepoch; + flux_pbu_walhdr.cid = flux_pbu_uur.uur_cid; + + nblocks = RegisterUndoLogBuffersWithData(first_block_id, + (const char *) &flux_pbu_walhdr, + SizeOfFluxUndo); + + /* + * Append the payload to the same block's data. The prepared record's + * payload buffer stays alive until FluxPbuFinishUndo(), i.e. past + * XLogInsert(). + */ + if (nblocks > 0 && flux_pbu_uur.uur_payload.len > 0) + XLogRegisterBufData(first_block_id, flux_pbu_uur.uur_payload.data, + flux_pbu_uur.uur_payload.len); + + return nblocks; +} + +/* + * FluxPbuUndoPending - true iff a FLUX undo record has been prepared+inserted + * for the current op and is awaiting its WAL registration + LSN stamp. The + * FLUX forward-op WAL functions (flux_xlog.c) query this to decide whether to + * fold the undo pages onto their record. + */ +bool +FluxPbuUndoPending(void) +{ + return flux_pbu_prepared; +} + +/* + * FluxPbuFinishUndo - stamp the WAL LSN onto the undo pages, then + * unlock/release the undo buffers and reset state. Call after the caller's + * XLogInsert(). lsn is the LSN of the FLUX forward-op WAL record that carried + * the undo buffers. + */ +void +FluxPbuFinishUndo(XLogRecPtr lsn) +{ + Assert(flux_pbu_prepared); + + UndoLogBuffersSetLSN(lsn); + UnlockReleaseUndoBuffers(); + + if (flux_pbu_uur.uur_payload.data != NULL) + { + pfree(flux_pbu_uur.uur_payload.data); + flux_pbu_uur.uur_payload.data = NULL; + } + flux_pbu_prepared = false; + flux_pbu_urp = InvalidUndoRecPtr; +} + +/* + * FluxPbuCancelUndo - release the pinned undo buffers without writing the + * record. Used when the DML op decides not to commit the reserved record + * (e.g. a same-size CAS update that finds no byte actually changed). Because + * InsertPreparedUndo() has NOT been called, no undo bytes were written and the + * log's insert pointer was not advanced; releasing the buffers leaves the + * reserved space unused (identical to the FILEOPS no-op path). + */ +void +FluxPbuCancelUndo(void) +{ + if (!flux_pbu_prepared) + return; + + UnlockReleaseUndoBuffers(); + + if (flux_pbu_uur.uur_payload.data != NULL) + { + pfree(flux_pbu_uur.uur_payload.data); + flux_pbu_uur.uur_payload.data = NULL; + } + flux_pbu_prepared = false; + flux_pbu_urp = InvalidUndoRecPtr; +} + +/* + * FluxPbuBlockIsUndo - true iff the given block reference of a WAL record is a + * per-backend UNDO page rather than a FLUX relation page. + * + * Undo pages live in the reserved undo database OID, so they are + * distinguishable from any relation block regardless of which block id they + * landed at. The FLUX redo handlers' overflow-replay loops iterate block ids + * 1..N looking for FLUX overflow pages, and the undo pages folded onto the same + * record sit in that range; without this test such a loop would treat an undo + * page as an overflow page -- FluxInitPage over it, parse undo bytes as overflow + * records -- destroying the before-image the rollback needs. Exposed here + * because UndoLogDatabaseOid lives in the per-backend engine headers, which the + * rest of FLUX cannot include. + */ +bool +FluxPbuBlockIsUndo(void *record, uint8 block_id) +{ + XLogReaderState *xlogrec = (XLogReaderState *) record; + RelFileLocator rlocator; + + if (!XLogRecGetBlockTagExtended(xlogrec, block_id, &rlocator, NULL, NULL, + NULL)) + return false; + + return rlocator.dbOid == UndoLogDatabaseOid; +} + +/* + * FluxPbuRedoUndo - re-run the undo insert carried on a FLUX forward-op WAL + * record. + * + * Called from flux_redo() for every FLUX opcode that can emit undo, mirroring + * zheap's redo routines (zheapamxlog.c): find the undo region, rebuild the + * UnpackedUndoRecord from it, PrepareUndoInsert() (which in recovery resolves + * the log from the xid->logno map that XLOG_UNDOLOG_ATTACH/META replay + * maintains, and pins/locks the undo buffers), then InsertPreparedUndo() to + * write the bytes. The undo page content is thus reconstructed, not copied from + * an 8 KB image. + * + * first_block_id is where the forward-op emitter started registering undo + * buffers. Undo blocks are always the LAST blocks of a FLUX record, and they + * are the only ones in the reserved undo database (UndoLogDatabaseOid), so the + * search is unambiguous even for opcodes whose forward block count varies with + * overflow / cross-page destinations. A record that carries no undo simply has + * no such block and this returns InvalidUndoRecPtr without doing anything; + * otherwise it returns the replayed record's UndoRecPtr (the one DO stamped + * into the tuple's t_verptr). + * + * IDEMPOTENCE: the sequence of undo allocations during redo mirrors the sequence + * during DO for a given xid, so on replay the record lands at the same offset in + * the same log and the insert rewrites byte-identical content. Replaying a + * record whose undo was already flushed therefore rewrites the same bytes rather + * than double-applying, and a page that has since been discarded yields an + * invalid buffer that InsertPreparedUndo() skips. + */ +UndoRecPtr +FluxPbuRedoUndo(void *record, uint8 first_block_id) +{ + XLogReaderState *xlogrec = (XLogReaderState *) record; + FullTransactionId fxid; + UnpackedUndoRecord uur; + xl_flux_undo undohdr; + FluxUndoPayloadHeader phdr; + UndoRecPtr urp; + uint8 block_id; + char *data = NULL; + Size datalen = 0; + const char *payload; + + /* + * Locate the undo region. Probe forward from first_block_id for a block + * that both lives in the undo database and carries block data (the region + * is attached to the FIRST undo block only). + */ + for (block_id = first_block_id; + block_id < first_block_id + FLUX_PBU_MAX_UNDO_BLOCKS; + block_id++) + { + if (!XLogRecHasBlockRef(xlogrec, block_id)) + break; /* no more blocks at all */ + + if (!FluxPbuBlockIsUndo(xlogrec, block_id)) + continue; /* a forward-op block, keep looking */ + + data = XLogRecGetBlockData(xlogrec, block_id, &datalen); + break; + } + + /* No undo on this record. */ + if (data == NULL || datalen == 0) + return InvalidUndoRecPtr; + + if (datalen < SizeOfFluxUndo) + elog(PANIC, "FLUX undo redo: undo region too short (%zu bytes, need %zu)", + datalen, SizeOfFluxUndo); + + memcpy(&undohdr, data, SizeOfFluxUndo); + payload = data + SizeOfFluxUndo; + + /* + * Resolve the undo log by the top-level xid that DO recorded, not by this + * WAL record's own xid. A write performed inside a subtransaction tags + * its record with the subxact xid, but XLOG_UNDOLOG_ATTACH mapped the log + * under the top-level xid. Looking the log up by the subxact xid reads + * an unpopulated slot of the xid->logno map, which is indistinguishable + * from log number 0 and ends recovery with "cannot find undo log number + * 0". The same xid is written into the rebuilt record below, so that it + * is byte-identical to the one DO wrote. + */ + fxid = FullTransactionIdFromEpochAndXid(undohdr.top_xid_epoch, + undohdr.top_xid); + + /* + * The before-image is the only copy of the pre-update bytes, so refuse to + * read past the WAL data rather than fabricate a record. + */ + if (datalen != SizeOfFluxUndo + (Size) undohdr.payload_len || + undohdr.payload_len < SizeOfFluxUndoPayloadHeader) + elog(PANIC, "FLUX undo redo: inconsistent undo region " + "(region %zu bytes, payload_len %u)", + datalen, undohdr.payload_len); + + /* + * Rebuild the record EXACTLY as FluxPbuPrepareInsert() built it. Any + * difference in the fields UndoRecordSetInfo() keys on (fork, block, + * payload length) would change UndoRecordExpectedSize(), so this record + * -- and every record after it in this log -- would land at a different + * offset than the original, and the version pointers already stamped into + * replayed tuples would dangle. + */ + MemSet(&uur, 0, sizeof(uur)); + uur.uur_rmid = UNDO_RMID_FLUX; + uur.uur_type = (uint8) undohdr.subtype; + uur.uur_info = 0; + uur.uur_reloid = undohdr.reloid; + uur.uur_fork = MAIN_FORKNUM; + uur.uur_xid = XidFromFullTransactionId(fxid); + uur.uur_xidepoch = EpochFromFullTransactionId(fxid); + uur.uur_cid = undohdr.cid; + + memcpy(&phdr, payload, SizeOfFluxUndoPayloadHeader); + uur.uur_block = ItemPointerGetBlockNumber(&phdr.tid); + uur.uur_offset = ItemPointerGetOffsetNumber(&phdr.tid); + + /* + * The payload is carried verbatim (FluxUndoPayloadHeader + before-image + * bytes), so the replayed record is byte-identical to the original -- + * including writer_xid, which the version-chain reader + * (FluxReconstructVisibleVersion) and the lost-update probe read back. + */ + initStringInfo(&uur.uur_payload); + appendBinaryStringInfo(&uur.uur_payload, payload, undohdr.payload_len); + + /* + * Seed the recovery xid->logno map from this record before resolving the + * log through it. The undo record pointer DO stamped here names the + * exact log, so recovery can map the xid even if the + * XLOG_UNDOLOG_ATTACH/META that normally carries the mapping was not + * replayed before this record -- which happens when LogUndoMetaData() + * skipped the META for the first undo record after a checkpoint whose + * redo point advanced under a concurrent checkpoint (otherwise: "xid to + * undo log number map not initialized"). + */ + UndoLogRecoverySetXidMap(XidFromFullTransactionId(fxid), + UndoRecPtrGetLogNo((UndoRecPtr) undohdr.urec_ptr)); + + urp = PrepareUndoInsert(&uur, fxid, UNDO_PERMANENT, xlogrec, NULL); + + /* + * The replayed insert must land where the DO-time insert did. A mismatch + * means the recovered undo-log metadata disagrees with the WAL, and + * continuing would write this transaction's before-image at an offset the + * rollback walk will not find -- silently losing the ability to undo it. + */ + if (urp != (UndoRecPtr) undohdr.urec_ptr) + elog(PANIC, "FLUX undo redo: undo record pointer mismatch " + "(replay " UINT64_FORMAT ", original " UINT64_FORMAT ")", + (uint64) urp, undohdr.urec_ptr); + + InsertPreparedUndo(); + + /* + * Release the undo buffers. The page LSNs are deliberately NOT stamped, + * matching zheap's redo routines: InsertPreparedUndo() has dirtied the + * buffers, so the content reaches disk via the end-of-recovery checkpoint + * (or an intervening restartpoint), and WAL replayed a second time + * re-inserts the identical bytes. It skips a page whose LSN is at or + * past this record's: that page was written out after a later LSN-gated + * change to these bytes (PbuAmendPayload, a transaction header update), + * which re-inserting would undo. + */ + UnlockReleaseUndoBuffers(); + + pfree(uur.uur_payload.data); + return urp; +} + +/* + * FluxPbuFetchVersion - fetch one FLUX UNDO record for the version-chain + * reader (FluxReconstructVisibleVersion). + * + * Returns false if the record was discarded / unreadable. On success: + * *out_xid = the xid that produced the CURRENT candidate image + * (the payload's writer_xid, a possibly-SUBXACT xid) -- + * the reader tests this against its snapshot. This is NOT + * uur_xid (which is the owning top xid). + * *out_subtype = the FLUX undo subtype (uur_type). + * *out_payload = a palloc'd copy of the record's uur_payload (the + * FluxUndoPayloadHeader + before-image tuple bytes); the + * caller must pfree it. + * *out_payload_len = length of *out_payload. + * + * The payload is copied out (not aliased into the undo buffer) because + * UndoRecordRelease frees the fetched record's buffers immediately. + */ +bool +FluxPbuFetchVersion(UndoRecPtr verptr, TransactionId *out_xid, + CommandId *out_cid, + uint16 *out_subtype, char **out_payload, + Size *out_payload_len) +{ + UnpackedUndoRecord *urec; + + if (!UndoRecPtrIsValid(verptr)) + return false; + + urec = UndoFetchRecord(verptr, InvalidBlockNumber, InvalidOffsetNumber, + InvalidTransactionId, NULL, NULL); + if (urec == NULL) + return false; + + if (out_cid != NULL) + *out_cid = urec->uur_cid; + + if (out_xid != NULL) + { + /* + * The producing (possibly SUBXACT) xid lives in the payload's + * FluxUndoPayloadHeader.writer_xid, not in uur_xid (which is the + * owning top xid the engine keys on). Fall back to uur_xid if the + * payload is somehow too short. + */ + if (urec->uur_payload.len >= (int) SizeOfFluxUndoPayloadHeader) + { + FluxUndoPayloadHeader phdr; + + memcpy(&phdr, urec->uur_payload.data, SizeOfFluxUndoPayloadHeader); + *out_xid = phdr.writer_xid; + } + else + *out_xid = urec->uur_xid; + } + if (out_subtype != NULL) + *out_subtype = urec->uur_type; + + if (out_payload != NULL) + { + Size len = urec->uur_payload.len; + + if (len > 0) + { + char *buf = palloc(len); + + memcpy(buf, urec->uur_payload.data, len); + *out_payload = buf; + } + else + *out_payload = NULL; + if (out_payload_len != NULL) + *out_payload_len = len; + } + + UndoRecordRelease(urec); + return true; +} + +/* + * FluxPbuFetchForRevert - fetch one FLUX UNDO record for a writer that is + * about to reverse-apply it (FluxRevertAbortedVersion). + * + * Like FluxPbuFetchVersion, but also returns the owning top-level xid + * (uur_xid), which the apply callback and its CLR are keyed on, and copies + * the whole payload. Returns false if the record is gone. + */ +bool +FluxPbuFetchForRevert(UndoRecPtr urp, TransactionId *out_top_xid, + uint16 *out_subtype, char **out_payload, + Size *out_payload_len) +{ + UnpackedUndoRecord *urec; + + if (!UndoRecPtrIsValid(urp)) + return false; + + urec = UndoFetchRecord(urp, InvalidBlockNumber, InvalidOffsetNumber, + InvalidTransactionId, NULL, NULL); + if (urec == NULL) + return false; + + if (urec->uur_rmid != UNDO_RMID_FLUX || + urec->uur_payload.len < (int) SizeOfFluxUndoPayloadHeader) + { + UndoRecordRelease(urec); + return false; + } + + *out_top_xid = urec->uur_xid; + *out_subtype = urec->uur_type; + *out_payload_len = urec->uur_payload.len; + *out_payload = palloc(urec->uur_payload.len); + memcpy(*out_payload, urec->uur_payload.data, urec->uur_payload.len); + + UndoRecordRelease(urec); + return true; +} + +/* + * FluxPbuFetchXid - header-only fetch: return just the producing xid of the + * record at verptr (the payload's writer_xid, a possibly-SUBXACT xid). + * Replaces the earlier fork-engine header probe on the lost-update conflict + * probe (flux_mvcc.c) and the ANALYZE dead-tuple proxy (flux_handler.c). Returns + * false if the record was discarded / unreadable. + */ +bool +FluxPbuFetchXid(UndoRecPtr verptr, TransactionId *out_xid) +{ + UnpackedUndoRecord *urec; + + if (!UndoRecPtrIsValid(verptr)) + return false; + + urec = UndoFetchRecord(verptr, InvalidBlockNumber, InvalidOffsetNumber, + InvalidTransactionId, NULL, NULL); + if (urec == NULL) + return false; + + if (out_xid != NULL) + { + if (urec->uur_payload.len >= (int) SizeOfFluxUndoPayloadHeader) + { + FluxUndoPayloadHeader phdr; + + memcpy(&phdr, urec->uur_payload.data, SizeOfFluxUndoPayloadHeader); + *out_xid = phdr.writer_xid; + } + else + *out_xid = urec->uur_xid; + } + + UndoRecordRelease(urec); + return true; +} + +/* + * FluxPbuRecordExists - true iff the record at verptr has not been discarded. + * Used by the ANALYZE dead-tuple proxy (flux_handler.c) as a cheap + * "is there retained history?" probe. + */ +bool +FluxPbuRecordExists(UndoRecPtr verptr) +{ + UndoLogControl *log; + bool valid; + + if (!UndoRecPtrIsValid(verptr)) + return false; + + /* + * UndoRecordIsValid() asserts the caller holds log->discard_lock in + * shared mode (it protects the record from being discarded concurrently, + * and it reads log->oldest_data under that lock). Acquire it here, + * matching every other UndoRecordIsValid() caller. This path runs in + * autovacuum/ANALYZE workers concurrently with inserting backends and the + * discard worker. + * + * If the whole log has already been dropped, UndoLogGet() returns NULL + * and the record is certainly gone. + */ + log = UndoLogGet(UndoRecPtrGetLogNo(verptr)); + if (log == NULL) + return false; + + LWLockAcquire(&log->discard_lock, LW_SHARED); + valid = UndoRecordIsValid(verptr); + + /* + * UndoRecordIsValid() releases discard_lock itself when it returns false; + * on true it leaves the lock held for the caller to release. + */ + if (valid) + LWLockRelease(&log->discard_lock); + + return valid; +} + +/* See PbuInSubxactApply(). */ +bool +FluxPbuInSubxactApply(void) +{ + return PbuInSubxactApply(); +} + +/* DML may freeze its before-image even without VACUUM. */ +void +FluxPbuResolveRecoveryConflict(void *record) +{ + XLogReaderState *rec = (XLogReaderState *) record; + + if (!InHotStandby) + return; + for (int i = 1; i <= XLogRecMaxBlockId(rec); i++) + { + char *data; + Size len; + xl_flux_undo hdr; + RelFileLocator locator; + + if (!XLogRecHasBlockRef(rec, i) || !FluxPbuBlockIsUndo(rec, i)) + continue; + data = XLogRecGetBlockData(rec, i, &len); + if (data == NULL) + continue; + if (len < SizeOfFluxUndo) + elog(PANIC, "FLUX undo freeze header truncated"); + memcpy(&hdr, data, SizeOfFluxUndo); + XLogRecGetBlockTag(rec, 0, &locator, NULL, NULL); + + /* + * Resolve the snapshot conflict the freeze creates on the standby: + * read-only queries whose snapshot still needs the before-image the + * freeze cut off must be cancelled. hdr.freeze_catalog_rel is always + * false for FLUX (user_catalog_table is refused and FLUX is never a + * system catalog), so the catalog branch inside + * ResolveRecoveryConflictWithSnapshot() is unreachable for FLUX; the + * flag is passed through unchanged to keep that resolution correct + * for a genuine catalog relation. + */ + ResolveRecoveryConflictWithSnapshot(hdr.freeze_conflict, + hdr.freeze_catalog_rel, locator); + break; + } +} diff --git a/src/backend/access/flux/flux_pvs.c b/src/backend/access/flux/flux_pvs.c new file mode 100644 index 0000000000000..0f155ccc81b12 --- /dev/null +++ b/src/backend/access/flux/flux_pvs.c @@ -0,0 +1,786 @@ +/*------------------------------------------------------------------------- + * + * flux_pvs.c + * FLUX per-relation versioned storage (PVS) read path + * + * In-place UPDATEs on a FLUX tuple stamp an UndoRecPtr (verptr) + * into the new on-page image. Each verptr points to the UNDO + * record that describes the update which produced its host image; reversing + * the diff (or full-tuple) in that record yields the immediately prior + * committed image, whose own trailing verptr -- preserved verbatim through + * the reverse-apply -- continues the chain one further step back. + * + * FluxReconstructVisibleVersion walks that chain on behalf of an MVCC + * reader, stepping back one version at a time until it finds the image + * created by an xid that is VISIBLE to the reader's snapshot. The result is + * the before-image the reader should see in place of the on-page (newer) + * data. + * + * urec_prevundorec is NOT used here: that field threads a per-relation, + * per-transaction rollback LIFO, not a per-tuple version chain. The + * authoritative per-tuple chain is the verptr threaded through reconstructed + * images. + * + * Visibility per step uses the same rule as the on-page check, + * FluxWriterVisibleToSnapshot(): the writer of the current candidate is + * visible only if it committed and is not in the snapshot's in-progress set. + * Visible -> serve the current candidate; otherwise step back. An aborted + * writer whose rollback has not been applied yet (deferred to an undo apply + * worker, or a crash loser) is therefore stepped past, never served. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_pvs.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/flux.h" +#include "access/flux_pbu.h" +#include "access/flux_undo.h" +#include "access/transam.h" +#include "access/xact.h" +#include "miscadmin.h" +#include "storage/predicate.h" +#include "storage/procarray.h" +#include "utils/snapmgr.h" + +/* + * A version-chain record that cannot be stepped past is corruption. Serving + * the newer image instead would return a row version the snapshot must not + * see, so raise an ERROR. + */ +static pg_noreturn void +flux_pvs_corrupt(Relation rel, ItemPointer tid, UndoRecPtr urp, + const char *what) +{ + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg_internal("FLUX version chain of (%u,%u) in relation \"%s\" is broken at undo record %llu: %s", + ItemPointerGetBlockNumber(tid), + ItemPointerGetOffsetNumber(tid), + RelationGetRelationName(rel), + (unsigned long long) urp, what))); +} + +static bool flux_pvs_walk(Relation rel, ItemPointer tid, + const char *onpage_image, Size onpage_len, + Snapshot snapshot, + char **out_data, int *out_len, + FluxSkippedUpdates *skipped, bool conflict_out); + +/* + * flux_pvs_prior_image + * Reverse-apply the UNDO record at verptr (already fetched: its subtype + * and payload) to candidate, the image that record produced, returning + * a palloc'd prior image in *out_image / *out_len. A record + * that cannot be stepped past is corruption and raises an ERROR (see + * flux_pvs_corrupt): serving a newer image instead would return a + * version the snapshot must not see. + */ +static void +flux_pvs_prior_image(Relation rel, ItemPointer tid, + UndoRecPtr verptr, uint16 urec_subtype, + const char *payload, Size payload_size, + const char *candidate, Size candidate_len, + char **out_image, Size *out_len) +{ + char *next_image = NULL; + Size next_len = 0; + + switch (urec_subtype) + { + case FLUX_UNDO_UPDATE: + case FLUX_UNDO_DELETE: + + /* + * Both carry the full prior image. A DELETE record is the chain + * head of a deleted row; stepping past an invisible delete yields + * the row as it was before it. + */ + { + FluxUndoPayloadHeader phdr; + + if (payload == NULL || + payload_size < SizeOfFluxUndoPayloadHeader) + flux_pvs_corrupt(rel, tid, verptr, "payload too short"); + memcpy(&phdr, payload, SizeOfFluxUndoPayloadHeader); + if (!(phdr.flags & FLUX_UNDO_FLAG_HAS_TUPLE) || + phdr.tuple_len == 0) + flux_pvs_corrupt(rel, tid, verptr, "no before-image"); + if (payload_size < SizeOfFluxUndoPayloadHeader + phdr.tuple_len) + flux_pvs_corrupt(rel, tid, verptr, "before-image truncated"); + + next_len = phdr.tuple_len; + next_image = (char *) palloc(next_len); + memcpy(next_image, + payload + SizeOfFluxUndoPayloadHeader, + next_len); + break; + } + + case FLUX_UNDO_UPDATE_DELTA: + { + FluxUndoPayloadHeader phdr; + const FluxUndoDelta *delta; + Size delta_len; + + if (payload == NULL || + payload_size < SizeOfFluxUndoPayloadHeader + + SizeOfFluxUndoDelta) + flux_pvs_corrupt(rel, tid, verptr, "delta payload too short"); + memcpy(&phdr, payload, SizeOfFluxUndoPayloadHeader); + if (!(phdr.flags & FLUX_UNDO_FLAG_HAS_TUPLE)) + flux_pvs_corrupt(rel, tid, verptr, "delta without before-image"); + + delta = (const FluxUndoDelta *) + (payload + SizeOfFluxUndoPayloadHeader); + + /* + * tuple_len is the delta's length; a reserved index-revive + * area (FLUX_UNDO_FLAG_INDEX_LIST) may follow it. + */ + delta_len = phdr.tuple_len; + if (delta_len < SizeOfFluxUndoDelta || + delta_len > payload_size - SizeOfFluxUndoPayloadHeader) + flux_pvs_corrupt(rel, tid, verptr, "delta length out of range"); + + /* + * Reconstruct the prior image by reverse-applying the delta + * onto the CURRENT candidate image (not onto the page). The + * delta always covers the t_verptr at offset 8, so the + * reconstructed image carries the prior chain link and the + * walk continues one step further back. + */ + next_len = delta->old_len; + next_image = (char *) palloc(next_len); + if (!FluxUndoDeltaReconstruct(delta, delta_len, + candidate, candidate_len, + next_image)) + flux_pvs_corrupt(rel, tid, verptr, "delta does not fit the newer image"); + break; + } + + default: + flux_pvs_corrupt(rel, tid, verptr, "not an UPDATE or DELETE record"); + } + + + *out_image = next_image; + *out_len = next_len; +} + +/* + * FluxReconstructVisibleVersion + * Walk the UNDO version chain and reconstruct the tuple version + * that satisfies the reader's MVCC snapshot. + * + * Inputs: + * rel - target relation (used to read its UNDO log) + * tid - on-page TID of the row (for error reports) + * onpage_image - pointer to the on-page tuple bytes (read-only) + * onpage_len - length of onpage_image (ItemIdGetLength of the slot) + * snapshot - MVCC snapshot of the reader + * + * Outputs (only populated when the function returns true): + * out_data - palloc'd buffer holding the reconstructed image + * out_len - length of *out_data + * + * Returns true if a different-from-on-page version should be served and + * *out_data / *out_len have been populated. Returns false when the on-page + * value is what the reader should see (caller must NOT free *out_data in + * that case; it is left untouched). + * + * The chain has no length limit: it holds one record per UPDATE committed + * since the reader's snapshot, however many that is, and the walk follows it + * to the version the snapshot sees. A record that has been discarded ends + * the walk at the current candidate, which is then the one to serve: UNDO is + * discarded only once its writer is visible to every snapshot. A malformed + * record or a chain that loops back on itself is corruption and raises an + * ERROR; serving the newer image instead would be a wrong answer. + * + * This is the walk of a read that serves the version it finds, so under + * SERIALIZABLE it reports a rw-conflict out to each writer it steps over, + * as heap does for every version of an update chain it examines + * (HeapCheckForSerializableConflictOut): the writer of each image the + * snapshot cannot see, and the inserter of a row the snapshot cannot see. + * The caller reports the on-page image itself + * (FluxCheckForSerializableConflictOut). Callers walking with a private + * snapshot, or locking rather than reading, use + * FluxReconstructVisibleVersionEx, which reports nothing. + */ +bool +FluxReconstructVisibleVersion(Relation rel, ItemPointer tid, + const char *onpage_image, Size onpage_len, + Snapshot snapshot, + char **out_data, int *out_len) +{ + return flux_pvs_walk(rel, tid, onpage_image, onpage_len, snapshot, + out_data, out_len, NULL, + CheckForSerializableConflictOutNeeded(rel, snapshot)); +} + +/* + * FluxReconstructVisibleVersionEx + * FluxReconstructVisibleVersion, also describing the updates the walk + * stepped back over. + * + * Each step goes back over one update the snapshot does not see: one made + * after the snapshot by a committed, in-progress or aborted transaction. If + * skipped is not NULL it describes those updates, which is what a row locker + * needs to answer as heap does (heap_lock_tuple and + * heap_lock_updated_tuple_rec test every version from the caller's to the + * latest; see flux_tuple_lock): + * + * committed_xid - writer of the newest committed one + * committed_key_xid - writer of the newest committed one that changed an + * indexed column + * inprogress_key_xid - writer of the newest in-progress one that changed + * an indexed column + * complete - the walk reached the snapshot's version of the row, + * or the start of the chain; if false the history is + * incomplete (a record was unreadable) and the fields + * above may miss an update + * + * Whether an update changed an indexed column is the FLUX_TUPLE_KEYS_UPDATED + * bit of the image it produced. The bit describes that one update: an + * in-place UPDATE forms its image with fresh flags and sets the bit only if + * it changed an indexed column, and the undo record of the next update keeps + * that image, flags included, byte for byte. So a key changed and then + * changed back is two key-changing updates here, as in heap's update chain. + * + * Updates by aborted transactions whose rollback is still pending are stepped + * over but not reported: heap ignores an aborted updater too + * (test_lockmode_for_conflict). Neither are our own transaction's. + */ +bool +FluxReconstructVisibleVersionEx(Relation rel, ItemPointer tid, + const char *onpage_image, Size onpage_len, + Snapshot snapshot, + char **out_data, int *out_len, + FluxSkippedUpdates *skipped) +{ + return flux_pvs_walk(rel, tid, onpage_image, onpage_len, snapshot, + out_data, out_len, skipped, false); +} + +/* + * The walk behind FluxReconstructVisibleVersion and + * FluxReconstructVisibleVersionEx. conflict_out: report each writer stepped + * over to SSI (the caller has checked CheckForSerializableConflictOutNeeded). + */ +static bool +flux_pvs_walk(Relation rel, ItemPointer tid, + const char *onpage_image, Size onpage_len, + Snapshot snapshot, + char **out_data, int *out_len, + FluxSkippedUpdates *skipped, bool conflict_out) +{ + const char *candidate = onpage_image; + Size candidate_len = onpage_len; + char *allocated = NULL; + UndoRecPtr cycle_mark = InvalidUndoRecPtr; + uint64 cycle_steps = 0; + uint64 cycle_limit = 1; + + if (skipped != NULL) + memset(skipped, 0, sizeof(FluxSkippedUpdates)); + + if (snapshot == NULL || onpage_image == NULL || onpage_len == 0) + return false; + + for (;;) + { + const FluxTupleHeader *hdr = (const FluxTupleHeader *) candidate; + UndoRecPtr verptr; + TransactionId urec_xid; + CommandId urec_cid; + uint16 urec_subtype; + char *payload = NULL; + Size payload_size = 0; + char *next_image = NULL; + Size next_len = 0; + + CHECK_FOR_INTERRUPTS(); + + /* + * A frozen candidate is a terminal, universally visible version. + * Never fetch an obsolete undo record (or its writer's CLOG entry) + * behind it. Frozen before-images are detached before logging. + */ + if (TransactionIdEquals(FluxTupleGetXmin(hdr), FrozenTransactionId) && + !TransactionIdIsValid(FluxTupleGetXmax(hdr))) + { + if (skipped != NULL) + skipped->complete = true; + break; + } + + /* + * The header identifies the writer without consulting undo: xmin for + * an in-place update, xmax for a delete or superseded version. Once + * that writer is committed and visible to this snapshot, no earlier + * image is needed. In particular, a new relation synced at + * wal_level=minimal can survive a crash without its unwritten undo. + * Our own writer still needs the undo command id below. + */ + { + TransactionId xmax = FluxTupleGetXmax(hdr); + bool removed = TransactionIdIsValid(xmax) && + (hdr->t_flags & (FLUX_TUPLE_DELETED | FLUX_TUPLE_UPDATED)); + TransactionId writer = removed ? xmax : FluxTupleGetXmin(hdr); + bool committed = (hdr->t_flags & (removed ? + FLUX_TUPLE_XMAX_COMMITTED : FLUX_TUPLE_XMIN_COMMITTED)) != 0; + + if (TransactionIdIsValid(writer) && + !TransactionIdIsCurrentTransactionId(writer) && + FluxWriterVisibleToSnapshot(writer, snapshot, committed)) + { + if (skipped != NULL) + skipped->complete = true; + if (removed) + { + if (allocated != NULL) + pfree(allocated); + *out_data = NULL; + *out_len = 0; + return false; + } + break; + } + } + + verptr = FluxTupleGetVersionPtr(hdr); + if (!UndoRecPtrIsValid(verptr)) + { + /* + * End of the version chain: this candidate is the original + * inserted image (an INSERT writes no undo record and leaves an + * invalid verptr). There is no older version to fall back to, so + * the row is visible only if the inserter itself is visible to + * the reader. If the inserter is invisible (aborted, crashed, or + * in-progress and not our own), the row was never really there + * for this snapshot: report no visible version rather than + * serving a phantom before-image. + */ + TransactionId ins_xid = FluxTupleGetXmin(hdr); + bool ins_visible; + + if (!TransactionIdIsValid(ins_xid) || + TransactionIdIsCurrentTransactionId(ins_xid)) + { + /* pre-upgrade tuple (no xmin) or our own insert: visible */ + ins_visible = true; + } + else + { + /* + * Heap-style: the inserted row is visible only if its + * inserter committed AND committed before this snapshot (not + * in the snapshot's in-progress set). An aborted or crashed + * inserter reports !DidCommit, so its image is never served + * -- this is the rollback signal now that INSERT writes no + * undo record. + */ + ins_visible = FluxWriterVisibleToSnapshot(ins_xid, snapshot, + (hdr->t_flags & FLUX_TUPLE_XMIN_COMMITTED) != 0); + } + + if (skipped != NULL) + skipped->complete = true; + + if (!ins_visible) + { + /* heap's invisible LIVE version: conflict out to xmin */ + if (conflict_out) + FluxSerializableConflictOutXid(rel, ins_xid, snapshot); + if (allocated != NULL) + pfree(allocated); + *out_data = NULL; + *out_len = 0; + return false; + } + break; /* no further history; serve this candidate */ + } + + /* + * A chain that returns to a record it has already passed would loop + * forever. Detect that with Brent's algorithm: compare each link + * with one remembered link, re-remembered after 1, 2, 4, ... steps. + */ + if (verptr == cycle_mark) + flux_pvs_corrupt(rel, tid, verptr, "the chain loops"); + if (++cycle_steps == cycle_limit) + { + cycle_mark = verptr; + cycle_steps = 0; + cycle_limit *= 2; + } + + if (!FluxPbuFetchVersion(verptr, &urec_xid, &urec_cid, &urec_subtype, + &payload, &payload_size)) + break; /* discarded: visible to every snapshot */ + + /* + * urec_xid/urec_cid identify the writer that produced the CURRENT + * candidate image. Decide whether that writer is visible to the + * reader; if it is, the candidate is what we should serve. + * + * For a writer in the reader's OWN transaction, XidInMVCCSnapshot is + * always false (our xid is never in our own in-progress set), so the + * command id decides instead: a candidate produced by a command at or + * after the reader's command id is not visible to this snapshot -- an + * earlier command's cursor must see the version as of its own + * command, not the row a later command in the same transaction + * produced. This is heap's HeapTupleHeaderGetCmin(candidate) >= + * curcid rule, applied to the in-place AM where the intermediate + * version survives only in the undo chain. + */ + if (TransactionIdIsCurrentTransactionId(urec_xid)) + { + if (snapshot->curcid == InvalidCommandId || + urec_cid == InvalidCommandId || + urec_cid < snapshot->curcid) + { + /* our own earlier-or-current command produced it: visible */ + if (payload != NULL) + pfree(payload); + if (skipped != NULL) + skipped->complete = true; + break; + } + /* our own later command: fall through and reverse-apply */ + } + else if (FluxWriterVisibleToSnapshot(urec_xid, snapshot, false)) + { + if (payload != NULL) + pfree(payload); + if (skipped != NULL) + skipped->complete = true; + break; + } + + /* + * We are about to step back over the update that produced this + * candidate; report it (see FluxReconstructVisibleVersionEx). Test + * in-progress before committed, as heap does: a committing writer is + * in CLOG before it leaves the proc array. + */ + if (skipped != NULL && !TransactionIdIsCurrentTransactionId(urec_xid)) + { + bool key_changed = + (hdr->t_flags & FLUX_TUPLE_SUPERSEDED) ? + !(hdr->t_flags & FLUX_TUPLE_KEY_INTACT) : + (hdr->t_flags & FLUX_TUPLE_KEYS_UPDATED) != 0; + + if (TransactionIdIsInProgress(urec_xid)) + { + if (key_changed && + !TransactionIdIsValid(skipped->inprogress_key_xid)) + skipped->inprogress_key_xid = urec_xid; + } + else if (TransactionIdDidCommit(urec_xid)) + { + if (!TransactionIdIsValid(skipped->committed_xid)) + skipped->committed_xid = urec_xid; + if (key_changed && + !TransactionIdIsValid(skipped->committed_key_xid)) + skipped->committed_key_xid = urec_xid; + } + } + + /* + * Heap would have examined the version this writer produced and, not + * seeing it, reported a conflict out to its xmin. Running and + * committed-concurrent writers count; aborted ones and our own do not + * (FluxSerializableConflictOutXid). + */ + if (conflict_out) + FluxSerializableConflictOutXid(rel, urec_xid, snapshot); + + /* + * Updater invisible to reader (in progress, committed after the + * snapshot, or aborted with its rollback still pending): + * reverse-apply the record to obtain the prior image and continue. + * The per-backend UNDO record's uur_payload is + * [FluxUndoPayloadHeader][old tuple bytes] (built by + * FluxPbuPrepareInsert), so the before-image is the tuple_len bytes + * immediately after the fixed header. + */ + flux_pvs_prior_image(rel, tid, verptr, urec_subtype, + payload, payload_size, + candidate, candidate_len, + &next_image, &next_len); + + pfree(payload); + + /* Replace the candidate with the reconstructed prior image. */ + if (allocated != NULL) + pfree(allocated); + allocated = next_image; + candidate = allocated; + candidate_len = next_len; + } + + if (allocated == NULL) + return false; /* no reconstruction; caller serves on-page */ + + *out_data = allocated; + *out_len = (int) candidate_len; + return true; +} + +/* + * FluxFindVersionSuperseder + * Find the version of the row at tid whose header identity is + * (xmin, cmin), and the writer that superseded it. + * + * A FLUX in-place UPDATE keeps the row at its TID, so a TID alone names only + * the newest version; a version is identified by the writer stamped in its + * header, t_xmin with t_cmin (see FluxTupleHeader.t_cmin). Walks the + * version chain from the on-page image, the same chain + * FluxReconstructVisibleVersion walks, until it reaches an image with that + * identity. On success returns true and sets *sup_xid / *sup_cid to the + * writer of the UNDO record whose reverse-apply produced it: the transaction + * (subtransaction XID, as stamped) and command that replaced the version, + * heap's xmax of that version. The caller has already seen that the + * on-page image is not the version sought. + * + * Returns false if the chain ends, or cannot be read, before the version is + * reached. + */ +bool +FluxFindVersionSuperseder(Relation rel, ItemPointer tid, + const char *onpage_image, Size onpage_len, + TransactionId xmin, CommandId cmin, + TransactionId *sup_xid, CommandId *sup_cid) +{ + const char *candidate = onpage_image; + Size candidate_len = onpage_len; + char *allocated = NULL; + bool found = false; + UndoRecPtr cycle_mark = InvalidUndoRecPtr; + uint64 cycle_steps = 0; + uint64 cycle_limit = 1; + + for (;;) + { + const FluxTupleHeader *hdr = (const FluxTupleHeader *) candidate; + UndoRecPtr verptr = FluxTupleGetVersionPtr(hdr); + TransactionId urec_xid; + CommandId urec_cid; + uint16 urec_subtype; + char *payload = NULL; + Size payload_size = 0; + char *next_image = NULL; + Size next_len = 0; + + CHECK_FOR_INTERRUPTS(); + + if (!UndoRecPtrIsValid(verptr)) + break; + + /* Same loop guard as FluxReconstructVisibleVersionEx (Brent). */ + if (verptr == cycle_mark) + flux_pvs_corrupt(rel, tid, verptr, "the chain loops"); + if (++cycle_steps == cycle_limit) + { + cycle_mark = verptr; + cycle_steps = 0; + cycle_limit *= 2; + } + + if (!FluxPbuFetchVersion(verptr, &urec_xid, &urec_cid, &urec_subtype, + &payload, &payload_size)) + break; /* discarded: the version is older than any + * undo */ + flux_pvs_prior_image(rel, tid, verptr, urec_subtype, + payload, payload_size, + candidate, candidate_len, + &next_image, &next_len); + if (payload != NULL) + pfree(payload); + + if (allocated != NULL) + pfree(allocated); + allocated = next_image; + candidate = allocated; + candidate_len = next_len; + hdr = (const FluxTupleHeader *) candidate; + + if (TransactionIdEquals(FluxTupleGetXmin(hdr), xmin) && + hdr->t_cmin == cmin) + { + *sup_xid = urec_xid; + *sup_cid = urec_cid; + found = true; + break; + } + } + + if (allocated != NULL) + pfree(allocated); + return found; +} + +/* + * Bound a relation's freeze horizon by the history a reader can still expose. + * New before-images are normalized at creation, but old records and images of + * our own transaction can contain normal XIDs. Do not assume record writers + * are ordered by XID. Fetching a discarded record is a safe end of history. + */ +TransactionId +FluxVersionChainOldestXid(Relation rel, ItemPointer tid, + const char *image, Size len, TransactionId oldest) +{ + const char *candidate = image; + char *allocated = NULL; + UndoRecPtr mark = InvalidUndoRecPtr; + uint64 steps = 0; + uint64 limit = 1; + + for (;;) + { + const FluxTupleHeader *hdr = (const FluxTupleHeader *) candidate; + TransactionId xids[2] = {FluxTupleGetXmin(hdr), FluxTupleGetXmax(hdr)}; + UndoRecPtr ptr = FluxTupleGetVersionPtr(hdr); + TransactionId writer; + CommandId cid; + uint16 subtype; + char *payload = NULL; + Size payload_len; + char *prior; + Size prior_len; + + CHECK_FOR_INTERRUPTS(); + for (int i = 0; i < 2; i++) + if (TransactionIdIsNormal(xids[i]) && TransactionIdPrecedes(xids[i], oldest)) + oldest = xids[i]; + if (!UndoRecPtrIsValid(ptr)) + break; + if (ptr == mark) + flux_pvs_corrupt(rel, tid, ptr, "the chain loops"); + if (++steps == limit) + { + mark = ptr; + steps = 0; + limit *= 2; + } + if (!FluxPbuFetchVersion(ptr, &writer, &cid, &subtype, &payload, &payload_len)) + break; + if (TransactionIdIsNormal(writer) && TransactionIdPrecedes(writer, oldest)) + oldest = writer; + flux_pvs_prior_image(rel, tid, ptr, subtype, payload, payload_len, + candidate, len, &prior, &prior_len); + pfree(payload); + if (allocated != NULL) + pfree(allocated); + allocated = prior; + candidate = prior; + len = prior_len; + } + if (allocated != NULL) + pfree(allocated); + return oldest; +} + +/* + * FluxRewriteChain + * The older versions of the row at tid that a CLUSTER / VACUUM FULL + * rewrite must copy along with image, its version on the page. + * + * A row updated in place keeps its older versions only in UNDO, which a + * rewrite does not carry over: the new relation's rows have no history. + * So the rewrite copies each older version some snapshot may still see as a + * row of its own, deleted by the update that replaced it, as heap's + * rewriteheap.c copies every RECENTLY_DEAD version of an update chain. + * Walk back from image while the update that replaced the version reached is + * not visible to every snapshot: it is ours, or it committed at or after + * oldest_xmin. The first update that is visible to everyone, the start of + * the chain, a discarded record (UNDO is discarded only once its writer is + * visible to everyone) or a frozen image ends the walk. + * + * A DELETE record heads the chain of a deleted row. Stepping past it gives + * the same version undeleted; the page version already carries the delete + * (its xmax), so that version is not returned again. + * + * The caller has reverted every aborted change (flux_revert_pending), and + * holds AccessExclusiveLock, so every other writer in the chain committed. + * + * Returns a List of FluxChainVersion, newest first, allocated in the + * current memory context. + */ +List * +FluxRewriteChain(Relation rel, ItemPointer tid, const char *image, Size len, + TransactionId oldest_xmin) +{ + List *versions = NIL; + const char *candidate = image; + char *undeleted = NULL; + UndoRecPtr mark = InvalidUndoRecPtr; + uint64 steps = 0; + uint64 limit = 1; + + for (;;) + { + UndoRecPtr ptr = FluxTupleGetVersionPtr((const FluxTupleHeader *) candidate); + TransactionId writer; + CommandId cid; + uint16 subtype; + char *payload = NULL; + Size payload_len; + FluxChainVersion *v; + + CHECK_FOR_INTERRUPTS(); + if (!UndoRecPtrIsValid(ptr)) + break; + if (ptr == mark) + flux_pvs_corrupt(rel, tid, ptr, "the chain loops"); + if (++steps == limit) + { + mark = ptr; + steps = 0; + limit *= 2; + } + if (!FluxPbuFetchVersion(ptr, &writer, &cid, &subtype, &payload, &payload_len)) + break; + if (subtype != FLUX_UNDO_DELETE && + !TransactionIdIsCurrentTransactionId(writer)) + { + if (!TransactionIdDidCommit(writer)) + elog(ERROR, "FLUX row (%u,%u) of \"%s\" has a change by transaction %u, which did not commit", + ItemPointerGetBlockNumber(tid), + ItemPointerGetOffsetNumber(tid), + RelationGetRelationName(rel), writer); + if (TransactionIdPrecedes(writer, oldest_xmin)) + { + pfree(payload); + break; + } + } + + v = palloc_object(FluxChainVersion); + flux_pvs_prior_image(rel, tid, ptr, subtype, payload, payload_len, + candidate, len, &v->image, &v->len); + pfree(payload); + if (undeleted != NULL) + pfree(undeleted); + undeleted = NULL; + candidate = v->image; + len = v->len; + if (subtype == FLUX_UNDO_DELETE) + { + undeleted = v->image; + pfree(v); + continue; + } + v->xmax = writer; + versions = lappend(versions, v); + } + if (undeleted != NULL) + pfree(undeleted); + return versions; +} diff --git a/src/backend/access/flux/flux_slot.c b/src/backend/access/flux/flux_slot.c new file mode 100644 index 0000000000000..1335aedf29404 --- /dev/null +++ b/src/backend/access/flux/flux_slot.c @@ -0,0 +1,1058 @@ +/*------------------------------------------------------------------------- + * + * flux_slot.c + * FLUX-specific TupleTableSlot implementation + * + * This implements custom TupleTableSlotOps for FLUX table access method. + * FLUX tuples use timestamps for MVCC instead of transaction IDs, and + * have a different on-disk format than heap tuples. This slot type handles + * the FLUX tuple format natively, avoiding unnecessary conversions + * through the heap tuple format. + * + * The slot can hold either: + * - A reference to a FLUX tuple in a buffer page (pinned buffer) + * - A materialized (palloc'd) copy of a FLUX tuple + * - Virtual data in tts_values/tts_isnull (after deforming or direct store) + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_slot.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/flux.h" +#include "access/slog.h" +#include "access/htup_details.h" +#include "access/tupdesc.h" +#include "access/xact.h" +#include "executor/tuptable.h" +#include "storage/bufmgr.h" +#include "utils/expandeddatum.h" +#include "utils/memutils.h" + +/* + * FluxTupleTableSlot - slot type for FLUX tuples + * + * This extends the base TupleTableSlot with FLUX-specific fields to + * hold a reference to a FLUX tuple either in a buffer or materialized + * in memory. + */ +typedef struct FluxTupleTableSlot +{ + TupleTableSlot base; + + /* Pointer to the FLUX tuple header (in buffer or materialized) */ + FluxTupleHeader *tuple; + + /* Length of the tuple data pointed to by 'tuple' */ + uint32 tuple_len; + + /* + * Values-only ("virtual") payload block. When copyslot deep-copies a + * source slot's datums instead of forming a physical tuple, the + * pass-by-reference values are packed into this single palloc'd block so + * that clear() can free them with one pfree (mirrors + * VirtualTupleTableSlot's ->data). NULL when the slot holds a physical + * tuple or has no pass-by-ref values. + */ + void *values_block; + + /* Deform state: offset into tuple data for lazy attribute extraction */ + uint32 off; + + /* + * If buffer is not InvalidBuffer, the slot holds a pin on this buffer and + * 'tuple' points into the buffer page. When the slot is cleared or + * materialized, the pin is released. + */ + Buffer buffer; + + /* + * Cached MVCC system-column values. + * + * The FLUX read paths deliberately leave a slot "virtual": they deform + * the on-page tuple into tts_values[] and then deep-copy those datums + * into slot-owned memory (ExecMaterializeSlot) so nothing aliases a + * buffer that is about to be unpinned. That drops 'tuple', which is + * where xmin/xmax used to be read from -- so slot_getsysattr() on such a + * slot raised "cannot retrieve a system column in this context". + * + * That is not a corner case: ON CONFLICT DO UPDATE/DO SELECT reads xmin + * off the existing-row slot to detect a self-conflict + * (nodeModifyTable.c), so INSERT ... ON CONFLICT against a FLUX table + * reported the wrong error instead of "command cannot affect row a second + * time". + * + * So capture the two header-derived system columns whenever a physical + * tuple is stored, and keep answering from the cache after the tuple + * pointer is gone. sys_valid says the cache holds real values; it is + * reset by clear() and by any store of a new tuple. + */ + bool sys_valid; + TransactionId sys_xmin; + TransactionId sys_xmax; + + /* + * The command id stamped with sys_xmin (the header's t_cmin): with it, + * sys_xmin names one version of the row (FluxSlotGetVersion). + */ + CommandId sys_cmin; +} FluxTupleTableSlot; + +/* Forward declarations */ +const TupleTableSlotOps TTSOpsFluxTuple; +static void tts_flux_deform(TupleTableSlot *slot, int natts); +static void tts_flux_materialize_values(TupleTableSlot *slot); +static void flux_slot_adopt_tuple(TupleTableSlot *slot, FluxTupleHeader *tuple, + uint32 tuple_len); + + +/* + * Initialization - nothing special needed. + */ +static void +tts_flux_init(TupleTableSlot *slot) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + rslot->tuple = NULL; + rslot->tuple_len = 0; + rslot->off = 0; + rslot->buffer = InvalidBuffer; + rslot->values_block = NULL; +} + +/* + * Destruction - release any resources. + */ +static void +tts_flux_release(TupleTableSlot *slot) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + /* If we own a materialized tuple, free it */ + if (TTS_SHOULDFREE(slot) && rslot->tuple) + { + pfree(rslot->tuple); + rslot->tuple = NULL; + } + if (TTS_SHOULDFREE(slot) && rslot->values_block) + { + pfree(rslot->values_block); + rslot->values_block = NULL; + } + + /* Release buffer pin if held */ + if (BufferIsValid(rslot->buffer)) + { + ReleaseBuffer(rslot->buffer); + rslot->buffer = InvalidBuffer; + } +} + +/* + * Clear the slot contents. + * + * Free materialized tuple if owned, release buffer pin, and reset + * the slot to empty state. + */ +static void +tts_flux_clear(TupleTableSlot *slot) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + /* + * Free materialized tuple data if we own it. A tuple residing in a buffer + * cannot be freed directly; only materialized copies can. + */ + if (TTS_SHOULDFREE(slot)) + { + Assert(!BufferIsValid(rslot->buffer)); + + if (rslot->tuple) + pfree(rslot->tuple); + if (rslot->values_block) + pfree(rslot->values_block); + + slot->tts_flags &= ~TTS_FLAG_SHOULDFREE; + } + + /* Release buffer pin if held */ + if (BufferIsValid(rslot->buffer)) + { + ReleaseBuffer(rslot->buffer); + rslot->buffer = InvalidBuffer; + } + + slot->tts_nvalid = 0; + slot->tts_flags |= TTS_FLAG_EMPTY; + ItemPointerSetInvalid(&slot->tts_tid); + rslot->tuple = NULL; + rslot->tuple_len = 0; + rslot->off = 0; + rslot->values_block = NULL; + + /* + * Drop the cached system columns with the tuple: an emptied slot must not + * answer xmin/xmax from a previous row (see sys_valid). + */ + rslot->sys_valid = false; + rslot->sys_xmin = InvalidTransactionId; + rslot->sys_xmax = InvalidTransactionId; + rslot->sys_cmin = InvalidCommandId; +} + +/* + * Deform FLUX tuple to extract attributes into tts_values/tts_isnull. + * + * This is the FLUX-native equivalent of slot_deform_heap_tuple. It reads + * the FLUX tuple format directly (bitmap + inline attribute data) rather + * than going through the heap tuple deforming path. + */ +static void +tts_flux_deform(TupleTableSlot *slot, int natts) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + TupleDesc tupdesc = slot->tts_tupleDescriptor; + FluxTupleHeader *header = rslot->tuple; + int attnum; + char *data_ptr; + uint8 *nulls_bitmap; + Size bitmap_len; + bool has_nulls; + bool tuple_has_compressed; + + Assert(header != NULL); + Assert(natts <= tupdesc->natts); + + /* Start from where we left off last time */ + attnum = slot->tts_nvalid; + if (attnum >= natts) + return; + + /* + * Use the tuple's actual natts for bitmap_len and data_ptr calculation, + * not the tupdesc's natts. After ALTER TABLE ADD COLUMN, old tuples may + * have fewer attributes than the current schema expects. + */ + { + int tuple_natts = header->t_natts; + + bitmap_len = BITMAPLEN(tuple_natts); + nulls_bitmap = (uint8 *) header->t_attrs_bitmap; + has_nulls = (header->t_infomask & FLUX_INFOMASK_HASNULL) != 0; + tuple_has_compressed = false; /* FLUX does not compress attributes */ + (void) tuple_has_compressed; + + /* + * If this is the first time deforming (attnum == 0), start from the + * beginning of the data area. Otherwise, resume from saved offset. + */ + if (attnum == 0) + data_ptr = (char *) header + MAXALIGN(FLUX_TUPLE_OVERHEAD + bitmap_len); + else + data_ptr = (char *) header + rslot->off; + + /* + * Limit deformation to the attributes physically present in the + * tuple. Attributes beyond tuple_natts were added by ALTER TABLE ADD + * COLUMN and will be filled with their defaults below. + */ + natts = Min(natts, tuple_natts); + } + + for (; attnum < natts; attnum++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, attnum); + + if (att->attisdropped) + { + slot->tts_values[attnum] = (Datum) 0; + slot->tts_isnull[attnum] = true; + + /* + * A column dropped after this tuple was written still occupies + * its bytes on the page, so we must step over them or every later + * attribute is read from the wrong offset. ALTER TABLE DROP + * COLUMN deliberately preserves attlen/attalign for exactly this + * purpose (see RemoveAttributeById), and the null bitmap tells us + * whether the bytes are physically present. Tuples formed after + * the drop store the column as NULL, so they correctly consume + * nothing here. + */ + if (!(has_nulls && att_isnull(attnum, nulls_bitmap))) + { + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + data_ptr = (char *) att_addlength_pointer(data_ptr, att->attlen, + data_ptr); + } + continue; + } + + /* Check null bitmap */ + if (has_nulls && att_isnull(attnum, nulls_bitmap)) + { + slot->tts_values[attnum] = (Datum) 0; + slot->tts_isnull[attnum] = true; + continue; + } + + slot->tts_isnull[attnum] = false; + + if (att->attlen > 0) + { + /* Fixed-length attribute - align first */ + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + slot->tts_values[attnum] = fetchatt(att, data_ptr); + data_ptr += att->attlen; + } + else if (att->attlen == -1) + { + Size attr_len; + + /* Variable-length attribute - align first */ + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + attr_len = VARSIZE_ANY(data_ptr); + + /* + * FLUX stores varlena values verbatim (wide values are TOASTed + * through the standard heap TOAST path; FLUX has no on-page + * overflow and does not compress attributes). + */ + slot->tts_values[attnum] = PointerGetDatum(data_ptr); + data_ptr += attr_len; + } + else if (att->attlen == -2) + { + /* C string */ + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + slot->tts_values[attnum] = CStringGetDatum(data_ptr); + data_ptr += strlen(data_ptr) + 1; + } + else + { + elog(ERROR, "unsupported attribute length: %d", att->attlen); + } + } + + /* Save deform state for incremental deforming */ + rslot->off = (uint32) (data_ptr - (char *) header); + slot->tts_nvalid = natts; +} + +/* + * Fill up first natts entries of tts_values and tts_isnull. + * + * If the slot has a FLUX tuple, deform it natively. If values were already + * stored directly (virtual-style), they are already present. + */ +static void +tts_flux_getsomeattrs(TupleTableSlot *slot, int natts) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + Assert(!TTS_EMPTY(slot)); + + if (rslot->tuple != NULL) + { + /* Deform from the FLUX tuple */ + tts_flux_deform(slot, natts); + + /* + * If the tuple had fewer attributes than requested (e.g., after ALTER + * TABLE ADD COLUMN), fill in defaults for the missing ones. + */ + if (slot->tts_nvalid < natts) + { + slot_getmissingattrs(slot, slot->tts_nvalid, natts); + slot->tts_nvalid = natts; + } + } + else + { + /* + * No physical tuple - values were stored directly into tts_values + * (virtual-style). Fill missing attributes. + */ + slot_getmissingattrs(slot, slot->tts_nvalid, natts); + slot->tts_nvalid = natts; + } +} + +/* + * FluxSlotCacheSysAttrs + * + * Record a tuple's MVCC system columns on the slot so slot_getsysattr() still + * works after the slot has been left "virtual". + * + * The FLUX read paths deform the on-page tuple into tts_values[] and then + * deep-copy those datums into slot-owned memory, deliberately NOT keeping a + * physical tuple pointer (nothing may alias a buffer that is about to be + * unpinned). xmin/xmax live only in the tuple header, so once that pointer is + * gone they are unrecoverable -- and ON CONFLICT DO UPDATE/DO SELECT reads xmin + * off exactly such a slot to detect a self-conflict. Callers that populate a + * slot from a header therefore stash the values here while the header is still + * in hand. + */ +void +FluxSlotCacheSysAttrs(TupleTableSlot *slot, const FluxTupleHeader *tuple) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + if (slot->tts_ops != &TTSOpsFluxTuple || tuple == NULL) + return; + + rslot->sys_valid = true; + rslot->sys_xmin = tuple->t_xmin; + rslot->sys_xmax = FluxTupleGetXmax(tuple); + rslot->sys_cmin = tuple->t_cmin; +} + +/* + * FluxSlotSetVersion + * Record in a slot the version a FLUX write just stored from it. + * + * tuple_insert, tuple_update and their kin store the slot's values as a + * new version stamped (xmin, cmin), and the executor then hands that slot + * to the AFTER triggers as the row's NEW image. The slot must name the + * version it now stands for, not one it may have been loaded with (an + * INSERT ... SELECT from a FLUX table, an UPDATE's re-fetched old row): + * deform any stored tuple into owned values, drop it, and cache the new + * identity. A slot of another kind is left alone; the identity of what it + * holds is not the AM's to answer. + */ +void +FluxSlotSetVersion(TupleTableSlot *slot, TransactionId xmin, CommandId cmin) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + if (slot->tts_ops != &TTSOpsFluxTuple || TTS_EMPTY(slot)) + return; + + if (rslot->tuple != NULL) + { + void *old_tuple = TTS_SHOULDFREE(slot) ? rslot->tuple : NULL; + void *old_block = TTS_SHOULDFREE(slot) ? rslot->values_block : NULL; + MemoryContext oldcxt = MemoryContextSwitchTo(slot->tts_mcxt); + + slot_getallattrs(slot); + slot->tts_flags &= ~TTS_FLAG_SHOULDFREE; + rslot->values_block = NULL; + tts_flux_materialize_values(slot); /* drops rslot->tuple */ + MemoryContextSwitchTo(oldcxt); + if (old_tuple) + pfree(old_tuple); + if (old_block) + pfree(old_block); + if (BufferIsValid(rslot->buffer)) + { + ReleaseBuffer(rslot->buffer); + rslot->buffer = InvalidBuffer; + } + } + + rslot->sys_valid = true; + rslot->sys_xmin = xmin; + rslot->sys_xmax = InvalidTransactionId; + rslot->sys_cmin = cmin; +} + +/* + * FluxSlotGetVersion + * The identity, (xmin, cmin), of the row version a slot holds, if it + * knows it. + * + * A FLUX slot knows it from the stored header or from the system-column + * cache. The cache survives a copy of the version through a heap-format + * tuple and back (tts_flux_copy_heap_tuple, tts_flux_copyslot): that is how + * the AFTER-trigger queue carries a version (am_inplace_update_keeps_tid). + * A slot of another kind is not the AM's to answer for. + */ +bool +FluxSlotGetVersion(TupleTableSlot *slot, TransactionId *xmin, CommandId *cmin) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + if (slot->tts_ops != &TTSOpsFluxTuple) + return false; + if (rslot->tuple != NULL) + { + *xmin = rslot->tuple->t_xmin; + *cmin = rslot->tuple->t_cmin; + return true; + } + if (rslot->sys_valid && rslot->sys_cmin != InvalidCommandId) + { + *xmin = rslot->sys_xmin; + *cmin = rslot->sys_cmin; + return true; + } + return false; +} + +/* + * Return system attribute value for FLUX tuples. + * + * FLUX tuples have timestamps instead of XIDs, so most heap system columns + * are not directly applicable. We handle the subset that makes sense. + */ +static Datum +tts_flux_getsysattr(TupleTableSlot *slot, int attnum, bool *isnull) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + Assert(!TTS_EMPTY(slot)); + + /* Materialization can leave only the cached system attributes. */ + if (!rslot->tuple) + { + if (rslot->sys_valid) + { + *isnull = false; + switch (attnum) + { + case MinTransactionIdAttributeNumber: + return TransactionIdGetDatum(rslot->sys_xmin); + case MaxTransactionIdAttributeNumber: + return TransactionIdGetDatum(rslot->sys_xmax); + case MinCommandIdAttributeNumber: + case MaxCommandIdAttributeNumber: + return CommandIdGetDatum(rslot->sys_cmin); + default: + break; + } + } + + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("cannot retrieve a system column in this context"))); + } + + /* + * Return the tuple's real MVCC system columns. FLUX uses heap-shaped + * xmin/xmax semantics: t_xmin is the inserter XID (always valid), and the + * xmax (deleter/updater XID) lives in the low 32 bits of t_commit_ts, + * accessed via FluxTupleGetXmax (0 == not deleted/updated). + */ + *isnull = false; + + switch (attnum) + { + case MinTransactionIdAttributeNumber: /* xmin */ + return TransactionIdGetDatum(rslot->tuple->t_xmin); + case MaxTransactionIdAttributeNumber: /* xmax */ + return TransactionIdGetDatum(FluxTupleGetXmax(rslot->tuple)); + case MinCommandIdAttributeNumber: /* cmin */ + case MaxCommandIdAttributeNumber: /* cmax */ + { + CommandId cid = rslot->tuple->t_cmin; + + return CommandIdGetDatum(cid); + } + default: + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("FLUX does not support system attribute %d", + attnum))); + return 0; /* silence compiler */ + } +} + +/* + * Check if the tuple was created by the current transaction. + * + * As for heap, that is a property of the stored version: its xmin, the XID + * of the writer that produced it (FLUX keeps heap-shaped xmin, and an in-place + * UPDATE stamps the updater's). The slot has it in the tuple header, or in + * the system-column cache once it has been left virtual (sys_valid). It does + * not depend on the slot's TID and table OID, which a copy of the version + * (the AFTER-trigger OLD row of an in-place UPDATE) does not carry. + */ +static bool +tts_flux_is_current_xact_tuple(TupleTableSlot *slot) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + TransactionId xmin; + + Assert(!TTS_EMPTY(slot)); + + if (rslot->tuple != NULL) + xmin = rslot->tuple->t_xmin; + else if (rslot->sys_valid) + xmin = rslot->sys_xmin; + else + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("don't have a storage tuple in this context"))); + + return TransactionIdIsCurrentTransactionId(xmin); +} + +/* + * Pack a slot's pass-by-reference Datums into a single palloc'd block owned by + * the slot, leaving the slot in a values-only ("virtual") state (tuple == NULL). + * + * This mirrors tts_virtual_materialize: a two-pass scan that first sums the + * aligned size of every non-null pass-by-reference attribute, makes one + * allocation in the slot's memory context, then copies each Datum in and + * repoints tts_values at the copy. byval and null Datums are left untouched. + * + * We deliberately do NOT form a physical FLUX tuple here. Forming one would + * compress every varlena, and the immediate next consumer (flux_multi_insert / + * flux_tuple_insert via slot_getallattrs) would decompress it again before + * re-forming the on-page tuple. Deferring tuple formation to the insert path + * makes bulk COPY compress each value exactly once instead of three times. + * + * The caller must have already populated tts_values/tts_isnull and set + * tts_nvalid to the attribute count; those stay valid because getsomeattrs and + * getsysattr handle tuple == NULL by working from tts_values. + */ +static void +tts_flux_materialize_values(TupleTableSlot *slot) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + TupleDesc desc = slot->tts_tupleDescriptor; + Size sz = 0; + char *data; + + /* First pass: compute the aligned size of the owned block. */ + for (int natt = 0; natt < desc->natts; natt++) + { + CompactAttribute *att = TupleDescCompactAttr(desc, natt); + Datum val; + + if (att->attbyval || slot->tts_isnull[natt]) + continue; + + val = slot->tts_values[natt]; + + if (att->attlen == -1 && + VARATT_IS_EXTERNAL_EXPANDED(DatumGetPointer(val))) + { + sz = att_nominal_alignby(sz, att->attalignby); + sz += EOH_get_flat_size(DatumGetEOHP(val)); + } + else + { + sz = att_nominal_alignby(sz, att->attalignby); + sz = att_addlength_datum(sz, att->attlen, val); + } + } + + rslot->tuple = NULL; + rslot->tuple_len = 0; + rslot->off = 0; + + /* all data is byval / null: nothing to own */ + if (sz == 0) + return; + + rslot->values_block = data = MemoryContextAlloc(slot->tts_mcxt, sz); + slot->tts_flags |= TTS_FLAG_SHOULDFREE; + + /* Second pass: copy each pass-by-reference Datum and repoint tts_values. */ + for (int natt = 0; natt < desc->natts; natt++) + { + CompactAttribute *att = TupleDescCompactAttr(desc, natt); + Datum val; + + if (att->attbyval || slot->tts_isnull[natt]) + continue; + + val = slot->tts_values[natt]; + + if (att->attlen == -1 && + VARATT_IS_EXTERNAL_EXPANDED(DatumGetPointer(val))) + { + ExpandedObjectHeader *eoh = DatumGetEOHP(val); + Size data_length = EOH_get_flat_size(eoh); + + data = (char *) att_nominal_alignby(data, att->attalignby); + EOH_flatten_into(eoh, data, data_length); + + slot->tts_values[natt] = PointerGetDatum(data); + data += data_length; + } + else + { + Size data_length = 0; + + data = (char *) att_nominal_alignby(data, att->attalignby); + data_length = att_addlength_datum(data_length, att->attlen, val); + + memcpy(data, DatumGetPointer(val), data_length); + + slot->tts_values[natt] = PointerGetDatum(data); + data += data_length; + } + } +} + +/* + * Materialize the slot contents. + * + * After materialization, the slot's data is independent of any external + * storage (buffers, other memory contexts). If the slot references a + * tuple in a buffer, the tuple data is copied and the buffer pin released. + */ +static void +tts_flux_materialize(TupleTableSlot *slot) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + MemoryContext oldContext; + + Assert(!TTS_EMPTY(slot)); + + /* Already materialized */ + if (TTS_SHOULDFREE(slot)) + return; + + oldContext = MemoryContextSwitchTo(slot->tts_mcxt); + + if (rslot->tuple != NULL) + { + /* + * We have a physical FLUX tuple (in a buffer or external memory). + * Copy it into the slot's own memory context. + */ + FluxTupleHeader *newtuple; + + newtuple = (FluxTupleHeader *) palloc(rslot->tuple_len); + memcpy(newtuple, rslot->tuple, rslot->tuple_len); + rslot->tuple = newtuple; + + /* + * Reset deform state since tts_values entries may point into the old + * (buffer) tuple data that we're about to release. + */ + rslot->off = 0; + slot->tts_nvalid = 0; + } + else + { + /* + * Virtual tuple (values stored directly). Materialize by copying all + * pass-by-reference Datums into a single block in the slot's memory + * context and leaving the slot in a values-only state (tuple == + * NULL). We deliberately do NOT form a physical FLUX tuple: that + * would compress every varlena, and the insert path + * (flux_multi_insert via slot_getallattrs) would decompress and + * recompress it. Deferring tuple formation to insert compresses each + * value exactly once. + */ + tts_flux_materialize_values(slot); + + /* + * tts_flux_materialize_values sets SHOULDFREE itself when it owns a + * block; return early so we don't set SHOULDFREE unconditionally for + * a slot that owns nothing (all-byval/null). We still must release + * any buffer pin first. + */ + if (BufferIsValid(rslot->buffer)) + { + ReleaseBuffer(rslot->buffer); + rslot->buffer = InvalidBuffer; + } + MemoryContextSwitchTo(oldContext); + return; + } + + /* + * Release buffer pin if held. Do this after copying but before setting + * TTS_FLAG_SHOULDFREE to avoid a transient state where the slot owns a + * buffer and has SHOULDFREE set. + */ + if (BufferIsValid(rslot->buffer)) + { + ReleaseBuffer(rslot->buffer); + rslot->buffer = InvalidBuffer; + } + + slot->tts_flags |= TTS_FLAG_SHOULDFREE; + + MemoryContextSwitchTo(oldContext); +} + +/* + * Copy the contents of srcslot into dstslot. + * + * The destination must not depend on the source slot's memory after this + * returns. We satisfy that by deep-copying the source's attribute values + * into the destination's own memory context and leaving the destination in + * a values-only ("virtual") state -- rdst->tuple stays NULL. We deliberately + * do NOT form a physical FLUX tuple here: forming one would compress every + * varlena, and the immediate next consumer (flux_multi_insert / + * flux_tuple_insert via slot_getallattrs) would have to decompress it again + * before re-forming the on-page tuple. Deferring tuple formation to the + * insert path makes bulk COPY compress each value exactly once instead of + * three times (compress here, decompress there, recompress there). + * + * That is for a source of another kind (COPY's scratch slot); a FLUX source + * holding a stored version is copied as that version, below. + * + * The values-only state is fully supported by the other slot ops: + * getsomeattrs and materialize both handle rslot->tuple == NULL by working + * from tts_values, and copy_heap_tuple/copy_minimal_tuple go through + * slot_getallattrs. + */ +static void +tts_flux_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot) +{ + FluxTupleTableSlot *rsrc = (FluxTupleTableSlot *) srcslot; + FluxTupleTableSlot *rdst = (FluxTupleTableSlot *) dstslot; + TupleDesc desc = dstslot->tts_tupleDescriptor; + + /* + * A FLUX source holding a stored version: copy the version itself, as + * heap's buffer slot copies a buffer slot's tuple. The copy keeps what + * only the header knows, xmin and xmax, so slot_getsysattr() and + * slot_is_current_xact_tuple() answer for it as they do for the source. + * The AFTER-trigger OLD row of an in-place UPDATE is such a copy, and the + * RI triggers ask it whether this transaction created it. + */ + if (srcslot->tts_ops == &TTSOpsFluxTuple && rsrc->tuple != NULL) + { + FluxTupleHeader *copy = MemoryContextAlloc(dstslot->tts_mcxt, + rsrc->tuple_len); + + memcpy(copy, rsrc->tuple, rsrc->tuple_len); + flux_slot_adopt_tuple(dstslot, copy, rsrc->tuple_len); + dstslot->tts_tid = srcslot->tts_tid; + return; + } + + tts_flux_clear(dstslot); + + slot_getallattrs(srcslot); + + /* Copy the datum pointers first; they still reference source memory. */ + for (int natt = 0; natt < desc->natts; natt++) + { + dstslot->tts_values[natt] = srcslot->tts_values[natt]; + dstslot->tts_isnull[natt] = srcslot->tts_isnull[natt]; + } + dstslot->tts_nvalid = desc->natts; + dstslot->tts_flags &= ~TTS_FLAG_EMPTY; + dstslot->tts_tid = srcslot->tts_tid; + + /* + * Deep-copy the pass-by-reference datums into a single owned block so the + * destination no longer depends on the source's memory (in COPY, the + * source is a per-row scratch slot that gets reset). This leaves the + * destination in a values-only state (tuple == NULL), deferring + * compression to the insert path. + */ + tts_flux_materialize_values(dstslot); + + /* + * A FLUX source left virtual still knows its version's system columns + * (sys_valid); carry them, for the same reason as above. + */ + if (srcslot->tts_ops == &TTSOpsFluxTuple && rsrc->sys_valid) + { + rdst->sys_valid = true; + rdst->sys_xmin = rsrc->sys_xmin; + rdst->sys_xmax = rsrc->sys_xmax; + rdst->sys_cmin = rsrc->sys_cmin; + } + + /* + * A heap-format copy of a stored version, made by + * tts_flux_copy_heap_tuple, names that version in its header and says so + * by a valid t_ctid (heap_form_tuple leaves it invalid). Take the + * identity back: the AFTER-trigger queue keeps an in-place AM's rows as + * such copies and fires the trigger with them in FLUX slots + * (am_inplace_update_keeps_tid), and the RI triggers ask + * flux_tuple_satisfies_snapshot about the version. + */ + else if (TTS_IS_HEAPTUPLE(srcslot)) + { + HeapTuple htup = ((HeapTupleTableSlot *) srcslot)->tuple; + + if (htup != NULL && ItemPointerIsValid(&htup->t_data->t_ctid)) + { + rdst->sys_valid = true; + rdst->sys_xmin = HeapTupleHeaderGetRawXmin(htup->t_data); + rdst->sys_xmax = HeapTupleHeaderGetRawXmax(htup->t_data); + rdst->sys_cmin = HeapTupleHeaderGetRawCommandId(htup->t_data); + } + } +} + +/* + * Return a HeapTuple "owned" by the slot. + * + * Since FLUX tuples are not heap tuples, we must form one from the + * deformed values. The result is a palloc'd HeapTuple that the slot owns. + * + * This is needed by parts of the executor that require heap tuples + * (e.g., for index tuple formation, triggers, etc.). + */ +static HeapTuple +tts_flux_copy_heap_tuple(TupleTableSlot *slot) +{ + HeapTuple htup; + + Assert(!TTS_EMPTY(slot)); + + /* Ensure all attributes are deformed */ + slot_getallattrs(slot); + + htup = heap_form_tuple(slot->tts_tupleDescriptor, + slot->tts_values, + slot->tts_isnull); + + /* + * Propagate TID and table OID from the slot to the HeapTuple. ANALYZE's + * compare_rows() sorts sample tuples by t_self (TID), which + * heap_form_tuple leaves zeroed. Without this, the ItemPointerIsValid + * assertion in ItemPointerGetBlockNumber fires. + */ + htup->t_self = slot->tts_tid; + htup->t_tableOid = slot->tts_tableOid; + + /* + * A copy of a stored version identifies the version, as a heap tuple's + * header does: xmin, cmin and xmax, with t_ctid set to the row's TID to + * say so (see FluxSlotGetVersion). The AFTER-trigger queue keeps an + * in-place AM's rows this way. + */ + { + TransactionId xmin; + CommandId cmin; + + if (ItemPointerIsValid(&slot->tts_tid) && + FluxSlotGetVersion(slot, &xmin, &cmin)) + { + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + TransactionId xmax = rslot->tuple ? FluxTupleGetXmax(rslot->tuple) : + rslot->sys_xmax; + + HeapTupleHeaderSetXmin(htup->t_data, xmin); + HeapTupleHeaderSetCmin(htup->t_data, cmin); + HeapTupleHeaderSetXmax(htup->t_data, xmax); + htup->t_data->t_infomask &= ~HEAP_XACT_MASK; + if (!TransactionIdIsValid(xmax)) + htup->t_data->t_infomask |= HEAP_XMAX_INVALID; + htup->t_data->t_ctid = slot->tts_tid; + } + } + + return htup; +} + +/* + * Return a MinimalTuple copy allocated in the caller's memory context. + */ +static MinimalTuple +tts_flux_copy_minimal_tuple(TupleTableSlot *slot, Size extra) +{ + Assert(!TTS_EMPTY(slot)); + + /* Ensure all attributes are deformed */ + slot_getallattrs(slot); + + return heap_form_minimal_tuple(slot->tts_tupleDescriptor, + slot->tts_values, + slot->tts_isnull, + extra); +} + +/* + * The FLUX TupleTableSlotOps structure. + * + * FLUX slots do not "own" heap tuples or minimal tuples natively, so + * get_heap_tuple and get_minimal_tuple are NULL. The copy_ variants are + * provided to satisfy the executor's needs. + */ +const TupleTableSlotOps TTSOpsFluxTuple = { + .base_slot_size = sizeof(FluxTupleTableSlot), + .init = tts_flux_init, + .release = tts_flux_release, + .clear = tts_flux_clear, + .getsomeattrs = tts_flux_getsomeattrs, + .getsysattr = tts_flux_getsysattr, + .is_current_xact_tuple = tts_flux_is_current_xact_tuple, + .materialize = tts_flux_materialize, + .copyslot = tts_flux_copyslot, + + /* FLUX slots do not natively own heap or minimal tuples */ + .get_heap_tuple = NULL, + .get_minimal_tuple = NULL, + .copy_heap_tuple = tts_flux_copy_heap_tuple, + .copy_minimal_tuple = tts_flux_copy_minimal_tuple, +}; + + +/* + * Store a copy of a FLUX tuple into the slot. + * + * This is the only way to put a physical FLUX tuple into a FLUX slot. The + * slot allocates the copy in its own context, slot->tts_mcxt, and frees it + * on clear or release; the caller keeps ownership of tuple. A slot never + * takes over a caller's allocation: the caller's CurrentMemoryContext may be + * a per-tuple context (ATRewriteTable, validateCheckConstraint) that is + * reset under the slot, after which clearing the slot would free the chunk a + * second time. + * + * If buffer is valid, tuple points into that buffer's page. Unlike heap, + * FLUX rewrites a tuple's bytes in place on UPDATE, so a slot must never + * point into the page: a datum deformed from it would change under the + * reader, to a version its snapshot must not see. The caller holds the + * buffer's content lock in a mode that excludes those writers + * (share-exclusive or exclusive; the byte-CAS UPDATE runs under + * share-exclusive), and decided visibility on these same bytes under that + * same lock. The slot holds no pin. If buffer is InvalidBuffer, tuple is + * in local memory, e.g. a version reconstructed from UNDO, which the caller + * frees. + */ +void +FluxSlotStoreTuple(TupleTableSlot *slot, FluxTupleHeader *tuple, + uint32 tuple_len, Buffer buffer) +{ + FluxTupleHeader *copy; + + Assert(slot->tts_ops == &TTSOpsFluxTuple); + Assert(!BufferIsValid(buffer) || + BufferIsLockedByMeInMode(buffer, BUFFER_LOCK_SHARE_EXCLUSIVE) || + BufferIsLockedByMeInMode(buffer, BUFFER_LOCK_EXCLUSIVE)); + + copy = MemoryContextAlloc(slot->tts_mcxt, tuple_len); + memcpy(copy, tuple, tuple_len); + flux_slot_adopt_tuple(slot, copy, tuple_len); +} + +/* + * Make tuple, a chunk of slot->tts_mcxt, the slot's tuple. The slot frees + * it when cleared or released. + */ +static void +flux_slot_adopt_tuple(TupleTableSlot *slot, FluxTupleHeader *tuple, + uint32 tuple_len) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + Assert(slot->tts_ops == &TTSOpsFluxTuple); + Assert(GetMemoryChunkContext(tuple) == slot->tts_mcxt); + + tts_flux_clear(slot); + + rslot->tuple = tuple; + rslot->tuple_len = tuple_len; + rslot->off = 0; + rslot->buffer = InvalidBuffer; + + /* Cache the system columns while we still have the header. */ + rslot->sys_valid = true; + rslot->sys_xmin = tuple->t_xmin; + rslot->sys_xmax = FluxTupleGetXmax(tuple); + rslot->sys_cmin = tuple->t_cmin; + + slot->tts_flags &= ~TTS_FLAG_EMPTY; + slot->tts_flags |= TTS_FLAG_SHOULDFREE; + slot->tts_nvalid = 0; +} diff --git a/src/backend/access/flux/flux_stats.c b/src/backend/access/flux/flux_stats.c new file mode 100644 index 0000000000000..92b55b3259ab6 --- /dev/null +++ b/src/backend/access/flux/flux_stats.c @@ -0,0 +1,286 @@ +/*------------------------------------------------------------------------- + * + * flux_stats.c + * FLUX-specific statistics collection for ANALYZE + * + * This module collects statistics that are unique to the FLUX storage + * format: compression ratios, overflow usage, space efficiency, and the + * distribution of the per-tuple commit-ts word. These statistics supplement + * the standard + * per-column statistics (MCV, histograms, NULL fractions, etc.) that + * PostgreSQL's ANALYZE framework collects automatically via the + * scan_analyze_next_block / scan_analyze_next_tuple callbacks. + * + * The collected statistics are logged at DEBUG1 level and made available + * through the FluxCollectRelationStats() interface so that the planner + * can incorporate FLUX-specific cost adjustments. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_stats.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/flux.h" +#include "miscadmin.h" +#include "storage/bufmgr.h" +#include "storage/smgr.h" +#include "utils/rel.h" + +/* + * FluxCollectRelationStats + * + * Scan the relation to collect FLUX-specific statistics. This performs + * a full sequential pass over every page, examining each item to measure + * compression ratios, overflow usage, tuple sizes, free space, and the + * distribution of the per-tuple commit-ts word. + * + * This is designed to be called during ANALYZE after the standard sampling + * is complete. It does its own full scan because the standard sampling + * only visits a random subset of blocks, which is fine for per-column + * statistics but insufficient for accurate relation-wide measurements + * like total overflow bytes or bloat factor. + * + * The caller must pass a zeroed FluxRelationStats struct. + */ +void +FluxCollectRelationStats(Relation rel, FluxRelationStats *stats) +{ + BlockNumber nblocks; + BlockNumber blkno; + int64 total_tuple_bytes = 0; + int64 total_compressed_tuples = 0; + int64 total_overflow_tuples = 0; + int64 total_overflow_chains = 0; + int64 total_live = 0; + int64 total_dead = 0; + double total_free_space = 0.0; + int64 total_uncompressed_size = 0; + int64 total_compressed_size = 0; + bool commit_ts_seen = false; + uint64 commit_ts_min = PG_UINT64_MAX; + uint64 commit_ts_max = 0; + + /* Initialize output */ + memset(stats, 0, sizeof(FluxRelationStats)); + + /* Get number of blocks */ + if (!smgrexists(RelationGetSmgr(rel), MAIN_FORKNUM)) + return; + + nblocks = smgrnblocks(RelationGetSmgr(rel), MAIN_FORKNUM); + stats->total_pages = nblocks; + + if (nblocks == 0) + return; + + /* + * Scan every page. We take only a shared lock on each page and release + * it before moving to the next, keeping contention low. + */ + for (blkno = 0; blkno < nblocks; blkno++) + { + Buffer buffer; + Page page; + OffsetNumber maxoff; + OffsetNumber offnum; + Size page_free; + + CHECK_FOR_INTERRUPTS(); + + buffer = ReadBufferExtended(rel, MAIN_FORKNUM, blkno, + RBM_NORMAL, NULL); + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page = BufferGetPage(buffer); + + /* Skip uninitialized pages */ + if (PageIsNew(page)) + { + UnlockReleaseBuffer(buffer); + continue; + } + + maxoff = PageGetMaxOffsetNumber(page); + page_free = PageGetFreeSpace(page); + total_free_space += (double) page_free / (double) BLCKSZ; + + for (offnum = FirstOffsetNumber; offnum <= maxoff; offnum++) + { + ItemId itemid = PageGetItemId(page, offnum); + FluxTupleHeader *hdr; + Size item_len; + + if (!ItemIdIsNormal(itemid)) + { + if (ItemIdIsDead(itemid)) + total_dead++; + continue; + } + + item_len = ItemIdGetLength(itemid); + hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Skip overflow records -- counted separately */ + if (FluxIsOverflowRecordInline(hdr, item_len)) + { + total_overflow_chains++; + stats->total_overflow_bytes += item_len; + continue; + } + + /* This is a real tuple */ + total_tuple_bytes += item_len; + + if (hdr->t_flags & FLUX_TUPLE_DELETED) + { + total_dead++; + continue; + } + + /* Live tuple */ + total_live++; + + /* Check compression */ + if (hdr->t_flags & FLUX_TUPLE_COMPRESSED) + { + total_compressed_tuples++; + + /* + * Estimate compression ratio from the compression header that + * follows the tuple header, if present. + */ + if (item_len > FLUX_TUPLE_OVERHEAD + sizeof(FluxCompressionHeader)) + { + FluxCompressionHeader *comp_hdr; + + comp_hdr = (FluxCompressionHeader *) + ((char *) hdr + FLUX_TUPLE_OVERHEAD); + total_uncompressed_size += comp_hdr->orig_size; + total_compressed_size += comp_hdr->comp_size; + } + } + + /* Check overflow */ + if (hdr->t_flags & FLUX_TUPLE_HAS_OVERFLOW) + total_overflow_tuples++; + + /* Track commit-ts word range (diagnostic) */ + if (hdr->t_commit_ts > 0) + { + commit_ts_seen = true; + if (hdr->t_commit_ts < commit_ts_min) + commit_ts_min = hdr->t_commit_ts; + if (hdr->t_commit_ts > commit_ts_max) + commit_ts_max = hdr->t_commit_ts; + } + } + + UnlockReleaseBuffer(buffer); + } + + /* Compute derived statistics */ + stats->total_live_tuples = total_live; + stats->total_dead_tuples = total_dead; + + if (total_live > 0) + { + stats->avg_tuple_size = (double) total_tuple_bytes / (double) total_live; + stats->pct_compressed = (double) total_compressed_tuples / (double) total_live; + stats->pct_overflow = (double) total_overflow_tuples / (double) total_live; + } + + if (total_compressed_size > 0 && total_uncompressed_size > 0) + stats->compression_ratio = (double) total_uncompressed_size / + (double) total_compressed_size; + else + stats->compression_ratio = 1.0; + + if (total_overflow_tuples > 0) + stats->avg_overflow_chain_len = (double) total_overflow_chains / + (double) total_overflow_tuples; + + if (nblocks > 0) + { + stats->avg_live_per_page = (double) total_live / (double) nblocks; + stats->free_space_frac = total_free_space / (double) nblocks; + } + + /* Bloat = total allocated space / actual live data */ + if (total_tuple_bytes > 0) + stats->bloat_factor = ((double) nblocks * BLCKSZ) / + (double) total_tuple_bytes; + else + stats->bloat_factor = 1.0; + + /* commit-ts word range */ + if (commit_ts_seen) + { + stats->commit_ts_stats_valid = true; + stats->commit_ts_min = commit_ts_min; + stats->commit_ts_max = commit_ts_max; + } +} + +/* + * FluxLogRelationStats + * + * Emit the collected FLUX statistics at the given log level (typically + * DEBUG1 during ANALYZE, or LOG for diagnostic purposes). Produces three + * separate ereport messages: + * 1. Page counts and live/dead tuple totals + * 2. Average tuple size, compression percentage/ratio, overflow stats + * 3. Average live tuples per page, free space fraction, bloat factor + * If commit-ts word statistics are valid, a fourth message shows the range. + * + * Parameters: + * rel - the relation whose statistics are being logged + * stats - the collected FluxRelationStats structure + * elevel - ereport log level (e.g., DEBUG1, LOG, WARNING) + */ +void +FluxLogRelationStats(Relation rel, const FluxRelationStats *stats, int elevel) +{ + ereport(elevel, + (errmsg("FLUX stats for \"%s\": " + "%lld pages, %lld live tuples, %lld dead tuples", + RelationGetRelationName(rel), + (long long) stats->total_pages, + (long long) stats->total_live_tuples, + (long long) stats->total_dead_tuples))); + + ereport(elevel, + (errmsg("FLUX stats for \"%s\": " + "avg tuple size %.1f bytes, " + "%.1f%% compressed (ratio %.2f), " + "%.1f%% overflow (avg chain %.1f)", + RelationGetRelationName(rel), + stats->avg_tuple_size, + stats->pct_compressed * 100.0, + stats->compression_ratio, + stats->pct_overflow * 100.0, + stats->avg_overflow_chain_len))); + + ereport(elevel, + (errmsg("FLUX stats for \"%s\": " + "avg %.1f live/page, " + "%.1f%% free space, " + "bloat factor %.2f", + RelationGetRelationName(rel), + stats->avg_live_per_page, + stats->free_space_frac * 100.0, + stats->bloat_factor))); + + if (stats->commit_ts_stats_valid) + { + ereport(elevel, + (errmsg("FLUX stats for \"%s\": " + "commit-ts word range [%llu .. %llu]", + RelationGetRelationName(rel), + (unsigned long long) stats->commit_ts_min, + (unsigned long long) stats->commit_ts_max))); + } +} diff --git a/src/backend/access/flux/flux_tuple.c b/src/backend/access/flux/flux_tuple.c new file mode 100644 index 0000000000000..a3e597bfcfb5c --- /dev/null +++ b/src/backend/access/flux/flux_tuple.c @@ -0,0 +1,1221 @@ +/*------------------------------------------------------------------------- + * + * flux_tuple.c + * FLUX tuple handling routines + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_tuple.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/detoast.h" +#include "access/flux.h" +#include "access/flux_xlog.h" +#include "access/heapam.h" +#include "access/heaptoast.h" +#include "access/toast_helper.h" +#include "access/toast_internals.h" +#include "access/tupdesc.h" +#include "access/tupmacs.h" +#include "catalog/pg_type.h" +#include "common/hashfn.h" +#include "executor/tuptable.h" +#include "storage/bufpage.h" +#include "utils/datum.h" +#include "utils/lsyscache.h" +#include "utils/memutils.h" + +/* + * FluxComputeDataSize + * + * Calculate the total on-disk size needed to store a tuple with the given + * attributes. This includes the fixed-size FluxTupleHeader, the null + * bitmap, alignment padding, and all attribute data. + * + * Parameters: + * tupdesc - tuple descriptor defining the attributes + * values - array of Datum values for each attribute + * isnull - array of boolean null indicators + * + * Returns the total size in bytes, including header and alignment. + */ +Size +FluxComputeDataSize(TupleDesc tupdesc, Datum *values, bool *isnull) +{ + Size data_length = 0; + Size bitmap_len; + int i; + + Assert(tupdesc != NULL); + Assert(values != NULL); + Assert(isnull != NULL); + + /* Calculate null bitmap length */ + bitmap_len = BITMAPLEN(tupdesc->natts); + + /* Start with tuple header size */ + data_length = MAXALIGN(FLUX_TUPLE_OVERHEAD + bitmap_len); + + /* Add space for each attribute */ + for (i = 0; i < tupdesc->natts; i++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + + if (att->attisdropped) + continue; + + if (!isnull[i]) + { + Size attr_len; + + /* Align attribute */ + data_length = att_align_nominal(data_length, att->attalign); + + if (att->attlen > 0) + { + /* Fixed-length attribute */ + attr_len = att->attlen; + } + else if (att->attlen == -1) + { + /* Variable-length attribute */ + attr_len = VARSIZE_ANY(DatumGetPointer(values[i])); + } + else if (att->attlen == -2) + { + /* C string */ + attr_len = strlen(DatumGetCString(values[i])) + 1; + } + else + { + elog(ERROR, "unsupported attribute length: %d", att->attlen); + } + + data_length += attr_len; + } + } + + return data_length; +} + +/* + * flux_toast_tuple + * + * TOAST the varlena columns of a to-be-stored FLUX tuple, exactly like heap. + * FLUX has no on-page overflow mechanism; wide values are pushed to the + * relation's standard heap TOAST table via the AM-agnostic toast_helper + * routines (toast_tuple_*). On return, values[]/isnull[] have had any + * externalized/compressed columns replaced so that the tuple formed from + * them fits within a FLUX page. + * + * oldvalues/oldisnull describe the previous tuple version on UPDATE (so + * unchanged external datums are reused and superseded ones scheduled for + * deletion); pass NULL for INSERT. + * + * The returned ToastTupleContext must be released with flux_toast_cleanup() + * after the caller has finished forming and storing the tuple. *changed is + * set true iff any column was actually toasted. + */ +void +flux_toast_tuple(Relation rel, Datum *values, bool *isnull, + Datum *oldvalues, bool *oldisnull, + ToastTupleContext *ttc, ToastAttrInfo *toast_attr, + bool *changed, uint32 options) +{ + TupleDesc tupleDesc = rel->rd_att; + int numAttrs = tupleDesc->natts; + Size hoff; + Size maxDataLen; + + options &= ~HEAP_INSERT_SPECULATIVE; + + ttc->ttc_rel = rel; + ttc->ttc_values = values; + ttc->ttc_isnull = isnull; + ttc->ttc_oldvalues = oldvalues; + ttc->ttc_oldisnull = oldisnull; + ttc->ttc_attr = toast_attr; + toast_tuple_init(ttc); + + /* + * Header overhead for a FLUX tuple: fixed header plus null bitmap when + * nulls are present. Convert to a data-size limit. FLUX targets the + * same TOAST_TUPLE_TARGET as heap so wide rows behave identically. + */ + hoff = FLUX_TUPLE_OVERHEAD; + if ((ttc->ttc_flags & TOAST_HAS_NULLS) != 0) + hoff += BITMAPLEN(numAttrs); + hoff = MAXALIGN(hoff); + maxDataLen = RelationGetToastTupleTarget(rel, TOAST_TUPLE_TARGET) - hoff; + + /* Round 1: compress EXTENDED, externalize very large EXTENDED/EXTERNAL */ + while (heap_compute_data_size(tupleDesc, values, isnull) > maxDataLen) + { + int biggest_attno; + + biggest_attno = toast_tuple_find_biggest_attribute(ttc, true, false); + if (biggest_attno < 0) + break; + if (TupleDescAttr(tupleDesc, biggest_attno)->attstorage == TYPSTORAGE_EXTENDED) + toast_tuple_try_compression(ttc, biggest_attno); + else + toast_attr[biggest_attno].tai_colflags |= TOASTCOL_INCOMPRESSIBLE; + if (toast_attr[biggest_attno].tai_size > maxDataLen && + rel->rd_rel->reltoastrelid != InvalidOid) + toast_tuple_externalize(ttc, biggest_attno, options); + } + + /* Round 2: externalize remaining inline EXTENDED/EXTERNAL */ + while (heap_compute_data_size(tupleDesc, values, isnull) > maxDataLen && + rel->rd_rel->reltoastrelid != InvalidOid) + { + int biggest_attno; + + biggest_attno = toast_tuple_find_biggest_attribute(ttc, false, false); + if (biggest_attno < 0) + break; + toast_tuple_externalize(ttc, biggest_attno, options); + } + + /* Round 3: compress MAIN */ + while (heap_compute_data_size(tupleDesc, values, isnull) > maxDataLen) + { + int biggest_attno; + + biggest_attno = toast_tuple_find_biggest_attribute(ttc, true, true); + if (biggest_attno < 0) + break; + toast_tuple_try_compression(ttc, biggest_attno); + } + + /* Round 4: externalize MAIN, at the larger MAIN target */ + maxDataLen = TOAST_TUPLE_TARGET_MAIN - hoff; + while (heap_compute_data_size(tupleDesc, values, isnull) > maxDataLen && + rel->rd_rel->reltoastrelid != InvalidOid) + { + int biggest_attno; + + biggest_attno = toast_tuple_find_biggest_attribute(ttc, false, true); + if (biggest_attno < 0) + break; + toast_tuple_externalize(ttc, biggest_attno, options); + } + + *changed = (ttc->ttc_flags & TOAST_NEEDS_CHANGE) != 0; +} + +/* + * flux_toast_cleanup + * + * Release toasting temporaries and delete any superseded external datums, + * mirroring toast_tuple_cleanup() as used by heap after the new tuple is + * durably stored. + */ +void +flux_toast_cleanup(ToastTupleContext *ttc) +{ + toast_tuple_cleanup(ttc); +} + +/* + * flux_toast_abandon + * + * Undo a flux_toast_tuple() whose tuple will not be stored after all: kill + * the values it externalized (nothing references them) and release the + * toasting temporaries, but keep the old version's external values. The + * values were inserted by the current command, so they are killed the way an + * aborted speculative insertion kills its TOAST values, not MVCC-deleted. + */ +void +flux_toast_abandon(ToastTupleContext *ttc) +{ + int numAttrs = ttc->ttc_rel->rd_att->natts; + + for (int i = 0; i < numAttrs; i++) + { + ToastAttrInfo *attr = &ttc->ttc_attr[i]; + + if ((attr->tai_colflags & TOASTCOL_NEEDS_FREE) != 0 && + VARATT_IS_EXTERNAL_ONDISK(DatumGetPointer(ttc->ttc_values[i]))) + toast_delete_datum(ttc->ttc_rel, ttc->ttc_values[i], true); + } + ttc->ttc_flags &= ~TOAST_NEEDS_DELETE_OLD; + toast_tuple_cleanup(ttc); +} + +/* + * flux_toast_delete + * + * Delete the external TOAST datums referenced by a FLUX tuple that is being + * removed (DELETE, or the old version of an out-of-place UPDATE). + */ +void +flux_toast_delete(Relation rel, Datum *values, bool *isnull, + bool is_speculative) +{ + toast_delete_external(rel, values, isnull, is_speculative); +} + +/* + * FluxFormTuple + * + * Create a new FLUX tuple from the given attribute values and null indicators. + * Allocates memory for the FluxTupleData wrapper and the on-disk + * FluxTupleHeader + attribute data. + * + * When compression is enabled (flux_enable_compression GUC), variable-length + * attributes exceeding FLUX_MIN_COMPRESS_SIZE (32 bytes) are automatically + * compressed using the algorithm selected by FluxChooseCompressionType(). + * Compressed attributes are stored with a FluxCompressionHeader prefix and + * the tuple's FLUX_INFOMASK_COMPRESSED bit is set. + * + * When a relation is provided, large attributes exceeding FLUX_OVERFLOW_THRESHOLD + * are automatically stored in overflow pages. Overflow pointers are collected in + * overflow_buffers for atomic WAL logging by the caller. + * + * Parameters: + * tupdesc - tuple descriptor defining the schema + * values - array of Datum values for each attribute + * isnull - array of boolean null indicators + * rel - relation for overflow storage (NULL to disable overflow handling) + * overflow_buffers - output for overflow buffers (NULL if rel is NULL) + * + * Returns a palloc'd FluxTuple. The caller is responsible for freeing it + * with FluxFreeTuple() when done. + */ +static FluxTuple flux_form_tuple_internal(TupleDesc tupdesc, Datum *values, + bool *isnull, Relation rel, + FluxOverflowBuffers *overflow_buffers, + bool force_shrink, + const FluxOverflowPtr *old_ovptrs, + const bool *old_ovpresent); + +FluxTuple +FluxFormTuple(TupleDesc tupdesc, Datum *values, bool *isnull, + Relation rel, FluxOverflowBuffers *overflow_buffers) +{ + return flux_form_tuple_internal(tupdesc, values, isnull, rel, + overflow_buffers, false, NULL, NULL); +} + +/* + * FluxFormTupleUpdate + * + * Like FluxFormTuple, but for the in-place UPDATE path. old_ovptrs and + * old_ovpresent (indexed by attnum, natts entries) describe the OLD tuple's + * on-page overflow pointers, collected while its buffer was still locked. Any + * over-threshold varlena whose content hash matches the old pointer's stored + * hash (and byte-verifies equal) is COW-referenced against the existing + * overflow chain instead of being re-stored, avoiding needless disk growth and + * WAL. Pass NULL arrays to disable this (identical to FluxFormTuple). + */ +FluxTuple +FluxFormTupleUpdate(TupleDesc tupdesc, Datum *values, bool *isnull, + Relation rel, FluxOverflowBuffers *overflow_buffers, + const FluxOverflowPtr *old_ovptrs, + const bool *old_ovpresent) +{ + return flux_form_tuple_internal(tupdesc, values, isnull, rel, + overflow_buffers, false, + old_ovptrs, old_ovpresent); +} + +/* + * FluxFormTupleForceShrink + * + * Like FluxFormTuple, but forces every inline varlena attribute larger than + * an overflow pointer off-page with a zero inline prefix, regardless of the + * normal FLUX_OVERFLOW_THRESHOLD. This shrinks the main tuple to its minimum + * footprint (header + fixed columns + one overflow pointer per large varlena). + * + * Used as a last resort by the in-place UPDATE path: when an updated tuple has + * grown beyond the space available on its page and TID stability forbids + * relocating it, pushing its variable-length data off-page lets the main tuple + * fit back into (or near) its original slot. A relation and overflow_buffers + * are mandatory because every forced column is written to overflow pages. + */ +FluxTuple +FluxFormTupleForceShrink(TupleDesc tupdesc, Datum *values, bool *isnull, + Relation rel, FluxOverflowBuffers *overflow_buffers) +{ + Assert(rel != NULL); + Assert(overflow_buffers != NULL); + return flux_form_tuple_internal(tupdesc, values, isnull, rel, + overflow_buffers, true, NULL, NULL); +} + +static FluxTuple +flux_form_tuple_internal(TupleDesc tupdesc, Datum *values, bool *isnull, + Relation rel, FluxOverflowBuffers *overflow_buffers, + bool force_shrink, + const FluxOverflowPtr *old_ovptrs, + const bool *old_ovpresent) +{ + FluxTuple tuple; + FluxTupleHeader *header; + Size data_length; + Size tuple_length; + Size bitmap_len; + char *data_ptr; + uint8 *nulls_bitmap; + int i; + bool has_nulls = false; + bool has_varwidth = false; + bool has_external = false; + bool has_compressed = false; + bool has_overflow = false; + + /* + * Working arrays for compressed/overflowed values. We attempt compression + * and overflow first, then compute the final tuple size using the + * (possibly compressed/overflowed) attribute values. + * + * Use stack arrays for small tuples (common OLTP case) to avoid palloc. + */ +#define FLUX_FORM_STACK_ATTRS 16 + Datum *work_values; + bool *is_compressed; /* Track which attrs were compressed */ + bool *is_overflowed; /* Track which attrs were overflowed */ + Datum work_values_stack[FLUX_FORM_STACK_ATTRS]; + bool is_compressed_stack[FLUX_FORM_STACK_ATTRS]; + bool is_overflowed_stack[FLUX_FORM_STACK_ATTRS]; + + Assert(tupdesc != NULL); + Assert(values != NULL); + Assert(isnull != NULL); + + if (tupdesc->natts <= FLUX_FORM_STACK_ATTRS) + { + work_values = work_values_stack; + is_compressed = is_compressed_stack; + is_overflowed = is_overflowed_stack; + memset(is_compressed, 0, tupdesc->natts * sizeof(bool)); + memset(is_overflowed, 0, tupdesc->natts * sizeof(bool)); + } + else + { + work_values = (Datum *) palloc(tupdesc->natts * sizeof(Datum)); + is_compressed = (bool *) palloc0(tupdesc->natts * sizeof(bool)); + is_overflowed = (bool *) palloc0(tupdesc->natts * sizeof(bool)); + } + memcpy(work_values, values, tupdesc->natts * sizeof(Datum)); + + /* + * FLUX has no on-page overflow mechanism; wide varlena values are TOASTed + * by the caller (standard heap TOAST path) before reaching here. + * Likewise, FLUX does not compress attributes. work_values therefore + * mirrors values exactly. + */ + + /* + * Phase 2: Calculate total space needed + */ + data_length = FluxComputeDataSize(tupdesc, work_values, isnull); + tuple_length = data_length; + + /* Allocate tuple */ + tuple = (FluxTuple) palloc0(sizeof(FluxTupleData)); + tuple->t_len = tuple_length; + tuple->t_data = (FluxTupleHeader *) palloc0(tuple_length); + + /* Set up header */ + header = tuple->t_data; + header->t_natts = tupdesc->natts; + header->t_flags = 0; + header->t_commit_ts = 0; /* Will be set during insert */ + ItemPointerSetInvalid(&header->t_ctid); + header->t_infomask = 0; + + if (has_compressed) + { + header->t_flags |= FLUX_TUPLE_COMPRESSED; + header->t_infomask |= FLUX_INFOMASK_COMPRESSED; + } + (void) has_compressed; + + /* Set up null bitmap */ + bitmap_len = BITMAPLEN(tupdesc->natts); + nulls_bitmap = (uint8 *) header->t_attrs_bitmap; + data_ptr = (char *) header + MAXALIGN(FLUX_TUPLE_OVERHEAD + bitmap_len); + + /* + * Initialize null bitmap - PostgreSQL expects all bits set to 1 initially + * (all NOT NULL) + */ + memset(nulls_bitmap, 0xFF, bitmap_len); + + /* Set infomask bits */ + for (i = 0; i < tupdesc->natts; i++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + + /* + * Dropped columns are never given storage in a newly formed tuple + * (FluxComputeDataSize and the store loop below both skip them), so + * they must be recorded as NULL in the bitmap. Otherwise the deform + * side would think the bytes are present and read every following + * attribute from the wrong offset. + */ + if (isnull[i] || att->attisdropped) + { + has_nulls = true; + /* Clear the bit for NULL attributes (bit=0 means NULL) */ + nulls_bitmap[i >> 3] &= ~(1 << (i & 0x07)); + } + else + { + if (att->attlen == -1 || att->attlen == -2) + has_varwidth = true; + + /* Check for external storage */ + if (att->attlen == -1 && VARATT_IS_EXTERNAL(DatumGetPointer(work_values[i]))) + has_external = true; + } + } + + if (has_nulls) + header->t_infomask |= FLUX_INFOMASK_HASNULL; + if (has_varwidth) + header->t_infomask |= FLUX_INFOMASK_HASVARWIDTH; + if (has_external) + header->t_infomask |= FLUX_INFOMASK_HASEXTERNAL; + if (has_overflow) + { + header->t_flags |= FLUX_TUPLE_HAS_OVERFLOW; + header->t_infomask |= FLUX_INFOMASK_HASOVERFLOW; + } + + /* + * Phase 3: Store attribute values (using compressed data where + * applicable) + */ + for (i = 0; i < tupdesc->natts; i++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + + if (att->attisdropped || isnull[i]) + continue; + + /* Align attribute */ + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + + if (att->attlen > 0) + { + /* + * Fixed-length attribute - never compressed. Must distinguish + * byval from by-reference fixed-length types (e.g., timetz is 12 + * bytes but passed by reference). + */ + if (att->attbyval) + store_att_byval(data_ptr, work_values[i], att->attlen); + else + memcpy(data_ptr, DatumGetPointer(work_values[i]), att->attlen); + data_ptr += att->attlen; + } + else if (att->attlen == -1) + { + /* Variable-length attribute (possibly compressed) */ + Size attr_len = VARSIZE_ANY(DatumGetPointer(work_values[i])); + + memcpy(data_ptr, DatumGetPointer(work_values[i]), attr_len); + data_ptr += attr_len; + } + else if (att->attlen == -2) + { + /* C string */ + Size attr_len = strlen(DatumGetCString(work_values[i])) + 1; + + memcpy(data_ptr, DatumGetCString(work_values[i]), attr_len); + data_ptr += attr_len; + } + } + + /* + * Free compressed and overflow datums that were allocated by + * FluxCompressAttribute and FluxStoreOverflowColumn + */ + for (i = 0; i < tupdesc->natts; i++) + { + if (is_compressed[i] || is_overflowed[i]) + pfree(DatumGetPointer(work_values[i])); + } + if (tupdesc->natts > FLUX_FORM_STACK_ATTRS) + { + pfree(work_values); + pfree(is_compressed); + pfree(is_overflowed); + } + + return tuple; +} + +/* + * FluxDeformTuple + * + * Extract attribute values and null indicators from a FLUX tuple into the + * provided arrays. This is the inverse of FluxFormTuple(). + * + * When the tuple has the FLUX_INFOMASK_COMPRESSED flag set, variable-length + * attributes may contain a FluxCompressionHeader prefix followed by + * compressed data. This function transparently decompresses such attributes + * so that callers always see the original uncompressed Datum values. + * + * Parameters: + * tuple - the FLUX tuple to deform + * tupdesc - tuple descriptor defining the schema + * values - output array of Datum values (must be pre-allocated) + * isnull - output array of boolean null indicators (must be pre-allocated) + */ +void +FluxDeformTuple(Relation rel, FluxTuple tuple, TupleDesc tupdesc, Datum *values, bool *isnull) +{ + FluxDeformTupleUpTo(rel, tuple, tupdesc, values, isnull, tupdesc->natts); +} + +/* + * FluxDeformTupleUpTo + * + * Like FluxDeformTuple(), but stops after extracting the first max_natts + * attributes. Because FLUX (like heap) stores variable-length attributes + * consecutively, deforming is inherently sequential: extracting attribute N + * still costs walking attributes 0..N. But a caller that only needs the + * low-numbered attributes (e.g. flux_indexed_attr_changed comparing indexed + * columns, which are typically the leading columns) can cap the walk at the + * highest attribute it will read instead of deforming every column of a wide + * tuple. Attributes at and beyond max_natts are returned as NULL/0 so a + * caller that accidentally reads past its bound gets a defined (not garbage) + * value; callers MUST NOT rely on those tail values. + */ +void +FluxDeformTupleUpTo(Relation rel, FluxTuple tuple, TupleDesc tupdesc, + Datum *values, bool *isnull, int max_natts) +{ + FluxTupleHeader *header; + uint8 *nulls_bitmap; + char *data_ptr; + Size bitmap_len; + int i; + bool tuple_has_compressed; + + Assert(tuple != NULL); + Assert(tupdesc != NULL); + Assert(values != NULL); + Assert(isnull != NULL); + + header = tuple->t_data; + + /* + * Use the tuple's actual natts for bitmap_len and data_ptr calculation. + * After ALTER TABLE ADD COLUMN, old tuples may have fewer attributes. + */ + { + int tuple_natts = header->t_natts; + int loop_natts = Min(tupdesc->natts, tuple_natts); + + /* Cap the deform at the caller's requested bound. */ + if (max_natts >= 0 && max_natts < loop_natts) + loop_natts = max_natts; + + bitmap_len = BITMAPLEN(tuple_natts); + nulls_bitmap = (uint8 *) header->t_attrs_bitmap; + data_ptr = (char *) header + MAXALIGN(FLUX_TUPLE_OVERHEAD + bitmap_len); + + tuple_has_compressed = false; /* FLUX does not compress attributes */ + (void) tuple_has_compressed; + + /* Extract each attribute present in the tuple */ + for (i = 0; i < loop_natts; i++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + + if (att->attisdropped) + { + values[i] = (Datum) 0; + isnull[i] = true; + + /* + * The dropped column's bytes are still physically present in + * tuples written before the DROP, so step over them (see the + * matching comment in tts_flux_deform()). + */ + if (!((header->t_infomask & FLUX_INFOMASK_HASNULL) && + att_isnull(i, nulls_bitmap))) + { + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + data_ptr = (char *) att_addlength_pointer(data_ptr, att->attlen, + data_ptr); + } + continue; + } + + /* + * Check null bitmap: bit=0 means NULL (bit cleared in + * FluxFormTuple) + */ + if (header->t_infomask & FLUX_INFOMASK_HASNULL && + att_isnull(i, nulls_bitmap)) + { + values[i] = (Datum) 0; + isnull[i] = true; + continue; + } + + isnull[i] = false; + + /* Align attribute */ + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + + if (att->attlen > 0) + { + /* + * Fixed-length attribute - never compressed. Use actual + * attbyval flag (e.g., timetz is 12 bytes but by-ref). + */ + values[i] = fetch_att(data_ptr, att->attbyval, att->attlen); + data_ptr += att->attlen; + } + else if (att->attlen == -1) + { + /* Variable-length attribute (FLUX stores it verbatim) */ + Size attr_len = VARSIZE_ANY(data_ptr); + + values[i] = PointerGetDatum(data_ptr); + data_ptr += attr_len; + } + else if (att->attlen == -2) + { + /* C string - never compressed */ + values[i] = CStringGetDatum(data_ptr); + data_ptr += strlen(data_ptr) + 1; + } + else + { + elog(ERROR, "unsupported attribute length: %d", att->attlen); + } + } + + /* + * Fill missing attributes with defaults for columns added by ALTER + * TABLE ADD COLUMN after this tuple was stored. + */ + for (i = loop_natts; i < tupdesc->natts; i++) + { + if (i >= tuple_natts && (max_natts < 0 || i < max_natts) && + !TupleDescAttr(tupdesc, i)->attisdropped) + values[i] = getmissingattr(tupdesc, i + 1, &isnull[i]); + else + { + values[i] = (Datum) 0; + isnull[i] = true; + } + } + } /* end of tuple_natts scope block */ +} + +/* + * FluxFreeTuple + * + * Free a FLUX tuple and its associated data. Safe to call with NULL. + * + * Parameters: + * tuple - the FluxTuple to free (may be NULL) + */ +void +FluxFreeTuple(FluxTuple tuple) +{ + if (tuple) + { + if (tuple->t_data) + pfree(tuple->t_data); + pfree(tuple); + } +} + +/* + * FluxInitPage + * + * Initialize a new FLUX page. Calls PostgreSQL's PageInit() with space + * reserved for FluxPageOpaqueData in the special area, then initializes + * the opaque data fields to their default values. + * + * Parameters: + * page - pointer to the page buffer + * pageSize - size of the page (typically BLCKSZ = 8192) + */ +void +FluxInitPage(Page page, Size pageSize) +{ + FluxPageOpaque phdr; + + PageInit(page, pageSize, sizeof(FluxPageOpaqueData)); + + phdr = FluxPageGetOpaque(page); + phdr->pd_commit_ts_and_flags = 0; +} + +/* + * FluxPageAddTuple + * + * Add a FLUX tuple to a page using PageAddItem(). Updates the page's + * opaque data (commit timestamp, free space) after successful insertion. + * + * Parameters: + * page - the page to add the tuple to (must be exclusively locked) + * tuple - the FLUX tuple to add + * tuple_size - size of the tuple data in bytes + * + * Returns the OffsetNumber where the tuple was placed, or + * InvalidOffsetNumber if the page does not have enough space. + */ +OffsetNumber +FluxPageAddTuple(Page page, FluxTuple tuple, Size tuple_size) +{ + FluxPageOpaque phdr; + OffsetNumber offnum; + + /* + * Like heap, never place a tuple past MaxHeapTuplesPerPage: tidbitmap.c + * and other AM-agnostic code size per-page offset arrays for that. + * PageGetHeapFreeSpace() is zero once the line-pointer array is full and + * none is reusable (LP_DEAD pointers left by pruning wait for VACUUM); + * fail quietly then, rather than through PageAddItemExtended's WARNING. + */ + if (PageGetHeapFreeSpace(page) == 0) + return InvalidOffsetNumber; + + offnum = PageAddItemExtended(page, tuple->t_data, tuple_size, + InvalidOffsetNumber, PAI_IS_HEAP); + + if (offnum == InvalidOffsetNumber) + return InvalidOffsetNumber; + + /* Update page header */ + phdr = FluxPageGetOpaque(page); + + /* Mark page for defragmentation if fragmented */ + if (PageGetFreeSpace(page) >= tuple_size * 2 && + PageGetMaxOffsetNumber(page) > FirstOffsetNumber + 5) + { + FluxPageSetFlag(phdr, FLUX_PAGE_DEFRAG_NEEDED); + } + + return offnum; +} + +/* + * FluxPageUpdateTuple + * + * Attempt to update a tuple in place on a FLUX page. If the new tuple + * fits within the existing allocation (same size or smaller), the data is + * overwritten directly (in-place update). If the new tuple is larger but + * the page has enough total free space, the old tuple is removed and the + * new tuple is added at the same or a new offset. + * + * Parameters: + * page - the page containing the tuple (must be exclusively locked) + * offnum - offset number of the tuple to update + * new_tuple - the new tuple data + * old_commit_ts - commit timestamp of the old version (for WAL logging) + * new_commit_ts - commit timestamp for the new version + * + * Returns true if the update was performed on this page, false if the new + * tuple does not fit (caller must handle cross-page update). + */ +bool +FluxPageUpdateTuple(Page page, OffsetNumber offnum, FluxTuple new_tuple, + uint64 old_commit_ts, uint64 new_commit_ts) +{ + ItemId itemid; + FluxTupleHeader *old_tuple; + Size old_size, + new_size; + FluxPageOpaque phdr; + Size available_space; + OffsetNumber new_offnum; + + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + return false; + + old_tuple = (FluxTupleHeader *) PageGetItem(page, itemid); + old_size = ItemIdGetLength(itemid); + new_size = new_tuple->t_len; + + /* Check if new tuple fits in same space */ + if (new_size <= old_size) + { + /* In-place update */ + memcpy(old_tuple, new_tuple->t_data, new_size); + if (new_size < old_size) + { + /* Update item length */ + ItemIdSetNormal(itemid, ItemIdGetOffset(itemid), new_size); + } + + /* Update page header */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), new_commit_ts)); + + return true; + } + + /* Need more space - check if available */ + available_space = PageGetFreeSpace(page) + old_size; + if (new_size <= available_space) + { + /* + * Remove old tuple and re-add the new (larger) one. + * + * We use FluxPageIndexTupleDelete instead of PageIndexTupleDelete + * because the page may contain LP_UNUSED items from defragmentation. + * PageIndexTupleDelete asserts all items are LP_NORMAL; + * FluxPageIndexTupleDelete skips LP_UNUSED items safely. + */ + FluxPageIndexTupleDelete(page, offnum); + + new_offnum = PageAddItem(page, new_tuple->t_data, + new_size, offnum, + false, true); + + if (new_offnum != InvalidOffsetNumber) + { + /* Update page header */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), new_commit_ts)); + return true; + } + } + + return false; /* Update failed - need new page */ +} + +/* + * Get number of live tuples on a FLUX page + */ +int +FluxPageGetLiveTuples(Page page, uint64 snapshot_ts) +{ + OffsetNumber maxoff = PageGetMaxOffsetNumber(page); + int live_tuples = 0; + OffsetNumber offnum; + + for (offnum = FirstOffsetNumber; offnum <= maxoff; offnum++) + { + ItemId itemid = PageGetItemId(page, offnum); + + if (ItemIdIsNormal(itemid)) + { + FluxTupleHeader *tuple = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Heap-shaped: "live" here means not carrying a committed delete + * marker. This helper is only an estimate (no current callers), + * so it counts non-DELETED tuples rather than doing a full + * snapshot visibility check. snapshot_ts is unused. + */ + (void) snapshot_ts; + if (!(tuple->t_flags & FLUX_TUPLE_DELETED)) + live_tuples++; + } + } + + return live_tuples; +} + +/* + * FluxPageDefragment + * + * Compact a FLUX page by calling PageRepairFragmentation() to consolidate + * free space. Updates the page opaque data with the new free space amount, + * increments the defrag counter, and clears the FLUX_PAGE_DEFRAG_NEEDED flag. + * + * Parameters: + * page - the page to defragment (must be exclusively locked) + */ +void +FluxPageDefragment(Page page) +{ + FluxPageOpaque phdr = FluxPageGetOpaque(page); + + /* Use standard PageRepairFragmentation */ + PageRepairFragmentation(page); + + /* Update page header */ + FluxPageClearFlag(phdr, FLUX_PAGE_DEFRAG_NEEDED); +} + +/* + * FluxPageIndexTupleDelete + * + * Like PageIndexTupleDelete, but tolerates LP_UNUSED items on the page. + * + * Standard PageIndexTupleDelete asserts that ALL line pointers have storage + * (ItemIdHasStorage). FLUX pages may contain LP_UNUSED items left behind + * by opportunistic defragmentation. This function skips LP_UNUSED items + * when adjusting offsets, preventing both assertion failures and data + * corruption (LP_UNUSED items have lp_off=0 and must not be adjusted). + */ +void +FluxPageIndexTupleDelete(Page page, OffsetNumber offnum) +{ + PageHeader phdr = (PageHeader) page; + char *addr; + ItemId tup; + Size size; + unsigned offset; + int nbytes; + int offidx; + int nline; + + if (phdr->pd_lower < SizeOfPageHeaderData || + phdr->pd_lower > phdr->pd_upper || + phdr->pd_upper > phdr->pd_special || + phdr->pd_special > BLCKSZ || + phdr->pd_special != MAXALIGN(phdr->pd_special)) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("corrupted page pointers: lower = %u, upper = %u, special = %u", + phdr->pd_lower, phdr->pd_upper, phdr->pd_special))); + + nline = PageGetMaxOffsetNumber(page); + if ((int) offnum <= 0 || (int) offnum > nline) + elog(ERROR, "invalid index offnum: %u", offnum); + + offidx = offnum - 1; + + tup = PageGetItemId(page, offnum); + Assert(ItemIdHasStorage(tup)); + size = ItemIdGetLength(tup); + offset = ItemIdGetOffset(tup); + + if (offset < phdr->pd_upper || (offset + size) > phdr->pd_special || + offset != MAXALIGN(offset)) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("corrupted line pointer: offset = %u, size = %zu", + offset, size))); + + size = MAXALIGN(size); + + /* Remove the line pointer entry by shifting subsequent entries down */ + nbytes = phdr->pd_lower - + ((char *) &phdr->pd_linp[offidx + 1] - (char *) phdr); + + if (nbytes > 0) + memmove(&(phdr->pd_linp[offidx]), + &(phdr->pd_linp[offidx + 1]), + nbytes); + + /* Shift tuple data forward to fill the gap */ + addr = (char *) page + phdr->pd_upper; + + if (offset > phdr->pd_upper) + memmove(addr + size, addr, offset - phdr->pd_upper); + + phdr->pd_upper += size; + phdr->pd_lower -= sizeof(ItemIdData); + + /* Adjust remaining line pointer offsets, skipping LP_UNUSED items */ + if (!PageIsEmpty(page)) + { + int i; + + nline--; + for (i = 1; i <= nline; i++) + { + ItemId ii = PageGetItemId(page, i); + + if (!ItemIdHasStorage(ii)) + continue; + if (ItemIdGetOffset(ii) <= offset) + ii->lp_off += size; + } + } +} + +/* + * Convert a FLUX tuple to a TupleTableSlot + * + * This is the primary retrieval path used during sequential scans. + * When the tuple has compressed attributes (FLUX_INFOMASK_COMPRESSED), + * they are transparently decompressed so the slot always contains + * uncompressed data visible to the executor. + * + * Overflow attributes (FLUX_INFOMASK_HASOVERFLOW) are returned as-is + * by this function since it has no Relation handle. Use + * FluxTupleToSlotWithOverflow() for transparent overflow fetching. + */ +bool +FluxTupleToSlot(FluxTupleHeader *tuple_header, TupleTableSlot *slot) +{ + return FluxTupleToSlotWithOverflow(tuple_header, slot, NULL); +} + +/* + * Convert a FLUX tuple to a TupleTableSlot with overflow support. + * + * When rel is non-NULL and the tuple has overflow attributes, they are + * transparently fetched from overflow records and the slot receives the + * complete original values. When rel is NULL, overflow pointers are + * returned as-is (same as FluxTupleToSlot). + */ +bool +FluxTupleToSlotWithOverflow(FluxTupleHeader *tuple_header, + TupleTableSlot *slot, Relation rel) +{ + TupleDesc tupdesc = slot->tts_tupleDescriptor; + char *data_ptr; + uint8 *nulls_bitmap; + int i; + Size bitmap_len; + bool tuple_has_compressed; + bool tuple_has_overflow; + + if (!tuple_header) + return false; + + /* Check if tuple is deleted */ + if (tuple_header->t_flags & FLUX_TUPLE_DELETED) + return false; + + /* Clear the slot first */ + ExecClearTuple(slot); + + /* + * Use the tuple's actual natts for bitmap_len and data_ptr calculation. + * After ALTER TABLE ADD COLUMN, old tuples may have fewer attributes than + * the current schema expects. + */ + { + int tuple_natts = tuple_header->t_natts; + int loop_natts = Min(tupdesc->natts, tuple_natts); + + bitmap_len = BITMAPLEN(tuple_natts); + + /* Set up pointers to data */ + nulls_bitmap = (uint8 *) tuple_header->t_attrs_bitmap; + data_ptr = (char *) tuple_header + MAXALIGN(FLUX_TUPLE_OVERHEAD + bitmap_len); + + tuple_has_compressed = false; /* FLUX does not compress attributes */ + tuple_has_overflow = false; /* FLUX uses TOAST, not on-page overflow */ + (void) tuple_has_compressed; + (void) tuple_has_overflow; + + /* Decode each attribute present in the tuple */ + for (i = 0; i < loop_natts; i++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + bool is_null; + + if (att->attisdropped) + { + slot->tts_values[i] = (Datum) 0; + slot->tts_isnull[i] = true; + + /* + * The dropped column's bytes are still physically present in + * tuples written before the DROP, so step over them (see the + * matching comment in tts_flux_deform()). + */ + if (!att_isnull(i, nulls_bitmap)) + { + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + data_ptr = (char *) att_addlength_pointer(data_ptr, att->attlen, + data_ptr); + } + continue; + } + + /* Check if attribute is null */ + is_null = att_isnull(i, nulls_bitmap); + + if (is_null) + { + slot->tts_values[i] = (Datum) 0; + slot->tts_isnull[i] = true; + } + else + { + /* Extract the actual data */ + if (att->attlen == -1) + { + Size attr_len; + + /* + * Align to the start of this varlena, matching the form + * path (FluxFormTuple aligns data_ptr at the start of + * each attribute before writing). Without this the + * previous attribute's raw += attr_len advance can leave + * data_ptr unaligned, so VARSIZE_ANY reads the length + * from padding bytes -> garbage length -> pglz-corrupt / + * overrun. + */ + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + attr_len = VARSIZE_ANY(data_ptr); + + /* + * FLUX stores varlena values verbatim (wide values are + * TOASTed by the standard heap TOAST path; no on-page + * overflow, no attribute compression). + */ + slot->tts_values[i] = PointerGetDatum(data_ptr); + + /* + * No padding after the value: the next attribute aligns + * itself (as in FluxFormTuple and tts_flux_deform). + * Padding here by this attribute's own alignment skipped + * bytes of a following less-aligned value. + */ + data_ptr += attr_len; + } + else if (att->attlen > 0) + { + /* Fixed-length attribute - never compressed or overflow */ + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + slot->tts_values[i] = fetchatt(att, data_ptr); + data_ptr += att->attlen; + } + else + { + /* This shouldn't happen */ + elog(ERROR, "unsupported attribute length: %d", att->attlen); + } + + slot->tts_isnull[i] = false; + } + } + + /* + * Fill missing attributes with defaults for columns added by ALTER + * TABLE ADD COLUMN after this tuple was stored. + */ + if (loop_natts < tupdesc->natts) + { + for (i = loop_natts; i < tupdesc->natts; i++) + { + slot->tts_values[i] = (Datum) 0; + slot->tts_isnull[i] = true; + } + slot->tts_nvalid = loop_natts; + slot_getmissingattrs(slot, loop_natts, tupdesc->natts); + } + } /* end of tuple_natts scope block */ + + /* + * This leaves the slot virtual (tts_values[] only, no physical tuple), so + * cache the header's system columns now -- otherwise slot_getsysattr() + * cannot answer xmin/xmax later. See FluxSlotCacheSysAttrs. + */ + FluxSlotCacheSysAttrs(slot, tuple_header); + + /* Mark slot as valid */ + slot->tts_flags &= ~TTS_FLAG_EMPTY; + slot->tts_nvalid = tupdesc->natts; + + return true; +} diff --git a/src/backend/access/flux/flux_undo.c b/src/backend/access/flux/flux_undo.c new file mode 100644 index 0000000000000..253d74c5d46f7 --- /dev/null +++ b/src/backend/access/flux/flux_undo.c @@ -0,0 +1,833 @@ +/*------------------------------------------------------------------------- + * + * flux_undo.c + * FLUX UNDO resource manager + * + * FLUX writes one UNDO record per tuple UPDATE and DELETE via the + * shared UNDO-in-WAL infrastructure. INSERT emits no UNDO record -- + * an inserted tuple has no before-image and its rollback is simply its + * inserter (t_xmin) never committing. + * Records carry rmid UNDO_RMID_FLUX and an info subtype + * (FLUX_UNDO_UPDATE / DELETE / UPDATE_DELTA); rollback is + * driven by undoapply.c which dispatches to flux_undo_apply() based on + * rmid. + * + * Visibility of aborted rows does not depend on the undo having been + * applied: a reader treats a writer that did not commit as invisible + * (CLOG, FluxWriterVisibleToSnapshot) and walks past its versions in the + * UNDO chain, and a writer that runs into such a version reverts it first + * (FluxRevertAbortedVersions). Applying the undo restores the before-images + * on the page. The apply is idempotent: it only restores a record that is + * still the tuple's current version (t_verptr == the record's pointer). + * + * Crash safety is provided by emitting an xl_undo_apply CLR record + * (XLOG_UNDO_APPLY_RECORD / RM_UNDO_ID) for every page modification. + * The CLR carries the new tuple image (or the LP-state change) and is + * replayed idempotently by the generic undo_xlog.c redo handler; + * FLUX does not need its own redo routine for the undo-apply path. + * + * The callback mirrors heapam_undo.c's control flow: + * + * 1. Defer while in crash recovery or inside a transaction's abort + * path (BumpContext makes relation_close/pfree unsafe); the + * logical-revert worker re-drives the record from a clean top- + * level memory context. + * 2. try_relation_open() the target. If the relation was dropped or + * truncated past the target block, there is nothing left to undo + * (UNDO_APPLY_SUCCESS) -- unless the aborting transaction itself is + * still running (inline abort, ROLLBACK TO SAVEPOINT): its own DROP or + * TRUNCATE may still be rolled back, so return UNDO_APPLY_SKIPPED and + * leave the record to be applied after that (see flux_undo_gone). + * 3. Dispatch on info to a page-modification branch, emit a CLR, + * mark the buffer dirty, release locks, close. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/backend/access/flux/flux_undo.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/heapam.h" +#include "access/flux.h" +#include "access/flux_pbu.h" +#include "access/flux_undo.h" +#include "access/relation.h" +#include "access/slog.h" +#include "access/table.h" +#include "access/undo_xlog.h" +#include "access/undormgr.h" +#include "access/xact.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "access/xlogrecovery.h" +#include "access/xlogutils.h" +#include "miscadmin.h" +#include "storage/bufmgr.h" +#include "storage/procarray.h" +#include "utils/injection_point.h" +#include "utils/rel.h" +#include "utils/relcache.h" + + +static UndoApplyResult flux_undo_apply(uint8 rmid, uint16 info, + TransactionId xid, Oid reloid, + const char *payload, Size payload_len, + UndoRecPtr urec_ptr); +static void flux_undo_desc(StringInfo buf, uint8 rmid, uint16 info, + const char *payload, Size payload_len); + + +/* The FLUX UNDO RM registration entry */ +static const UndoRmgrData flux_undo_rmgr = { + .rm_name = "flux", + .rm_undo = flux_undo_apply, + .rm_desc = flux_undo_desc, +}; + + +/* + * FluxUndoRmgrInit + * Register the FLUX UNDO resource manager. + * + * Called from InitializeUndoSubsystem() at postmaster startup, alongside + * HeapUndoRmgrInit() and NbtreeUndoRmgrInit(). + */ +void +FluxUndoRmgrInit(void) +{ + RegisterUndoRmgr(UNDO_RMID_FLUX, &flux_undo_rmgr); +} + + +/* + * emit_flux_undo_clr + * Emit an XLOG_UNDO_APPLY_RECORD CLR for the page modification + * just performed. Must be called inside the critical section, + * before END_CRIT_SECTION / UnlockReleaseBuffer. + * + * tuple_data is the image to replay into the target slot on redo + * (NULL for LP_UNUSED cases). tuple_len must match the on-page slot + * length that should be installed. + */ +static void +emit_flux_undo_clr(Relation rel, Buffer buffer, UndoRecPtr urec_ptr, + TransactionId xid, BlockNumber blkno, OffsetNumber offnum, + uint16 info, uint16 clr_flags, + const char *tuple_data, uint32 tuple_len) +{ + xl_undo_apply xlrec; + XLogRecPtr lsn; + + if (!RelationNeedsWAL(rel)) + { + /* + * Unlogged / temp relations need no CLR: they do not survive a crash, + * so replay idempotency is irrelevant. + */ + PageSetLSN(BufferGetPage(buffer), GetXLogInsertRecPtr()); + return; + } + + /* + * During crash recovery, UNDO is reverse-applied by PerformUndoRecovery() + * BEFORE WAL insertion is enabled (LocalSetXLogInsertAllowed runs later + * in StartupXLOG). Emitting a CLR here would call XLogInsert -> + * GetWALInsertionTimeLine and trip its RECOVERY_STATE_DONE assertion (and + * is unnecessary): the physical restore is made durable by the + * end-of-recovery checkpoint, and re-application is prevented by the page + * LSN just as in the WAL-logged path. So in recovery, set the page LSN + * to the current replay position and skip the CLR record. + */ + if (!XLogInsertAllowed()) + { + TimeLineID replayTLI; + + PageSetLSN(BufferGetPage(buffer), GetXLogReplayRecPtr(&replayTLI)); + return; + } + + xlrec.urec_ptr = urec_ptr; + xlrec.xid = xid; + xlrec.target_locator = rel->rd_locator; + xlrec.target_block = blkno; + xlrec.target_offset = offnum; + xlrec.operation_type = info; + xlrec.clr_flags = clr_flags; + xlrec.tuple_len = tuple_len; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfUndoApply); + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + + if ((clr_flags & UNDO_CLR_HAS_TUPLE) && tuple_data != NULL && tuple_len > 0) + XLogRegisterBufData(0, tuple_data, tuple_len); + + lsn = XLogInsert(RM_UNDO_ID, XLOG_UNDO_APPLY_RECORD); + PageSetLSN(BufferGetPage(buffer), lsn); +} + + +/* + * apply_flux_undo_restore_tuple + * Shared helper for UPDATE, DELETE and DELTA_UPDATE undo: overwrite + * the current on-disk tuple with an in-memory before-image. The + * caller is responsible for preparing the before-image (direct + * copy for DELETE/UPDATE, reverse-diff reconstruction for + * DELTA_UPDATE). + * + * For DELETE undo the before-image already carries the pre-delete + * header, so the FLUX_TUPLE_DELETED bit will be cleared as a + * side-effect of the overwrite. + * + * The before-image always fits: an in-place UPDATE never shrinks the slot + * (a smaller new image is widened to the slot length, a larger one grows + * it), so a record's before-image is never longer than the slot of the + * version it produced, and only that current version is ever restored (see + * flux_undo_record_is_current). If it does not fit anyway, the restore is + * refused and reported as a failure so the rollback request is kept, rather + * than silently left half done. + */ +static bool +apply_flux_undo_restore_tuple(Relation rel, Buffer buffer, OffsetNumber offnum, + BlockNumber blkno, UndoRecPtr urec_ptr, + TransactionId xid, uint16 info, + const char *old_image, uint32 old_len) +{ + Page page = BufferGetPage(buffer); + ItemId lp; + char *slot; + + Assert(old_image != NULL && old_len > 0); + + lp = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(lp)) + { + ereport(DEBUG2, + (errmsg_internal("FLUX UNDO: item (%u, %u) no longer normal, skipping", + blkno, offnum))); + return false; + } + + if (ItemIdGetLength(lp) < old_len || + IS_INJECTION_POINT_ATTACHED("flux-undo-restore-no-room")) + { + ereport(WARNING, + (errmsg_internal("FLUX UNDO: current slot at (%u, %u) is smaller " + "than before-image (%u < %u); cannot restore", + blkno, offnum, + (unsigned) ItemIdGetLength(lp), + (unsigned) old_len))); + return false; + } + + slot = (char *) PageGetItem(page, lp); + + START_CRIT_SECTION(); + + memcpy(slot, old_image, old_len); + if (ItemIdGetLength(lp) != old_len) + ItemIdSetNormal(lp, ItemIdGetOffset(lp), old_len); + + MarkBufferDirty(buffer); + + emit_flux_undo_clr(rel, buffer, urec_ptr, xid, blkno, offnum, + info, UNDO_CLR_HAS_TUPLE, + old_image, old_len); + + END_CRIT_SECTION(); + return true; +} + + +/* + * flux_undo_gone + * Result for a record whose relation or block no longer exists. + * + * Once the aborting transaction xid is over, its relation is dropped or + * truncated for good, by another transaction that committed: nothing is left + * to undo. While xid is still running -- its own inline abort, or ROLLBACK + * TO SAVEPOINT -- the DROP or TRUNCATE may be its own, about to be rolled + * back with it, so the record must be tried again afterwards. + */ +static UndoApplyResult +flux_undo_gone(TransactionId xid) +{ + return TransactionIdEquals(xid, GetTopTransactionIdIfAny()) ? + UNDO_APPLY_SKIPPED : UNDO_APPLY_SUCCESS; +} + + +/* + * flux_undo_record_is_current + * Does the tuple still carry the version this UNDO record produced? + * + * Every UPDATE and DELETE stamps its record's pointer into the tuple's + * t_verptr, and the before-image it restores carries the previous pointer. + * So the record is current iff t_verptr still equals urec_ptr, as in + * OrioleDB (tuphdr.undoLocation == item). + * + * A record that is not current was already undone (ROLLBACK TO SAVEPOINT, + * the inline abort, a worker, or a writer that reverted it), and the tuple + * may since hold a version another transaction committed. Restoring the + * before-image again would destroy that version. + */ +static bool +flux_undo_record_is_current(Page page, OffsetNumber offnum, + UndoRecPtr urec_ptr) +{ + ItemId lp; + + if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) + return false; + lp = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(lp)) + return false; + return ((FluxTupleHeader *) PageGetItem(page, lp))->t_verptr == urec_ptr; +} + + +/* + * flux_undo_index_first + * Reverse the index changes of a rolled-back in-place indexed-column + * UPDATE: reverse the recorded insertion or revival and clear the mark on + * (k_old, TID). + * + * This runs before the tuple is restored. The other order loses the index + * reversal when it fails (evaluating an index expression can ERROR): the + * record is no longer current once the tuple is restored, so no retry ever + * gets back to it, and the (k_new, TID) entry stays live for good. Done + * first, a failure leaves the record current and the whole record is + * retried; the index reversal is idempotent (btdeletemark, btundomark), so redoing it is + * harmless. + * + * Index work must not run under a table content lock (AB-BA with bottom-up + * deletion), so the buffer lock is released around it, keeping the pin. + * Returns false, with *result set, if the tuple must not be restored now: + * UNDO_APPLY_SKIPPED if the index reversal has to wait for the aborting + * transaction's catalog changes to be rolled back (see + * flux_inplace_index_undo), UNDO_APPLY_SUCCESS if, once relocked, the record + * is no longer current: someone else (a helper, the owner, the worker) + * undid it meanwhile. + */ +static bool +flux_undo_index_first(Relation rel, Buffer buffer, OffsetNumber offnum, + ItemPointer tid, UndoRecPtr urec_ptr, + const char *old_image, Size old_len, + const char *indexes, uint16 nindexes, TransactionId xid, + UndoApplyResult *result) +{ + Page page = BufferGetPage(buffer); + ItemId lp = PageGetItemId(page, offnum); + Size new_len = ItemIdGetLength(lp); + FluxTupleHeader *new_hdr = palloc(new_len); + FluxTupleHeader *old_hdr = palloc(old_len); + + /* Aligned copies: the index code reads them as FluxTupleHeaders. */ + memcpy(new_hdr, PageGetItem(page, lp), new_len); + memcpy(old_hdr, old_image, old_len); + + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + *result = flux_inplace_index_undo(rel, tid, new_hdr, new_len, old_hdr, + old_len, indexes, nindexes, xid) ? + UNDO_APPLY_SUCCESS : UNDO_APPLY_SKIPPED; + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + + pfree(new_hdr); + pfree(old_hdr); + + return *result == UNDO_APPLY_SUCCESS && + flux_undo_record_is_current(page, offnum, urec_ptr); +} + + +/* + * flux_undo_apply_locked + * Reverse-apply one FLUX UNDO record to its tuple. + * + * The caller holds an exclusive lock on the record's buffer. It is held + * again on return, but for a rolled-back indexed-key UPDATE it is released + * meanwhile (flux_undo_index_first), so the caller must re-read the page. + * + * Returns UNDO_APPLY_SUCCESS if the record was applied or had already been + * undone (nothing to do), and UNDO_APPLY_ERROR if it is current but could + * not be applied, so that the rollback request is kept instead of being + * marked complete. Shared by flux_undo_apply (rollback by the owner or an + * undo worker) and FluxRevertAbortedVersions (a writer reverting an aborted + * transaction's version it ran into). + */ +UndoApplyResult +flux_undo_apply_locked(Relation rel, Buffer buffer, uint16 info, + TransactionId xid, const char *payload, + Size payload_len, UndoRecPtr urec_ptr) +{ + FluxUndoPayloadHeader hdr; + const char *image_bytes; + Size image_len; + BlockNumber blkno; + OffsetNumber offnum; + char *delta_recon = NULL; + const char *indexes = NULL; + uint16 nindexes = 0; + UndoApplyResult result = UNDO_APPLY_ERROR; + + Assert(payload_len >= SizeOfFluxUndoPayloadHeader); + memcpy(&hdr, payload, SizeOfFluxUndoPayloadHeader); + image_bytes = payload + SizeOfFluxUndoPayloadHeader; + image_len = hdr.tuple_len; + if (image_len > payload_len - SizeOfFluxUndoPayloadHeader) + elog(ERROR, "truncated FLUX undo image"); + if (hdr.flags & FLUX_UNDO_FLAG_INDEX_LIST) + { + const char *area = image_bytes + image_len; + + if (hdr.pad < sizeof(uint16) || + SizeOfFluxUndoPayloadHeader + image_len + hdr.pad != payload_len) + elog(ERROR, "invalid FLUX undo index list"); + memcpy(&nindexes, area, sizeof(nindexes)); + if (sizeof(uint16) + nindexes * sizeof(FluxUndoIndex) > hdr.pad) + elog(ERROR, "truncated FLUX undo index list"); + indexes = area + sizeof(uint16); + } + blkno = ItemPointerGetBlockNumber(&hdr.tid); + offnum = ItemPointerGetOffsetNumber(&hdr.tid); + Assert(BufferGetBlockNumber(buffer) == blkno); + + if (!flux_undo_record_is_current(BufferGetPage(buffer), offnum, + urec_ptr)) + { + ereport(DEBUG2, + (errmsg_internal("FLUX UNDO: record at %llu for (%u,%u) was " + "already undone, skipping", + (unsigned long long) urec_ptr, + blkno, offnum))); + return UNDO_APPLY_SUCCESS; + } + + /* + * A delta record carries only the changed middle of the old image. + * Rebuild the full old image from the current on-page bytes first; the + * restore, the index reversal and the CLR all need the full image. The + * record is current, so the page holds the image the delta was taken + * against. + */ + if (info == FLUX_UNDO_UPDATE_DELTA) + { + Page pg = BufferGetPage(buffer); + ItemId lp = PageGetItemId(pg, offnum); + char *delta_aligned; + + if (!(hdr.flags & FLUX_UNDO_FLAG_HAS_TUPLE) || + image_len < SizeOfFluxUndoDelta) + { + ereport(WARNING, + (errmsg_internal("FLUX UNDO UPDATE_DELTA: truncated delta " + "(%zu bytes) at %llu", + image_len, + (unsigned long long) urec_ptr))); + return UNDO_APPLY_ERROR; + } + + /* + * The payload sits at an arbitrary offset in the undo page, so copy + * the delta to an aligned buffer before reading it as a struct. + */ + delta_aligned = (char *) palloc(image_len); + memcpy(delta_aligned, image_bytes, image_len); + delta_recon = (char *) palloc(((FluxUndoDelta *) delta_aligned)->old_len); + if (!FluxUndoDeltaReconstruct((FluxUndoDelta *) delta_aligned, image_len, + (char *) PageGetItem(pg, lp), + ItemIdGetLength(lp), delta_recon)) + { + ereport(WARNING, + (errmsg_internal("FLUX UNDO UPDATE_DELTA: reverse-diff " + "failed at %llu; leaving tuple in place", + (unsigned long long) urec_ptr))); + pfree(delta_aligned); + pfree(delta_recon); + return UNDO_APPLY_ERROR; + } + image_bytes = delta_recon; + image_len = ((FluxUndoDelta *) delta_aligned)->old_len; + info = FLUX_UNDO_UPDATE; + pfree(delta_aligned); + } + + switch (info) + { + case FLUX_UNDO_UPDATE: + case FLUX_UNDO_DELETE: + if (!(hdr.flags & FLUX_UNDO_FLAG_HAS_TUPLE) || image_len == 0) + { + ereport(WARNING, + (errmsg_internal("FLUX UNDO %s: missing before-image at %llu", + info == FLUX_UNDO_UPDATE ? "UPDATE" : "DELETE", + (unsigned long long) urec_ptr))); + break; + } + + /* + * For an in-place indexed-column UPDATE the index changes must be + * reversed too, before the tuple: see flux_undo_index_first. + */ + if (info == FLUX_UNDO_UPDATE && + RelationSupportsDeleteMarking(rel) && + nindexes > 0 && + !flux_undo_index_first(rel, buffer, offnum, &hdr.tid, + urec_ptr, image_bytes, image_len, + indexes, nindexes, xid, &result)) + break; + + if (apply_flux_undo_restore_tuple(rel, buffer, offnum, blkno, + urec_ptr, xid, info, + image_bytes, (uint32) image_len)) + result = UNDO_APPLY_SUCCESS; + break; + + default: + ereport(WARNING, + (errmsg_internal("FLUX UNDO: unknown subtype 0x%x at %llu", + info, (unsigned long long) urec_ptr))); + break; + } + + if (delta_recon != NULL) + pfree(delta_recon); + return result; +} + + +/* + * flux_aborted_writer + * The xid whose aborted, not-yet-reverted change is the tuple's current + * version, or InvalidTransactionId. + * + * A DELETED tuple's current change is its delete (t_xmax); an in-place + * UPDATED tuple's current change is the update (t_xmin). Both stamped their + * UNDO record into t_verptr. The change is aborted when its xid neither + * committed nor is still running. An INSERT writes no UNDO (the row is + * hidden by its aborted t_xmin alone), so a never-updated tuple is never + * reported. + */ +static TransactionId +flux_aborted_writer(FluxTupleHeader *tup) +{ + TransactionId xid; + uint16 hint; + + if (!UndoRecPtrIsValid(tup->t_verptr)) + return InvalidTransactionId; + + if (tup->t_flags & FLUX_TUPLE_DELETED) + { + xid = FluxTupleGetXmax(tup); + hint = FLUX_TUPLE_XMAX_COMMITTED; + + /* + * A killed speculative insertion (flux_tuple_complete_speculative, + * succeeded = false) is DELETED by its own inserter and writes no + * UNDO: nothing to revert. FLUX_TUPLE_SPECULATIVE says so, as it is + * cleared only when the insertion is confirmed. t_xmax == t_xmin + * does not: a transaction that UPDATEs a row in place and then + * DELETEs it leaves the same pair, with two UNDO records to apply. + */ + if (tup->t_flags & FLUX_TUPLE_SPECULATIVE) + return InvalidTransactionId; + } + else if (tup->t_flags & FLUX_TUPLE_UPDATED) + { + xid = FluxTupleGetXmin(tup); + hint = FLUX_TUPLE_XMIN_COMMITTED; + } + else + return InvalidTransactionId; + + if ((tup->t_flags & hint) || !TransactionIdIsNormal(xid) || + TransactionIdIsCurrentTransactionId(xid)) + return InvalidTransactionId; + + /* + * Committed is the common answer, so ask CLOG first. A transaction can + * commit between the two remaining tests, hence the second DidCommit. + */ + if (TransactionIdDidCommit(xid) || + TransactionIdIsInProgress(xid) || + TransactionIdDidCommit(xid)) + return InvalidTransactionId; + + return xid; +} + +/* + * FluxTupleHasAbortedVersion + * True if the tuple's current version belongs to an aborted transaction + * whose UNDO has not been applied yet. + */ +bool +FluxTupleHasAbortedVersion(FluxTupleHeader *tup) +{ + return TransactionIdIsValid(flux_aborted_writer(tup)); +} + +/* + * FluxRevertAbortedVersions + * "Helping": revert the aborted, not-yet-reverted versions at the top + * of a tuple's version chain before a writer acts on it. + * + * FLUX updates in place, so a transaction that aborted without its UNDO + * being applied yet (a rollback handed to an undo apply worker, or a crash + * loser) leaves its images on the page. Readers walk past them (see + * FluxWriterVisibleToSnapshot), but a writer works on the page version, and + * overwriting an aborted image would leave it in the version chain of the + * new version for good. So the writer reverts it first, as OrioleDB does + * (btree/modify.c), instead of failing or waiting for the worker. + * + * The caller holds an exclusive lock on buffer, which holds the tuple at + * offnum. Each step reuses the rollback apply (flux_undo_apply_locked), + * with its CLR, and leaves t_verptr at the previous record, so the owner's + * or the worker's later apply of the same record finds it no longer current + * and skips it. That apply releases the lock around the index reversal of + * an in-place key UPDATE, so the tuple is re-read after every step. + * + * Returns true if anything was reverted; the caller must then re-read the + * tuple. + */ +bool +FluxRevertAbortedVersions(Relation rel, Buffer buffer, OffsetNumber offnum) +{ + Page page = BufferGetPage(buffer); + bool reverted = false; + + for (;;) + { + ItemId lp; + FluxTupleHeader *tup; + TransactionId xid; + TransactionId top_xid; + uint16 subtype; + char *payload; + Size payload_len; + FluxUndoPayloadHeader phdr; + UndoRecPtr urp; + bool deleted; + + if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) + break; + lp = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(lp)) + break; + tup = (FluxTupleHeader *) PageGetItem(page, lp); + + xid = flux_aborted_writer(tup); + if (!TransactionIdIsValid(xid)) + break; + + urp = tup->t_verptr; + deleted = (tup->t_flags & FLUX_TUPLE_DELETED) != 0; + if (!FluxPbuFetchForRevert(urp, &top_xid, &subtype, &payload, + &payload_len)) + break; + + /* The record must be the one that produced this version. */ + memcpy(&phdr, payload, SizeOfFluxUndoPayloadHeader); + if (!TransactionIdEquals(phdr.writer_xid, xid) || + ItemPointerGetBlockNumber(&phdr.tid) != BufferGetBlockNumber(buffer) || + ItemPointerGetOffsetNumber(&phdr.tid) != offnum || + deleted != (subtype == FLUX_UNDO_DELETE)) + { + elog(WARNING, + "FLUX: UNDO record at %llu does not match aborted version of (%u,%u)", + (unsigned long long) urp, BufferGetBlockNumber(buffer), offnum); + pfree(payload); + break; + } + + if (flux_undo_apply_locked(rel, buffer, subtype, top_xid, payload, + payload_len, urp) != UNDO_APPLY_SUCCESS) + { + pfree(payload); + break; + } + pfree(payload); + + /* Stop if the restore did not happen (e.g. the slot shrank). */ + lp = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(lp) || + ((FluxTupleHeader *) PageGetItem(page, lp))->t_verptr == urp) + break; + + reverted = true; + } + + return reverted; +} + + +/* + * flux_undo_apply + * Apply a single FLUX UNDO record. + * + * Dispatched by the per-backend UNDO engine (execute_undo_actions_page and + * PbuApplySubxactUndoAll) for records tagged UNDO_RMID_FLUX. urec_ptr is + * the record's own location. The apply is idempotent: a record that is no + * longer current (see flux_undo_record_is_current) is skipped and reported as + * done. + */ +static UndoApplyResult +flux_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, + const char *payload, Size payload_len, UndoRecPtr urec_ptr) +{ + FluxUndoPayloadHeader hdr; + Relation rel; + Buffer buffer; + BlockNumber blkno; + UndoApplyResult result; + + Assert(rmid == UNDO_RMID_FLUX); + + /* + * Defer during crash recovery (syscache may not be initialised) or during + * an aborting transaction (BumpContext makes relation_close() and pfree() + * unsafe). The logical-revert worker will re-drive the record from a + * clean memory context. + */ + if (InRecovery || IsAbortedTransactionBlockState()) + { + ereport(DEBUG2, + (errmsg_internal("FLUX UNDO: deferring xid %u record at %llu " + "(in recovery or abort path)", + xid, + (unsigned long long) urec_ptr))); + return UNDO_APPLY_SKIPPED; + } + + /* Decode the common payload header */ + if (payload_len < SizeOfFluxUndoPayloadHeader) + { + ereport(WARNING, + (errmsg_internal("FLUX UNDO: payload too short (%zu bytes) " + "for record at %llu", + payload_len, + (unsigned long long) urec_ptr))); + return UNDO_APPLY_ERROR; + } + memcpy(&hdr, payload, SizeOfFluxUndoPayloadHeader); + blkno = ItemPointerGetBlockNumber(&hdr.tid); + + /* Open the relation; skip if dropped */ + rel = try_relation_open(reloid, RowExclusiveLock); + if (rel == NULL) + { + ereport(DEBUG2, + (errmsg_internal("FLUX UNDO: relation %u no longer exists, " + "skipping record at %llu", + reloid, + (unsigned long long) urec_ptr))); + return flux_undo_gone(xid); + } + + /* + * The record was written against one physical file of the relation. If + * the relation now has a different one (TRUNCATE, or a rewrite such as + * ALTER TABLE ... TYPE), the record does not apply here: while the + * aborting transaction is still running the old file may be about to + * become current again (its own TRUNCATE/rewrite is rolled back with it), + * so the record is retried after the relcache revert; once it is over the + * old file is gone for good. + */ + if (hdr.relnumber != rel->rd_locator.relNumber) + { + ereport(DEBUG2, + (errmsg_internal("FLUX UNDO: record at %llu is for file %u of " + "relation %u, which now uses file %u", + (unsigned long long) urec_ptr, + hdr.relnumber, reloid, + rel->rd_locator.relNumber))); + relation_close(rel, RowExclusiveLock); + return flux_undo_gone(xid); + } + + /* Skip if the target block was truncated away */ + if (RelationGetNumberOfBlocks(rel) <= blkno) + { + ereport(DEBUG2, + (errmsg_internal("FLUX UNDO: block %u beyond end of " + "relation %u, skipping", + blkno, reloid))); + relation_close(rel, RowExclusiveLock); + return flux_undo_gone(xid); + } + + buffer = ReadBuffer(rel, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + + result = flux_undo_apply_locked(rel, buffer, info, xid, payload, + payload_len, urec_ptr); + + UnlockReleaseBuffer(buffer); + relation_close(rel, RowExclusiveLock); + + return result; +} + + +/* + * flux_undo_desc + * Describe a FLUX UNDO record for pg_waldump / debug logging. + */ +static void +flux_undo_desc(StringInfo buf, uint8 rmid, uint16 info, + const char *payload, Size payload_len) +{ + const char *subtype; + FluxUndoPayloadHeader hdr; + + switch (info) + { + case FLUX_UNDO_UPDATE: + subtype = "UPDATE"; + break; + case FLUX_UNDO_DELETE: + subtype = "DELETE"; + break; + case FLUX_UNDO_UPDATE_DELTA: + subtype = "UPDATE_DELTA"; + break; + default: + subtype = "UNKNOWN"; + break; + } + + if (payload_len >= SizeOfFluxUndoPayloadHeader) + { + memcpy(&hdr, payload, SizeOfFluxUndoPayloadHeader); + appendStringInfo(buf, + "%s rel=%u tid=(%u,%u) tuple_len=%u flags=0x%x", + subtype, + hdr.relnumber, + ItemPointerGetBlockNumber(&hdr.tid), + ItemPointerGetOffsetNumber(&hdr.tid), + hdr.tuple_len, + hdr.flags); + + if (info == FLUX_UNDO_UPDATE_DELTA && + payload_len >= SizeOfFluxUndoPayloadHeader + SizeOfFluxUndoDelta) + { + FluxUndoDelta delta; + + memcpy(&delta, payload + SizeOfFluxUndoPayloadHeader, + SizeOfFluxUndoDelta); + appendStringInfo(buf, + " delta old_len=%u prefix=%u suffix=%u middle=%u", + delta.old_len, delta.prefixlen, delta.suffixlen, + delta.old_len - delta.prefixlen - delta.suffixlen); + } + } + else + { + appendStringInfo(buf, "%s (truncated payload, %zu bytes)", + subtype, payload_len); + } +} diff --git a/src/backend/access/flux/flux_vm.c b/src/backend/access/flux/flux_vm.c new file mode 100644 index 0000000000000..1ad3f15294764 --- /dev/null +++ b/src/backend/access/flux/flux_vm.c @@ -0,0 +1,716 @@ +/*------------------------------------------------------------------------- + * + * flux_vm.c + * Visibility Map implementation for FLUX + * + * The Visibility Map (VM) tracks the visibility status of pages in a FLUX + * relation. It stores two bits per heap page: + * + * - ALL_VISIBLE: All tuples on the page are visible to all transactions + * - ALL_FROZEN: All tuples on the page are frozen (transaction IDs removed) + * + * The VM enables two critical optimizations: + * 1. Index-only scans can skip heap fetches for all-visible pages + * 2. VACUUM can skip pages that are already all-visible or all-frozen + * + * The VM is stored in a separate fork of the relation (VISIBILITYMAP_FORKNUM) + * and is WAL-logged for crash recovery. + * + * This implementation is based on the heap visibility map + * (src/backend/access/heap/visibilitymap.c) but adapted for FLUX's + * timestamp-based MVCC model. + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/backend/access/flux/flux_vm.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/flux.h" +#include "access/flux_xlog.h" +#include "access/visibilitymapdefs.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "miscadmin.h" +#include "port/pg_bitutils.h" +#include "storage/bufmgr.h" +#include "storage/buf_internals.h" +#include "storage/lmgr.h" +#include "storage/smgr.h" +#include "utils/inval.h" +#include "utils/rel.h" + +/* + * Size of the bitmap on each visibility map page, in bytes. There's no + * extra headers, so the whole page minus the standard page header is + * used for the bitmap. + */ +#define MAPSIZE (BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) + +/* Number of heap blocks we can represent in one VM page */ +#define HEAPBLOCKS_PER_PAGE (MAPSIZE * 4) + +/* Mapping macros */ +#define HEAPBLK_TO_MAPBLOCK(x) ((x) / HEAPBLOCKS_PER_PAGE) +#define HEAPBLK_TO_MAPBYTE(x) (((x) % HEAPBLOCKS_PER_PAGE) / 4) +#define HEAPBLK_TO_OFFSET(x) (((x) % HEAPBLOCKS_PER_PAGE) % 4) + +/* Bit manipulation - use FLUX-specific values that match PostgreSQL's VM bits */ + +/* Forward declaration */ +static Buffer flux_vm_extend(Relation rel, BlockNumber vm_nblocks); +static Buffer flux_vm_readbuf(Relation rel, BlockNumber blkno, bool extend); + +/* + * FluxVMInit - Initialize visibility map for a FLUX relation + * + * This is called when a FLUX table is created to ensure the VM fork exists. + */ +void +FluxVMInit(Relation rel) +{ + /* + * Create the visibility map fork if it doesn't exist. This happens + * automatically when we first try to extend it via flux_vm_extend(). + */ +} + +/* + * FluxVMSet - Set visibility map bits for a page + * + * Sets the specified bits for the given heap block. The heap buffer must + * be exclusively locked. The VM buffer will be pinned and locked as needed. + */ +void +FluxVMSet(Relation rel, BlockNumber heapBlk, Buffer heapBuf, uint8 flags, TransactionId conflict_xid) +{ + BlockNumber mapBlock; + uint32 mapByte; + uint8 mapOffset; + Page page; + uint8 *map; + Buffer vmBuf; + + Assert(BufferIsValid(heapBuf)); + /* Buffer should be exclusively locked */ + + /* Only set valid bits */ + flags &= FLUX_VM_VALID_BITS; + if (flags == 0) + return; + + /* Calculate the VM page and offset for this heap block */ + mapBlock = HEAPBLK_TO_MAPBLOCK(heapBlk); + mapByte = HEAPBLK_TO_MAPBYTE(heapBlk); + mapOffset = HEAPBLK_TO_OFFSET(heapBlk); + + /* + * Read or extend the visibility map buffer. flux_vm_readbuf() will + * create the VM fork if it doesn't exist yet. + */ + vmBuf = flux_vm_readbuf(rel, mapBlock, true); + LockBuffer(vmBuf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(vmBuf); + + /* If the page is new, initialize it */ + if (PageIsNew(page)) + PageInit(page, BLCKSZ, 0); + + map = (uint8 *) PageGetContents(page); + + /* Set the bits for this heap block */ + map[mapByte] |= (flags << (mapOffset * 2)); + + MarkBufferDirty(vmBuf); + + /* XLOG stuff */ + if (RelationNeedsWAL(rel)) + { + xl_flux_vm_set xlrec; + XLogRecPtr recptr; + + xlrec.conflict_xid = conflict_xid; + xlrec.heapBlk = heapBlk; + xlrec.flags = flags; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xlrec)); + + /* + * Register the heap buffer with REGBUF_NO_IMAGE. We reference the + * heap page so that redo can update its LSN, but we do NOT need a + * full-page image of the heap page in this WAL record. The heap + * buffer may not be dirty (e.g., during VACUUM VM updates), so we + * must not let XLogInsert try to take an FPI of it -- that would trip + * the BufferIsDirty assertion. + */ + XLogRegisterBuffer(0, heapBuf, REGBUF_NO_IMAGE | REGBUF_NO_CHANGE); + + /* + * Not REGBUF_STANDARD: the map lives between pd_lower and pd_upper, + * which a standard page image leaves out, and redo of a restored page + * applies nothing, so every bit on the page would be lost. + */ + XLogRegisterBuffer(1, vmBuf, 0); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_VM_SET); + PageSetLSN(page, recptr); + } + + UnlockReleaseBuffer(vmBuf); +} + +/* + * FluxVMClear - Clear visibility map bits for a page + * + * Clears the specified bits for the given heap block. The heap buffer must + * be exclusively locked. + */ +void +FluxVMClear(Relation rel, BlockNumber heapBlk, Buffer heapBuf, uint8 flags) +{ + BlockNumber mapBlock; + uint32 mapByte; + uint8 mapOffset; + Page page; + uint8 *map; + Buffer vmBuf; + + Assert(BufferIsValid(heapBuf)); + /* Buffer should be exclusively locked */ + + /* Only clear valid bits */ + flags &= FLUX_VM_VALID_BITS; + if (flags == 0) + return; + + /* Calculate the VM page and offset for this heap block */ + mapBlock = HEAPBLK_TO_MAPBLOCK(heapBlk); + mapByte = HEAPBLK_TO_MAPBYTE(heapBlk); + mapOffset = HEAPBLK_TO_OFFSET(heapBlk); + + /* Check if the VM fork/page exists; if not, nothing to clear */ + if (!smgrexists(RelationGetSmgr(rel), VISIBILITYMAP_FORKNUM)) + return; + if (mapBlock >= RelationGetNumberOfBlocksInFork(rel, VISIBILITYMAP_FORKNUM)) + return; + + vmBuf = ReadBufferExtended(rel, VISIBILITYMAP_FORKNUM, mapBlock, + RBM_NORMAL, NULL); + LockBuffer(vmBuf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(vmBuf); + map = (uint8 *) PageGetContents(page); + + /* + * Check if the requested bits are already clear. If so, skip the + * modification and WAL logging entirely. This is the common case after + * the first modification to a page since the last VACUUM, and avoids + * significant WAL amplification on hot pages. + */ + if ((map[mapByte] & (flags << (mapOffset * 2))) == 0) + { + UnlockReleaseBuffer(vmBuf); + return; + } + + /* Clear the bits for this heap block */ + map[mapByte] &= ~(flags << (mapOffset * 2)); + + MarkBufferDirty(vmBuf); + + /* XLOG stuff */ + if (RelationNeedsWAL(rel)) + { + xl_flux_vm_clear xlrec; + XLogRecPtr recptr; + + xlrec.heapBlk = heapBlk; + xlrec.flags = flags; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xlrec)); + + /* + * Register the heap buffer with REGBUF_NO_IMAGE for the same reason + * as in FluxVMSet: the heap buffer may not be dirty. + */ + XLogRegisterBuffer(0, heapBuf, REGBUF_NO_IMAGE | REGBUF_NO_CHANGE); + /* Not REGBUF_STANDARD, see FluxVMSet. */ + XLogRegisterBuffer(1, vmBuf, 0); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_VM_CLEAR); + PageSetLSN(page, recptr); + } + + UnlockReleaseBuffer(vmBuf); +} + +/* + * FluxVMCheck - Check visibility map bits for a page + * + * Returns true if ALL the specified bits are set for the given heap block. + * This function does not require any locks and can be called from + * index-only scan paths. + */ +bool +FluxVMCheck(Relation rel, BlockNumber heapBlk, uint8 flags) +{ + BlockNumber mapBlock; + uint32 mapByte; + uint8 mapOffset; + Page page; + uint8 *map; + Buffer vmBuf; + bool result; + + /* Only check valid bits */ + flags &= FLUX_VM_VALID_BITS; + if (flags == 0) + return true; /* No bits to check */ + + /* Calculate the VM page and offset for this heap block */ + mapBlock = HEAPBLK_TO_MAPBLOCK(heapBlk); + mapByte = HEAPBLK_TO_MAPBYTE(heapBlk); + mapOffset = HEAPBLK_TO_OFFSET(heapBlk); + + /* If the VM fork/page doesn't exist, the bits can't be set */ + if (!smgrexists(RelationGetSmgr(rel), VISIBILITYMAP_FORKNUM)) + return false; + if (mapBlock >= RelationGetNumberOfBlocksInFork(rel, VISIBILITYMAP_FORKNUM)) + return false; + + vmBuf = ReadBufferExtended(rel, VISIBILITYMAP_FORKNUM, mapBlock, + RBM_NORMAL, NULL); + LockBuffer(vmBuf, BUFFER_LOCK_SHARE); + page = BufferGetPage(vmBuf); + map = (uint8 *) PageGetContents(page); + + /* Check if all requested bits are set */ + result = ((map[mapByte] >> (mapOffset * 2)) & flags) == flags; + + UnlockReleaseBuffer(vmBuf); + + return result; +} + +/* + * FluxVMCheckCached - Check visibility map bits with caller-managed buffer cache + * + * Like FluxVMCheck, but the caller provides pointers to a cached VM buffer + * and its block number. The VM buffer is kept pinned across calls; it is + * only released and re-read when the heap block maps to a different VM page. + * This eliminates per-page ReadBufferExtended + UnlockReleaseBuffer overhead + * for sequential scans (one VM page covers HEAPBLOCKS_PER_PAGE heap pages, + * typically ~32K pages with 8KB blocks). + * + * The caller must release the buffer when done (e.g., at scan end). + */ +bool +FluxVMCheckCached(Relation rel, BlockNumber heapBlk, uint8 flags, + Buffer *vmbuf, BlockNumber *vm_blockno) +{ + BlockNumber mapBlock; + uint32 mapByte; + uint8 mapOffset; + Page page; + uint8 *map; + bool result; + + /* Only check valid bits */ + flags &= FLUX_VM_VALID_BITS; + if (flags == 0) + return true; /* No bits to check */ + + /* Calculate the VM page and offset for this heap block */ + mapBlock = HEAPBLK_TO_MAPBLOCK(heapBlk); + mapByte = HEAPBLK_TO_MAPBYTE(heapBlk); + mapOffset = HEAPBLK_TO_OFFSET(heapBlk); + + /* If the VM fork doesn't exist, the bits can't be set */ + if (!smgrexists(RelationGetSmgr(rel), VISIBILITYMAP_FORKNUM)) + return false; + if (mapBlock >= RelationGetNumberOfBlocksInFork(rel, VISIBILITYMAP_FORKNUM)) + return false; + + /* + * Re-read the VM buffer only when the target VM page changes. Each VM + * page covers HEAPBLOCKS_PER_PAGE heap pages, so for sequential scans + * this avoids ~32K redundant buffer reads per VM page. + */ + if (!BufferIsValid(*vmbuf) || *vm_blockno != mapBlock) + { + if (BufferIsValid(*vmbuf)) + ReleaseBuffer(*vmbuf); + *vmbuf = ReadBufferExtended(rel, VISIBILITYMAP_FORKNUM, mapBlock, + RBM_NORMAL, NULL); + *vm_blockno = mapBlock; + } + + LockBuffer(*vmbuf, BUFFER_LOCK_SHARE); + page = BufferGetPage(*vmbuf); + map = (uint8 *) PageGetContents(page); + + /* Check if all requested bits are set */ + result = ((map[mapByte] >> (mapOffset * 2)) & flags) == flags; + + LockBuffer(*vmbuf, BUFFER_LOCK_UNLOCK); + + return result; +} + +/* + * FluxVMPinBuffer - Pin the visibility map buffer for a heap block + * + * This is used when we need to keep the VM buffer pinned across multiple + * operations. The caller is responsible for unpinning the buffer. + */ +void +FluxVMPinBuffer(Relation rel, BlockNumber heapBlk, Buffer *vmbuf) +{ + BlockNumber mapBlock; + + /* Calculate the VM page for this heap block */ + mapBlock = HEAPBLK_TO_MAPBLOCK(heapBlk); + + /* Pin the buffer if not already pinned */ + if (!BufferIsValid(*vmbuf) || BufferGetBlockNumber(*vmbuf) != mapBlock) + { + if (BufferIsValid(*vmbuf)) + ReleaseBuffer(*vmbuf); + *vmbuf = flux_vm_readbuf(rel, mapBlock, true); + } +} + +/* + * FluxVMExtend - Extend the visibility map to cover more heap blocks + * + * This is called when the heap relation is extended. + */ +void +FluxVMExtend(Relation rel, BlockNumber nheapblocks) +{ + BlockNumber newnblocks; + + /* Calculate how many VM blocks we need */ + newnblocks = (nheapblocks + HEAPBLOCKS_PER_PAGE - 1) / HEAPBLOCKS_PER_PAGE; + + /* Extend the VM fork if necessary, creating it if needed */ + if (newnblocks > 0) + { + Buffer buf; + + buf = flux_vm_extend(rel, newnblocks); + ReleaseBuffer(buf); + } +} + +/* + * FluxVMGetPageSize - Get the size of a VM page + */ +Size +FluxVMGetPageSize(void) +{ + return MAPSIZE; +} + +/* + * FluxVMMapHeapToVM - Map a heap block number to VM block number + */ +BlockNumber +FluxVMMapHeapToVM(BlockNumber heapBlk) +{ + return HEAPBLK_TO_MAPBLOCK(heapBlk); +} + +/* + * flux_vm_extend - Extend the VM fork to at least vm_nblocks. + * + * Creates the VM fork if it doesn't exist yet. Returns a buffer for + * the last block of the extended fork (pinned but not locked). + */ +static Buffer +flux_vm_extend(Relation rel, BlockNumber vm_nblocks) +{ + Buffer buf; + + buf = ExtendBufferedRelTo(BMR_REL(rel), VISIBILITYMAP_FORKNUM, NULL, + EB_CREATE_FORK_IF_NEEDED | + EB_CLEAR_SIZE_CACHE, + vm_nblocks, + RBM_ZERO_ON_ERROR); + + /* + * Send a shared-inval message to force other backends to close any smgr + * references they may have for this rel, which we are about to change. + */ + CacheInvalidateSmgr(RelationGetSmgr(rel)->smgr_rlocator); + + return buf; +} + +/* + * flux_vm_readbuf - Read or extend the VM to get the page for blkno. + * + * If extend is true and the block doesn't exist, extends the fork + * (creating it if needed). Returns InvalidBuffer if extend is false + * and the block doesn't exist. Buffer is returned pinned but not locked. + */ +static Buffer +flux_vm_readbuf(Relation rel, BlockNumber blkno, bool extend) +{ + Buffer buf; + SMgrRelation reln = RelationGetSmgr(rel); + + /* + * Ensure we have the cached nblocks value for the VM fork. + */ + if (reln->smgr_cached_nblocks[VISIBILITYMAP_FORKNUM] == InvalidBlockNumber) + { + if (smgrexists(reln, VISIBILITYMAP_FORKNUM)) + smgrnblocks(reln, VISIBILITYMAP_FORKNUM); + else + reln->smgr_cached_nblocks[VISIBILITYMAP_FORKNUM] = 0; + } + + if (blkno >= reln->smgr_cached_nblocks[VISIBILITYMAP_FORKNUM]) + { + if (extend) + buf = flux_vm_extend(rel, blkno + 1); + else + return InvalidBuffer; + } + else + buf = ReadBufferExtended(rel, VISIBILITYMAP_FORKNUM, blkno, + RBM_ZERO_ON_ERROR, NULL); + + /* + * Initializing the page when needed is trickier than it looks, because of + * the possibility of multiple backends doing this concurrently, and our + * desire to not uselessly take the buffer lock in the normal path where + * the page is OK. For a page that's just been extended, this is not + * needed since it was already initialized by ExtendBufferedRelTo. + */ + if (PageIsNew(BufferGetPage(buf))) + { + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + if (PageIsNew(BufferGetPage(buf))) + PageInit(BufferGetPage(buf), BLCKSZ, 0); + LockBuffer(buf, BUFFER_LOCK_UNLOCK); + } + + return buf; +} + +/* + * Clearing the visibility map for a change to a heap page. + * + * An INSERT, UPDATE or DELETE clears the page's PD_ALL_VISIBLE and its map + * bits in its own critical section and WAL record, as heap_insert, + * heap_update and heap_delete do, and replay of the record clears them too. + * Clearing them afterwards, in a record of their own, let a standby that had + * replayed the change but not the clear, or a crash between the two, keep an + * all-visible page that holds a version not every snapshot sees. + * + * With the heap page exclusively locked and before the critical section, + * FluxVMPinForChange pins the map page (reading it may need I/O). In the + * critical section, before the change's record is assembled, + * FluxVMClearForChange clears the flag and the bits; the record's emitter + * calls FluxVMRegisterClear, which flags the record + * FLUX_WAL_ALL_VISIBLE_CLEARED and registers the map page at + * FLUX_VM_BLOCK_ID; and FluxVMFinishClear sets the map page's LSN and + * unlocks it. The caller releases the pin. The bits of a heap page are set + * and cleared only by the holder of its exclusive lock, so they can be read + * without the map page's lock. The flag alone may be set (a hint set by + * VACUUM), and the bits alone (PD_ALL_VISIBLE is not WAL-logged, so a crash + * or a standby can lose it), so both are looked at. + */ +static Buffer flux_vm_clear_buf = InvalidBuffer; /* locked map page */ +static bool flux_vm_cleared = false; + +/* The bits of heapBlk in the map page vmpage. */ +static inline uint8 +flux_vm_bits(Page vmpage, BlockNumber heapBlk) +{ + uint8 *map = (uint8 *) PageGetContents(vmpage); + + return (map[HEAPBLK_TO_MAPBYTE(heapBlk)] >> + (HEAPBLK_TO_OFFSET(heapBlk) * 2)) & FLUX_VM_VALID_BITS; +} + +/* + * Clear the bits of heapBlk in the map page vmpage; true if any was set. + * Also used by redo. + */ +bool +FluxVMClearMapBits(Page vmpage, BlockNumber heapBlk) +{ + uint8 *map = (uint8 *) PageGetContents(vmpage); + + if (flux_vm_bits(vmpage, heapBlk) == 0) + return false; + map[HEAPBLK_TO_MAPBYTE(heapBlk)] &= + ~(FLUX_VM_VALID_BITS << (HEAPBLK_TO_OFFSET(heapBlk) * 2)); + return true; +} + +/* + * Pin the map page of the locked heap page heapBuf, or return InvalidBuffer + * if the map has none (then no bit can be set). + * + * The size of the map this backend cached may be out of date: VACUUM may + * have created or extended the map since, and set bits there, and the + * invalidation that says so is only processed at the next lock this backend + * takes, which a running statement may never do. So if the cached size does + * not cover the page, read the size again, as heap's visibilitymap_pin does + * by extending the map. VACUUM sets a page's bits only under the page's + * lock, which the caller holds, so the size read now covers any bit set. + * + * Skip that when the map did not exist and the page is not PD_ALL_VISIBLE: a + * map that a VACUUM created since then set PD_ALL_VISIBLE with the bits, so + * a table that was never vacuumed costs no lookup per change. Only replay + * sets bits without PD_ALL_VISIBLE, and a backend that cached the size before + * a map existed cannot have been running then: the read paths + * (FluxVMCheck) do not cache a missing map, and a standby does not write. + */ +Buffer +FluxVMPinForChange(Relation rel, Buffer heapBuf) +{ + SMgrRelation reln = RelationGetSmgr(rel); + BlockNumber mapBlock = HEAPBLK_TO_MAPBLOCK(BufferGetBlockNumber(heapBuf)); + BlockNumber cached = reln->smgr_cached_nblocks[VISIBILITYMAP_FORKNUM]; + + /* Room for the block reference at FLUX_VM_BLOCK_ID. */ + if (RelationNeedsWAL(rel)) + XLogEnsureRecordSpace(FLUX_VM_BLOCK_ID, 0); + if (cached != InvalidBlockNumber && mapBlock >= cached && + (cached > 0 || PageIsAllVisible(BufferGetPage(heapBuf)))) + reln->smgr_cached_nblocks[VISIBILITYMAP_FORKNUM] = InvalidBlockNumber; + return flux_vm_readbuf(rel, mapBlock, false); +} + +/* + * Would a change to the locked heap page heapBuf have to clear its flag or + * bits? For a path that cannot log the clear, to stay off such a page. + */ +bool +FluxVMNeedsClear(Relation rel, Buffer heapBuf) +{ + Buffer vmbuf; + bool set; + + if (PageIsAllVisible(BufferGetPage(heapBuf))) + return true; + vmbuf = FluxVMPinForChange(rel, heapBuf); + if (!BufferIsValid(vmbuf)) + return false; + set = flux_vm_bits(BufferGetPage(vmbuf), BufferGetBlockNumber(heapBuf)) != 0; + ReleaseBuffer(vmbuf); + return set; +} + +/* + * In the change's critical section: clear PD_ALL_VISIBLE of heapBuf and its + * bits in vmbuf (from FluxVMPinForChange, may be invalid). + */ +void +FluxVMClearForChange(Buffer heapBuf, Buffer vmbuf) +{ + BlockNumber heapBlk = BufferGetBlockNumber(heapBuf); + Page page = BufferGetPage(heapBuf); + + Assert(CritSectionCount > 0); + Assert(!BufferIsValid(flux_vm_clear_buf)); + + flux_vm_cleared = PageIsAllVisible(page); + PageClearAllVisible(page); + if (BufferIsValid(vmbuf) && + flux_vm_bits(BufferGetPage(vmbuf), heapBlk) != 0) + { + LockBuffer(vmbuf, BUFFER_LOCK_EXCLUSIVE); + (void) FluxVMClearMapBits(BufferGetPage(vmbuf), heapBlk); + MarkBufferDirty(vmbuf); + flux_vm_clear_buf = vmbuf; + flux_vm_cleared = true; + } +} + +/* In the emitter of the change's record, see FluxVMClearForChange. */ +void +FluxVMRegisterClear(uint16 *flags) +{ + if (flux_vm_cleared) + *flags |= FLUX_WAL_ALL_VISIBLE_CLEARED; + + /* + * Not REGBUF_STANDARD: the map lives between pd_lower and pd_upper, which + * a standard page image leaves out. + */ + if (BufferIsValid(flux_vm_clear_buf)) + XLogRegisterBuffer(FLUX_VM_BLOCK_ID, flux_vm_clear_buf, 0); +} + +/* + * After the change's record (lsn, or InvalidXLogRecPtr if it is not logged): + * stamp and unlock the map page. Does nothing if there is nothing to finish. + */ +void +FluxVMFinishClear(XLogRecPtr lsn) +{ + if (BufferIsValid(flux_vm_clear_buf)) + { + if (lsn != InvalidXLogRecPtr) + PageSetLSN(BufferGetPage(flux_vm_clear_buf), lsn); + LockBuffer(flux_vm_clear_buf, BUFFER_LOCK_UNLOCK); + flux_vm_clear_buf = InvalidBuffer; + } + flux_vm_cleared = false; +} + +/* + * FluxVMVacuumPage - Update VM during VACUUM + * + * This is called by VACUUM after processing a page to set the appropriate + * visibility map bits based on the page's contents. + */ +void +FluxVMVacuumPage(Relation rel, Buffer buffer, bool all_visible, bool all_frozen, TransactionId conflict_xid) +{ + BlockNumber blkno = BufferGetBlockNumber(buffer); + uint8 flags = 0; + + if (all_visible) + flags |= FLUX_VM_ALL_VISIBLE; + if (all_frozen) + flags |= FLUX_VM_ALL_FROZEN; + + Assert(!all_frozen || all_visible); + /* Repair stale bits as well as setting newly established facts. */ + FluxVMClear(rel, blkno, buffer, FLUX_VM_VALID_BITS & ~flags); + if (flags != 0) + FluxVMSet(rel, blkno, buffer, flags, conflict_xid); + + /* + * Synchronize the in-page PD_ALL_VISIBLE flag with the VM. Use + * MarkBufferDirtyHint since losing this flag on crash is benign (just + * falls back to VM check on next scan; VACUUM will re-set it). + */ + if (all_visible) + { + if (!PageIsAllVisible(BufferGetPage(buffer))) + { + PageSetAllVisible(BufferGetPage(buffer)); + MarkBufferDirtyHint(buffer, true); + } + } + else + { + if (PageIsAllVisible(BufferGetPage(buffer))) + { + PageClearAllVisible(BufferGetPage(buffer)); + MarkBufferDirtyHint(buffer, true); + } + } +} diff --git a/src/backend/access/flux/flux_xlog.c b/src/backend/access/flux/flux_xlog.c new file mode 100644 index 0000000000000..e6007b80fbc1f --- /dev/null +++ b/src/backend/access/flux/flux_xlog.c @@ -0,0 +1,2826 @@ +/*------------------------------------------------------------------------- + * + * flux_xlog.c + * FLUX WAL (Write-Ahead Logging) implementation + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_xlog.c + * + * NOTES + * This implements WAL logging for FLUX operations, providing + * UNDO/REDO functionality for crash recovery. Unlike heap, + * FLUX uses in-place updates with before/after images. + * + * PANIC policy during redo + * ------------------------ + * The per-opcode redo helpers below use elog(PANIC, ...) for any + * invariant violation detected during WAL replay. This is + * deliberate: a mismatch between the WAL stream and the on-disk + * state (truncated overflow payload, failure to add a tuple the + * forward path just wrote, a page full the forward path just + * defragmented, an unknown opcode) is not a recoverable condition. + * Downgrading these sites to ERROR would promote silent divergence + * between the primary and a standby, or between the on-disk + * heap state and the WAL record that described it; PANIC forces + * a postmaster-wide restart and, in the standby case, marks the + * standby inconsistent. Each PANIC site is therefore guarded by + * logic that only fires on actually-corrupt input; fixing a PANIC + * that fires in practice is a correctness bug in the forward + * path, not a reason to soften the guard. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/htup_details.h" +#include "access/heaptoast.h" +#include "catalog/pg_class.h" +#include "utils/datum.h" +#include "access/flux.h" +#include "access/flux_xlog.h" +#include "access/bufmask.h" +#include "catalog/catalog.h" +#include "storage/standby.h" +#include "access/flux_pbu.h" +#include "access/slog.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "access/xlogrecord.h" +#include "access/xlogutils.h" +#include "storage/buf_internals.h" +#include "storage/bufmgr.h" +#include "miscadmin.h" +#include "utils/rel.h" +#include "utils/timestamp.h" + +/* + * FluxXLogMaybeAppendLogicalTuple + * Append a heap-format image of `rtup` to the in-progress WAL record + * if `rel` is logically logged. Returns true and sets + * FLUX_WAL_LOGICAL_TUPLE in `*flags` if the image was appended. + * + * The heap image is what logical decoding consumes. Physical REDO + * reads the FLUX-format tuple that precedes this region. By writing + * both, we avoid the need for decode.c to call RelidByRelfilenumber / + * RelationIdGetRelation, which are unsafe before SetupHistoricSnapshot. + * + * The image is appended at the END of the main WAL data channel with + * the length trailing the bytes: + * + * ... [heap bytes] [uint32 heap_len] + * + * So the decoder can read heap_len from (end-4) and back up heap_len + * bytes to find the heap payload, regardless of what precedes it in + * the record (which may vary with compression / cross-page). + * + * For UPDATE we append two back-to-back trailers (old then new); see + * FluxXLogUpdate. + */ +void +FluxXLogPrepareLogicalImage(Relation rel, FluxTuple rtup, + FluxLogicalImage *img) +{ + TupleDesc tupdesc; + Datum *values; + bool *isnull; + HeapTuple heaptup; + + img->data = NULL; + img->len = 0; + + if (rel == NULL || rtup == NULL || !RelationIsLogicallyLogged(rel)) + return; + + tupdesc = RelationGetDescr(rel); + values = (Datum *) palloc(tupdesc->natts * sizeof(Datum)); + isnull = (bool *) palloc(tupdesc->natts * sizeof(bool)); + + FluxDeformTuple(rel, rtup, tupdesc, values, isnull); + for (int i = rtup->t_data->t_natts; i < tupdesc->natts; i++) + values[i] = getmissingattr(tupdesc, i + 1, &isnull[i]); + heaptup = heap_form_tuple(tupdesc, values, isnull); + + /* + * Copy the heap tuple body into an image buffer that outlives the + * critical section. XLogRegisterData() only records pointers, so the + * bytes must remain valid until XLogInsert() reads them. + */ + img->len = (uint32) heaptup->t_len; + img->data = (char *) palloc(img->len); + memcpy(img->data, heaptup->t_data, img->len); + + heap_freetuple(heaptup); + pfree(values); + pfree(isnull); +} + +/* + * Build the old replica identity, independently of the physical undo image. + * As in heap's ExtractReplicaIdentity, log FULL unconditionally, otherwise + * only the key, and for UPDATE only if it changed or contains external data. + * Flatten external values here: decoding cannot fetch them from TOAST. + * A zero-length image denotes no old identity, but still has a WAL trailer. + */ +void +FluxXLogPrepareReplicaIdentity(Relation rel, FluxTuple oldtup, + FluxTuple newtup, FluxLogicalImage *img) +{ + TupleDesc desc = RelationGetDescr(rel); + char replident = rel->rd_rel->relreplident; + HeapTupleData tuple; + HeapTuple key; + Bitmapset *idattrs; + Datum values[MaxHeapAttributeNumber]; + Datum newvalues[MaxHeapAttributeNumber]; + bool nulls[MaxHeapAttributeNumber]; + bool newnulls[MaxHeapAttributeNumber]; + bool required = (newtup == NULL); + + FluxXLogPrepareLogicalImage(rel, oldtup, img); + if (img->data == NULL) + return; + + if (replident == REPLICA_IDENTITY_NOTHING) + { + img->len = 0; + return; + } + + tuple.t_len = img->len; + tuple.t_data = (HeapTupleHeader) img->data; + if (replident == REPLICA_IDENTITY_FULL) + key = &tuple; + else + { + idattrs = RelationGetIndexAttrBitmap(rel, INDEX_ATTR_BITMAP_IDENTITY_KEY); + heap_deform_tuple(&tuple, desc, values, nulls); + if (newtup != NULL) + FluxDeformTuple(rel, newtup, desc, newvalues, newnulls); + + for (int i = 0; i < desc->natts; i++) + { + CompactAttribute *att = TupleDescCompactAttr(desc, i); + + if (!bms_is_member(i + 1 - FirstLowInvalidHeapAttributeNumber, + idattrs)) + { + nulls[i] = true; + continue; + } + Assert(!nulls[i]); + if (newtup != NULL && + (newnulls[i] || + !datumIsEqual(values[i], newvalues[i], att->attbyval, att->attlen) || + (att->attlen == -1 && VARATT_IS_EXTERNAL(DatumGetPointer(values[i]))))) + required = true; + } + if (!required || bms_is_empty(idattrs)) + { + bms_free(idattrs); + img->len = 0; + return; + } + bms_free(idattrs); + key = heap_form_tuple(desc, values, nulls); + } + + if (HeapTupleHasExternal(key)) + { + HeapTuple flat = toast_flatten_tuple(key, desc); + + if (key != &tuple) + heap_freetuple(key); + key = flat; + } + if (key != &tuple) + { + pfree(img->data); + img->len = key->t_len; + img->data = palloc(img->len); + memcpy(img->data, key->t_data, img->len); + heap_freetuple(key); + } +} + +void +FluxXLogReleaseLogicalImage(FluxLogicalImage *img) +{ + if (img->data != NULL) + { + pfree(img->data); + img->data = NULL; + } + img->len = 0; +} + +/* + * FluxXLogRegisterLogicalImage + * Register a previously prepared heap-format image onto the in-progress + * WAL record. Allocation-free: safe to call inside a critical section. + * Appends "[heap bytes][uint32 heap_len]" to the main data channel and + * sets FLUX_WAL_LOGICAL_TUPLE in *flags. No-op when img is NULL or + * empty (relation not logically logged). + */ +void +FluxXLogRegisterLogicalImage(FluxLogicalImage *img, uint16 *flags) +{ + if (img == NULL || img->data == NULL) + return; + + if (img->len > 0) + XLogRegisterData(img->data, img->len); + XLogRegisterData((char *) &img->len, sizeof(uint32)); + + *flags |= FLUX_WAL_LOGICAL_TUPLE; +} + +/* ---------------------------------------------------------------- + * WAL Record Logging Functions + * ---------------------------------------------------------------- + */ + +/* + * Log a tuple insert operation. + */ +XLogRecPtr +FluxXLogInsert(Relation rel, Buffer buffer, OffsetNumber offnum, + FluxTuple tuple, uint64 commit_ts, + FluxOverflowBuffers *overflow_buffers, + FluxLogicalImage *logical_img, + bool force_page_image) +{ + xl_flux_insert xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + uint8 info = XLOG_FLUX_INSERT; + uint8 main_buf_flags = REGBUF_STANDARD; + int i; + int flux_undo_block_id = 1; /* next free WAL block id after main */ + xl_flux_overflow_write ovf_xlrecs[MAX_OVERFLOW_BUFFERS]; + + /* + * The multi-insert (COPY/bulk) path adds many tuples to one page but + * emits a single INSERT record describing only the first tuple. Under + * full_page_writes the page's initial touch captures the whole page, so + * redo restores every tuple. With full_page_writes off there is no such + * image, and the unlogged tuples vanish on crash recovery, leaving later + * CAS_UPDATE redo to PANIC on a missing item. Force a full-page image so + * the batch is crash-safe regardless of full_page_writes. + */ + if (force_page_image) + main_buf_flags |= REGBUF_FORCE_IMAGE; + + /* Fill in the insert record */ + xlrec.relid = RelationGetRelid(rel); + xlrec.offnum = offnum; + xlrec.flags = 0; + xlrec.tuple_len = tuple->t_len; + xlrec.commit_ts = commit_ts; + + /* + * NOTE: XLogEnsureRecordSpace() has already been called by the caller + * (before entering the critical section) to pre-allocate space for the + * main buffer plus all overflow buffers. + */ + XLogBeginInsert(); + + /* Register buffer FIRST, before any data */ + XLogRegisterBuffer(0, buffer, main_buf_flags); + + /* + * Register all overflow buffers (buffers 1..N) for atomic WAL logging. + * This ensures the main tuple and all overflow records are restored + * together during crash recovery, preventing orphaned overflow pages. + * + * IMPORTANT: Due to spatial locality optimization, multiple overflow + * records may reside on the same page. We must register each unique + * buffer only once, but register data for all overflow records. + * + * For each overflow buffer, we need to include: 1. The offset where the + * record should be placed 2. The actual overflow record data + */ + if (overflow_buffers != NULL) + { + int registered_block_id = 1; /* Start after main buffer */ + Buffer registered_buffers[MAX_OVERFLOW_BUFFERS]; + int registered_buffer_ids[MAX_OVERFLOW_BUFFERS]; + int num_registered = 0; + + /* + * NOTE: ovf_xlrecs[] is declared at function scope (not here) so that + * the pointers registered via XLogRegisterBufData() remain valid + * until XLogInsert() is called after this block ends. + */ + + /* Ensure we don't exceed PostgreSQL's hard limit */ + if (overflow_buffers->count > XLR_MAX_BLOCK_ID) + elog(ERROR, "too many overflow records: %d (max %d)", + overflow_buffers->count, XLR_MAX_BLOCK_ID); + + for (i = 0; i < overflow_buffers->count; i++) + { + FluxOverflowBuffer *ovb = &overflow_buffers->buffers[i]; + int block_id = -1; + int j; + + /* + * First check if this overflow buffer is the SAME as the main + * buffer. This can happen when spatial locality places an + * overflow record on the same page as the main tuple. In this + * case, reuse block_id=0. + */ + if (ovb->buffer == buffer) + { + block_id = 0; + xlrec.flags |= FLUX_WAL_HAS_OVERFLOW_BLK0; + } + else + { + /* + * Check if this buffer was already registered among overflow + * buffers (spatial locality: multiple overflow records on + * same page). If so, reuse its block_id instead of + * registering again. + */ + for (j = 0; j < num_registered; j++) + { + if (registered_buffers[j] == ovb->buffer) + { + block_id = registered_buffer_ids[j]; + break; + } + } + + /* If not registered yet, register it now */ + if (block_id < 0) + { + block_id = registered_block_id++; + + /* + * Force a full-page image for overflow buffers. See + * FluxXLogUpdate for the rationale. + */ + XLogRegisterBuffer(block_id, ovb->buffer, + REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + + /* Track this buffer so we don't register it again */ + registered_buffers[num_registered] = ovb->buffer; + registered_buffer_ids[num_registered] = block_id; + num_registered++; + } + } + + /* + * Create a proper xl_flux_overflow_write header with the offset. + * This tells the redo handler where to place the record. Each + * header is stored in a dedicated array slot so the pointer + * passed to XLogRegisterBufData remains valid until XLogInsert. + */ + ovf_xlrecs[i].offnum = ovb->offset; + ovf_xlrecs[i].flags = ovb->flags; + ovf_xlrecs[i].data_len = ovb->record_len; + ovf_xlrecs[i].commit_ts = commit_ts; + + /* Register the header first, then the data */ + XLogRegisterBufData(block_id, (char *) &ovf_xlrecs[i], sizeof(xl_flux_overflow_write)); + XLogRegisterBufData(block_id, ovb->record_data, ovb->record_len); + } + + /* Undo pages (if any) fold in after the last overflow block. */ + flux_undo_block_id = registered_block_id; + } + + /* Now register the main data */ + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_insert)); + XLogRegisterData((char *) tuple->t_data, tuple->t_len); + FluxVMRegisterClear(&xlrec.flags); + + FluxXLogRegisterLogicalImage(logical_img, &xlrec.flags); + + /* + * Fold the per-backend UNDO record onto THIS forward-op WAL record: the + * undo pages become block references and the undo record's own bytes ride + * as their block data, so flux_redo() re-runs the undo insert. The undo + * bytes reach disk on the same record as the forward FLUX mutation, which + * is what makes it crash-safe. Caller prepared+inserted the undo record + * before opening the crit section; see flux_pbu.c. + */ + if (FluxPbuUndoPending()) + FluxPbuRegisterUndoBuffers((uint8) flux_undo_block_id); + + recptr = XLogInsert(RM_FLUX_ID, info); + + /* Set LSN on main page */ + PageSetLSN(page, recptr); + + /* + * Set LSN on all overflow pages. Due to spatial locality, some buffers + * may appear multiple times in overflow_buffers. PageSetLSN is idempotent + * (setting the same LSN multiple times is safe), so we can just iterate + * through all entries without checking for duplicates. + */ + if (overflow_buffers != NULL) + { + for (i = 0; i < overflow_buffers->count; i++) + { + Page ovpage = BufferGetPage(overflow_buffers->buffers[i].buffer); + + PageSetLSN(ovpage, recptr); + } + } + + return recptr; +} + +/* + * Log a batched multi-tuple insert (COPY/bulk load) into one page. + * + * Unlike FluxXLogInsert, this records EVERY tuple body in the batch rather + * than relying on a forced full-page image, so the batch replays correctly + * regardless of full_page_writes. No overflow buffers are involved: tuples + * that need overflow are routed to the single-insert path by the caller. + * + * WAL main-data layout: + * [xl_flux_multi_insert header] + * ntuples * [xl_flux_multi_insert_tuple header][tuple t_data body] + * ntuples * [logical image bytes][uint32 len] (only when logically logged) + */ +XLogRecPtr +FluxXLogMultiInsert(Relation rel, Buffer buffer, + OffsetNumber *offnums, FluxTuple *tuples, + int ntuples, uint64 commit_ts, + FluxLogicalImage *logical_imgs) +{ + xl_flux_multi_insert xlrec; + + /* + * Serialize the whole per-tuple region into one scratch buffer and + * register it as a single rdata chunk. XLogRegisterData() is limited to + * XLR_NORMAL_RDATAS (20) slots, and XLogEnsureRecordSpace() cannot grow + * that inside a critical section, so registering two chunks per tuple + * would overflow the slot array once a page packs more than a handful of + * rows. Mirrors heap_multi_insert()'s single-scratch-buffer pattern; the + * on-WAL bytes are identical to per-tuple registration because redo and + * logical decoding read the region as one contiguous XLogRecGetData() + * stream. + * + * The region is bounded by BLCKSZ: every tuple consumed + * sizeof(ItemIdData) + MAXALIGN(t_len) on the page, so the sum of + * SizeOfFluxMultiInsertTuple + t_len across the batch is strictly less + * than one page. + */ + PGAlignedBlock scratch; + char *scratchptr = scratch.data; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + uint8 info = XLOG_FLUX_MULTI_INSERT; + int i; + + Assert(ntuples > 0); + Assert(ntuples <= MaxOffsetNumber); + + xlrec.relid = RelationGetRelid(rel); + xlrec.ntuples = (uint16) ntuples; + xlrec.flags = 0; + xlrec.commit_ts = commit_ts; + + for (i = 0; i < ntuples; i++) + { + xl_flux_multi_insert_tuple *tuphdr; + + /* + * SHORTALIGN each per-tuple header so its uint16 fields land on an + * even offset, mirroring heap_multi_insert(). Readers (redo and + * logical decode) advance with the identical SHORTALIGN, so the + * on-WAL stride stays byte-for-byte in lockstep. + */ + scratchptr = (char *) SHORTALIGN(scratchptr); + tuphdr = (xl_flux_multi_insert_tuple *) scratchptr; + + tuphdr->offnum = offnums[i]; + tuphdr->datalen = (uint16) tuples[i]->t_len; + scratchptr += SizeOfFluxMultiInsertTuple; + + memcpy(scratchptr, (char *) tuples[i]->t_data, tuples[i]->t_len); + scratchptr += tuples[i]->t_len; + } + Assert((scratchptr - scratch.data) < BLCKSZ); + + XLogBeginInsert(); + + /* + * Register the page WITHOUT forcing a full-page image. Crash safety + * comes from logging every tuple body above, not from an FPI. + */ + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + + XLogRegisterData((char *) &xlrec, SizeOfFluxMultiInsert); + FluxVMRegisterClear(&xlrec.flags); + XLogRegisterData(scratch.data, (uint32) (scratchptr - scratch.data)); + + /* + * Append one logical-decoding image per tuple (when logically logged). + * The caller pre-serialized all images into one contiguous blob before + * entering the critical section (logical_imgs[0].data), so a single + * registration covers the whole region and keeps the rdata slot count + * constant. + */ + if (logical_imgs != NULL && logical_imgs[0].data != NULL) + { + XLogRegisterData(logical_imgs[0].data, logical_imgs[0].len); + xlrec.flags |= FLUX_WAL_LOGICAL_TUPLE; + } + + recptr = XLogInsert(RM_FLUX_ID, info); + + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * Log a tuple update operation (in-place with before/after images) + */ +XLogRecPtr +FluxXLogUpdate(Relation rel, Buffer buffer, OffsetNumber offnum, + FluxTuple old_tuple, FluxTuple new_tuple, + uint64 old_commit_ts, uint64 new_commit_ts, + FluxOverflowBuffers *overflow_buffers, + Buffer new_buffer, + FluxLogicalImage *old_img, + FluxLogicalImage *new_img, + bool no_logical) +{ + xl_flux_update xlrec; + xl_flux_prefix_suffix ps; + + /* Registered by pointer: must stay live until XLogInsert(). */ + xl_flux_overflow_write ovf_xlrecs[MAX_OVERFLOW_BUFFERS]; + + XLogRecPtr recptr; + uint8 info = XLOG_FLUX_UPDATE_INPLACE; + int i; + int flux_undo_block_id = 1; /* next free WAL block id after main */ + bool is_cross_page = (BufferIsValid(new_buffer) && + new_buffer != buffer); + + /* Fill in the update record */ + xlrec.offnum = offnum; + xlrec.flags = no_logical ? FLUX_WAL_NO_LOGICAL : 0; + xlrec.old_commit_ts = old_commit_ts; + xlrec.new_commit_ts = new_commit_ts; + xlrec.old_tuple_len = (uint16) old_tuple->t_len; + xlrec.new_tuple_len = (uint16) new_tuple->t_len; + xlrec.dst_block_id = 0; + memset(xlrec.pad, 0, sizeof(xlrec.pad)); + + if (is_cross_page) + xlrec.flags |= FLUX_WAL_CROSS_PAGE; + + /* + * NOTE: XLogEnsureRecordSpace() has already been called by the caller + * (before entering the critical section) to pre-allocate space for the + * main buffer plus all overflow buffers. + */ + XLogBeginInsert(); + + /* + * Register the source buffer (block 0). + * + * A same-page update rewrites the tuple in place: redo overwrites the + * slot with the new tuple data (or patches the prefix/suffix diff) for a + * same-size update, and for a GROWING update redo replays the identical + * PageIndexTupleOverwrite the primary performed (see + * flux_xlog_update_inplace_redo). That primitive is keyed purely by + * (offnum, new size) and reproduces the primary page byte-for-byte under + * any recovery ordering, so growth no longer needs a forced full-page + * image. Removing that forced FPI is the dominant WAL-amplification win + * under OLTP: previously every in-place update whose tuple grew even a + * few bytes (e.g. a numeric field widening) logged a full 8 KB page. + * + * For cross-page updates the redo handler marks the old tuple UPDATED + * directly (see FLUX_WAL_CROSS_PAGE handling in flux_redo) and the new + * tuple is restored from the destination page's image. + */ + { + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + } + + /* + * Register all overflow buffers (buffers 1..N) for atomic WAL logging. + * This ensures the main tuple UPDATE and all overflow records are + * restored together during crash recovery, preventing orphaned overflow + * pages. + * + * IMPORTANT: Due to spatial locality optimization, multiple overflow + * records may reside on the same page. We must register each unique + * buffer only once, but register data for all overflow records. + */ + { + int next_block_id = 1; /* Start after main buffer (block 0) */ + + if (overflow_buffers != NULL) + { + Buffer registered_buffers[MAX_OVERFLOW_BUFFERS]; + int registered_buffer_ids[MAX_OVERFLOW_BUFFERS]; + int num_registered = 0; + + /* Ensure we don't exceed PostgreSQL's hard limit */ + if (overflow_buffers->count > XLR_MAX_BLOCK_ID) + elog(ERROR, "too many overflow records: %d (max %d)", + overflow_buffers->count, XLR_MAX_BLOCK_ID); + + for (i = 0; i < overflow_buffers->count; i++) + { + FluxOverflowBuffer *ovb = &overflow_buffers->buffers[i]; + int block_id = -1; + int j; + + /* + * First check if this overflow buffer is the SAME as the main + * buffer. This can happen when spatial locality places an + * overflow record on the same page as the main tuple. In this + * case, reuse block_id=0. + */ + if (ovb->buffer == buffer) + { + block_id = 0; + xlrec.flags |= FLUX_WAL_HAS_OVERFLOW_BLK0; + } + else + { + /* + * Check if this buffer was already registered among + * overflow buffers (spatial locality: multiple overflow + * records on same page). If so, reuse its block_id + * instead of registering again. + */ + for (j = 0; j < num_registered; j++) + { + if (registered_buffers[j] == ovb->buffer) + { + block_id = registered_buffer_ids[j]; + break; + } + } + + /* If not registered yet, register it now */ + if (block_id < 0) + { + block_id = next_block_id++; + + /* + * Force a full-page image for overflow buffers. The + * redo handler for overflow pages reconstructs items + * using PageAddItem, but the page layout can differ + * from the primary when the page already contains + * items from prior operations (e.g., free space + * fragmentation, item alignment). Using + * REGBUF_FORCE_IMAGE guarantees the page is restored + * exactly as the primary had it. + */ + XLogRegisterBuffer((uint8) block_id, ovb->buffer, + REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + + /* Track this buffer so we don't register it again */ + registered_buffers[num_registered] = ovb->buffer; + registered_buffer_ids[num_registered] = block_id; + num_registered++; + } + } + + /* + * Header first, then the record, as the insert path logs + * them: update redo parses [xl_flux_overflow_write][data] and + * places the record at the logged offnum. + */ + ovf_xlrecs[i].offnum = ovb->offset; + ovf_xlrecs[i].flags = ovb->flags; + ovf_xlrecs[i].data_len = ovb->record_len; + ovf_xlrecs[i].commit_ts = new_commit_ts; + XLogRegisterBufData((uint8) block_id, (char *) &ovf_xlrecs[i], + sizeof(xl_flux_overflow_write)); + XLogRegisterBufData((uint8) block_id, ovb->record_data, + ovb->record_len); + } + } + + /* + * For cross-page out-of-place updates, register the destination + * buffer so both pages are crash-safe. Force a full-page image so + * redo simply restores the page without needing replay logic. + */ + if (is_cross_page) + { + xlrec.dst_block_id = (uint8) next_block_id; + XLogRegisterBuffer((uint8) next_block_id, new_buffer, + REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + next_block_id++; + } + + /* Undo pages (if any) fold in after the last forward block. */ + flux_undo_block_id = next_block_id; + } + + /* Now register the main data */ + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_update)); + FluxVMRegisterClear(&xlrec.flags); + + /* + * Log only new tuple for REDO. Old tuple data is stored exclusively in + * the shared UNDO log (UNDO_RMID_FLUX record written via the per-backend + * UNDO engine) and is not needed during WAL replay: + * + * - Same-size/shrinking updates: redo overwrites the slot in place using + * only the new tuple data. - Growing updates: REGBUF_FORCE_IMAGE is set + * above, so redo restores the page from a full-page image and never + * enters BLK_NEEDS_REDO. + * + * Prefix/suffix compression: For same-size in-place updates, we compute + * the common prefix and suffix between old and new tuple data. If the + * savings exceed sizeof(xl_flux_prefix_suffix) (4 bytes), we log only the + * changed bytes plus a small header. The redo handler reconstructs the + * full new tuple from the existing page data + diff. + * + * This is only safe for same-size updates without cross-page moves. + * Growing updates use REGBUF_FORCE_IMAGE and never enter BLK_NEEDS_REDO. + */ + if (!is_cross_page && + old_tuple->t_len == new_tuple->t_len && + new_tuple->t_len > 0) + { + char *oldp = (char *) old_tuple->t_data; + char *newp = (char *) new_tuple->t_data; + int len = new_tuple->t_len; + int difflen; + + /* Compute common prefix */ + for (ps.prefixlen = 0; ps.prefixlen < len; ps.prefixlen++) + if (oldp[ps.prefixlen] != newp[ps.prefixlen]) + break; + + /* Compute common suffix (don't overlap with prefix) */ + for (ps.suffixlen = 0; + ps.suffixlen < len - ps.prefixlen; + ps.suffixlen++) + if (oldp[len - 1 - ps.suffixlen] != newp[len - 1 - ps.suffixlen]) + break; + + difflen = len - ps.prefixlen - ps.suffixlen; + + /* + * Use compression only if the savings exceed the header overhead. The + * header is 4 bytes (two uint16s), so we need the prefix + suffix to + * save more than that. + */ + if (ps.prefixlen + ps.suffixlen > (int) sizeof(xl_flux_prefix_suffix) && + difflen >= 0) + { + xlrec.flags |= FLUX_WAL_PREFIX_SUFFIX; + XLogRegisterData((char *) &ps, sizeof(xl_flux_prefix_suffix)); + if (difflen > 0) + XLogRegisterData(newp + ps.prefixlen, difflen); + } + else + { + /* Not worth compressing, log full new tuple */ + XLogRegisterData((char *) new_tuple->t_data, new_tuple->t_len); + } + } + else + { + /* Cross-page or size-changing: log full new tuple */ + XLogRegisterData((char *) new_tuple->t_data, new_tuple->t_len); + } + + /* + * Append heap-format images of old + new tuples for logical decoding. + * Order: old first, then new. Flag is set uniformly on both or neither. + * The images were prepared by the caller before the critical section. + */ + { + uint16 tmpflag = 0; + + FluxXLogRegisterLogicalImage(old_img, &tmpflag); + FluxXLogRegisterLogicalImage(new_img, &tmpflag); + xlrec.flags |= tmpflag; + } + + /* + * Fold the per-backend UNDO record onto this forward UPDATE record (undo + * pages as block references carrying the undo record's bytes; redo + * re-runs the insert). Undo blocks start after the main page, all + * overflow buffers, and the cross-page destination buffer. + */ + if (FluxPbuUndoPending()) + FluxPbuRegisterUndoBuffers((uint8) flux_undo_block_id); + + recptr = XLogInsert(RM_FLUX_ID, info); + + /* + * Every page the record changes carries its LSN, so that none reaches + * disk before the record does (as FluxXLogInsert does). + */ + PageSetLSN(BufferGetPage(buffer), recptr); + if (is_cross_page) + PageSetLSN(BufferGetPage(new_buffer), recptr); + if (overflow_buffers != NULL) + { + for (i = 0; i < overflow_buffers->count; i++) + PageSetLSN(BufferGetPage(overflow_buffers->buffers[i].buffer), + recptr); + } + + return recptr; +} + +/* + * Log a tuple delete operation + */ +XLogRecPtr +FluxXLogDelete(Relation rel, Buffer buffer, OffsetNumber offnum, + FluxTuple tuple, uint64 commit_ts, + FluxLogicalImage *logical_img) +{ + xl_flux_delete xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + uint8 info = XLOG_FLUX_DELETE; + + /* Fill in the delete record */ + xlrec.offnum = offnum; + xlrec.flags = 0; + if (((FluxTupleHeader *) PageGetItem(page, PageGetItemId(page, offnum)))->t_flags & + FLUX_TUPLE_MOVED_PARTITION) + xlrec.flags |= FLUX_WAL_MOVED_PARTITION; + xlrec.tuple_len = tuple->t_len; + xlrec.commit_ts = commit_ts; + + XLogBeginInsert(); + + /* Register buffer FIRST, before any data */ + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + + /* + * Register delete header only -- old tuple data is stored exclusively in + * the UNDO log. The redo handler only needs the offset and commit_ts to + * set FLUX_TUPLE_DELETED on the existing tuple. + */ + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_delete)); + FluxVMRegisterClear(&xlrec.flags); + + /* + * Append heap-format image of the deleted tuple for logical decoding. + * DELETE's REDO path doesn't need the old tuple image on-page (it just + * flips a flag), so this region is strictly for the decode side. The + * image was prepared by the caller before the critical section. + */ + FluxXLogRegisterLogicalImage(logical_img, &xlrec.flags); + + /* + * Fold the per-backend UNDO record onto this forward DELETE record (undo + * pages as block references carrying the undo record's bytes; redo + * re-runs the insert). DELETE registers only block 0, so undo blocks + * start at 1. + */ + if (FluxPbuUndoPending()) + FluxPbuRegisterUndoBuffers(1); + + recptr = XLogInsert(RM_FLUX_ID, info); + + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * Log page defragmentation + */ +XLogRecPtr +FluxXLogDefrag(Relation rel, Buffer buffer, FluxOffsetMapping *mappings, + int nmappings, uint64 commit_ts, + TransactionId snapshotConflictHorizon) +{ + xl_flux_defrag xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + uint8 info = XLOG_FLUX_DEFRAG; + + /* Fill in the defrag record */ + memset(&xlrec, 0, sizeof(xlrec)); + xlrec.ntuples = nmappings; + xlrec.commit_ts = commit_ts; + xlrec.snapshotConflictHorizon = snapshotConflictHorizon; + + /* + * Always false for a FLUX relation: FLUX refuses user_catalog_table and + * is never a system catalog, so RelationIsAccessibleInLogicalDecoding() + * cannot be true here. The field and the catalog branch it drives in + * ResolveRecoveryConflictWithSnapshot() at redo are therefore unreachable + * for FLUX records; they are kept so the WAL format and the conflict + * resolution stay correct for a genuine catalog relation and so the test + * tracks the one place the rule could change. + */ + xlrec.isCatalogRel = RelationIsAccessibleInLogicalDecoding(rel); + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_defrag)); + if (nmappings > 0) + XLogRegisterData((char *) mappings, + sizeof(FluxOffsetMapping) * nmappings); + + /* + * Force a full-page image. The caller may have removed dead tuples + * (ItemIdSetUnused) before compaction, and those removals are not encoded + * in the DEFRAG WAL record. Without an FPI the redo handler would call + * PageRepairFragmentation() on a page that still contains the dead + * tuples, producing a page inconsistent with the primary. + */ + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + + recptr = XLogInsert(RM_FLUX_ID, info); + + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * Log overflow record write. + * + * The caller must already hold an exclusive lock on the buffer and have + * written the overflow record data to the page. We log either a new + * overflow record (header + data) or a link update (header only). + * + * buffer: already-locked buffer containing the overflow record + * offnum: offset of the overflow record on the page + * record_data: pointer to the record data to log (header, or header+data) + * record_len: length of data to log + * flags: FLUX_OVERFLOW_WAL_NEW_RECORD or FLUX_OVERFLOW_WAL_LINK_UPDATE + * commit_ts: commit timestamp + */ +XLogRecPtr +FluxXLogOverflowWrite(Relation rel, Buffer buffer, OffsetNumber offnum, + char *record_data, uint32 record_len, uint16 flags, + uint64 commit_ts) +{ + xl_flux_overflow_write xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + uint8 info = XLOG_FLUX_OVERFLOW_WRITE; + + /* Fill in the overflow write record */ + xlrec.offnum = offnum; + xlrec.flags = flags; + xlrec.data_len = record_len; + xlrec.commit_ts = commit_ts; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_overflow_write)); + XLogRegisterData(record_data, record_len); + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + + recptr = XLogInsert(RM_FLUX_ID, info); + + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * Log attribute compression + */ +XLogRecPtr +FluxXLogCompress(Relation rel, Buffer buffer, OffsetNumber offnum, + uint16 attr_num, uint8 comp_type, + uint8 comp_level, char *comp_data, + uint32 orig_size, uint32 comp_size, uint64 commit_ts) +{ + xl_flux_compress xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + uint8 info = XLOG_FLUX_COMPRESS; + + /* Fill in the compress record */ + xlrec.offnum = offnum; + xlrec.attr_num = attr_num; + xlrec.comp_type = comp_type; + xlrec.comp_level = comp_level; + xlrec.orig_size = orig_size; + xlrec.comp_size = comp_size; + xlrec.commit_ts = commit_ts; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_compress)); + XLogRegisterData(comp_data, comp_size); + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + + recptr = XLogInsert(RM_FLUX_ID, info); + + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * Log page initialization + */ +XLogRecPtr +FluxXLogInitPage(Relation rel, Buffer buffer, uint32 flags, uint64 commit_ts) +{ + xl_flux_init_page xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + uint8 info = XLOG_FLUX_INIT_PAGE; + + /* Fill in the init page record */ + xlrec.flags = flags; + xlrec.commit_ts = commit_ts; + + XLogBeginInsert(); + + /* Register buffer FIRST, before any data */ + XLogRegisterBuffer(0, buffer, REGBUF_WILL_INIT | REGBUF_STANDARD); + + /* Now register the data */ + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_init_page)); + + recptr = XLogInsert(RM_FLUX_ID, info); + + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * FluxXLogCasUpdate -- WAL record for same-size CAS in-place update. + * + * Logs only the changed byte range within the tuple. This is the minimal + * WAL record for the tuple-level CAS fast path where the entire tuple does + * not need to be logged (same size, only data bytes changed). + * + * The caller holds BUFFER_LOCK_SHARE_EXCLUSIVE, which makes it the tuple's + * only writer. We do NOT force a full-page image because: + * (a) the modification is confined to a single tuple's data bytes, and + * (b) the redo handler is idempotent (memcpy of fixed-length data at + * a fixed offset within the tuple). + */ +XLogRecPtr +FluxXLogCasUpdate(Relation rel, Buffer buffer, OffsetNumber offnum, + uint16 data_offset, uint16 data_len, + const char *new_data, uint64 new_commit_ts) +{ + xl_flux_cas_update xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + + xlrec.offnum = offnum; + xlrec.flags = RelationIsLogicallyLogged(rel) ? FLUX_WAL_LOGICAL_REL : 0; + xlrec.data_offset = data_offset; + xlrec.data_len = data_len; + xlrec.new_commit_ts = new_commit_ts; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_cas_update)); + XLogRegisterData(new_data, data_len); + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_CAS_UPDATE); + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * FluxXLogCasUpdateUndo -- FOLD variant of FluxXLogCasUpdate. + * + * Emits ONE combined WAL record carrying both the main-fork redo byte-diff + * (block 0, identical to FluxXLogCasUpdate) and the per-backend UNDO pages + * (block 1+, via FluxPbuRegisterUndoBuffers, which also attaches the undo + * record's own bytes as their block data). The caller has already written the + * undo record into its pinned undo buffers (FluxPbuInsertUndo) and holds them + * locked; this function logs the change in one XLogInsert, stamps the data page + * LSN, and returns the record LSN so the caller can release the undo buffers + * (FluxPbuFinishUndo). A CAS update is same-size and touches no overflow + * pages, so the undo blocks always start at block 1. + */ +XLogRecPtr +FluxXLogCasUpdateUndo(Relation rel, Buffer buffer, OffsetNumber offnum, + uint16 data_offset, uint16 data_len, + const char *new_data, uint64 new_commit_ts) +{ + xl_flux_cas_update_undo xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + + /* redo half (block 0) */ + xlrec.offnum = offnum; + xlrec.flags = RelationIsLogicallyLogged(rel) ? FLUX_WAL_LOGICAL_REL : 0; + xlrec.data_offset = data_offset; + xlrec.data_len = data_len; + xlrec.new_commit_ts = new_commit_ts; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfFluxCasUpdateUndo); + XLogRegisterData(new_data, data_len); + + /* block 0: main-fork page, redo byte-diff */ + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + + /* block 1+: per-backend undo pages, carrying the undo record's bytes */ + FluxPbuRegisterUndoBuffers(1); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_CAS_UPDATE_UNDO); + + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * FluxXLogWriteDict -- WAL-log a compression-dictionary fork page. + * + * The dictionary fork uses a non-standard page layout that the redo path + * cannot rebuild from a logical delta, so we register the page as a forced + * full-page image and the redo handler restores it verbatim. The caller + * must already hold the buffer locked and have dirtied it inside the same + * critical section. + */ +XLogRecPtr +FluxXLogWriteDict(Relation rel, Buffer buffer) +{ + xl_flux_write_dict xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + + xlrec.blkno = BufferGetBlockNumber(buffer); + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_write_dict)); + XLogRegisterBuffer(0, buffer, REGBUF_FORCE_IMAGE); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_WRITE_DICT); + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * REDO function for FLUX WAL records + */ +/* ---------------------------------------------------------------- + * Per-opcode REDO handlers. + * + * flux_redo() is the thin dispatcher; the real work for each + * XLOG_FLUX_* opcode lives in a dedicated static helper below. + * ---------------------------------------------------------------- + */ +static void flux_xlog_insert_redo(XLogReaderState *record); +static void flux_xlog_multi_insert_redo(XLogReaderState *record); +static void flux_xlog_update_inplace_redo(XLogReaderState *record); +static void flux_xlog_delete_redo(XLogReaderState *record); +static void flux_xlog_defrag_redo(XLogReaderState *record); +static void flux_xlog_overflow_write_redo(XLogReaderState *record); +static void flux_xlog_compress_redo(XLogReaderState *record); +static void flux_xlog_init_page_redo(XLogReaderState *record); +static void flux_xlog_vm_set_redo(XLogReaderState *record); +static void flux_xlog_vm_clear_redo(XLogReaderState *record); +static void flux_xlog_lock_redo(XLogReaderState *record); +static void flux_xlog_supersede_redo(XLogReaderState *record); + +/* + * Replay the visibility map clear of an INSERT, UPDATE or DELETE record, see + * FluxVMClearForChange. Done before the heap page, as heap does. + */ +static void +flux_redo_vm_clear(XLogReaderState *record) +{ + BlockNumber heapBlk; + Buffer vmbuf; + + if (!XLogRecHasBlockRef(record, FLUX_VM_BLOCK_ID)) + return; + XLogRecGetBlockTag(record, 0, NULL, NULL, &heapBlk); + if (XLogReadBufferForRedoExtended(record, FLUX_VM_BLOCK_ID, + RBM_ZERO_ON_ERROR, false, + &vmbuf) == BLK_NEEDS_REDO) + { + Page vmpage = BufferGetPage(vmbuf); + + if (PageIsNew(vmpage)) + PageInit(vmpage, BLCKSZ, 0); + (void) FluxVMClearMapBits(vmpage, heapBlk); + PageSetLSN(vmpage, record->EndRecPtr); + MarkBufferDirty(vmbuf); + } + if (BufferIsValid(vmbuf)) + UnlockReleaseBuffer(vmbuf); +} + +/* + * flux_xlog_insert_redo + * REDO handler for XLOG_FLUX_INSERT. + */ +static void +flux_xlog_insert_redo(XLogReaderState *record) +{ + RelFileLocator rlocator; + BlockNumber blkno; + Buffer buffer; + Page page; + + XLogRecGetBlockTag(record, 0, &rlocator, NULL, &blkno); + + { + xl_flux_insert *xlrec = (xl_flux_insert *) XLogRecGetData(record); + char *tuple_data = (char *) xlrec + sizeof(xl_flux_insert); + FluxTupleHeader *tuple_hdr = (FluxTupleHeader *) tuple_data; + XLogRedoAction action; + + flux_redo_vm_clear(record); + action = XLogReadBufferForRedo(record, 0, &buffer); + + if (action == BLK_NEEDS_REDO) + { + FluxPageOpaque phdr; + OffsetNumber inserted_offnum; + char *ovf_data; + Size ovf_len; + + page = BufferGetPage(buffer); + + /* + * XLogInitBufferForRedo does standard PageInit for new pages, but + * doesn't set up FLUX opaque space. Initialize it here if needed. + */ + if (PageIsNew(page)) + { + FluxInitPage(page, BufferGetPageSize(buffer)); + } + + /* + * CRITICAL: During normal operation, overflow records are + * inserted BEFORE the main tuple (via FluxStoreOverflowColumn + * called from FluxFormTuple, then FluxPageAddTuple for main). + * This means overflow records get lower offsets (1, 2, 3...) and + * the main tuple gets a higher offset (4, ...). + * + * We MUST replay in the same order. If there are overflow records + * on block_id=0 (same page as main tuple due to spatial + * locality), replay them FIRST before the main tuple. + */ + ovf_data = XLogRecGetBlockData(record, 0, &ovf_len); + if (ovf_data != NULL && ovf_len > 0 && + (xlrec->flags & FLUX_WAL_HAS_OVERFLOW_BLK0)) + { + char *ovf_ptr = ovf_data; + Size ovf_remaining = ovf_len; + + /* + * Block 0 has overflow data. Parse and replay all overflow + * records on this block before the main tuple. Each overflow + * record has format: [xl_flux_overflow_write header][actual + * record data] + */ + while (ovf_remaining > sizeof(xl_flux_overflow_write)) + { + xl_flux_overflow_write *ovf_xlrec = (xl_flux_overflow_write *) ovf_ptr; + char *actual_data = ovf_ptr + sizeof(xl_flux_overflow_write); + Size actual_len = ovf_xlrec->data_len; + OffsetNumber ovf_offnum; + + if (ovf_remaining < sizeof(xl_flux_overflow_write) + actual_len) + elog(PANIC, "FLUX INSERT redo: corrupt overflow data on block 0: " + "ovf_remaining=%zu, sizeof(hdr)=%zu, data_len=%u, " + "total_len=%zu, offnum=%u, flags=%u", + ovf_remaining, sizeof(xl_flux_overflow_write), + (unsigned) actual_len, ovf_len, + (unsigned) ovf_xlrec->offnum, + (unsigned) ovf_xlrec->flags); + + /* + * Use InvalidOffsetNumber to let PageAddItem choose the + * next available offset. This ensures sequential offsets + * matching the original insertion order. + */ + ovf_offnum = PageAddItem(page, actual_data, actual_len, + InvalidOffsetNumber, false, false); + if (ovf_offnum == InvalidOffsetNumber) + elog(PANIC, "FLUX INSERT redo: failed to add overflow record on block %u", + blkno); + + /* Advance to next overflow record in the block data */ + ovf_ptr += sizeof(xl_flux_overflow_write) + actual_len; + ovf_remaining -= sizeof(xl_flux_overflow_write) + actual_len; + } + } + + /* + * A record without a tuple body of the advertised length is + * restored from its full-page image and cannot reach here. + */ + if (xlrec->tuple_len == 0 || + XLogRecGetDataLen(record) < + sizeof(xl_flux_insert) + xlrec->tuple_len) + elog(PANIC, "FLUX INSERT redo: record on block %u lacks a tuple body", + blkno); + + /* + * Now replay the main tuple at the EXACT offset the primary used + * (xlrec->offnum). FluxPageAddTuple on the primary can reuse a + * specific freed/unused line pointer (after a prior DELETE on the + * page), so letting PageAddItem pick the next sequential slot on + * redo would place the tuple at a different offset and produce a + * page that differs from the primary's -- caught by + * wal_consistency_checking as an "inconsistent page", and a + * latent standby/replica divergence. overwrite=true fills a + * reused dead/unused line pointer in place; for a plain append + * (offnum == max+1) it behaves like a normal add. Any block-0 + * overflow records added above already occupy their own (lower) + * offsets, which the primary's offnum accounts for. + */ + inserted_offnum = PageAddItem(page, tuple_hdr, xlrec->tuple_len, + xlrec->offnum, true, true); + if (inserted_offnum == InvalidOffsetNumber) + elog(PANIC, "FLUX INSERT redo: failed to add tuple"); + + /* + * Fix the tuple's t_ctid to point to itself at the correct + * location. During normal operation, this is set in + * flux_tuple_insert after we know the final TID. During redo, we + * must fix it here. + * + * Defensive: validate the ItemId is LP_NORMAL *and* has storage + * before dereferencing via PageGetItem. PageGetItem asserts + * ItemIdHasStorage (lp_len != 0), which ItemIdIsNormal does not + * imply: PageAddItem can produce an LP_NORMAL line pointer with + * lp_len == 0 for a zero-length payload, and after crash recovery + * involving the UNDO revert worker the slot could be in an + * unexpected state. Skipping the t_ctid fixup for a zero-storage + * item is safe (there is no tuple body to point at). + */ + { + ItemId itemid = PageGetItemId(page, inserted_offnum); + + if (ItemIdIsNormal(itemid) && ItemIdHasStorage(itemid)) + { + FluxTupleHeader *inserted_hdr = + (FluxTupleHeader *) PageGetItem(page, itemid); + + /* blkno was already fetched at function entry */ + ItemPointerSet(&inserted_hdr->t_ctid, blkno, inserted_offnum); + } + } + + /* + * Update page header. CRITICAL: Must replicate the exact logic + * from FluxPageAddTuple() so the page matches the Full Page + * Write. FluxPageAddTuple sets the FLUX_PAGE_DEFRAG_NEEDED flag + * based on fragmentation heuristics, so we must do the same here. + */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), xlrec->commit_ts)); + + /* + * Mark page for defragmentation if fragmented. This matches the + * logic in FluxPageAddTuple() at flux_tuple.c:513-517. + */ + if (PageGetFreeSpace(page) >= xlrec->tuple_len * 2 && + PageGetMaxOffsetNumber(page) > FirstOffsetNumber + 5) + { + FluxPageSetFlag(phdr, FLUX_PAGE_DEFRAG_NEEDED); + } + + if (xlrec->flags & FLUX_WAL_ALL_VISIBLE_CLEARED) + PageClearAllVisible(page); + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + + /* + * Process overflow buffers on separate pages (buffers 1..N). Each + * overflow buffer contains an overflow record that was registered + * with XLogRegisterBufData during WAL logging. + * + * Note: Overflow records on block_id=0 were already handled above + * before the main tuple to preserve insertion order. + */ + for (int ovf_idx = 1; ovf_idx < XLR_MAX_BLOCK_ID; ovf_idx++) + { + Buffer ovf_buffer; + Page ovf_page; + XLogRedoAction ovf_action; + + if (!XLogRecHasBlockRef(record, ovf_idx)) + break; /* No more overflow buffers */ + + /* + * Skip the per-backend UNDO pages folded onto this record. They + * are not FLUX overflow pages: replaying them as such would + * FluxInitPage over the undo page and parse undo bytes as + * overflow records, destroying the before-image rollback needs. + * FluxPbuRedoUndo() below rebuilds them from the record's undo + * region. + */ + if (FluxPbuBlockIsUndo(record, (uint8) ovf_idx)) + continue; + + ovf_action = XLogReadBufferForRedo(record, (uint8) ovf_idx, &ovf_buffer); + if (ovf_action == BLK_NEEDS_REDO) + { + char *ovf_data; + Size ovf_len; + + ovf_page = BufferGetPage(ovf_buffer); + + /* Initialize as FLUX page if new */ + if (PageIsNew(ovf_page)) + { + FluxInitPage(ovf_page, BufferGetPageSize(ovf_buffer)); + } + + /* Get the overflow record data from WAL */ + ovf_data = XLogRecGetBlockData(record, (uint8) ovf_idx, &ovf_len); + if (ovf_data != NULL && ovf_len > 0) + { + char *ovf_ptr = ovf_data; + Size ovf_remaining = ovf_len; + + /* + * Parse and replay all overflow records on this block. + * Multiple overflow records may be on the same page due + * to spatial locality optimization. + */ + while (ovf_remaining > sizeof(xl_flux_overflow_write)) + { + xl_flux_overflow_write *ovf_xlrec = (xl_flux_overflow_write *) ovf_ptr; + char *actual_data = ovf_ptr + sizeof(xl_flux_overflow_write); + Size actual_len = ovf_xlrec->data_len; + OffsetNumber ovf_offnum; + + if (ovf_remaining < sizeof(xl_flux_overflow_write) + actual_len) + elog(PANIC, "FLUX INSERT redo: corrupt overflow data on block %u", + BufferGetBlockNumber(ovf_buffer)); + + /* + * Use the specific offset from WAL record. Overflow + * pointers reference these offsets. Use the specific + * offset from WAL record. Overflow pointers reference + * these offsets. + */ + ovf_offnum = PageAddItem(ovf_page, actual_data, actual_len, + ovf_xlrec->offnum, false, false); + if (ovf_offnum == InvalidOffsetNumber) + elog(PANIC, "FLUX INSERT redo: failed to add overflow record on block %u", + BufferGetBlockNumber(ovf_buffer)); + + /* Advance to next overflow record */ + ovf_ptr += sizeof(xl_flux_overflow_write) + actual_len; + ovf_remaining -= sizeof(xl_flux_overflow_write) + actual_len; + } + } + + PageSetLSN(ovf_page, record->EndRecPtr); + MarkBufferDirty(ovf_buffer); + } + if (BufferIsValid(ovf_buffer)) + UnlockReleaseBuffer(ovf_buffer); + } + + /* + * Re-run the per-backend UNDO insert folded onto this record, + * rebuilding the undo page content from the undo region rather than + * from a forced full-page image. After this, + * PbuPerformUndoRecovery() can read the undo records back to roll + * losers back. No-op if this INSERT carried no undo (unlogged/temp + * relations). + */ + FluxPbuRedoUndo(record, 1); + } +} + +/* + * flux_xlog_multi_insert_redo + * REDO handler for XLOG_FLUX_MULTI_INSERT. + * + * Replays a page-at-a-time batch insert. The WAL main data is: + * [xl_flux_multi_insert header] + * ntuples * { [xl_flux_multi_insert_tuple header][tuple t_data body] } + * ntuples * { [logical image bytes][uint32 len] } (only if logical) + * + * Unlike single INSERT, the batch path never spills to overflow (large + * tuples route to the single-insert path), so there is no overflow replay. + */ +static void +flux_xlog_multi_insert_redo(XLogReaderState *record) +{ + RelFileLocator rlocator; + BlockNumber blkno; + Buffer buffer; + Page page; + + XLogRecGetBlockTag(record, 0, &rlocator, NULL, &blkno); + + { + xl_flux_multi_insert *xlrec = (xl_flux_multi_insert *) XLogRecGetData(record); + int ntuples = xlrec->ntuples; + char *cursor = (char *) xlrec + SizeOfFluxMultiInsert; + XLogRedoAction action; + int i; + + flux_redo_vm_clear(record); + action = XLogReadBufferForRedo(record, 0, &buffer); + + if (action == BLK_NEEDS_REDO) + { + FluxPageOpaque phdr; + Size max_body = 0; + + page = BufferGetPage(buffer); + + if (PageIsNew(page)) + FluxInitPage(page, BufferGetPageSize(buffer)); + + for (i = 0; i < ntuples; i++) + { + xl_flux_multi_insert_tuple *thdr; + char *body; + Size datalen; + OffsetNumber inserted_offnum; + + cursor = (char *) SHORTALIGN(cursor); + thdr = (xl_flux_multi_insert_tuple *) cursor; + body = cursor + SizeOfFluxMultiInsertTuple; + datalen = thdr->datalen; + + cursor = body + datalen; + + if (datalen == 0) + elog(PANIC, "FLUX MULTI_INSERT redo: zero-length tuple %d on block %u", + i, blkno); + + /* + * Use InvalidOffsetNumber so PageAddItem packs sequentially, + * matching the original batch insertion order. + */ + inserted_offnum = PageAddItem(page, body, datalen, + InvalidOffsetNumber, false, true); + if (inserted_offnum == InvalidOffsetNumber) + elog(PANIC, "FLUX MULTI_INSERT redo: failed to add tuple %d on block %u", + i, blkno); + + if (datalen > max_body) + max_body = datalen; + + /* + * Fix the tuple's t_ctid to point at itself. Guard the + * PageGetItem dereference on ItemIdHasStorage as the single + * INSERT redo path does. + */ + { + ItemId itemid = PageGetItemId(page, inserted_offnum); + + if (ItemIdIsNormal(itemid) && ItemIdHasStorage(itemid)) + { + FluxTupleHeader *inserted_hdr = + (FluxTupleHeader *) PageGetItem(page, itemid); + + ItemPointerSet(&inserted_hdr->t_ctid, blkno, inserted_offnum); + } + } + } + + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), xlrec->commit_ts)); + + /* + * Mark the page for defragmentation if fragmented, matching + * FluxPageAddTuple(). Use the largest tuple body in the batch as + * the heuristic threshold. + */ + if (max_body > 0 && + PageGetFreeSpace(page) >= max_body * 2 && + PageGetMaxOffsetNumber(page) > FirstOffsetNumber + 5) + { + FluxPageSetFlag(phdr, FLUX_PAGE_DEFRAG_NEEDED); + } + + if (xlrec->flags & FLUX_WAL_ALL_VISIBLE_CLEARED) + PageClearAllVisible(page); + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +/* + * flux_xlog_update_inplace_redo + * REDO handler for XLOG_FLUX_UPDATE_INPLACE. + */ +static void +flux_xlog_update_inplace_redo(XLogReaderState *record) +{ + Buffer buffer; + Page page; + + { + xl_flux_update *xlrec = (xl_flux_update *) XLogRecGetData(record); + XLogRedoAction action; + + /* + * WAL record layout depends on FLUX_WAL_PREFIX_SUFFIX flag: + * + * Without prefix/suffix: [xl_flux_update][full new tuple data] With + * prefix/suffix: [xl_flux_update][xl_flux_prefix_suffix][diff + * bytes] + * + * Old tuple data is in the UNDO log exclusively. + */ + char *after_header = (char *) xlrec + sizeof(xl_flux_update); + bool use_prefix_suffix = (xlrec->flags & FLUX_WAL_PREFIX_SUFFIX) != 0; + xl_flux_prefix_suffix ps_info = {0, 0}; + char *diff_data = NULL; + char *new_tuple_data = NULL; + FluxTupleHeader *new_tuple_hdr = NULL; + ItemId itemid; + FluxPageOpaque phdr; + + if (use_prefix_suffix) + { + memcpy(&ps_info, after_header, sizeof(xl_flux_prefix_suffix)); + diff_data = after_header + sizeof(xl_flux_prefix_suffix); + } + else + { + new_tuple_data = after_header; + new_tuple_hdr = (FluxTupleHeader *) new_tuple_data; + } + + flux_redo_vm_clear(record); + action = XLogReadBufferForRedo(record, 0, &buffer); + + if (action == BLK_NEEDS_REDO) + { + char *blk0_ovf_data; + Size blk0_ovf_len; + + page = BufferGetPage(buffer); + + /* + * XLogInitBufferForRedo does standard PageInit for new pages, but + * doesn't set up FLUX opaque space. Initialize it here if needed. + */ + if (PageIsNew(page)) + { + FluxInitPage(page, BufferGetPageSize(buffer)); + } + + /* + * Process overflow records on block 0 BEFORE the main tuple, + * matching the original insertion order. During normal + * operation, overflow records stored on the same page as the main + * tuple (spatial locality) get lower offsets. We must replay + * them first so the main tuple ends up at the correct offset. + */ + blk0_ovf_data = XLogRecGetBlockData(record, 0, &blk0_ovf_len); + if (blk0_ovf_data != NULL && blk0_ovf_len > 0 && + (xlrec->flags & FLUX_WAL_HAS_OVERFLOW_BLK0)) + { + char *ovf_ptr = blk0_ovf_data; + Size ovf_remaining = blk0_ovf_len; + + while (ovf_remaining > sizeof(xl_flux_overflow_write)) + { + xl_flux_overflow_write *blk0_ovf_xlrec = + (xl_flux_overflow_write *) ovf_ptr; + char *actual_data = ovf_ptr + sizeof(xl_flux_overflow_write); + Size actual_len = blk0_ovf_xlrec->data_len; + OffsetNumber ovf_offnum; + + if (ovf_remaining < sizeof(xl_flux_overflow_write) + actual_len) + elog(PANIC, "FLUX UPDATE redo: corrupt overflow data on block 0"); + + ovf_offnum = PageAddItem(page, actual_data, actual_len, + blk0_ovf_xlrec->offnum, true, false); + if (ovf_offnum == InvalidOffsetNumber) + elog(PANIC, "FLUX UPDATE redo: failed to add overflow record on block 0"); + + ovf_ptr += sizeof(xl_flux_overflow_write) + actual_len; + ovf_remaining -= sizeof(xl_flux_overflow_write) + actual_len; + } + } + + /* + * Apply the update. BLK_NEEDS_REDO is only returned when the + * page LSN < record LSN (no FPI). + * + * For cross-page out-of-place updates, the new tuple lives on the + * destination page (restored from its FPI). Here we just mark + * the old tuple as UPDATED so visibility checks filter it + * correctly. + * + * Same-page out-of-place updates always force an FPI (see + * FluxXLogUpdate), so they get BLK_RESTORED and never reach this + * code path. + */ + itemid = PageGetItemId(page, xlrec->offnum); + if (xlrec->offnum <= PageGetMaxOffsetNumber(page) && + ItemIdIsNormal(itemid) && ItemIdHasStorage(itemid)) + { + FluxTupleHeader *existing_tuple = + (FluxTupleHeader *) PageGetItem(page, itemid); + + if (xlrec->flags & FLUX_WAL_CROSS_PAGE) + { + /* + * Cross-page out-of-place update: mark the old tuple as + * UPDATED. The new version is on the destination page + * restored from its FPI. + */ + existing_tuple->t_flags |= FLUX_TUPLE_UPDATED; + existing_tuple->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; + existing_tuple->t_commit_ts = xlrec->new_commit_ts; + + /* + * The row moved to another page, so this stub is not the + * live version any more. Mark KEYS_UPDATED so a KeyShare + * probe can never take the key-intact skip against it. + * Erring towards SET only costs a wait; a clear bit here + * would be an FK-integrity hole (see + * FLUX_TUPLE_KEYS_UPDATED in flux.h). + */ + existing_tuple->t_flags |= FLUX_TUPLE_KEYS_UPDATED; + } + else + { + Size existing_len = ItemIdGetLength(itemid); + + if (use_prefix_suffix) + { + /* + * Prefix/suffix compressed update: reconstruct new + * tuple by patching the diff bytes into the existing + * tuple data on the page. + * + * The emitter computes prefix/suffix/difflen against + * new_tuple->t_len (a same-size update only), so redo + * must anchor on xlrec->new_tuple_len -- NOT the + * on-page slot length. A prior non-shrinking shrink + * update can leave the slot larger than the tuple's + * logical length (see flux_operations.c), so using + * existing_len here would over-copy past the diff + * bytes and corrupt the tuple's suffix. + */ + int difflen = (int) xlrec->new_tuple_len - + ps_info.prefixlen - ps_info.suffixlen; + + if (difflen < 0 || + ps_info.prefixlen + ps_info.suffixlen > xlrec->new_tuple_len) + elog(PANIC, "FLUX UPDATE REDO: invalid prefix/suffix " + "(prefix=%u, suffix=%u, tuple_len=%u)", + ps_info.prefixlen, ps_info.suffixlen, + xlrec->new_tuple_len); + + if (difflen > 0) + memcpy((char *) existing_tuple + ps_info.prefixlen, + diff_data, difflen); + } + else if (xlrec->new_tuple_len <= existing_len) + { + /* + * Full new tuple: overwrite in place. Do NOT shrink + * the line pointer; the write path keeps the original + * slot length so the undo before-image can be + * restored into it (see flux_operations.c). Redo + * must produce the identical slot length or WAL + * consistency checking + * (wal_consistency_checking=flux) will diverge. + */ + memcpy(existing_tuple, new_tuple_hdr, xlrec->new_tuple_len); + } + else + { + /* + * Growing in-place update: the new tuple is larger + * than the current slot. The primary grew it with + * PageIndexTupleOverwrite at this same offnum; replay + * the identical primitive here. It is keyed purely + * by (offnum, new size) -- it shifts only the tuple + * data physically preceding this tuple and fixes the + * affected line-pointer offsets -- so it reproduces + * the primary's page byte-for-byte regardless of + * where the other tuples' data sits, which is why + * growing updates no longer need a forced full-page + * image (see FluxXLogUpdate). The full new tuple is + * carried verbatim in the record (the size-changing + * branch logs it uncompressed). + */ + if (!PageIndexTupleOverwrite(page, xlrec->offnum, + (const char *) new_tuple_hdr, + xlrec->new_tuple_len)) + elog(PANIC, "FLUX UPDATE REDO: failed to overwrite-grow " + "tuple at offnum %u (new_len %u)", + xlrec->offnum, xlrec->new_tuple_len); + } + } + } + else + elog(PANIC, "FLUX UPDATE redo: invalid lp at offset %u", xlrec->offnum); + + /* Update page header */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), xlrec->new_commit_ts)); + + if (xlrec->flags & FLUX_WAL_ALL_VISIBLE_CLEARED) + PageClearAllVisible(page); + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + + /* + * Process overflow buffers on separate pages (buffers 1..N) for + * UPDATE. Multiple overflow records may share a single block due to + * spatial locality, so we loop through all records within each + * block's data (matching INSERT redo). + */ + for (int ovf_idx = 1; ovf_idx < XLR_MAX_BLOCK_ID; ovf_idx++) + { + Buffer ovf_buffer; + Page ovf_page; + XLogRedoAction ovf_action; + + if (!XLogRecHasBlockRef(record, ovf_idx)) + break; /* No more overflow buffers */ + + /* + * Skip the per-backend UNDO pages folded onto this record (see + * the same guard in flux_xlog_insert_redo). The cross-page + * destination buffer is a FLUX page and is handled by this loop + * as before. + */ + if (FluxPbuBlockIsUndo(record, (uint8) ovf_idx)) + continue; + + ovf_action = XLogReadBufferForRedo(record, (uint8) ovf_idx, &ovf_buffer); + if (ovf_action == BLK_NEEDS_REDO) + { + char *ovf_data; + Size ovf_len; + + ovf_page = BufferGetPage(ovf_buffer); + + /* Initialize as FLUX page if new */ + if (PageIsNew(ovf_page)) + { + FluxInitPage(ovf_page, BufferGetPageSize(ovf_buffer)); + } + + /* Get the overflow record data from WAL */ + ovf_data = XLogRecGetBlockData(record, (uint8) ovf_idx, &ovf_len); + if (ovf_data != NULL && ovf_len > 0) + { + char *ovf_ptr = ovf_data; + Size ovf_remaining = ovf_len; + + /* + * Parse and replay all overflow records on this block. + * Multiple overflow records may share a page due to + * spatial locality. + */ + while (ovf_remaining > sizeof(xl_flux_overflow_write)) + { + xl_flux_overflow_write *ovf_xlrec2 = + (xl_flux_overflow_write *) ovf_ptr; + char *actual_data = ovf_ptr + sizeof(xl_flux_overflow_write); + Size actual_len = ovf_xlrec2->data_len; + OffsetNumber ovf_offnum; + + if (ovf_remaining < sizeof(xl_flux_overflow_write) + actual_len) + elog(PANIC, "FLUX UPDATE redo: corrupt overflow data on block %u", + BufferGetBlockNumber(ovf_buffer)); + + /* + * Place the record at the offset the primary used, + * with overwrite=true, exactly as insert redo does. + */ + ovf_offnum = PageAddItem(ovf_page, actual_data, actual_len, + ovf_xlrec2->offnum, true, false); + if (ovf_offnum == InvalidOffsetNumber) + elog(PANIC, "FLUX UPDATE redo: failed to add overflow record on block %u", + BufferGetBlockNumber(ovf_buffer)); + + ovf_ptr += sizeof(xl_flux_overflow_write) + actual_len; + ovf_remaining -= sizeof(xl_flux_overflow_write) + actual_len; + } + } + + PageSetLSN(ovf_page, record->EndRecPtr); + MarkBufferDirty(ovf_buffer); + } + if (BufferIsValid(ovf_buffer)) + UnlockReleaseBuffer(ovf_buffer); + } + + /* + * Re-run the per-backend UNDO insert folded onto this record (see + * flux_xlog_insert_redo). This is the before-image that makes an + * in-place UPDATE reversible, so it must be rebuilt before the undo + * recovery driver walks the log. + */ + FluxPbuRedoUndo(record, 1); + } +} + +/* + * flux_xlog_delete_redo + * REDO handler for XLOG_FLUX_DELETE. + */ +static void +flux_xlog_delete_redo(XLogReaderState *record) +{ + Buffer buffer; + Page page; + + { + xl_flux_delete *xlrec = (xl_flux_delete *) XLogRecGetData(record); + XLogRedoAction action; + ItemId itemid; + FluxPageOpaque phdr; + UndoRecPtr del_urp; + + /* + * Re-run the per-backend UNDO insert folded onto this record first, + * rebuilding the DELETE's before-image so it survives the crash and + * can be reverse-applied by per-backend recovery. It yields the + * record's pointer, which the delete stamps into t_verptr. + */ + del_urp = FluxPbuRedoUndo(record, 1); + + /* + * WAL record contains only the delete header (offset + commit_ts). + * Old tuple data is stored exclusively in the UNDO log for + * transaction rollback and is not needed here. + */ + + flux_redo_vm_clear(record); + action = XLogReadBufferForRedo(record, 0, &buffer); + if (action == BLK_NEEDS_REDO) + { + page = BufferGetPage(buffer); + + /* + * XLogInitBufferForRedo does standard PageInit for new pages, but + * doesn't set up FLUX opaque space. Initialize it here if needed. + */ + if (PageIsNew(page)) + { + FluxInitPage(page, BufferGetPageSize(buffer)); + } + + /* REDO: Mark tuple as deleted */ + itemid = PageGetItemId(page, xlrec->offnum); + if (xlrec->offnum > PageGetMaxOffsetNumber(page) || + !ItemIdIsNormal(itemid) || !ItemIdHasStorage(itemid)) + elog(PANIC, "FLUX DELETE redo: invalid lp at offset %u", xlrec->offnum); + { + FluxTupleHeader *tuple = + (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * What flux_tuple_delete_internal does to the tuple, but for + * the UNCOMMITTED hint, which commit clears without WAL. + * xlrec->commit_ts carries the deleter's xid. + */ + tuple->t_flags |= FLUX_TUPLE_DELETED; + tuple->t_flags &= ~FLUX_TUPLE_XMAX_COMMITTED; + FluxTupleSetXmax(tuple, (TransactionId) xlrec->commit_ts); + tuple->t_flags &= ~(FLUX_TUPLE_MOVED_PARTITION | + FLUX_TUPLE_SUPERSEDED | FLUX_TUPLE_KEY_INTACT); + if (xlrec->flags & FLUX_WAL_MOVED_PARTITION) + tuple->t_flags |= FLUX_TUPLE_MOVED_PARTITION; + if (UndoRecPtrIsValid(del_urp)) + tuple->t_verptr = del_urp; + ItemPointerSet(&tuple->t_ctid, + BufferGetBlockNumber(buffer), xlrec->offnum); + } + + /* Update page header */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), xlrec->commit_ts)); + FluxPageSetFlag(phdr, FLUX_PAGE_DEFRAG_NEEDED); + + if (xlrec->flags & FLUX_WAL_ALL_VISIBLE_CLEARED) + PageClearAllVisible(page); + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +/* + * flux_xlog_defrag_redo + * REDO handler for XLOG_FLUX_DEFRAG. + */ +static void +flux_xlog_defrag_redo(XLogReaderState *record) +{ + Buffer buffer; + Page page; + + { + xl_flux_defrag *xlrec = (xl_flux_defrag *) XLogRecGetData(record); + XLogRedoAction action; + + FluxPageOpaque phdr; + + /* + * The tuples this record removes may still be visible to queries on a + * hot standby: resolve that before the page changes under them. + */ + if (InHotStandby && + TransactionIdIsValid(xlrec->snapshotConflictHorizon)) + { + RelFileLocator locator; + + XLogRecGetBlockTag(record, 0, &locator, NULL, NULL); + ResolveRecoveryConflictWithSnapshot(xlrec->snapshotConflictHorizon, + xlrec->isCatalogRel, + locator); + } + + /* + * Compaction moves tuples, so like heap's prune replay take a cleanup + * lock: no standby backend may hold a pin on the page meanwhile. + */ + action = XLogReadBufferForRedoExtended(record, 0, RBM_NORMAL, true, + &buffer); + if (action == BLK_NEEDS_REDO) + { + page = BufferGetPage(buffer); + + /* + * XLogInitBufferForRedo does standard PageInit for new pages, but + * doesn't set up FLUX opaque space. Initialize it here if needed. + */ + if (PageIsNew(page)) + { + FluxInitPage(page, BufferGetPageSize(buffer)); + } + + /* Defragment the page */ + PageRepairFragmentation(page); + + /* Update page header */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), xlrec->commit_ts)); + FluxPageClearFlag(phdr, FLUX_PAGE_DEFRAG_NEEDED); + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +/* + * flux_xlog_overflow_write_redo + * REDO handler for XLOG_FLUX_OVERFLOW_WRITE. + */ +static void +flux_xlog_overflow_write_redo(XLogReaderState *record) +{ + Buffer buffer; + Page page; + + { + xl_flux_overflow_write *xlrec = + (xl_flux_overflow_write *) XLogRecGetData(record); + char *record_data = (char *) xlrec + sizeof(xl_flux_overflow_write); + XLogRedoAction action; + + action = XLogReadBufferForRedo(record, 0, &buffer); + if (action == BLK_NEEDS_REDO) + { + page = BufferGetPage(buffer); + + /* Initialize as normal FLUX page if needed */ + if (PageIsNew(page)) + { + FluxInitPage(page, BufferGetPageSize(buffer)); + } + + if (xlrec->flags & FLUX_OVERFLOW_WAL_LINK_UPDATE) + { + /* + * Link update: overwrite the existing overflow record header + * at the specified offset with updated chain pointers. + */ + ItemId itemid; + + itemid = PageGetItemId(page, xlrec->offnum); + if (xlrec->offnum > PageGetMaxOffsetNumber(page) || + !ItemIdIsNormal(itemid) || !ItemIdHasStorage(itemid)) + elog(PANIC, "FLUX OVERFLOW_WRITE redo: invalid lp at offset %u", xlrec->offnum); + { + FluxOverflowRecordHeader *existing_hdr = + (FluxOverflowRecordHeader *) PageGetItem(page, itemid); + + memcpy(existing_hdr, record_data, + sizeof(FluxOverflowRecordHeader)); + } + } + else + { + /* + * New overflow record: the logged data is the complete record + * (FluxOverflowRecordHeader + chunk data). Add it to the page + * at the specified offset. + */ + OffsetNumber offnum; + + offnum = PageAddItem(page, record_data, xlrec->data_len, + xlrec->offnum, false, false); + if (offnum == InvalidOffsetNumber) + elog(ERROR, "failed to add overflow record to page during redo"); + } + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +/* + * flux_xlog_compress_redo + * REDO handler for XLOG_FLUX_COMPRESS. + */ +static void +flux_xlog_compress_redo(XLogReaderState *record) +{ + Buffer buffer; + Page page; + + { + xl_flux_compress *xlrec = (xl_flux_compress *) XLogRecGetData(record); + XLogRedoAction action; + + ItemId itemid; + FluxPageOpaque phdr; + + action = XLogReadBufferForRedo(record, 0, &buffer); + if (action == BLK_NEEDS_REDO) + { + page = BufferGetPage(buffer); + + /* + * XLogInitBufferForRedo does standard PageInit for new pages, but + * doesn't set up FLUX opaque space. Initialize it here if needed. + */ + if (PageIsNew(page)) + { + FluxInitPage(page, BufferGetPageSize(buffer)); + } + + /* Apply compression to the tuple attribute */ + itemid = PageGetItemId(page, xlrec->offnum); + if (xlrec->offnum > PageGetMaxOffsetNumber(page) || + !ItemIdIsNormal(itemid) || !ItemIdHasStorage(itemid)) + elog(PANIC, "FLUX COMPRESS redo: invalid lp at offset %u", xlrec->offnum); + { + FluxTupleHeader *tuple = + (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Mark tuple as compressed */ + tuple->t_flags |= FLUX_TUPLE_COMPRESSED; + tuple->t_infomask |= FLUX_INFOMASK_COMPRESSED; + tuple->t_commit_ts = xlrec->commit_ts; + } + + /* Update page header */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), xlrec->commit_ts)); + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +/* + * flux_xlog_init_page_redo + * REDO handler for XLOG_FLUX_INIT_PAGE. + */ +static void +flux_xlog_init_page_redo(XLogReaderState *record) +{ + Buffer buffer; + Page page; + + { + xl_flux_init_page *xlrec = (xl_flux_init_page *) XLogRecGetData(record); + XLogRedoAction action; + + action = XLogReadBufferForRedoExtended(record, 0, RBM_ZERO_AND_LOCK, false, &buffer); + if (action == BLK_NEEDS_REDO) + { + FluxPageOpaque phdr; + + page = BufferGetPage(buffer); + + /* Initialize page with FLUX opaque space */ + FluxInitPage(page, BufferGetPageSize(buffer)); + + /* Override commit_ts and flags from WAL record */ + phdr = FluxPageGetOpaque(page); + phdr->pd_commit_ts_and_flags = ((uint64) (xlrec->commit_ts) & FLUX_PAGE_TS_MASK) | (uint64) (xlrec->flags); + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +/* + * flux_xlog_vm_set_redo + * REDO handler for XLOG_FLUX_VM_SET. + */ +static void +flux_xlog_vm_set_redo(XLogReaderState *record) +{ + { + xl_flux_vm_set *xlrec = (xl_flux_vm_set *) XLogRecGetData(record); + Buffer vmBuf; + Page vmPage; + uint32 mapByte; + uint8 mapOffset; + uint8 *map; + + RelFileLocator locator; + + XLogRecGetBlockTag(record, 0, &locator, NULL, NULL); + if (InHotStandby) + ResolveRecoveryConflictWithSnapshot(xlrec->conflict_xid, false, locator); + + /* + * Block 0 is the heap buffer, registered with REGBUF_NO_CHANGE. We + * don't need to redo it since the heap page is not modified by VM + * operations. + */ + + /* Redo VM buffer (block 1) */ + if (XLogReadBufferForRedoExtended(record, 1, RBM_ZERO_ON_ERROR, false, &vmBuf) == BLK_NEEDS_REDO) + { + vmPage = BufferGetPage(vmBuf); + if (PageIsNew(vmPage)) + PageInit(vmPage, BLCKSZ, 0); + + /* Calculate the VM byte and offset for this heap block */ + mapByte = (xlrec->heapBlk % ((BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) * 4)) / 4; + mapOffset = (xlrec->heapBlk % ((BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) * 4)) % 4; + + map = (uint8 *) PageGetContents(vmPage); + map[mapByte] |= (xlrec->flags << (mapOffset * 2)); + + PageSetLSN(vmPage, record->EndRecPtr); + MarkBufferDirty(vmBuf); + } + if (BufferIsValid(vmBuf)) + UnlockReleaseBuffer(vmBuf); + } +} + +/* + * flux_xlog_vm_clear_redo + * REDO handler for XLOG_FLUX_VM_CLEAR. + */ +static void +flux_xlog_vm_clear_redo(XLogReaderState *record) +{ + { + xl_flux_vm_clear *xlrec = (xl_flux_vm_clear *) XLogRecGetData(record); + Buffer vmBuf; + Page vmPage; + uint32 mapByte; + uint8 mapOffset; + uint8 *map; + + /* + * Block 0 is the heap buffer, registered with REGBUF_NO_CHANGE -- + * skip it. + */ + + /* Redo VM buffer (block 1) */ + if (XLogReadBufferForRedo(record, 1, &vmBuf) == BLK_NEEDS_REDO) + { + vmPage = BufferGetPage(vmBuf); + + /* Calculate the VM byte and offset for this heap block */ + mapByte = (xlrec->heapBlk % ((BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) * 4)) / 4; + mapOffset = (xlrec->heapBlk % ((BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) * 4)) % 4; + + map = (uint8 *) PageGetContents(vmPage); + map[mapByte] &= ~(xlrec->flags << (mapOffset * 2)); + + PageSetLSN(vmPage, record->EndRecPtr); + MarkBufferDirty(vmBuf); + } + if (BufferIsValid(vmBuf)) + UnlockReleaseBuffer(vmBuf); + } +} + +/* + * flux_xlog_lock_redo + * REDO handler for XLOG_FLUX_LOCK. + */ +static void +flux_xlog_lock_redo(XLogReaderState *record) +{ + Buffer buffer; + Page page; + + { + xl_flux_lock *xlrec = (xl_flux_lock *) XLogRecGetData(record); + XLogRedoAction action; + + action = XLogReadBufferForRedo(record, 0, &buffer); + if (action == BLK_NEEDS_REDO) + { + ItemId itemid; + + page = BufferGetPage(buffer); + + if (PageIsNew(page)) + { + FluxInitPage(page, BufferGetPageSize(buffer)); + } + + itemid = PageGetItemId(page, xlrec->offnum); + if (xlrec->offnum > PageGetMaxOffsetNumber(page) || + !ItemIdIsNormal(itemid) || !ItemIdHasStorage(itemid)) + elog(PANIC, "FLUX LOCK redo: invalid lp at offset %u", xlrec->offnum); + { + FluxTupleHeader *tuple = + (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Apply the lock state from the WAL record */ + tuple->t_infomask = xlrec->infomask; + tuple->t_flags |= FLUX_TUPLE_LOCKED; + } + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +/* + * flux_xlog_supersede_redo + * REDO handler for XLOG_FLUX_SUPERSEDE: link a moved row's old version + * to its new TID (see flux_link_superseded). + */ +static void +flux_xlog_supersede_redo(XLogReaderState *record) +{ + xl_flux_supersede *xlrec = (xl_flux_supersede *) XLogRecGetData(record); + Buffer buffer; + + if (XLogReadBufferForRedo(record, 0, &buffer) == BLK_NEEDS_REDO) + { + Page page = BufferGetPage(buffer); + ItemId itemid = PageGetItemId(page, xlrec->offnum); + + if (xlrec->offnum > PageGetMaxOffsetNumber(page) || + !ItemIdIsNormal(itemid) || !ItemIdHasStorage(itemid)) + elog(PANIC, "FLUX SUPERSEDE redo: invalid lp at offset %u", xlrec->offnum); + { + FluxTupleHeader *tuple = (FluxTupleHeader *) PageGetItem(page, itemid); + + tuple->t_flags |= FLUX_TUPLE_SUPERSEDED; + if (xlrec->flags & FLUX_WAL_KEY_INTACT) + tuple->t_flags |= FLUX_TUPLE_KEY_INTACT; + tuple->t_ctid = xlrec->new_tid; + } + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); +} + +/* + * flux_xlog_cas_update_redo + * REDO handler for XLOG_FLUX_CAS_UPDATE. + * + * Patches a contiguous byte range within a tuple on the page. The record + * carries only the changed bytes (data_offset..data_offset+data_len) and + * the new commit timestamp. Idempotent memcpy; safe for replay. + */ +static void +flux_xlog_cas_update_redo(XLogReaderState *record) +{ + xl_flux_cas_update *xlrec = (xl_flux_cas_update *) XLogRecGetData(record); + char *new_data = ((char *) xlrec) + sizeof(xl_flux_cas_update); + Buffer buffer; + + if (XLogReadBufferForRedo(record, 0, &buffer) == BLK_NEEDS_REDO) + { + Page page = BufferGetPage(buffer); + ItemId itemid; + FluxTupleHeader *tuple; + + itemid = PageGetItemId(page, xlrec->offnum); + if (!ItemIdIsNormal(itemid) || !ItemIdHasStorage(itemid)) + elog(PANIC, "FLUX CAS_UPDATE redo: invalid item at offset %u", + xlrec->offnum); + + tuple = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Patch the changed data bytes */ + memcpy(((char *) tuple) + xlrec->data_offset, new_data, xlrec->data_len); + + /* Update commit timestamp */ + tuple->t_commit_ts = xlrec->new_commit_ts; + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); +} + +/* + * flux_xlog_cas_update_undo_redo + * REDO handler for XLOG_FLUX_CAS_UPDATE_UNDO (FOLD variant). + * + * Replays from one record: + * block 0 - main-fork page: patch the tuple data bytes, set commit ts + * (identical to flux_xlog_cas_update_redo). + * block 1+ - per-backend UNDO page(s): the undo insert is re-run from the + * record's undo region (FluxPbuRedoUndo) so the folded undo + * survives the crash and can be reverse-applied by per-backend + * recovery. + * + * Both are idempotent; safe for replay. + */ +static void +flux_xlog_cas_update_undo_redo(XLogReaderState *record) +{ + XLogRecPtr lsn = record->EndRecPtr; + xl_flux_cas_update_undo *xlrec = + (xl_flux_cas_update_undo *) XLogRecGetData(record); + char *new_data = ((char *) xlrec) + SizeOfFluxCasUpdateUndo; + Buffer buffer; + + /* ---- block 0: main-fork redo byte-diff ---- */ + if (XLogReadBufferForRedo(record, 0, &buffer) == BLK_NEEDS_REDO) + { + Page page = BufferGetPage(buffer); + ItemId itemid; + FluxTupleHeader *tuple; + + itemid = PageGetItemId(page, xlrec->offnum); + if (!ItemIdIsNormal(itemid) || !ItemIdHasStorage(itemid)) + elog(PANIC, "FLUX CAS_UPDATE_UNDO redo: invalid item at offset %u", + xlrec->offnum); + + tuple = (FluxTupleHeader *) PageGetItem(page, itemid); + + memcpy(((char *) tuple) + xlrec->data_offset, new_data, xlrec->data_len); + tuple->t_commit_ts = xlrec->new_commit_ts; + + PageSetLSN(page, lsn); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + + /* ---- block 1+: per-backend UNDO, re-run the undo insert ---- */ + FluxPbuRedoUndo(record, 1); +} + +/* + * flux_xlog_write_dict_redo + * REDO handler for XLOG_FLUX_WRITE_DICT. + * + * The record always carries a forced full-page image of the dictionary-fork + * block, so XLogReadBufferForRedo restores the page directly and there is no + * delta to replay. We never reach BLK_NEEDS_REDO without the FPI present; + * the handler simply restores and releases the buffer. + */ +static void +flux_xlog_write_dict_redo(XLogReaderState *record) +{ + Buffer buffer; + + /* + * With REGBUF_FORCE_IMAGE the page is reconstructed from the FPI by + * XLogReadBufferForRedo, which returns BLK_RESTORED. BLK_NEEDS_REDO is + * not expected, but if it ever occurs there is nothing to apply. + */ + (void) XLogReadBufferForRedo(record, 0, &buffer); + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); +} + +static void +flux_xlog_freeze_redo(XLogReaderState *record) +{ + xl_flux_freeze_page *xlrec = (xl_flux_freeze_page *) XLogRecGetData(record); + Buffer buffer; + RelFileLocator locator; + + XLogRecGetBlockTag(record, 0, &locator, NULL, NULL); + if (InHotStandby) + ResolveRecoveryConflictWithSnapshot(xlrec->conflict_xid, + xlrec->is_catalog_rel, locator); + if (XLogReadBufferForRedo(record, 0, &buffer) == BLK_NEEDS_REDO) + { + Size len; + xl_flux_freeze_tuple *plans = + (xl_flux_freeze_tuple *) XLogRecGetBlockData(record, 0, &len); + + if (len != xlrec->nplans * sizeof(*plans)) + elog(PANIC, "invalid FLUX freeze plan length"); + FluxExecuteFreeze(buffer, plans, xlrec->nplans); + PageSetLSN(BufferGetPage(buffer), record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); +} + +/* + * flux_redo + * Thin dispatcher for all XLOG_FLUX_* opcodes. Each case is + * delegated to a dedicated per-opcode static helper above. + */ +void +flux_redo(XLogReaderState *record) +{ + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + + FluxPbuResolveRecoveryConflict(record); + + switch (info) + { + case XLOG_FLUX_FREEZE_PAGE: + flux_xlog_freeze_redo(record); + break; + case XLOG_FLUX_INSERT: + flux_xlog_insert_redo(record); + break; + + case XLOG_FLUX_UPDATE_INPLACE: + flux_xlog_update_inplace_redo(record); + break; + + case XLOG_FLUX_DELETE: + flux_xlog_delete_redo(record); + break; + + case XLOG_FLUX_DEFRAG: + flux_xlog_defrag_redo(record); + break; + + case XLOG_FLUX_OVERFLOW_WRITE: + flux_xlog_overflow_write_redo(record); + break; + + case XLOG_FLUX_COMPRESS: + flux_xlog_compress_redo(record); + break; + + case XLOG_FLUX_INIT_PAGE: + flux_xlog_init_page_redo(record); + break; + + + case XLOG_FLUX_VM_SET: + flux_xlog_vm_set_redo(record); + break; + + case XLOG_FLUX_VM_CLEAR: + flux_xlog_vm_clear_redo(record); + break; + + case XLOG_FLUX_LOCK: + flux_xlog_lock_redo(record); + break; + + case XLOG_FLUX_CAS_UPDATE: + flux_xlog_cas_update_redo(record); + break; + + case XLOG_FLUX_CAS_UPDATE_UNDO: + flux_xlog_cas_update_undo_redo(record); + break; + + case XLOG_FLUX_WRITE_DICT: + flux_xlog_write_dict_redo(record); + break; + + case XLOG_FLUX_MULTI_INSERT: + flux_xlog_multi_insert_redo(record); + break; + + case XLOG_FLUX_SUPERSEDE: + flux_xlog_supersede_redo(record); + break; + + default: + elog(PANIC, "flux_redo: unknown op code %u", info); + } +} + + +/* Execute the exact same prepared changes on primary and standby. */ +void +FluxExecuteFreeze(Buffer buffer, xl_flux_freeze_tuple *plans, int nplans) +{ + Page page = BufferGetPage(buffer); + + for (int i = 0; i < nplans; i++) + { + ItemId item = PageGetItemId(page, plans[i].offset); + FluxTupleHeader *tuple = (FluxTupleHeader *) PageGetItem(page, item); + + Assert(ItemIdIsNormal(item)); + if (plans[i].actions & FLUX_FREEZE_XMIN) + { + FluxTupleSetXmin(tuple, FrozenTransactionId); + tuple->t_flags |= FLUX_TUPLE_XMIN_COMMITTED; + } + if (plans[i].actions & FLUX_FREEZE_XMAX) + { + FluxTupleSetXmax(tuple, InvalidTransactionId); + tuple->t_flags &= ~(FLUX_TUPLE_DELETED | FLUX_TUPLE_UPDATED | + FLUX_TUPLE_XMAX_COMMITTED | + FLUX_TUPLE_MOVED_PARTITION | + FLUX_TUPLE_SUPERSEDED | FLUX_TUPLE_KEY_INTACT); + } + if (plans[i].actions & FLUX_FREEZE_DETACH) + { + tuple->t_verptr = InvalidUndoRecPtr; + tuple->t_flags &= ~(FLUX_TUPLE_HAS_VERSION_PTR | FLUX_TUPLE_UNCOMMITTED); + } + } +} + +XLogRecPtr +FluxXLogFreeze(Relation rel, Buffer buffer, xl_flux_freeze_tuple *plans, + int nplans, TransactionId conflict_xid) +{ + xl_flux_freeze_page xlrec = {0}; + + xlrec.conflict_xid = conflict_xid; + xlrec.nplans = nplans; + + /* + * Always false for a FLUX relation: FLUX refuses user_catalog_table and + * is never a system catalog, so RelationIsAccessibleInLogicalDecoding() + * cannot be true here. The field and the catalog branch it drives in + * ResolveRecoveryConflictWithSnapshot() at redo are therefore unreachable + * for FLUX records; they are kept so the WAL format and the conflict + * resolution stay correct for a genuine catalog relation and so the test + * tracks the one place the rule could change. + */ + xlrec.is_catalog_rel = RelationIsAccessibleInLogicalDecoding(rel); + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xlrec)); + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + XLogRegisterBufData(0, (char *) plans, nplans * sizeof(*plans)); + return XLogInsert(RM_FLUX_ID, XLOG_FLUX_FREEZE_PAGE); +} + +/* + * Mask function for FLUX pages (for consistency checking) + */ +void +flux_mask(char *page, BlockNumber blkno) +{ + Page flux_page = (Page) page; + FluxPageOpaque phdr; + bool is_overflow; + OffsetNumber offnum; + OffsetNumber maxoff; + + mask_page_lsn_and_checksum(flux_page); + + mask_page_hint_bits(flux_page); + mask_unused_space(flux_page); + + /* + * Dictionary-fork pages (XLOG_FLUX_WRITE_DICT full-page images) have no + * opaque special area and no line-pointer array; they are initialized + * with PageInit(page, BLCKSZ, 0). FluxPageGetOpaque would read 8 bytes + * past the page end and the line-pointer loop below would mis-mask dict + * payload, producing spurious "inconsistent pages" failures under + * wal_consistency_checking. Detect them by their zero special size and + * leave the (already LSN/checksum/hint-masked) page untouched. + */ + if (PageGetSpecialSize(flux_page) != MAXALIGN(sizeof(FluxPageOpaqueData))) + return; + + phdr = FluxPageGetOpaque(flux_page); + + /* Check page type before masking flags */ + is_overflow = (phdr->pd_commit_ts_and_flags & FLUX_PAGE_OVERFLOW) != 0; + + /* + * Mask the entire packed commit_ts_and_flags field. + * + * The timestamp uses Max(existing, new) during redo which can produce a + * different value if the page was concurrently modified. Heuristic flags + * (e.g., FLUX_PAGE_DEFRAG_NEEDED) may be set by redo but not by the + * original operation, or vice versa. + */ + phdr->pd_commit_ts_and_flags = 0; + + /* + * Ignore the alignment padding after an item whose length is not + * MAXALIGNed, as heap_mask does. PageAddItem copies only the item's + * length, so the padding keeps whatever the page's free space held: on + * the primary, bytes of tuples that an earlier compaction moved away; on + * a standby that restored the page from a full-page image, the zeroed + * hole. This applies to overflow records as much as to tuples. + */ + maxoff = PageGetMaxOffsetNumber(flux_page); + for (offnum = FirstOffsetNumber; offnum <= maxoff; offnum++) + { + ItemId itemid = PageGetItemId(flux_page, offnum); + + if (ItemIdHasStorage(itemid)) + { + int len = ItemIdGetLength(itemid); + int padlen = MAXALIGN(len) - len; + + if (padlen > 0) + memset((char *) PageGetItem(flux_page, itemid) + len, + MASK_MARKER, padlen); + } + } + + /* + * Overflow pages contain FluxOverflowRecordHeader items, not regular + * tuples. Their contents are fully determined by the WAL data, so no + * per-item masking is needed. + */ + if (is_overflow) + return; + + /* + * Mask tuple-level fields that function as hint bits and are not + * faithfully reproduced by WAL redo. The redo handlers only set the + * minimal fields needed for correctness (t_flags, t_commit_ts); + * transactional fields like infomask bits are set on the primary but not + * replayed. + */ + maxoff = PageGetMaxOffsetNumber(flux_page); + for (offnum = FirstOffsetNumber; offnum <= maxoff; offnum++) + { + ItemId itemid = PageGetItemId(flux_page, offnum); + FluxTupleHeader *tuple_hdr; + + if (!ItemIdIsNormal(itemid) || !ItemIdHasStorage(itemid)) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(flux_page, itemid); + tuple_hdr->t_infomask = 0; + + /* + * xmin and verptr are deliberately NOT masked: freezing is WAL state. + * Nor are FLUX_TUPLE_MOVED_PARTITION and FLUX_TUPLE_SUPERSEDED, which + * only WAL-logged changes set or clear, nor the forward TID that + * XLOG_FLUX_SUPERSEDE stores in a superseded version's t_ctid. + */ + tuple_hdr->t_flags &= FLUX_TUPLE_MOVED_PARTITION | FLUX_TUPLE_SUPERSEDED | + FLUX_TUPLE_KEY_INTACT; + tuple_hdr->t_commit_ts = 0; + if (!(tuple_hdr->t_flags & FLUX_TUPLE_SUPERSEDED)) + ItemPointerSetInvalid(&tuple_hdr->t_ctid); + } +} diff --git a/src/backend/access/flux/meson.build b/src/backend/access/flux/meson.build new file mode 100644 index 0000000000000..adf67952c3654 --- /dev/null +++ b/src/backend/access/flux/meson.build @@ -0,0 +1,17 @@ +# Copyright (c) 2022-2025, PostgreSQL Global Development Group + +backend_sources += files( + 'flux_fsm.c', + 'flux_handler.c', + 'flux_operations.c', + 'flux_tuple.c', + 'flux_mvcc.c', + 'flux_stats.c', + 'flux_xlog.c', + 'flux_lock.c', + 'flux_slot.c', + 'flux_vm.c', + 'flux_undo.c', + 'flux_pbu.c', + 'flux_pvs.c', +) \ No newline at end of file diff --git a/src/backend/access/index/indexam.c b/src/backend/access/index/indexam.c index 24052bc1cfa31..11210dd2bcab6 100644 --- a/src/backend/access/index/indexam.c +++ b/src/backend/access/index/indexam.c @@ -298,7 +298,24 @@ index_undo_mark(Relation indexRelation, return indexRelation->rd_indam->amundomark(indexRelation, heapRelation, values, isnull, heap_t_ctid, - clear_mark); + clear_mark, false); +} + +/* + * Probe without changing a tombstone, before recording the intent to revive. + * Forward path only: a found tombstone is checked for a serializable + * rw-conflict, as an insert of the entry would be, and that may ERROR. + */ +bool +index_probe_delete_mark(Relation indexRelation, Datum *values, bool *isnull, + ItemPointer tid, Relation heapRelation) +{ + RELATION_CHECKS; + if (indexRelation->rd_indam->amundomark == NULL || + RelationUndoEngine(heapRelation) == UNDO_ENGINE_NONE) + return false; + return indexRelation->rd_indam->amundomark(indexRelation, heapRelation, + values, isnull, tid, true, true); } /* ------------------------- diff --git a/src/backend/access/meson.build b/src/backend/access/meson.build index d569ac4e6e32a..ad3c1084395e3 100644 --- a/src/backend/access/meson.build +++ b/src/backend/access/meson.build @@ -7,6 +7,7 @@ subdir('gist') subdir('hash') subdir('heap') subdir('index') +subdir('flux') subdir('nbtree') subdir('rmgrdesc') subdir('sequence') diff --git a/src/backend/access/nbtree/nbtdedup.c b/src/backend/access/nbtree/nbtdedup.c index f1b4aa045c006..40c92c9a2b3af 100644 --- a/src/backend/access/nbtree/nbtdedup.c +++ b/src/backend/access/nbtree/nbtdedup.c @@ -158,10 +158,10 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz, * Tuple is equal to base tuple of pending posting list. Heap * TID(s) for itup have been saved in state. * - * A delete-marked tombstone is never merged in either - * direction: it carries alt-TID metadata in t_tid instead of a - * heap TID, and its key no longer describes the live version. The - * guards above force it through the "not equal" path, where + * A delete-marked tombstone is never merged in either direction: + * it carries alt-TID metadata in t_tid instead of a heap TID, and + * its key no longer describes the live version. The guards above + * force it through the "not equal" path, where * _bt_dedup_finish_pending() emits it verbatim as a single-item * pending list (nitems stays 1, so the bogus t_tid is never * used). diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c index 5aa1597927fc4..889a0c2fd1640 100644 --- a/src/backend/access/nbtree/nbtinsert.c +++ b/src/backend/access/nbtree/nbtinsert.c @@ -524,13 +524,13 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, Assert(!BTreeTupleIsPivot(curitup)); /* - * A delete-marked entry is a tombstone: an - * in-place UPDATE moved the row at its TID off this key. - * The key is still taken while that UPDATE is running - * (wait for it) or after it aborted (its rollback may not - * have been applied yet), and free once it committed. - * Only the table AM knows which: table_index_fetch_tid() - * below. The tombstone is removed by VACUUM + * A delete-marked entry is a tombstone: an in-place + * UPDATE moved the row at its TID off this key. The key + * is still taken while that UPDATE is running (wait for + * it) or after it aborted (its rollback may not have been + * applied yet), and free once it committed. Only the + * table AM knows which: table_index_fetch_tid() below. + * The tombstone is removed by VACUUM * (table_index_tombstone_removable), never here. An AM * that cannot judge it has the tombstone skipped, the * live (k_new, T) entry being the authority. @@ -2948,8 +2948,8 @@ _bt_delete_or_dedup_one_page(Relation rel, Relation heapRel, /* * Perform deduplication pass (when enabled and index-is-allequalimage). * - * Deduplication is disabled for a delete-marking table's index. - * A delete-marked tombstone is an alt-TID leaf tuple whose t_tid holds + * Deduplication is disabled for a delete-marking table's index. A + * delete-marked tombstone is an alt-TID leaf tuple whose t_tid holds * status metadata (not a heap TID) and which must linger until VACUUM; * folding equal-key entries into posting lists around such tombstones * (and around the revive/re-insert an in-place key UPDATE produces) @@ -3027,9 +3027,9 @@ _bt_simpledel_pass(Relation rel, Buffer buffer, Relation heapRel, void *match; /* - * A delete-marked tombstone is never LP_DEAD and its t_tid - * holds alt-TID metadata rather than a heap TID; skip it so we never - * feed a bogus TID into the table AM's index-deletion call. + * A delete-marked tombstone is never LP_DEAD and its t_tid holds + * alt-TID metadata rather than a heap TID; skip it so we never feed a + * bogus TID into the table AM's index-deletion call. */ if (BTreeTupleIsDeleteMarked(itup)) { diff --git a/src/backend/access/nbtree/nbtreadpage.c b/src/backend/access/nbtree/nbtreadpage.c index dbc2f37b167e1..c1892a2a72b1e 100644 --- a/src/backend/access/nbtree/nbtreadpage.c +++ b/src/backend/access/nbtree/nbtreadpage.c @@ -1037,12 +1037,11 @@ _bt_saveitem(BTScanOpaque so, int itemIndex, Assert(!BTreeTupleIsPivot(itup) && !BTreeTupleIsPosting(itup)); /* - * A delete-marked leaf tuple stores its heap TID in a trailer, - * not in t_tid (which holds alt-TID metadata). Use - * BTreeTupleGetHeapTID() to fetch the real TID, and flag the item so the - * caller rechecks the qual against the visible heap version: the - * delete-marked entry's key may describe an older version of the - * row. + * A delete-marked leaf tuple stores its heap TID in a trailer, not in + * t_tid (which holds alt-TID metadata). Use BTreeTupleGetHeapTID() to + * fetch the real TID, and flag the item so the caller rechecks the qual + * against the visible heap version: the delete-marked entry's key may + * describe an older version of the row. */ if (BTreeTupleIsDeleteMarked(itup)) { diff --git a/src/backend/access/nbtree/nbtree.c b/src/backend/access/nbtree/nbtree.c index 4eef07dcdad62..cb1c8bf8cc7c0 100644 --- a/src/backend/access/nbtree/nbtree.c +++ b/src/backend/access/nbtree/nbtree.c @@ -34,6 +34,7 @@ #include "storage/ipc.h" #include "storage/lmgr.h" #include "storage/lwlock.h" +#include "storage/predicate.h" #include "storage/read_stream.h" #include "utils/datum.h" #include "utils/fmgrprotos.h" @@ -1592,14 +1593,14 @@ btvacuumpage(BTVacState *vstate, Buffer buf) if (BTreeTupleIsDeleteMarked(itup)) { /* - * Delete-marked tombstone: its heap TID lives - * in the trailer, not in t_tid. Ask the callback about - * that TID. The callback (driven by undo-informed index + * Delete-marked tombstone: its heap TID lives in the + * trailer, not in t_tid. Ask the callback about that + * TID. The callback (driven by undo-informed index * pruning) is the sole authority on removability: we * remove the tombstone only when it says so -- i.e. when - * no snapshot can still need the before-image at this TID. - * We never remove a tombstone merely - * because it is marked. + * no snapshot can still need the before-image at this + * TID. We never remove a tombstone merely because it is + * marked. */ ItemPointer htid = BTreeTupleGetHeapTID(itup); TransactionId conflict_xid = InvalidTransactionId; @@ -2146,13 +2147,17 @@ btdeletemark(Relation rel, Relation heapRel, * reports as "item order invariant violated". VACUUM cannot be relied on to * clean up first, and LP_DEAD is only a hint, so it can also be lost. * + * probe == true: only report whether the exact entry is delete-marked. The + * caller is about to revive it in a forward in-place UPDATE, so a found + * tombstone is checked for a serializable rw-conflict here, as an insert is. + * * Returns true if the entry was found and reversed, false otherwise (already * gone -- not an error). */ bool btundomark(Relation rel, Relation heapRel, Datum *values, bool *isnull, ItemPointer heap_t_ctid, - bool clear_mark) + bool clear_mark, bool probe) { IndexTuple searchitup; BTScanInsert itup_key; @@ -2202,7 +2207,22 @@ btundomark(Relation rel, Relation heapRel, itemid = PageGetItemId(page, offnum); olditup = (IndexTuple) PageGetItem(page, itemid); - if (clear_mark) + if (probe) + { + done = BTreeTupleIsDeleteMarked(olditup); + + /* + * The caller revives this tombstone instead of inserting a new + * entry, so report the rw-conflict an insert would report to + * serializable readers that locked this leaf page (see + * _bt_doinsert). The probe runs only in the forward path, before + * anything is changed, so an error here is harmless; the revive + * itself must not check, as a rollback reuses it. + */ + if (done) + CheckForSerializableConflictIn(rel, NULL, BufferGetBlockNumber(buf)); + } + else if (clear_mark) { /* Demote the OLD key's tombstone back to a plain live entry. */ if (BTreeTupleIsDeleteMarked(olditup)) diff --git a/src/backend/access/nbtree/nbtutils.c b/src/backend/access/nbtree/nbtutils.c index 728f09feba9a1..d53a115597388 100644 --- a/src/backend/access/nbtree/nbtutils.c +++ b/src/backend/access/nbtree/nbtutils.c @@ -339,10 +339,10 @@ _bt_killitems(IndexScanDesc scan) /* * A delete-marked tombstone is never LP_DEAD-killed via * kill_prior_tuple: it must linger for old-snapshot readers - * until VACUUM decides it is removable. Also, its t_tid holds - * alt-TID metadata rather than the heap TID, so comparing it - * against kitem->heapTid would be meaningless. Leave killtuple - * false. + * until VACUUM decides it is removable. Also, its t_tid + * holds alt-TID metadata rather than the heap TID, so + * comparing it against kitem->heapTid would be meaningless. + * Leave killtuple false. */ } else if (ItemPointerEquals(&ituple->t_tid, &kitem->heapTid)) @@ -720,15 +720,14 @@ _bt_truncate(Relation rel, IndexTuple lastleft, IndexTuple firstright, Assert(!BTreeTupleIsPivot(lastleft) && !BTreeTupleIsPivot(firstright)); /* - * A delete-marked leaf tuple carries alt-TID status metadata - * in t_tid and its heap TID in a trailer. index_truncate_tuple() would - * copy that alt-TID metadata / oversized trailer into the new pivot, - * producing a malformed separator whose heap-TID tiebreaker is wrong (a - * later scantid descent then routes to the wrong leaf -- the delete-mark - * revive miss that leaves a duplicate (key,TID)). Demote firstright to a - * plain leaf tuple (same key attrs, heap TID back in t_tid, no trailer, - * no status bit) for forming the pivot; the pivot only needs key + heap - * TID. + * A delete-marked leaf tuple carries alt-TID status metadata in t_tid and + * its heap TID in a trailer. index_truncate_tuple() would copy that + * alt-TID metadata / oversized trailer into the new pivot, producing a + * malformed separator whose heap-TID tiebreaker is wrong (a later scantid + * descent then routes to the wrong leaf -- the delete-mark revive miss + * that leaves a duplicate (key,TID)). Demote firstright to a plain leaf + * tuple (same key attrs, heap TID back in t_tid, no trailer, no status + * bit) for forming the pivot; the pivot only needs key + heap TID. */ if (BTreeTupleIsDeleteMarked(firstright)) { diff --git a/src/backend/access/rmgrdesc/Makefile b/src/backend/access/rmgrdesc/Makefile index 3394ae8f765e0..7cd57928336be 100644 --- a/src/backend/access/rmgrdesc/Makefile +++ b/src/backend/access/rmgrdesc/Makefile @@ -15,6 +15,7 @@ OBJS = \ committsdesc.o \ dbasedesc.o \ fileopsdesc.o \ + fluxdesc.o \ genericdesc.o \ gindesc.o \ gistdesc.o \ diff --git a/src/backend/access/rmgrdesc/fluxdesc.c b/src/backend/access/rmgrdesc/fluxdesc.c new file mode 100644 index 0000000000000..110d45b551865 --- /dev/null +++ b/src/backend/access/rmgrdesc/fluxdesc.c @@ -0,0 +1,534 @@ +/*------------------------------------------------------------------------- + * + * fluxdesc.c + * Resource manager descriptor for FLUX - frontend version + * + * This provides minimal desc/identify functions for frontend tools like pg_waldump. + * The full implementations are in flux_xlog.c for backend use. + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/rmgrdesc/fluxdesc.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/xlog.h" +#include "access/xlog_internal.h" +#include "access/xlogreader.h" + +/* Function prototypes */ +extern void flux_desc(StringInfo buf, XLogReaderState *record); +extern const char *flux_identify(uint8 info); + +/* FLUX WAL record types - keep in sync with flux_xlog.h */ +#define XLOG_FLUX_INSERT 0x00 +#define XLOG_FLUX_UPDATE 0x10 +#define XLOG_FLUX_DELETE 0x20 +#define XLOG_FLUX_VACUUM 0x30 +#define XLOG_FLUX_OVERFLOW_WRITE 0x40 +#define XLOG_FLUX_COMPRESS 0x50 +#define XLOG_FLUX_FREEZE_PAGE 0x70 +#define XLOG_FLUX_INIT_PAGE 0x60 +#define XLOG_FLUX_VM_SET 0x80 +#define XLOG_FLUX_VM_CLEAR 0x90 +#define XLOG_FLUX_LOCK 0xA0 +#define XLOG_FLUX_CAS_UPDATE 0xB0 +#define XLOG_FLUX_WRITE_DICT 0xC0 +#define XLOG_FLUX_MULTI_INSERT 0xD0 +#define XLOG_FLUX_CAS_UPDATE_UNDO 0xE0 +#define XLOG_FLUX_SUPERSEDE 0xF0 +#define XLOG_FLUX_OPMASK 0xF0 + +/* WAL record flags - keep in sync with flux_xlog.h */ +#define FLUX_WAL_CROSS_PAGE 0x0002 + +/* + * Frontend-safe copies of WAL record structures from flux_xlog.h. + * Duplicated here because fluxdesc.c is compiled with FRONTEND defined and + * we need to parse these records in pg_waldump without pulling in backend + * headers. + */ +typedef struct xl_flux_freeze_page +{ + TransactionId conflict_xid; + uint16 nplans; + bool is_catalog_rel; +} xl_flux_freeze_page; + +typedef struct xl_flux_insert_fe +{ + uint16 offnum; + uint16 flags; + uint64 commit_ts; + uint64 xact_ts; +} xl_flux_insert_fe; + +typedef struct xl_flux_delete_fe +{ + uint16 offnum; + uint16 flags; + uint64 commit_ts; + uint64 xact_ts; +} xl_flux_delete_fe; + +typedef struct xl_flux_update_fe +{ + uint16 offnum; + uint16 flags; + uint64 old_commit_ts; + uint64 new_commit_ts; + uint64 xact_ts; +} xl_flux_update_fe; + +/* Mirrors backend xl_flux_cas_update_undo (flux_xlog.h) */ +typedef struct xl_flux_cas_update_undo_fe +{ + uint16 offnum; + uint16 flags; + uint16 data_offset; + uint16 data_len; + uint64 new_commit_ts; + /* char redo_data[data_len] follows (block 0 byte-diff) */ + /* per-backend undo pages ride as full-page images at block 1+ */ +} xl_flux_cas_update_undo_fe; + +typedef struct xl_flux_vacuum_fe +{ + uint16 ntuples; + uint64 commit_ts; + TransactionId snapshotConflictHorizon; + bool isCatalogRel; +} xl_flux_vacuum_fe; + +typedef struct xl_flux_compress_fe +{ + uint16 offnum; + uint16 attr_num; + uint8 comp_type; + uint8 comp_level; + uint32 orig_size; + uint32 comp_size; + uint64 commit_ts; +} xl_flux_compress_fe; + +typedef struct xl_flux_overflow_write_fe +{ + uint16 offnum; + uint16 flags; + uint32 data_len; + uint64 commit_ts; +} xl_flux_overflow_write_fe; + +typedef struct xl_flux_init_page_fe +{ + uint32 flags; + uint64 commit_ts; +} xl_flux_init_page_fe; + +typedef struct xl_flux_vm_set_fe +{ + TransactionId conflict_xid; + uint32 heapBlk; + uint8 flags; +} xl_flux_vm_set_fe; + +typedef struct xl_flux_vm_clear_fe +{ + uint32 heapBlk; + uint8 flags; +} xl_flux_vm_clear_fe; + +typedef struct xl_flux_supersede_fe +{ + uint16 offnum; + uint16 new_blkhi; + uint16 new_blklo; + uint16 new_offnum; + uint16 flags; +} xl_flux_supersede_fe; + +typedef struct xl_flux_lock_fe +{ + uint16 offnum; + uint16 flags; + uint32 xmax; + uint16 infomask; + uint16 infomask2; + uint8 lock_mode; +} xl_flux_lock_fe; + +typedef struct xl_flux_multi_insert_fe +{ + uint16 ntuples; + uint16 flags; + uint64 commit_ts; +} xl_flux_multi_insert_fe; + +/* + * Human-readable compression type names. + */ +static const char * +flux_comp_type_name(uint8 comp_type) +{ + switch (comp_type) + { + case 0: + return "NONE"; + case 1: + return "LZ4"; + case 2: + return "ZSTD"; + case 3: + return "DELTA"; + case 4: + return "DICTIONARY"; + default: + return "UNKNOWN"; + } +} + +void +flux_desc(StringInfo buf, XLogReaderState *record) +{ + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + char *data = XLogRecGetData(record); + Size datalen = XLogRecGetDataLen(record); + uint16 flags = 0; + + switch (info & XLOG_FLUX_OPMASK) + { + case XLOG_FLUX_FREEZE_PAGE: + { + xl_flux_freeze_page *xlrec = (xl_flux_freeze_page *) data; + + appendStringInfo(buf, "conflict xid: %u, plans: %u", + xlrec->conflict_xid, xlrec->nplans); + } + break; + case XLOG_FLUX_INSERT: + { + if (datalen >= sizeof(xl_flux_insert_fe)) + { + xl_flux_insert_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_insert_fe)); + flags = xlrec.flags; + appendStringInfo(buf, "off: %u, flags: 0x%04X, " + "commit_ts: " UINT64_FORMAT ", " + "xact_ts: " UINT64_FORMAT, + xlrec.offnum, xlrec.flags, + xlrec.commit_ts, xlrec.xact_ts); + } + else + appendStringInfoString(buf, "insert (truncated)"); + } + break; + case XLOG_FLUX_DELETE: + { + if (datalen >= sizeof(xl_flux_delete_fe)) + { + xl_flux_delete_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_delete_fe)); + flags = xlrec.flags; + appendStringInfo(buf, "off: %u, flags: 0x%04X, " + "commit_ts: " UINT64_FORMAT ", " + "xact_ts: " UINT64_FORMAT, + xlrec.offnum, xlrec.flags, + xlrec.commit_ts, xlrec.xact_ts); + } + else + appendStringInfoString(buf, "delete (truncated)"); + } + break; + case XLOG_FLUX_UPDATE: + { + if (datalen >= sizeof(xl_flux_update_fe)) + { + xl_flux_update_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_update_fe)); + flags = xlrec.flags; + appendStringInfo(buf, "off: %u, flags: 0x%04X, " + "old_commit_ts: " UINT64_FORMAT ", " + "new_commit_ts: " UINT64_FORMAT ", " + "xact_ts: " UINT64_FORMAT, + xlrec.offnum, xlrec.flags, + xlrec.old_commit_ts, + xlrec.new_commit_ts, + xlrec.xact_ts); + if (flags & FLUX_WAL_CROSS_PAGE) + appendStringInfoString(buf, ", cross_page: true"); + } + else + appendStringInfoString(buf, "update (truncated)"); + } + break; + case XLOG_FLUX_VACUUM: + { + if (datalen >= sizeof(xl_flux_vacuum_fe)) + { + xl_flux_vacuum_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_vacuum_fe)); + appendStringInfo(buf, "ntuples: %u, snapshotConflictHorizon: %u, isCatalogRel: %c", + xlrec.ntuples, + xlrec.snapshotConflictHorizon, + xlrec.isCatalogRel ? 'T' : 'F'); + } + else + appendStringInfoString(buf, "vacuum (truncated)"); + } + break; + case XLOG_FLUX_COMPRESS: + { + if (datalen >= sizeof(xl_flux_compress_fe)) + { + xl_flux_compress_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_compress_fe)); + appendStringInfo(buf, "off: %u, attr: %u, " + "comp_type: %s, comp_level: %u, " + "orig_size: %u, comp_size: %u, " + "commit_ts: " UINT64_FORMAT, + xlrec.offnum, xlrec.attr_num, + flux_comp_type_name(xlrec.comp_type), + xlrec.comp_level, + xlrec.orig_size, xlrec.comp_size, + xlrec.commit_ts); + } + else + appendStringInfoString(buf, "compress (truncated)"); + } + break; + case XLOG_FLUX_OVERFLOW_WRITE: + { + if (datalen >= sizeof(xl_flux_overflow_write_fe)) + { + xl_flux_overflow_write_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_overflow_write_fe)); + appendStringInfo(buf, "off: %u, flags: 0x%04X, " + "data_len: %u, " + "commit_ts: " UINT64_FORMAT, + xlrec.offnum, xlrec.flags, + xlrec.data_len, xlrec.commit_ts); + } + else + appendStringInfoString(buf, "overflow_write (truncated)"); + } + break; + case XLOG_FLUX_INIT_PAGE: + { + if (datalen >= sizeof(xl_flux_init_page_fe)) + { + xl_flux_init_page_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_init_page_fe)); + appendStringInfo(buf, "flags: 0x%08X, " + "commit_ts: " UINT64_FORMAT, + xlrec.flags, xlrec.commit_ts); + } + else + appendStringInfoString(buf, "init_page (truncated)"); + } + break; + case XLOG_FLUX_VM_SET: + { + if (datalen >= sizeof(xl_flux_vm_set_fe)) + { + xl_flux_vm_set_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_vm_set_fe)); + appendStringInfo(buf, "heapBlk: %u, flags: 0x%02X, conflict xid: %u", + xlrec.heapBlk, xlrec.flags, xlrec.conflict_xid); + } + else + appendStringInfoString(buf, "vm_set (truncated)"); + } + break; + case XLOG_FLUX_VM_CLEAR: + { + if (datalen >= sizeof(xl_flux_vm_clear_fe)) + { + xl_flux_vm_clear_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_vm_clear_fe)); + appendStringInfo(buf, "heapBlk: %u, flags: 0x%02X", + xlrec.heapBlk, xlrec.flags); + } + else + appendStringInfoString(buf, "vm_clear (truncated)"); + } + break; + case XLOG_FLUX_LOCK: + { + if (datalen >= sizeof(xl_flux_lock_fe)) + { + xl_flux_lock_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_lock_fe)); + appendStringInfo(buf, "off: %u, xmax: %u, " + "infomask: 0x%04X, infomask2: 0x%04X, " + "lock_mode: %u", + xlrec.offnum, xlrec.xmax, + xlrec.infomask, xlrec.infomask2, + xlrec.lock_mode); + } + else + appendStringInfoString(buf, "lock (truncated)"); + } + break; + case XLOG_FLUX_SUPERSEDE: + { + if (datalen >= sizeof(xl_flux_supersede_fe)) + { + xl_flux_supersede_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_supersede_fe)); + appendStringInfo(buf, "off: %u, new_tid: (%u,%u)", + xlrec.offnum, + ((uint32) xlrec.new_blkhi << 16) | xlrec.new_blklo, + xlrec.new_offnum); + } + else + appendStringInfoString(buf, "supersede (truncated)"); + } + break; + case XLOG_FLUX_CAS_UPDATE: + { + if (datalen >= 14) /* minimum: + * offnum(2)+flags(2)+offset(2)+len(2)+ts(8) + * - 2 padding */ + { + uint16 offnum; + uint16 d_offset; + uint16 d_len; + + memcpy(&offnum, data, sizeof(uint16)); + memcpy(&d_offset, data + 4, sizeof(uint16)); + memcpy(&d_len, data + 6, sizeof(uint16)); + appendStringInfo(buf, "off: %u, data_offset: %u, data_len: %u", + offnum, d_offset, d_len); + } + else + appendStringInfoString(buf, "cas_update (truncated)"); + } + break; + case XLOG_FLUX_CAS_UPDATE_UNDO: + { + if (datalen >= (int) sizeof(xl_flux_cas_update_undo_fe)) + { + xl_flux_cas_update_undo_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_cas_update_undo_fe)); + appendStringInfo(buf, + "off: %u, data_offset: %u, data_len: %u", + xlrec.offnum, xlrec.data_offset, + xlrec.data_len); + } + else + appendStringInfoString(buf, "cas_update_undo (truncated)"); + } + break; + case XLOG_FLUX_WRITE_DICT: + { + if (datalen >= sizeof(uint32)) + { + uint32 blkno; + + memcpy(&blkno, data, sizeof(uint32)); + appendStringInfo(buf, "blkno: %u (full-page image)", blkno); + } + else + appendStringInfoString(buf, "write_dict (truncated)"); + } + break; + case XLOG_FLUX_MULTI_INSERT: + { + if (datalen >= sizeof(xl_flux_multi_insert_fe)) + { + xl_flux_multi_insert_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_multi_insert_fe)); + appendStringInfo(buf, "ntuples: %u, flags: 0x%04X, " + "commit_ts: " UINT64_FORMAT, + xlrec.ntuples, xlrec.flags, + xlrec.commit_ts); + } + else + appendStringInfoString(buf, "multi_insert (truncated)"); + } + break; + default: + appendStringInfoString(buf, "UNKNOWN"); + break; + } +} + +const char * +flux_identify(uint8 info) +{ + const char *id = NULL; + + switch (info & XLOG_FLUX_OPMASK) + { + case XLOG_FLUX_FREEZE_PAGE: + id = "FREEZE_PAGE"; + break; + case XLOG_FLUX_INSERT: + id = "INSERT"; + break; + case XLOG_FLUX_DELETE: + id = "DELETE"; + break; + case XLOG_FLUX_UPDATE: + id = "UPDATE"; + break; + case XLOG_FLUX_VACUUM: + id = "VACUUM"; + break; + case XLOG_FLUX_COMPRESS: + id = "COMPRESS"; + break; + case XLOG_FLUX_OVERFLOW_WRITE: + id = "OVERFLOW_WRITE"; + break; + case XLOG_FLUX_INIT_PAGE: + id = "INIT_PAGE"; + break; + case XLOG_FLUX_VM_SET: + id = "VM_SET"; + break; + case XLOG_FLUX_VM_CLEAR: + id = "VM_CLEAR"; + break; + case XLOG_FLUX_LOCK: + id = "LOCK"; + break; + case XLOG_FLUX_CAS_UPDATE: + id = "CAS_UPDATE"; + break; + case XLOG_FLUX_CAS_UPDATE_UNDO: + id = "CAS_UPDATE_UNDO"; + break; + case XLOG_FLUX_WRITE_DICT: + id = "WRITE_DICT"; + break; + case XLOG_FLUX_MULTI_INSERT: + id = "MULTI_INSERT"; + break; + case XLOG_FLUX_SUPERSEDE: + id = "SUPERSEDE"; + break; + default: + id = NULL; + break; + } + + return id; +} diff --git a/src/backend/access/rmgrdesc/meson.build b/src/backend/access/rmgrdesc/meson.build index 4535cabfde24c..4144dad0bbc33 100644 --- a/src/backend/access/rmgrdesc/meson.build +++ b/src/backend/access/rmgrdesc/meson.build @@ -32,6 +32,8 @@ rmgr_desc_sources = files( # FLUX is always built; rmgrlist.h registers flux_desc/flux_identify +# unconditionally, so fluxdesc.c must always be compiled. +rmgr_desc_sources += files('fluxdesc.c') # FILEOPS is always built (USE_FILEOPS); rmgrlist.h registers # fileops_desc/fileops_identify, so fileopsdesc.c must always be compiled or diff --git a/src/backend/access/transam/rmgr.c b/src/backend/access/transam/rmgr.c index 38bc228c7339a..688f5ba397f47 100644 --- a/src/backend/access/transam/rmgr.c +++ b/src/backend/access/transam/rmgr.c @@ -43,6 +43,9 @@ #include "utils/relmapper.h" #include "access/undo_xlog.h" #include "access/atm.h" +#ifdef USE_FLUX +#include "access/flux_xlog.h" +#endif #include "access/perbackend/pbu_undolog_xlog.h" #include "access/perbackend/pbu_undoaction_xlog.h" /* IWYU pragma: end_keep */ diff --git a/src/backend/access/transam/twophase.c b/src/backend/access/transam/twophase.c index ad8f8da40209e..2b6f4597bb651 100644 --- a/src/backend/access/transam/twophase.c +++ b/src/backend/access/transam/twophase.c @@ -1751,6 +1751,12 @@ FinishPreparedTransaction(const char *gid, bool isCommit) XLogRecPtr perm_lsn = hdr->last_batch_lsn[UNDOPERSISTENCE_PERMANENT]; + /* + * The prepared transaction's backend may be from before a + * restart. + */ + if (XLogRecPtrIsValid(perm_lsn)) + ATMEnsureCreated(); if (XLogRecPtrIsValid(perm_lsn) && !ATMAddAborted(xid, hdr->database, perm_lsn, hdr->undo_first_batch_lsn, true)) diff --git a/src/backend/access/transam/twophase_rmgr.c b/src/backend/access/transam/twophase_rmgr.c index 7ef5f7508dda2..4d9263142617f 100644 --- a/src/backend/access/transam/twophase_rmgr.c +++ b/src/backend/access/transam/twophase_rmgr.c @@ -15,6 +15,7 @@ #include "postgres.h" #include "access/multixact.h" +#include "access/flux.h" #include "access/twophase_rmgr.h" #include "pgstat.h" #include "storage/lock.h" @@ -48,7 +49,8 @@ const TwoPhaseCallback twophase_recover_callbacks[TWOPHASE_RM_MAX_ID + 1] = multixact_twophase_recover, /* MultiXact */ predicatelock_twophase_recover, /* PredicateLock */ NULL, /* FILEOPS (post-commit/abort only) */ - NULL /* per-backend UNDO (postabort-only) */ + NULL, /* per-backend UNDO (postabort-only) */ + flux_twophase_recover /* FLUX */ }; const TwoPhaseCallback twophase_postcommit_callbacks[TWOPHASE_RM_MAX_ID + 1] = @@ -59,7 +61,8 @@ const TwoPhaseCallback twophase_postcommit_callbacks[TWOPHASE_RM_MAX_ID + 1] = multixact_twophase_postcommit, /* MultiXact */ NULL, /* PredicateLock */ fileops_twophase_postcommit, /* FILEOPS */ - pbu_twophase_postcommit /* per-backend UNDO */ + pbu_twophase_postcommit, /* per-backend UNDO */ + flux_twophase_postcommit /* FLUX */ }; const TwoPhaseCallback twophase_postabort_callbacks[TWOPHASE_RM_MAX_ID + 1] = @@ -70,7 +73,8 @@ const TwoPhaseCallback twophase_postabort_callbacks[TWOPHASE_RM_MAX_ID + 1] = multixact_twophase_postabort, /* MultiXact */ NULL, /* PredicateLock */ fileops_twophase_postabort, /* FILEOPS */ - pbu_twophase_postabort /* per-backend UNDO */ + pbu_twophase_postabort, /* per-backend UNDO */ + flux_twophase_postabort /* FLUX */ }; const TwoPhaseCallback twophase_standby_recover_callbacks[TWOPHASE_RM_MAX_ID + 1] = @@ -81,5 +85,6 @@ const TwoPhaseCallback twophase_standby_recover_callbacks[TWOPHASE_RM_MAX_ID + 1 NULL, /* MultiXact */ NULL, /* PredicateLock */ NULL, /* FILEOPS */ - NULL /* per-backend UNDO */ + NULL, /* per-backend UNDO */ + flux_twophase_recover /* FLUX */ }; diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c index e49a14fe76a84..dad1110005014 100644 --- a/src/backend/access/transam/xact.c +++ b/src/backend/access/transam/xact.c @@ -21,6 +21,7 @@ #include #include "access/commit_ts.h" +#include "access/flux.h" #include "access/multixact.h" #include "access/parallel.h" #include "access/subtrans.h" @@ -3054,6 +3055,7 @@ PrepareTransaction(void) AtPrepare_PgStat(); AtPrepare_MultiXact(); AtPrepare_RelationMap(); + AtPrepare_Flux(); /* * Here is where we really truly prepare. @@ -3336,9 +3338,9 @@ AbortTransaction(void) * Roll back the per-backend UNDO engine's records for this transaction, * if any. This engine coexists with the per-relation engine handled by * AtAbort_XactUndo() above; the call is a no-op unless the per-backend - * engine produced UNDO for this xact (FLUX tables do). UNDO - * appliers open relations, so present TRANS_INPROGRESS for the duration - * (all backing resources are still live at this point). + * engine produced UNDO for this xact (FLUX tables do). UNDO appliers open + * relations, so present TRANS_INPROGRESS for the duration (all backing + * resources are still live at this point). */ { int pbu_saved_state = EnterInlineUndoApplyState(); diff --git a/src/backend/access/undo/Makefile b/src/backend/access/undo/Makefile index ab403117f6eae..30ee65c20f53f 100644 --- a/src/backend/access/undo/Makefile +++ b/src/backend/access/undo/Makefile @@ -16,7 +16,6 @@ OBJS = \ atm.o \ logical_revert_worker.o \ slog.o \ - slog_flathash.o \ slog_tuple.o \ undo.o \ undo_bufmgr.o \ diff --git a/src/backend/access/undo/README b/src/backend/access/undo/README index 3d89e630cd890..7ffbe2f4cf589 100644 --- a/src/backend/access/undo/README +++ b/src/backend/access/undo/README @@ -118,8 +118,8 @@ and the snapshot used, redo of WAL that still refers to that log can fail with an error such as "cannot find undo log number N for xid X", and the server does not start. -No in-tree access method writes per-backend UNDO yet; the engine compiles, -links, and registers, and its dispatch path is ready for a future consumer. +The FLUX table access method (src/backend/access/flux) writes its table +UNDO to the per-backend engine. ## Recovery Model @@ -186,14 +186,18 @@ deferring the physical undo to the background: redo) and ROLLBACK returns without applying the UNDO, so its latency does not depend on how much UNDO the transaction wrote. - - **sLog (slog.c, slog_flathash.c, slog_tuple.c).** A shared-memory - map consulted by an in-place-update AM's visibility checks so that an - aborted transaction's writes are invisible to every reader - immediately, before any physical undo has run. The sLog serves the - abort/self-visibility window; the commit oracle remains CLOG. The - optional per-tuple flat-hash extension (the tuple sLog) tracks - uncommitted writers for bounded recovery and is engine-agnostic - (keyed on physical tuple identity). + - **sLog (slog.c, slog_tuple.c).** The transaction sLog (slog.c) is + the ATM above, kept in shared memory. The tuple sLog (slog_tuple.c) + is per-tuple state for UNDO-based table AMs, keyed by (relation OID, + relation storage, TID) and independent of any AM's page format. Such + an AM records no row locker in the tuple (no locker xmax, no + MultiXact), so SLogTupleLock* keeps the holder, strength and locking + subtransaction of each row lock, in per-backend records and hash + tables in shared memory created on the first row lock, with a holder + index that lets a conflict probe visit only the backends that may hold + a lock on its tuple; PREPARE hands them to the prepared transaction. It also tracks, backend-locally, the tuples the current + transaction inserted. The commit oracle remains CLOG and the writer + xids in the tuple header. - **Logical Revert worker (logical_revert_worker.c).** A background worker that scans the ATM for entries not yet reverted and calls diff --git a/src/backend/access/undo/atm.c b/src/backend/access/undo/atm.c index f084863da32b4..9f9d93d0e5ea9 100644 --- a/src/backend/access/undo/atm.c +++ b/src/backend/access/undo/atm.c @@ -1,7 +1,7 @@ /*------------------------------------------------------------------------- * * atm.c - * Aborted Transaction Map for CTR (Constant-Time Recovery) + * Aborted Transaction Map: aborted transactions whose UNDO is not applied * * The ATM is a shared-memory data structure mapping TransactionId to UNDO * chain metadata for aborted transactions. It enables: @@ -14,9 +14,8 @@ * ATM entry (sLog + WAL) instead of performing synchronous * rollback, making ROLLBACK O(1). * - * Implementation: All ATM functions are thin wrappers around the sLog - * (Secondary Log) hash tables defined in access/slog.h. The sLog - * provides O(1) lookups, replacing the old fixed-size linear array. + * Implementation: All ATM functions are thin wrappers around the + * transaction sLog (access/slog.h), a shared radix tree. * * WAL: ATMAddAborted() emits XLOG_ATM_ABORT; ATMForget() emits * XLOG_ATM_FORGET. During recovery, atm_redo() replays these without @@ -117,6 +116,18 @@ ATMShmemInit(void) /* sLog initialization is done separately via SLogShmemInit() */ } +/* + * ATMEnsureCreated + * Create the ATM's shared memory if nobody has yet. Call it where an + * ERROR is still harmless, before a transaction may have to abort into + * the ATM: the abort path does not allocate it (SLogTxnInsert). + */ +void +ATMEnsureCreated(void) +{ + SLogTxnEnsureCreated(); +} + /* * ATMGetLastBatchLSN * Retrieve the WAL LSN of the last UNDO batch for an aborted transaction. diff --git a/src/backend/access/undo/meson.build b/src/backend/access/undo/meson.build index 1c74d6e856583..46e6c8c382199 100644 --- a/src/backend/access/undo/meson.build +++ b/src/backend/access/undo/meson.build @@ -4,7 +4,6 @@ backend_sources += files( 'atm.c', 'logical_revert_worker.c', 'slog.c', - 'slog_flathash.c', 'slog_tuple.c', 'undo.c', 'undo_bufmgr.c', diff --git a/src/backend/access/undo/perbackend/pbu_undoworker.c b/src/backend/access/undo/perbackend/pbu_undoworker.c index 85da6c8dafda4..c5636160038fe 100644 --- a/src/backend/access/undo/perbackend/pbu_undoworker.c +++ b/src/backend/access/undo/perbackend/pbu_undoworker.c @@ -56,6 +56,7 @@ #include "tcop/tcopprot.h" #include "utils/hsearch.h" +#include "utils/injection_point.h" #include "utils/memutils.h" #include "utils/resowner.h" @@ -499,6 +500,9 @@ UndoWorkerPerformRequest(UndoRequestInfo *urinfo) /* Should be connected to the database. */ Assert(MyDatabaseId != InvalidOid); + /* Lets tests hold a rollback request back from the worker. */ + INJECTION_POINT("pbu-undo-worker-before-apply", NULL); + StartTransactionCommand(); PG_TRY(); { diff --git a/src/backend/access/undo/slog.c b/src/backend/access/undo/slog.c index 9cf5923c6f644..9b8a6238809d6 100644 --- a/src/backend/access/undo/slog.c +++ b/src/backend/access/undo/slog.c @@ -1,25 +1,33 @@ /*------------------------------------------------------------------------- * * slog.c - * Secondary Log (sLog) -- transaction Aborted Transaction Map (ATM) + * Secondary Log (sLog) -- the transaction sLog, which holds the + * Aborted Transaction Map (ATM) * - * The sLog tracks aborted transactions in shared memory for the UNDO - * subsystem's Constant-Time Recovery. + * The sLog is the UNDO subsystem's shared state that is not WAL. It has + * two parts: + * + * - The transaction sLog (this file): the aborted transactions whose UNDO + * has not been applied yet (the ATM, atm.c), which the logical revert + * worker reverts and which checkpoints and redo carry across a crash + * (RM_ATM_ID records). + * - The tuple sLog (slog_tuple.c): per-tuple state of UNDO-based table + * AMs -- who holds row locks on their tuples (they record no locker in + * the tuple), and which tuples this transaction inserted. * * Transaction sLog (adaptive radix tree): * - A shared-memory adaptive radix tree (radixtree.h, RT_SHMEM) keyed by * (xid, reloid) packed into a uint64 stores full abort metadata. With * xid in the high bits, ordered iteration groups all entries for a * given xid contiguously, enabling efficient per-xid range operations. - * - The tree lives in the sLog's DSA area and grows on demand. + * - The tree lives in a DSA area of its own, created by the first + * transaction that writes UNDO (SLogTxnEnsureCreated), and grows on + * demand. Nothing but the lock and the tree's handle is + * allocated at startup. Until it exists, the ATM is empty: readers say + * so without creating it. * - The radix tree is protected by a single LWLock (sLog modifications * only occur on transaction abort, an uncommon path). * - * An optional per-tuple flat-hash extension (see the tuple sLog, added by - * the consumer that needs bounded-recovery uncommitted-writer tracking) - * shares this file's shared-memory segment and initialization but is not - * required by the UNDO core. - * * Locking: Transaction sLog uses LWTRANCHE_SLOG. * * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group @@ -124,10 +132,8 @@ slog_atm_key_reloid(uint64 key) } /* - * Initial size for the sLog DSA area (backs the aborted-txn radix tree). - * Grows dynamically as needed up to slog_dsa_max_size_mb. SLOG_DSA_INIT_SIZE - * and the shared-state struct are defined in access/slog_internal.h, shared - * with the optional tuple sLog (slog_tuple.c). + * Maximum size of the ATM's DSA area. The shared-state struct is defined in + * access/slog_internal.h. */ #define SLOG_DSA_MAX_SIZE_MB 256 /* default max: 256 MB */ @@ -140,7 +146,7 @@ int slog_dsa_max_size_mb = SLOG_DSA_MAX_SIZE_MB; */ SLogSharedState *SLogState = NULL; -/* Per-backend DSA attachment (lazy, via SLogEnsureDsaAttached) */ +/* Per-backend attachment of the ATM's DSA area (lazy, via slog_atm_tree) */ static dsa_area *slog_dsa_handle = NULL; /* Per-backend attached ATM radix tree (lazy, via slog_atm_tree) */ @@ -151,35 +157,96 @@ static slog_atm_radix_tree * slog_atm_tree_local = NULL; * slog_atm_tree * Return this backend's attached ATM radix tree, attaching lazily. * - * Ensures the sLog DSA area is attached, then attaches to the shared - * radix tree via its handle. The attached wrapper is cached for the - * life of the backend (the control block lives in the pinned DSA area). - * The caller must hold txn_lock across any use of the returned tree. + * Returns NULL if the tree was never created and create is false; readers + * then treat the ATM as empty. With create, the first caller in the cluster + * creates the DSA area and the tree, which raises ERROR on failure: callers + * that must not fail (an aborting transaction) pass false, and their + * transaction created the tree when it first wrote UNDO + * (SLogTxnEnsureCreated). Once created the area is pinned and lasts until + * shared memory is reinitialized. The attached wrapper is cached for the + * life of the backend. The caller must hold txn_lock across any use of the + * returned tree, and must not hold it here. */ static slog_atm_radix_tree * -slog_atm_tree(void) +slog_atm_tree(bool create) { MemoryContext oldcxt; + bool created = false; if (slog_atm_tree_local != NULL) return slog_atm_tree_local; - SLogEnsureDsaAttached(); - if (slog_dsa_handle == NULL) - elog(PANIC, "sLog: DSA not attached for ATM radix tree"); + LWLockAcquire(&SLogState->txn_lock.lock, create ? LW_EXCLUSIVE : LW_SHARED); + if (!DsaPointerIsValid(SLogState->atm_handle) && !create) + { + LWLockRelease(&SLogState->txn_lock.lock); + return NULL; + } /* - * Attach in TopMemoryContext so the wrapper persists across transactions, - * mirroring the DSA attach above. + * Keep the attachment and the wrapper across transactions. The area is + * remembered only once its mapping is pinned, so that an ERROR in between + * cannot leave a dangling pointer behind. The smallest first segment + * will do: an entry is a few dozen bytes, and the area grows as needed. */ oldcxt = MemoryContextSwitchTo(TopMemoryContext); - slog_atm_tree_local = slog_atm_attach(slog_dsa_handle, - SLogState->atm_handle); + if (slog_dsa_handle == NULL) + { + dsa_area *area; + + if (SLogState->area != DSA_HANDLE_INVALID) + { + area = dsa_attach(SLogState->area); + dsa_pin_mapping(area); + } + else + { + area = dsa_create_ext(LWTRANCHE_SLOG, DSA_MIN_SEGMENT_SIZE, + DSA_MAX_SEGMENT_SIZE); + dsa_pin_mapping(area); + dsa_pin(area); + dsa_set_size_limit(area, + (Size) slog_dsa_max_size_mb * 1024 * 1024); + SLogState->area = dsa_get_handle(area); + } + slog_dsa_handle = area; + } + if (!DsaPointerIsValid(SLogState->atm_handle)) + { + slog_atm_radix_tree *tree; + + tree = slog_atm_create(slog_dsa_handle, LWTRANCHE_SLOG); + SLogState->atm_handle = slog_atm_get_handle(tree); + slog_atm_tree_local = tree; + created = true; + } + else + slog_atm_tree_local = slog_atm_attach(slog_dsa_handle, + SLogState->atm_handle); MemoryContextSwitchTo(oldcxt); + LWLockRelease(&SLogState->txn_lock.lock); + + if (created) + elog(DEBUG1, "created the aborted transaction map"); return slog_atm_tree_local; } +/* + * SLogTxnEnsureCreated + * Create the ATM now, if nobody has yet, so that this transaction can + * abort into it without allocating. + * + * Called when a transaction first writes UNDO, and by ROLLBACK PREPARED, + * outside any critical section: a failure here is an ordinary ERROR, before + * anything that would need rolling back exists. + */ +void +SLogTxnEnsureCreated(void) +{ + (void) slog_atm_tree(true); +} + /* ---------------------------------------------------------------- * Shared memory sizing and initialization * ---------------------------------------------------------------- @@ -189,23 +256,20 @@ slog_atm_tree(void) * SLogShmemSize * Calculate shared memory needed for the sLog. * - * Note: The DSA initial region is embedded in SLogSharedState (dsa_space[]), - * so sizeof(SLogSharedState) already includes SLOG_DSA_INIT_SIZE. The ATM - * radix tree is allocated from that DSA area on demand, so it needs no fixed - * reservation here. + * Only the lock and the tree's handle: the tree and its DSA area are + * created on first use (slog_atm_tree). */ Size SLogShmemSize(void) { - return add_size(MAXALIGN(sizeof(SLogSharedState)), SLogTupleShmemSize()); + return MAXALIGN(sizeof(SLogSharedState)); } /* * SLogShmemRequest * Register shared memory needs for the sLog. * - * Registers the shared state struct (which embeds the DSA initial region - * backing the aborted-transaction radix tree). + * Registers the shared state struct. */ void SLogShmemRequest(void) @@ -215,9 +279,6 @@ SLogShmemRequest(void) .size = sizeof(SLogSharedState), .ptr = (void **) &SLogState, ); - - /* Optional tuple sLog: register its flat-hash partition block. */ - SLogTupleShmemRequest(); } /* @@ -225,46 +286,14 @@ SLogShmemRequest(void) * Initialize sLog shared memory contents. * * Called during the init_fn phase. The framework has already allocated - * SLogState. We initialize the transaction ATM radix tree and its DSA area. + * SLogState. The ATM's DSA area and tree do not exist yet. */ void SLogShmemInit(void) { - /* ATM radix tree is created below, after the DSA area exists. */ - SLogState->atm_handle = InvalidDsaPointer; - - /* ---- Initialize locks ---- */ LWLockInitialize(&SLogState->txn_lock.lock, LWTRANCHE_SLOG); - - /* ---- Optional tuple sLog: allocate + init the flat-hash partitions ---- */ - SLogTupleShmemInit(); - - /* ---- Initialize DSA area (backs the aborted-txn radix tree) ---- */ - SLogState->dsa_area = dsa_create_in_place(SLogState->dsa_space, - SLOG_DSA_INIT_SIZE, - LWTRANCHE_SLOG, - 0); - dsa_pin(SLogState->dsa_area); - dsa_set_size_limit(SLogState->dsa_area, - (Size) slog_dsa_max_size_mb * 1024 * 1024); - - /* - * Create the ATM radix tree in the DSA area and record its handle. The - * transient tree wrapper returned here is discarded when init's memory - * context is reset; the control block lives in the pinned DSA area, and - * each backend re-attaches lazily via slog_atm_tree(). We reuse - * LWTRANCHE_SLOG for the tree's internal lock (unused -- txn_lock - * serializes all access -- but a valid tranche is required). - */ - { - slog_atm_radix_tree *tree; - - tree = slog_atm_create(SLogState->dsa_area, LWTRANCHE_SLOG); - SLogState->atm_handle = slog_atm_get_handle(tree); - } - - dsa_detach(SLogState->dsa_area); - SLogState->dsa_area = NULL; /* backends re-attach lazily */ + SLogState->area = DSA_HANDLE_INVALID; + SLogState->atm_handle = InvalidDsaPointer; } /* @@ -298,41 +327,6 @@ const ShmemCallbacks SLogShmemCallbacks = { .init_fn = SLogShmemInit_cb, }; -/* ---------------------------------------------------------------- - * DSA lifecycle for the shared sLog DSA area - * ---------------------------------------------------------------- - */ - -/* - * SLogEnsureDsaAttached - * Lazy per-backend DSA attachment. - * - * Must be called before any DSA alloc/free/get_address operations. - * Safe to call multiple times (no-op after first attach). - */ -void -SLogEnsureDsaAttached(void) -{ - MemoryContext oldcxt; - - if (slog_dsa_handle != NULL) - return; /* already attached */ - - if (SLogState == NULL) - return; /* sLog not yet initialized */ - - /* - * Allocate in TopMemoryContext so the dsa_area struct persists across - * transactions. dsa_attach_in_place internally palloc's, so if we're in - * a transaction context, the handle would become a dangling pointer after - * transaction end. - */ - oldcxt = MemoryContextSwitchTo(TopMemoryContext); - slog_dsa_handle = dsa_attach_in_place(SLogState->dsa_space, NULL); - dsa_pin_mapping(slog_dsa_handle); - MemoryContextSwitchTo(oldcxt); -} - /* ================================================================ * Transaction sLog functions * ================================================================ @@ -367,11 +361,15 @@ SLogTxnInsert(TransactionId xid, Oid reloid, Oid dboid, XLogRecPtr first_batch_lsn, bool fileops_owned, bool check_space) { - slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_radix_tree *tree = slog_atm_tree(!check_space); uint64 key = slog_atm_key(xid, reloid); slog_atm_value_t value; slog_atm_value_t *found; + /* Aborting, and the ATM was never created: report it as full. */ + if (tree == NULL) + return false; + value.last_batch_lsn = last_batch_lsn; value.stop_lsn = stop_lsn; value.first_batch_lsn = first_batch_lsn; @@ -428,10 +426,14 @@ SLogTxnInsert(TransactionId xid, Oid reloid, Oid dboid, bool SLogTxnLookup(TransactionId xid, Oid reloid, SLogTxnEntry *entry_out) { - slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_radix_tree *tree = slog_atm_tree(false); uint64 key = slog_atm_key(xid, reloid); slog_atm_value_t *found; + /* never created: the ATM is empty */ + if (tree == NULL) + return false; + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); found = slog_atm_find(tree, key); @@ -467,12 +469,16 @@ SLogTxnLookup(TransactionId xid, Oid reloid, SLogTxnEntry *entry_out) bool SLogTxnLookupByXid(TransactionId xid, XLogRecPtr *lsn_out) { - slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_radix_tree *tree = slog_atm_tree(false); slog_atm_iter *iter; slog_atm_value_t *val; uint64 k; bool result = false; + /* never created: the ATM is empty */ + if (tree == NULL) + return false; + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); iter = slog_atm_begin_iterate(tree); @@ -503,9 +509,13 @@ SLogTxnLookupByXid(TransactionId xid, XLogRecPtr *lsn_out) void SLogTxnRemove(TransactionId xid, Oid reloid) { - slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_radix_tree *tree = slog_atm_tree(false); uint64 key = slog_atm_key(xid, reloid); + /* never created: the ATM is empty */ + if (tree == NULL) + return; + LWLockAcquire(&SLogState->txn_lock.lock, LW_EXCLUSIVE); (void) slog_atm_delete(tree, key); @@ -525,7 +535,7 @@ SLogTxnRemove(TransactionId xid, Oid reloid) void SLogTxnRemoveByXid(TransactionId xid) { - slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_radix_tree *tree = slog_atm_tree(false); slog_atm_iter *iter; slog_atm_value_t *val; uint64 k; @@ -534,6 +544,10 @@ SLogTxnRemoveByXid(TransactionId xid) int max_remove = 64; int i; + /* never created: the ATM is empty */ + if (tree == NULL) + return; + to_remove = (uint64 *) palloc(max_remove * sizeof(uint64)); LWLockAcquire(&SLogState->txn_lock.lock, LW_EXCLUSIVE); @@ -582,7 +596,7 @@ SLogTxnRemoveByXid(TransactionId xid) void SLogTxnMarkReverted(TransactionId xid) { - slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_radix_tree *tree = slog_atm_tree(false); slog_atm_iter *iter; slog_atm_value_t *val; uint64 k; @@ -592,6 +606,10 @@ SLogTxnMarkReverted(TransactionId xid) int max_n = 64; int i; + /* never created: the ATM is empty */ + if (tree == NULL) + return; + keys = (uint64 *) palloc(max_n * sizeof(uint64)); vals = (slog_atm_value_t *) palloc(max_n * sizeof(slog_atm_value_t)); @@ -647,12 +665,16 @@ SLogTxnGetNextUnreverted(Oid dboid, TransactionId after_xid, TransactionId *xid_out, XLogRecPtr *lsn_out, XLogRecPtr *stop_lsn_out) { - slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_radix_tree *tree = slog_atm_tree(false); slog_atm_iter *iter; slog_atm_value_t *val; uint64 k; bool result = false; + /* never created: the ATM is empty */ + if (tree == NULL) + return false; + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); iter = slog_atm_begin_iterate(tree); @@ -690,12 +712,16 @@ SLogTxnGetNextUnreverted(Oid dboid, TransactionId after_xid, int SLogTxnCollectUnrevertedDatabases(Oid *dboids, int max_dboids) { - slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_radix_tree *tree = slog_atm_tree(false); slog_atm_iter *iter; slog_atm_value_t *val; uint64 k; int n = 0; + /* never created: the ATM is empty */ + if (tree == NULL) + return 0; + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); iter = slog_atm_begin_iterate(tree); @@ -731,13 +757,23 @@ SLogTxnCollectUnrevertedDatabases(Oid *dboids, int max_dboids) void SLogRecoveryFinalize(int *total_out, int *unreverted_out) { - slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_radix_tree *tree = slog_atm_tree(false); slog_atm_iter *iter; slog_atm_value_t *val; uint64 k; int total = 0; int unreverted = 0; + /* never created: the ATM is empty */ + if (tree == NULL) + { + if (total_out) + *total_out = 0; + if (unreverted_out) + *unreverted_out = 0; + return; + } + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); iter = slog_atm_begin_iterate(tree); @@ -769,12 +805,16 @@ SLogRecoveryFinalize(int *total_out, int *unreverted_out) XLogRecPtr SLogTxnGetOldestUnrevertedLSN(void) { - slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_radix_tree *tree = slog_atm_tree(false); slog_atm_iter *iter; slog_atm_value_t *val; uint64 k; XLogRecPtr oldest = InvalidXLogRecPtr; + /* never created: the ATM is empty */ + if (tree == NULL) + return InvalidXLogRecPtr; + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); iter = slog_atm_begin_iterate(tree); @@ -813,7 +853,7 @@ SLogTxnGetOldestUnrevertedLSN(void) int SLogTxnSnapshotForCheckpoint(SLogTxnEntry **entries_out) { - slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_radix_tree *tree = slog_atm_tree(false); slog_atm_iter *iter; slog_atm_value_t *val; uint64 k; @@ -823,6 +863,10 @@ SLogTxnSnapshotForCheckpoint(SLogTxnEntry **entries_out) *entries_out = NULL; + /* never created: the ATM is empty */ + if (tree == NULL) + return 0; + arr = (SLogTxnEntry *) palloc(sizeof(SLogTxnEntry) * capacity); LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); diff --git a/src/backend/access/undo/slog_flathash.c b/src/backend/access/undo/slog_flathash.c deleted file mode 100644 index 2e0aaf8a0658f..0000000000000 --- a/src/backend/access/undo/slog_flathash.c +++ /dev/null @@ -1,796 +0,0 @@ -/*------------------------------------------------------------------------- - * - * slog_flathash.c - * Seqlock-protected flat open-addressing hash for sLog tuple tracking. - * - * Implements the flat hash table operations (probe, insert, remove) and - * the apply handler that mutates the single hash copy. The hash uses - * linear probing with a power-of-2 capacity and tombstone markers. A - * per-partition seqlock (in SLogFlatPartition) provides retry-based - * consistent reads; the writer lock serializes mutations. - * - * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group - * Portions Copyright (c) 1994, Regents of the University of California - * - * IDENTIFICATION - * src/backend/access/undo/slog_flathash.c - * - *------------------------------------------------------------------------- - */ -#include "postgres.h" - -#include "access/slog_flathash.h" -#include "access/transam.h" -#include "common/hashfn.h" -#include "utils/dsa.h" - -/* - * Maximum probe distance before giving up. With load factor < 0.7 and - * power-of-2 sizing, typical probe chains are very short (< 5). - * We cap at 128 to bound worst-case scan time. - */ -#define SLOG_FLAT_MAX_PROBE 128 - -/* ---------------------------------------------------------------- - * Size computation - * ---------------------------------------------------------------- - */ - -/* - * SLogFlatHashDataSize - * Size of one copy of the flat hash data structure. - */ -Size -SLogFlatHashDataSize(int capacity) -{ - return offsetof(SLogFlatHash, buckets) + - (Size) capacity * sizeof(SLogFlatBucket); -} - -/* - * SLogFlatHashShmemSize - * Shared memory needed for one flat-hash copy (the seqlock guards a - * single copy in place; the sequence counter and writer lock live in - * the SLogFlatPartition struct, not here). - */ -Size -SLogFlatHashShmemSize(int capacity, int max_backends) -{ - (void) max_backends; /* retained for call-site compatibility */ - return MAXALIGN(SLogFlatHashDataSize(capacity)); -} - -/* - * SLogFlatHashPartitionedShmemSize - * Total shared memory needed for all partitions' flat-hash copies. - * - * Each partition gets capacity/N buckets in one copy. The per-partition - * seqlock counter and writer lock are embedded in SLogFlatPartition in the - * SLogSharedState, so only the hash copies are sized here. - */ -Size -SLogFlatHashPartitionedShmemSize(int total_capacity, int max_backends) -{ - int per_partition_capacity; - Size per_partition_size; - Size total; - - per_partition_capacity = total_capacity / SLogNumPartitions; - if (per_partition_capacity < 64) - per_partition_capacity = 64; - - per_partition_size = SLogFlatHashShmemSize(per_partition_capacity, - max_backends); - total = (Size) SLogNumPartitions * MAXALIGN(per_partition_size); - - return total; -} - -/* ---------------------------------------------------------------- - * Initialization - * ---------------------------------------------------------------- - */ - -/* - * SLogFlatHashInit - * Initialize a flat hash data block (one copy). - * - * Sets all buckets to EMPTY state. - */ -void -SLogFlatHashInit(void *data, int capacity) -{ - SLogFlatHash *ht = (SLogFlatHash *) data; - int i; - - ht->capacity = capacity; - ht->num_entries = 0; - ht->num_tombstones = 0; - ht->padding = 0; - - for (i = 0; i < capacity; i++) - { - ht->buckets[i].hash_val = SLOG_FLAT_EMPTY; - memset(&ht->buckets[i].key, 0, sizeof(SLogTupleKey)); - ht->buckets[i].padding = 0; - ht->buckets[i].entry.nops = 0; - memset(ht->buckets[i].entry.ops, 0, - sizeof(SLogTupleOp) * SLOG_MAX_TUPLE_OPS); - } -} - -/* ---------------------------------------------------------------- - * Hash function - * ---------------------------------------------------------------- - */ - -/* - * SLogFlatHashComputeHash - * Compute a 32-bit hash for an SLogTupleKey. - * - * The key must have been zeroed before population (for padding bytes). - * Returns a non-zero, non-TOMBSTONE value (adjusts if hash_bytes returns - * 0 or UINT32_MAX). - */ -uint32 -SLogFlatHashComputeHash(const SLogTupleKey *key) -{ - uint32 h; - - h = hash_bytes((const unsigned char *) key, sizeof(SLogTupleKey)); - - /* Ensure we never produce EMPTY or TOMBSTONE values */ - if (h == SLOG_FLAT_EMPTY) - h = 1; - else if (h == SLOG_FLAT_TOMBSTONE) - h = SLOG_FLAT_TOMBSTONE - 1; - - return h; -} - -/* ---------------------------------------------------------------- - * Probe operations - * ---------------------------------------------------------------- - */ - -/* - * SLogFlatHashProbe - * Look up a key in the flat hash. Returns bucket pointer if found, - * NULL if not present. - * - * Linear probing: start at hash_val % capacity, walk forward skipping - * tombstones, stop at EMPTY (not found) or matching key (found). - */ -SLogFlatBucket * -SLogFlatHashProbe(const SLogFlatHash *ht, const SLogTupleKey *key) -{ - uint32 h; - uint32 idx; - int probe; - - /* - * Fast path: an empty hash cannot contain the key, so skip the key hash - * computation and bucket walk entirely. This lookup sits on the calling - * AM's per-tuple visibility/conflict hot path and the sLog - * (uncommitted-writer tracking) is empty for the vast majority of lookups - * in a committed-data OLTP workload (measured ~4%% of AM CPU under a - * TPROC-C-style workload before this guard). Callers read the hash under - * the partition seqlock, so a concurrent writer's insert (which bumps - * num_entries) is caught by the seqlock retry. - */ - if (ht->num_entries == 0) - return NULL; - - h = SLogFlatHashComputeHash(key); - idx = h & (uint32) (ht->capacity - 1); - - for (probe = 0; probe < SLOG_FLAT_MAX_PROBE; probe++) - { - const SLogFlatBucket *bucket = &ht->buckets[idx]; - - if (bucket->hash_val == SLOG_FLAT_EMPTY) - return NULL; /* definitive miss */ - - if (bucket->hash_val != SLOG_FLAT_TOMBSTONE && - bucket->hash_val == h && - memcmp(&bucket->key, key, sizeof(SLogTupleKey)) == 0) - { - return (SLogFlatBucket *) bucket; /* found */ - } - - idx = (idx + 1) & (uint32) (ht->capacity - 1); - } - - return NULL; /* probe limit exceeded */ -} - -/* - * SLogFlatHashHasOpForXid - * Return true iff the entry for key holds an in-use op for xid. - * - * Used by SLogTupleInsert to confirm an op was actually stored, rather than - * silently dropped because the per-TID ops array was full. Probing the - * bucket alone is insufficient on a hot row, where the bucket pre-exists with - * other markers. - */ -bool -SLogFlatHashHasOpForXid(const SLogFlatHash *ht, const SLogTupleKey *key, - TransactionId xid) -{ - const SLogFlatBucket *bucket = SLogFlatHashProbe(ht, key); - const SLogTupleEntry *entry; - int i; - - if (bucket == NULL) - return false; - - entry = &bucket->entry; - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) - { - if (entry->ops[i].in_use && - TransactionIdEquals(entry->ops[i].xid, xid)) - return true; - } - return false; -} - -/* - * SLogFlatHashProbeForInsert - * Find a slot for inserting a key. Returns the bucket to use. - * - * If the key already exists, returns that bucket (for update-in-place). - * Otherwise returns the first EMPTY or TOMBSTONE slot encountered. - * Returns NULL if probe limit exceeded without finding a slot. - */ -SLogFlatBucket * -SLogFlatHashProbeForInsert(SLogFlatHash *ht, const SLogTupleKey *key, - uint32 hash_val) -{ - uint32 idx; - int probe; - SLogFlatBucket *first_free = NULL; - - idx = hash_val & (uint32) (ht->capacity - 1); - - for (probe = 0; probe < SLOG_FLAT_MAX_PROBE; probe++) - { - SLogFlatBucket *bucket = &ht->buckets[idx]; - - if (bucket->hash_val == SLOG_FLAT_EMPTY) - { - /* Definitive miss; use first_free if we found one, else this */ - return first_free ? first_free : bucket; - } - - if (bucket->hash_val == SLOG_FLAT_TOMBSTONE) - { - /* Remember first tombstone for potential reuse */ - if (first_free == NULL) - first_free = bucket; - } - else if (bucket->hash_val == hash_val && - memcmp(&bucket->key, key, sizeof(SLogTupleKey)) == 0) - { - /* Key already exists */ - return bucket; - } - - idx = (idx + 1) & (uint32) (ht->capacity - 1); - } - - /* Probe limit exceeded; return first_free if available */ - return first_free; -} - -/* ---------------------------------------------------------------- - * Apply handler (single-copy, under the seqlock writer) - * ---------------------------------------------------------------- - */ - -/* - * flat_hash_apply_insert - * Apply an INSERT operation: find/create entry, add op to slot. - */ -static void -flat_hash_apply_insert(SLogFlatHash *ht, const SLogFlatOp *op) -{ - uint32 h; - SLogFlatBucket *bucket; - SLogTupleEntry *entry; - int i; - bool existing; - - h = SLogFlatHashComputeHash(&op->key); - bucket = SLogFlatHashProbeForInsert(ht, &op->key, h); - - if (bucket == NULL) - { - elog(WARNING, "SLOG_LOST_OP table_full relid=%u xid=%u op=%d", - op->key.relid, op->tuple_op.xid, (int) op->tuple_op.op_type); - return; /* table full, operation lost */ - } - - /* Determine if this is an existing entry */ - existing = (bucket->hash_val != SLOG_FLAT_EMPTY && - bucket->hash_val != SLOG_FLAT_TOMBSTONE); - - if (!existing) - { - /* New entry */ - if (bucket->hash_val == SLOG_FLAT_TOMBSTONE) - ht->num_tombstones--; - - bucket->hash_val = h; - memcpy(&bucket->key, &op->key, sizeof(SLogTupleKey)); - bucket->entry.nops = 0; - memset(&bucket->entry.key, 0, sizeof(SLogTupleKey)); - memcpy(&bucket->entry.key, &op->key, sizeof(SLogTupleKey)); - memset(bucket->entry.ops, 0, sizeof(bucket->entry.ops)); - ht->num_entries++; - } - - entry = &bucket->entry; - - /* Check if this xid already has an op (overwrite) */ - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) - { - if (entry->ops[i].in_use && - entry->ops[i].xid == op->tuple_op.xid) - { - /* Overwrite existing op for same xid */ - memcpy(&entry->ops[i], &op->tuple_op, sizeof(SLogTupleOp)); - return; - } - } - - /* Find a free slot */ - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) - { - if (!entry->ops[i].in_use) - { - memcpy(&entry->ops[i], &op->tuple_op, sizeof(SLogTupleOp)); - entry->nops++; - return; - } - } - - /* - * No free slot; reclaim the oldest horizon-eligible retained UPDATE - * marker. - * - * Hot rows (e.g. a TPC-C district) accumulate one retained UPDATE marker - * per committed transaction. Committed UPDATE markers are removed at - * commit (flat_hash_apply_commit_xid), so in steady state the array only - * fills with in-progress writers or stragglers; this path drains the - * latter. - */ - { - int oldest_idx = -1; - TransactionId reclaim_xid_horizon = op->reclaim_xid_horizon; - - /* - * Take the first horizon-eligible UPDATE marker. A marker is - * reclaimable once its xid precedes the oldest active snapshot's xmin - * (reclaim_xid_horizon): at that point the xid's outcome is settled - * and visible to (or irrelevant to) every live snapshot, so no - * residual consumer can still need it (the write-write conflict probe - * and MVCC read are served from the durable UNDO log chain). An - * in-progress xid can never precede this horizon, so this test alone - * never frees a marker a concurrent reader or writer still needs. - * Every marker that passes the horizon predicate is equally - * reclaimable, so we take the first one found. - * - * An INVALID reclaim_xid_horizon disables reclamation entirely: every - * marker is treated as non-reclaimable and we fall through to - * oldest_idx == -1. This is the fail-safe direction and is exactly - * what the caller relies on -- SLogTupleInsert passes - * InvalidTransactionId on the fast path (skipping the ProcArrayLock - * horizon scan) and only supplies a real, freshly-computed horizon on - * the full-array retry. A missing horizon must never make markers - * look reclaimable, or a live reader's / in-progress writer's op - * could be freed (lost update / MVCC corruption). - * - * The xid horizon is the authoritative, self-healing reclaim gate: a - * below-horizon marker is reclaimable whether or not it was ever - * committed in CLOG. - */ - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) - { - if (!entry->ops[i].in_use) - continue; - if (entry->ops[i].op_type != SLOG_OP_UPDATE) - continue; - if (!TransactionIdIsValid(entry->ops[i].xid)) - continue; - if (!TransactionIdIsValid(reclaim_xid_horizon) || - !TransactionIdPrecedes(entry->ops[i].xid, reclaim_xid_horizon)) - continue; /* invalid horizon disables reclaim - * (fail-safe); else still visible-relevant to - * an active snapshot, or in-progress */ - oldest_idx = i; - break; - } - - if (oldest_idx >= 0) - { - /* Safe to reclaim; no active snapshot needs this entry */ - entry->ops[oldest_idx].in_use = false; - entry->nops--; - - /* Now insert the new op in the freed slot */ - memcpy(&entry->ops[oldest_idx], &op->tuple_op, sizeof(SLogTupleOp)); - entry->nops++; - return; - } - } - - /* - * No reclaimable slot: every one of the SLOG_MAX_TUPLE_OPS slots holds an - * UPDATE whose xid is still at/above the reclaim horizon (in-progress or - * very recently committed) or a non-UPDATE marker. With the xid-horizon - * reclaim above this requires SLOG_MAX_TUPLE_OPS concurrent unsettled - * writers on a single TID, which is not reachable under normal load. The - * caller (SLogTupleInsert) detects the drop via SLogFlatHashHasOpForXid - * and falls back to local-only tracking + UNDO replay, so this is safe - * but worth surfacing. - */ - elog(WARNING, "SLOG_LOST_OP ops_full relid=%u xid=%u op=%d nops=%d horizon=%u", - op->key.relid, op->tuple_op.xid, (int) op->tuple_op.op_type, - entry->nops, op->reclaim_xid_horizon); -} - -/* - * flat_hash_apply_remove_xid - * Remove all ops for a given xid from an entry. - * Remove the entry entirely if nops reaches 0. - */ -static void -flat_hash_apply_remove_xid(SLogFlatHash *ht, const SLogFlatOp *op) -{ - SLogFlatBucket *bucket; - SLogTupleEntry *entry; - int i; - - bucket = SLogFlatHashProbe(ht, &op->key); - if (bucket == NULL) - return; - - entry = &bucket->entry; - - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) - { - if (entry->ops[i].in_use && - entry->ops[i].xid == op->xid) - { - entry->ops[i].in_use = false; - entry->nops--; - } - } - - if (entry->nops == 0) - { - bucket->hash_val = SLOG_FLAT_TOMBSTONE; - ht->num_entries--; - ht->num_tombstones++; - } -} - -/* - * flat_hash_apply_remove_entry - * Remove an entire entry (tombstone it). - */ -static void -flat_hash_apply_remove_entry(SLogFlatHash *ht, const SLogFlatOp *op) -{ - SLogFlatBucket *bucket; - - bucket = SLogFlatHashProbe(ht, &op->key); - if (bucket == NULL) - return; - - bucket->hash_val = SLOG_FLAT_TOMBSTONE; - ht->num_entries--; - ht->num_tombstones++; -} - -/* - * flat_hash_apply_mark_aborted - * Mark all ops for a given xid as SLOG_OP_ABORTED. - */ -static void -flat_hash_apply_mark_aborted(SLogFlatHash *ht, const SLogFlatOp *op) -{ - SLogFlatBucket *bucket; - SLogTupleEntry *entry; - int i; - - bucket = SLogFlatHashProbe(ht, &op->key); - if (bucket == NULL) - return; - - entry = &bucket->entry; - - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) - { - if (entry->ops[i].in_use && - entry->ops[i].xid == op->xid) - { - entry->ops[i].op_type = SLOG_OP_ABORTED; - } - } -} - -/* - * flat_hash_apply_update_op - * Update a specific op slot in place (e.g. re-parent a subxid). - */ -static void -flat_hash_apply_update_op(SLogFlatHash *ht, const SLogFlatOp *op) -{ - SLogFlatBucket *bucket; - SLogTupleEntry *entry; - int i; - - bucket = SLogFlatHashProbe(ht, &op->key); - if (bucket == NULL) - return; - - entry = &bucket->entry; - - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) - { - if (entry->ops[i].in_use && - entry->ops[i].xid == op->xid) - { - /* - * Apply selective updates from the op. We use the tuple_op - * fields as the source of truth for what to update. - */ - if (op->subxid != InvalidTransactionId) - { - /* Re-parent subxid */ - if (entry->ops[i].subxid == op->tuple_op.subxid) - entry->ops[i].subxid = op->subxid; - } - break; - } - } -} - -/* - * flat_hash_apply_commit_xid - * Remove every op belonging to xid. - * - * Committed UPDATE markers are no longer retained here. - * The write-write conflict probe reads the head verptr on the tuple - * and resolves it in the durable UNDO log (via the AM's version-reconstruction - * walk), and no shared before-image is kept. With no consumer - * for a retained marker, an UPDATE at commit is treated like INSERT/DELETE/LOCK: - * removed. This drains bucket table_full pressure. - */ -static void -flat_hash_apply_commit_xid(SLogFlatHash *ht, const SLogFlatOp *op) -{ - SLogFlatBucket *bucket; - SLogTupleEntry *entry; - int i; - - bucket = SLogFlatHashProbe(ht, &op->key); - if (bucket == NULL) - return; - - entry = &bucket->entry; - - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) - { - if (!entry->ops[i].in_use) - continue; - if (entry->ops[i].xid != op->xid) - continue; - - entry->ops[i].in_use = false; - entry->nops--; - } - - if (entry->nops == 0) - { - bucket->hash_val = SLOG_FLAT_TOMBSTONE; - ht->num_entries--; - ht->num_tombstones++; - } -} - -/* - * flat_hash_apply_cleanup_retained - * Remove retained committed UPDATE markers whose committing xid - * precedes the reclaim xid horizon (op->reclaim_xid_horizon). - * - * Gates on the xid horizon -- NOT an HLC threshold -- so a marker is - * reclaimed only when its committing xid precedes every live snapshot. - * The write-write conflict probe and MVCC read use the durable per-backend - * UNDO log chain, not this hash, so retained UPDATE markers here are dead - * bookkeeping; this sweep still applies for any leftover - * entries not removed at commit. - */ -static void -flat_hash_apply_cleanup_retained(SLogFlatHash *ht, const SLogFlatOp *op) -{ - SLogFlatBucket *bucket; - SLogTupleEntry *entry; - int i; - - bucket = SLogFlatHashProbe(ht, &op->key); - if (bucket == NULL) - return; - - entry = &bucket->entry; - - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) - { - if (!entry->ops[i].in_use) - continue; - if (entry->ops[i].op_type != SLOG_OP_UPDATE) - continue; - if (!TransactionIdIsValid(entry->ops[i].xid)) - continue; - if (TransactionIdIsValid(op->reclaim_xid_horizon) && - !TransactionIdPrecedes(entry->ops[i].xid, - op->reclaim_xid_horizon)) - continue; - - /* - * Expired retained entry. Gated on the xid horizon alone -- a marker - * committed in CLOG or an aborted/in-flight one is drained the moment - * its xid precedes the horizon, so no slot is pinned forever. Must - * match the read-side eligibility scan in SLogTupleCleanupRetained. - */ - entry->ops[i].in_use = false; - entry->nops--; - } - - if (entry->nops == 0) - { - bucket->hash_val = SLOG_FLAT_TOMBSTONE; - ht->num_entries--; - ht->num_tombstones++; - } -} - -/* - * flat_hash_apply_create_aborted - * Create a new entry with an ABORTED op (for local-only INSERT abort). - */ -static void -flat_hash_apply_create_aborted(SLogFlatHash *ht, const SLogFlatOp *op) -{ - uint32 h; - SLogFlatBucket *bucket; - SLogTupleEntry *entry; - int i; - bool existing; - - h = SLogFlatHashComputeHash(&op->key); - bucket = SLogFlatHashProbeForInsert(ht, &op->key, h); - - if (bucket == NULL) - return; /* table full */ - - existing = (bucket->hash_val != SLOG_FLAT_EMPTY && - bucket->hash_val != SLOG_FLAT_TOMBSTONE); - - if (!existing) - { - if (bucket->hash_val == SLOG_FLAT_TOMBSTONE) - ht->num_tombstones--; - - bucket->hash_val = h; - memcpy(&bucket->key, &op->key, sizeof(SLogTupleKey)); - bucket->entry.nops = 0; - memset(&bucket->entry.key, 0, sizeof(SLogTupleKey)); - memcpy(&bucket->entry.key, &op->key, sizeof(SLogTupleKey)); - memset(bucket->entry.ops, 0, sizeof(bucket->entry.ops)); - ht->num_entries++; - } - - entry = &bucket->entry; - - /* Find a free slot for the ABORTED entry */ - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) - { - if (!entry->ops[i].in_use) - { - entry->ops[i].xid = op->xid; - entry->ops[i].subxid = op->subxid; - entry->ops[i].op_type = SLOG_OP_ABORTED; - entry->ops[i].in_use = true; - entry->ops[i].commit_ts = 0; - entry->ops[i].spec_token = 0; - entry->ops[i].cid = InvalidCommandId; - entry->nops++; - return; - } - } - /* No free slot; operation lost */ -} - -/* - * SLogFlatHashApply - * Apply one op to the flat hash. Dispatches to operation-specific handlers. - */ -void -SLogFlatHashApply(void *data, const void *operation, Size op_size) -{ - SLogFlatHash *ht = (SLogFlatHash *) data; - const SLogFlatOp *op = (const SLogFlatOp *) operation; - - Assert(op_size == sizeof(SLogFlatOp)); - - switch (op->kind) - { - case SLOG_FLAT_OP_INSERT: - flat_hash_apply_insert(ht, op); - break; - case SLOG_FLAT_OP_REMOVE_XID: - flat_hash_apply_remove_xid(ht, op); - break; - case SLOG_FLAT_OP_REMOVE_ENTRY: - flat_hash_apply_remove_entry(ht, op); - break; - case SLOG_FLAT_OP_MARK_ABORTED: - flat_hash_apply_mark_aborted(ht, op); - break; - case SLOG_FLAT_OP_UPDATE_OP: - flat_hash_apply_update_op(ht, op); - break; - case SLOG_FLAT_OP_COMMIT_XID: - flat_hash_apply_commit_xid(ht, op); - break; - case SLOG_FLAT_OP_CLEANUP_RETAINED: - flat_hash_apply_cleanup_retained(ht, op); - break; - case SLOG_FLAT_OP_CREATE_ABORTED: - flat_hash_apply_create_aborted(ht, op); - break; - } -} - -/* ---------------------------------------------------------------- - * Scan API - * ---------------------------------------------------------------- - */ - -/* - * SLogFlatHashScanInit - * Initialize a sequential scan over the flat hash. - */ -void -SLogFlatHashScanInit(SLogFlatHashScanState *state) -{ - state->current_index = 0; -} - -/* - * SLogFlatHashScanNext - * Return the next occupied bucket, or NULL when the scan is complete. - * - * Skips EMPTY and TOMBSTONE slots. The returned pointer is valid only - * within the current seqlock read section (or under the writer lock). - */ -const SLogFlatBucket * -SLogFlatHashScanNext(const SLogFlatHash *ht, SLogFlatHashScanState *state) -{ - while (state->current_index < ht->capacity) - { - const SLogFlatBucket *bucket = &ht->buckets[state->current_index]; - - state->current_index++; - - if (bucket->hash_val != SLOG_FLAT_EMPTY && - bucket->hash_val != SLOG_FLAT_TOMBSTONE) - { - return bucket; - } - } - - return NULL; -} diff --git a/src/backend/access/undo/slog_tuple.c b/src/backend/access/undo/slog_tuple.c index 8aa62cb874abc..6057516549f74 100644 --- a/src/backend/access/undo/slog_tuple.c +++ b/src/backend/access/undo/slog_tuple.c @@ -1,24 +1,62 @@ /*------------------------------------------------------------------------- * * slog_tuple.c - * Tuple sLog -- optional per-tuple UNDO tracking extension - * - * The tuple sLog is an OPTIONAL extension of the sLog subsystem (slog.c). - * It provides bounded-recovery-time uncommitted-writer tracking for in-place - * MVCC table access methods: a flat, partitioned, seqlock-guarded hash of - * in-flight per-tuple operations, keyed by (relid, tid). Because the - * uncommitted writers of every hot tuple are recorded in shared memory, - * recovery and cross-backend visibility need not scan an unbounded UNDO - * chain to decide whether a given physical tuple version is visible; the - * hash answers "is there an in-flight/aborted writer of this tuple?" in - * O(1), which is what bounds recovery and read cost. - * - * The flat hash and its shared-memory partitions are carved from the same - * shared segment as the transaction sLog (see slog.c's SLogShmemInit); the - * two facilities share only that segment and its initialization, never each - * other's data structures. - * - * WAL-free: entries are transient, removed at commit/abort. + * Tuple sLog -- per-tuple state of UNDO-based table access methods + * + * An UNDO-based table AM updates rows in place and judges a version by the + * writer xids in its header and CLOG, as heap does, so it needs no shared + * per-tuple record of who wrote what. It keeps two other things here, keyed + * by tuple (relation OID, relation storage, TID), without reference to any + * AM's page format: + * + * Row locks (SLogTupleLock*). Such an AM records no row locker in the tuple + * (no locker xmax, no MultiXact), so the holder and strength of each row + * lock live here. Nothing is allocated at startup. The first row-lock call + * in the cluster creates, through the DSM registry, a directory with one + * slot per PGPROC (so also one per prepared transaction's dummy proc). A + * slot keeps the first few TIDs its transaction locks in records of its own, + * and any more in one hash table per relation storage, in a DSA that is + * created by the first such lock and grows as far as needed. So a + * transaction that locks a few rows touches neither the shared allocator + * nor any LWLock of the store: other backends read its records under a + * change counter, and take the slot's LWLock (shared; the owner changes a + * table under it exclusive) only when the slot has tables. Only the owner + * writes its slot and tables, and it reads them without the lock. A table + * is one allocation, and a slot keeps its small emptied tables for its next + * transactions, so a transaction that locks a few hundred rows usually + * makes no allocation either. + * + * A conflict probe does not visit every holder: a shared index, created + * with the directory, maps a hash of each locked TID to the set of slots + * that may hold a lock on it, and the probe visits only those. + * + * A TID's entries are in one place: its record, or its table. In a table + * the key is the TID, with a small "depth" in the high bits: + * (depth << 48) | (block << 16) | offset; a record keeps the same entries + * in an array. A TID usually has one entry, at + * depth 0. Its value is the lock strength and the xid of the subtransaction + * that took it, which is what a conflicting waiter sleeps on, so that + * ROLLBACK TO wakes it, as with heap's xmax. A transaction keeps one entry + * per subtransaction level only while the inner lock is stronger: after + * SAVEPOINT s; FOR UPDATE; ROLLBACK TO s the outer FOR KEY SHARE must still + * be there. Strengths strictly increase with depth, so a TID has at most + * four entries. + * + * Nothing is done at ROLLBACK TO or at subtransaction commit: an entry whose + * subtransaction aborted is dead because its xid is neither current (for the + * owner) nor in progress (for everyone else). The AM calls + * SLogTupleLockReleaseAll at top-level commit or abort, which empties the + * slot and frees or empties its tables. SLogTupleLockAtPrepare moves them to the + * prepared transaction's dummy-proc slot and SLogTupleLockReleasePrepared + * frees them at COMMIT/ROLLBACK PREPARED; the AM logs the live locks in its + * own two-phase records and restores them at the end of recovery + * (SLogTupleLockRestore). + * + * Inserted tuples (SLogTupleTrackLocalOnly and friends), backend-locally: + * the tuples this transaction inserted, so that commit can clear their + * "uncommitted" hint and so that savepoint rollback can say which of them + * were rolled back (SLogTupleCollectTrackedKeys). Nothing is allocated + * until a transaction inserts a row, and all of it goes at transaction end. * * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group * Portions Copyright (c) 1994, Regents of the University of California @@ -30,213 +68,42 @@ */ #include "postgres.h" -#include -#ifdef WIN32 -#include -#endif - #include "access/slog.h" -#include "access/slog_flathash.h" -#include "access/slog_internal.h" -#include "access/transam.h" +#include "access/twophase.h" #include "access/xact.h" #include "common/hashfn.h" -#include "nodes/lockoptions.h" +#include "port/pg_bitutils.h" #include "miscadmin.h" +#include "storage/dsm_registry.h" +#include "storage/ipc.h" #include "storage/lock.h" #include "storage/lwlock.h" #include "storage/off.h" #include "storage/proc.h" #include "storage/procarray.h" -#include "storage/shmem.h" #include "storage/spin.h" #include "utils/dsa.h" +#include "utils/injection_point.h" #include "utils/memutils.h" -#include "utils/snapmgr.h" -#include "utils/timestamp.h" - - -/* - * slog_num_cpus - portable replacement for sysconf(_SC_NPROCESSORS_ONLN). - * - * Returns the number of online CPUs (or a sane positive fallback if the - * platform refuses to answer). Used to auto-size the sLog flat-hash - * partition count when the GUC is left at zero. - */ -static int -slog_num_cpus(void) -{ -#ifdef WIN32 - SYSTEM_INFO si; - - GetSystemInfo(&si); - if (si.dwNumberOfProcessors > 0) - return (int) si.dwNumberOfProcessors; - return 4; -#else - long n = sysconf(_SC_NPROCESSORS_ONLN); - - if (n <= 0) - return 4; - return (int) n; -#endif -} - - -/* - * Maximum number of abort ops applied to a partition between seqlock - * begin/end cycles in SLogTupleMarkAborted / SLogTupleCleanupRetained. - * - * The seqlock keeps its counter odd for the whole write cycle, so any - * concurrent wait-free reader spins until the counter turns even. A very - * large rollback (hundreds of thousands of tuples applied in one cycle) - * would pin readers spinning for the entire batch. Ending and re-beginning - * the seqlock every SLOG_ABORT_PUBLISH_BATCH ops bounds that odd-hold - * window; the partition writer_lock is held across the whole function, so - * dropping to even at a batch boundary leaves the partition consistent for - * any reader that observes it. - */ -#define SLOG_ABORT_PUBLISH_BATCH 24 - - -/* - * Wall-clock sampling period for the self-clocking throttles in - * SLogTupleInsert() and SLogTupleMaybeCleanupRetained(). GetCurrentTimestamp() - * is a clock_gettime() syscall that dominates the per-tuple hot path, so we - * read it once every SLOG_INSERT_CLOCK_PERIOD calls and reuse the cached - * horizon in between. Must be a power of two (callers mask with PERIOD - 1). - */ -#define SLOG_INSERT_CLOCK_PERIOD 256 - - -/* - * SLogTupleNumEntries - * Calculate the number of hash entries for the tuple sLog. - * - * Auto-sizing formula: MaxBackends * 1024, clamped to [4096, 4194304]. - */ -int -SLogTupleNumEntries(void) -{ - int n = MaxBackends * SLOG_TUPLE_PER_BACKEND_SLOTS; - - n = Max(n, SLOG_TUPLE_MIN_ENTRIES); - n = Min(n, SLOG_TUPLE_MAX_ENTRIES); - return n; -} - - - -/* - * GUC: slog_num_partitions; number of flat hash partitions. - * 0 = auto (heuristic: 4 x NumCPUs, clamped [16..256], power of 2). - * Set at postmaster startup, immutable thereafter. - */ -int slog_num_partitions = 0; - -/* Runtime partition count (set once during SLogShmemInit, read everywhere) */ -int SLogNumPartitions = SLOG_FLAT_DEFAULT_PARTITIONS; - -/* - * Compute the effective partition count from the GUC value. - * Called once during SLogShmemInit. - */ -static int -SLogComputeNumPartitions(void) -{ - int n; - - if (slog_num_partitions > 0) - { - /* Explicit GUC value; clamp and round to power of 2 */ - n = slog_num_partitions; - } - else - { - /* Auto-size: 4 x number of CPUs */ - int ncpus = slog_num_cpus(); - - n = ncpus * 4; - } - - /* Clamp */ - n = Max(n, SLOG_FLAT_MIN_PARTITIONS); - n = Min(n, SLOG_FLAT_MAX_PARTITIONS); - - /* Round up to next power of 2 (for fast modulo via bitmask) */ - { - int p = 1; - - while (p < n) - p <<= 1; - n = p; - } - - return n; -} - -/* - * Flat hash capacity: round up SLogTupleNumEntries to next power of 2, - * then divide by 0.7 (max load factor) to ensure probe chains stay short. - */ -static inline int -SLogFlatHashCapacity(void) -{ - int n = SLogTupleNumEntries(); - int cap; - - /* Target: num_entries / capacity <= 0.7, so capacity >= n / 0.7 */ - cap = (int) ((double) n / 0.7) + 1; - - /* Round up to next power of 2 */ - cap--; - cap |= cap >> 1; - cap |= cap >> 2; - cap |= cap >> 4; - cap |= cap >> 8; - cap |= cap >> 16; - cap++; - - /* Clamp to reasonable bounds */ - if (cap < 2048) - cap = 2048; - if (cap > 2 * 1048576) - cap = 2 * 1048576; - - return cap; -} - -/* ---------------------------------------------------------------- - * Static variables (tuple sLog) - * ---------------------------------------------------------------- - */ - -/* Pointer to ShmemAlloc'd flat-hash region, set by ShmemRequestStruct framework */ -static char *SLogFlatHashBlock = NULL; /* single allocation for all partition - * flat-hash copies */ - -/* Rate-limiting for sLog overflow warnings (per-backend) */ -static int slog_overflow_warning_count = 0; -static TimestampTz slog_overflow_last_warning = 0; +static void slog_tl_forget_storage(Oid relid, RelFileNumber relnumber); /* ---------------------------------------------------------------- - * Backend-private tracking for tuple sLog cleanup + * Backend-private tracking of inserted tuples * ---------------------------------------------------------------- */ typedef struct SLogTrackedKey { SLogTupleKey key; + RelFileNumber relnumber; /* the relation's storage the row went to */ TransactionId xid; TransactionId subxid; - bool local_only; /* no shared hash entry (INSERT-only) */ - SLogOpType op_type; /* DML type (for commit retention decisions) */ + SLogOpType op_type; /* INSERT, or ABORTED once its savepoint is */ struct SLogTrackedKey *next; } SLogTrackedKey; static SLogTrackedKey *slog_tracked_keys = NULL; -static bool slog_has_shared_entries = false; /* any non-local_only entries? */ /* ---------------------------------------------------------------- * TID encoding for backend-local INSERT tracking @@ -277,21 +144,23 @@ static bool slog_has_shared_entries = false; /* any non-local_only entries? */ typedef struct SLogInsertTidKey { Oid relid; + RelFileNumber relnumber; /* the relation's storage the row went to */ uint64 encoded_tid; /* SLOG_ENCODE_TID(blkno, offnum) */ } SLogInsertTidKey; typedef struct SLogInsertTidEntry { - SLogInsertTidKey key; /* (relid, encoded_tid) */ + SLogInsertTidKey key; /* (relid, relnumber, encoded_tid) */ char status; /* required by simplehash */ } SLogInsertTidEntry; static inline uint32 -slog_insert_tid_hash(Oid relid, uint64 encoded_tid) +slog_insert_tid_hash(const SLogInsertTidKey *key) { - uint64 h = hash_combine64((uint64) hash_uint32((uint32) relid), - murmurhash64(encoded_tid)); + uint64 h = hash_combine64((uint64) hash_uint32((uint32) key->relid), + murmurhash64(key->encoded_tid)); + h = hash_combine64(h, (uint64) hash_uint32((uint32) key->relnumber)); return (uint32) (h ^ (h >> 32)); } @@ -299,8 +168,10 @@ slog_insert_tid_hash(Oid relid, uint64 encoded_tid) #define SH_ELEMENT_TYPE SLogInsertTidEntry #define SH_KEY_TYPE SLogInsertTidKey #define SH_KEY key -#define SH_HASH_KEY(tb, key) slog_insert_tid_hash((key).relid, (key).encoded_tid) -#define SH_EQUAL(tb, a, b) ((a).relid == (b).relid && (a).encoded_tid == (b).encoded_tid) +#define SH_HASH_KEY(tb, key) slog_insert_tid_hash(&(key)) +#define SH_EQUAL(tb, a, b) ((a).relid == (b).relid && \ + (a).relnumber == (b).relnumber && \ + (a).encoded_tid == (b).encoded_tid) #define SH_SCOPE static inline #define SH_DEFINE #define SH_DECLARE @@ -320,2392 +191,1433 @@ slog_insert_tids_ensure(void) return slog_insert_tids; } -/* ---------------------------------------------------------------- - * Internal helpers - * ---------------------------------------------------------------- - */ - /* - * Partition accessor helpers for the tuple sLog. + * SLogTupleTrackLocalOnly + * Lightweight local-only tracking (INSERTs only). + * + * Records (relid, relnumber, tid, xid, subxid) in backend-local memory. * - * These inline functions encapsulate the key->partition routing so that - * callers don't need to repeat the pattern. + * Outside a subtransaction the TID goes into a backend-local open-addressing + * hash (simplehash) keyed by (relid, relnumber, encoded_tid), which keeps + * bulk INSERT at a few bytes per row. Inside one, it goes on a linked list, + * as subtransaction abort needs per-entry subxid filtering. */ -static inline SLogFlatPartition * -SLogGetPartition(const SLogTupleKey *key) +void +SLogTupleTrackLocalOnly(Oid relid, RelFileNumber relnumber, ItemPointer tid, + TransactionId xid, TransactionId subxid) { - int part = SLogFlatHashPartitionIndex(key); + MemoryContext oldcxt; - return &SLogState->tuple_partitions[part]; -} + /* + * Fast path: top-level transaction with no savepoint -> use the hash. The + * subxid is InvalidTransactionId in this case (top-level INSERTs always + * pass the top xid as both xid and subxid=Invalid). + */ + if (!IsSubTransaction()) + { + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + SLogInsertTidKey ikey; + bool found; -static inline SLogFlatPartition * -SLogGetPartitionByIndex(int part) -{ - Assert(part >= 0 && part < SLogNumPartitions); - return &SLogState->tuple_partitions[part]; -} + if (offnum > MaxOffsetNumber) + elog(ERROR, "offset %u exceeds page item limit %d in sLog TID encoding", + offnum, MaxOffsetNumber); + ikey.relid = relid; + ikey.relnumber = relnumber; + ikey.encoded_tid = SLOG_ENCODE_TID(blkno, offnum); -/* - * Convenience macros for partition-routed locking. - * - * Most functions have a local `key` variable of type SLogTupleKey and need - * to route to the correct partition. These macros minimize the diff from - * the old single-lock code. The `fp__` variable is defined in-scope by - * SLOG_PART_READ_BEGIN / SLOG_PART_WRITE_BEGIN. - */ -#define SLOG_PART_WRITER_LOCK(key_ptr) \ - (&SLogGetPartition(key_ptr)->writer_lock.lock) + /* + * Record the TID in the backend-local hash. Insert is amortized + * O(1); the table and its entries live in TopTransactionContext and + * are destroyed at transaction end. + */ + (void) sloginsert_insert(slog_insert_tids_ensure(), ikey, &found); + return; + } -/* - * SLogPartApplyOne - * Single-op write helper: acquire writer_lock, one seqlock cycle, one - * apply, release. Covers the many write sites that apply exactly one - * SLogFlatOp to fp->hash. - */ -static inline void -SLogPartApplyOne(SLogFlatPartition *fp, const SLogFlatOp *op) -{ - LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); - SLogFlatHashApply(fp->hash, op, sizeof(*op)); - LWLockRelease(&fp->writer_lock.lock); -} + { + SLogTrackedKey *tk; + SLogTupleKey key; -/* ---------------------------------------------------------------- - * Tuple sLog shared-memory sizing and initialization - * - * Called from slog.c's SLogShmemSize/Request/Init so the tuple flat-hash - * partitions share the sLog's shared segment. Keeping the tuple-specific - * shmem code here (rather than in slog.c) keeps slog.c free of any - * flat-hash/partition knowledge. - * ---------------------------------------------------------------- - */ + memset(&key, 0, sizeof(key)); + key.relid = relid; + key.relnumber = relnumber; + ItemPointerCopy(tid, &key.tid); -/* - * SLogTupleShmemSize - * Additional shared memory needed for the tuple flat-hash partitions. - */ -Size -SLogTupleShmemSize(void) -{ - return SLogFlatHashPartitionedShmemSize(SLogFlatHashCapacity(), MaxBackends); + oldcxt = MemoryContextSwitchTo(TopTransactionContext); + + tk = (SLogTrackedKey *) palloc(sizeof(SLogTrackedKey)); + memcpy(&tk->key, &key, sizeof(SLogTupleKey)); + tk->relnumber = relnumber; + tk->xid = xid; + tk->subxid = subxid; + tk->op_type = SLOG_OP_INSERT; + tk->next = slog_tracked_keys; + slog_tracked_keys = tk; + + MemoryContextSwitchTo(oldcxt); + } } /* - * SLogTupleShmemRequest - * Register the tuple flat-hash partition block. + * SLogTupleRemoveBySubXid + * Mark the inserts of a rolled-back subtransaction as rolled back. + * + * Its tuples are already invisible to everyone (their xmin is the aborted + * subtransaction's xid); the mark tells commit not to clear their + * uncommitted hint and lets the AM discard them. */ void -SLogTupleShmemRequest(void) +SLogTupleRemoveBySubXid(TransactionId xid, TransactionId subxid) { - /* Compute partition count early so shmem sizing is correct */ - SLogNumPartitions = SLogComputeNumPartitions(); - - ShmemRequestStruct(.name = "sLog Flat Hash Partitions", - .size = SLogFlatHashPartitionedShmemSize( - SLogFlatHashCapacity(), MaxBackends), - .ptr = (void **) &SLogFlatHashBlock, - ); + SLogTrackedKey *tk; + + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (TransactionIdEquals(tk->xid, xid) && tk->subxid == subxid) + tk->op_type = SLOG_OP_ABORTED; + } } /* - * SLogTupleShmemInit - * Allocate and initialize the tuple flat-hash partitions. - * - * Invoked from SLogShmemInit() after the shared state struct exists. Runs - * unconditionally (whether or not an AM has opted in) so the two sLog files - * share one segment with no init-ordering dependency; an idle flat hash - * costs only its shared-memory footprint. + * SLogTupleUpdateSubXid + * Re-parent a committed subtransaction's inserts to its parent, so a + * later rollback of the parent finds them. */ void -SLogTupleShmemInit(void) +SLogTupleUpdateSubXid(TransactionId xid, + TransactionId old_subxid, + TransactionId new_subxid) { - int total_capacity; - int per_part_cap; - Size per_part_shmem_size; - char *block_ptr; - int part; - - /* ---- Compute and set partition count (once, globally) ---- */ - SLogNumPartitions = SLogComputeNumPartitions(); - SLogState->num_partitions = SLogNumPartitions; - - /* Allocate partition array in shared memory (after SLogState) */ - SLogState->tuple_partitions = (SLogFlatPartition *) - ShmemAlloc(sizeof(SLogFlatPartition) * SLogNumPartitions); - memset(SLogState->tuple_partitions, 0, - sizeof(SLogFlatPartition) * SLogNumPartitions); - - ereport(DEBUG1, - (errmsg("sLog: %d flat hash partitions (slog_num_partitions=%d, CPUs=%d)", - SLogNumPartitions, slog_num_partitions, - slog_num_cpus()))); - - /* ---- Initialize partitioned seqlock flat hashes ---- */ - total_capacity = SLogFlatHashCapacity(); - per_part_cap = total_capacity / SLogNumPartitions; - if (per_part_cap < 64) - per_part_cap = 64; - - per_part_shmem_size = MAXALIGN(SLogFlatHashShmemSize(per_part_cap, - MaxBackends)); - block_ptr = SLogFlatHashBlock; - - for (part = 0; part < SLogNumPartitions; part++) - { - SLogFlatPartition *fp = &SLogState->tuple_partitions[part]; - - /* Initialize per-partition writer lock */ - LWLockInitialize(&fp->writer_lock.lock, LWTRANCHE_SLOG); - - /* Single flat-hash copy carved from the shared block */ - fp->hash = (SLogFlatHash *) block_ptr; - SLogFlatHashInit(fp->hash, per_part_cap); + SLogTrackedKey *tk; - block_ptr += per_part_shmem_size; + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (TransactionIdEquals(tk->xid, xid) && tk->subxid == old_subxid) + tk->subxid = new_subxid; } } -/* ================================================================ - * Tuple sLog functions - * ================================================================ - */ - -/* ---------------------------------------------------------------- - * Emergency eviction - * ---------------------------------------------------------------- +/* + * SLogTupleResetTracking + * Forget this transaction's tracked inserts (at transaction end). + * + * Everything lives in TopTransactionContext, so only the pointers need + * resetting. */ +void +SLogTupleResetTracking(void) +{ + slog_insert_tids = NULL; + slog_tracked_keys = NULL; +} /* - * SLogTupleEvictCommitted -- evict entries for committed transactions. + * SLogTupleCollectTrackedKeys + * Collect all tracked keys for a given xid into a palloc'd array. * - * Called when the sLog flat hash is full. Scans each partition under its - * writer lock to collect evictable keys, then applies REMOVE_ENTRY ops. - * (This is a cold path, run only when a partition is full; holding the - * writer lock across the scan is simpler than a seqlock retry loop and the - * per-op TransactionIdIsInProgress/DidCommit probes must not run repeatedly.) + * Returns the number of collected keys. The caller can then sort the + * array for batch processing (e.g. by relid/blockno to amortize buffer + * I/O at commit time). * - * Returns the number of entries evicted. + * The returned array is allocated in the current memory context; the caller + * is responsible for pfree(). */ -static int -SLogTupleEvictCommitted(void) +int +SLogTupleCollectTrackedKeys(TransactionId xid, SLogTrackedKeyInfo **out_keys) { - SLogTupleKey *keys_to_evict; - int nkeys = 0; - int max_evict = 1024; - int part; + SLogTrackedKey *tk; + int count = 0; + int capacity = 64; + SLogTrackedKeyInfo *arr; - keys_to_evict = (SLogTupleKey *) - palloc(sizeof(SLogTupleKey) * max_evict); + /* + * A single very large transaction can touch tens of millions of tracked + * keys; at 48 bytes each the array crosses MaxAllocSize (1 GB) past ~22M + * entries. Use the huge-allocation API so bulk loads do not fail at + * commit. This array is transient and pfree'd by the caller. + */ + arr = (SLogTrackedKeyInfo *) MemoryContextAllocHuge(CurrentMemoryContext, + sizeof(SLogTrackedKeyInfo) * (Size) capacity); - /* First pass: scan each partition under its writer lock to collect keys */ - for (part = 0; part < SLogNumPartitions && nkeys < max_evict; part++) + /* Collect backend-local INSERT-hash entries (top-level local-only) */ + if (slog_insert_tids != NULL) { - SLogFlatPartition *fp = SLogGetPartitionByIndex(part); - SLogFlatHashScanState scan; - const SLogFlatBucket *bucket; - - LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + sloginsert_iterator it; + SLogInsertTidEntry *ie; - SLogFlatHashScanInit(&scan); - while ((bucket = SLogFlatHashScanNext(fp->hash, &scan)) != NULL) + sloginsert_start_iterate(slog_insert_tids, &it); + while ((ie = sloginsert_iterate(slog_insert_tids, &it)) != NULL) { - const SLogTupleEntry *entry = &bucket->entry; - bool all_committed = true; - bool has_any_op = false; - int i; - - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + if (count >= capacity) { - if (!entry->ops[i].in_use) - continue; - has_any_op = true; - - if (TransactionIdIsInProgress(entry->ops[i].xid) || - !TransactionIdDidCommit(entry->ops[i].xid)) - { - all_committed = false; - break; - } + capacity *= 2; + arr = (SLogTrackedKeyInfo *) + repalloc_huge(arr, sizeof(SLogTrackedKeyInfo) * (Size) capacity); } - if (has_any_op && all_committed) - { - keys_to_evict[nkeys++] = bucket->key; - if (nkeys >= max_evict) - break; - } + memset(&arr[count].key, 0, sizeof(SLogTupleKey)); + arr[count].key.relid = ie->key.relid; + arr[count].key.relnumber = ie->key.relnumber; + ItemPointerSet(&arr[count].key.tid, + SLOG_DECODE_BLKNO(ie->key.encoded_tid), + SLOG_DECODE_OFFNUM(ie->key.encoded_tid)); + arr[count].relnumber = ie->key.relnumber; + arr[count].xid = xid; + arr[count].subxid = InvalidTransactionId; + arr[count].op_type = SLOG_OP_INSERT; + count++; } - - LWLockRelease(&fp->writer_lock.lock); } - /* Second pass: apply removals grouped by partition */ - if (nkeys > 0) + /* Collect linked-list entries */ + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) { - int i; + if (!TransactionIdEquals(tk->xid, xid)) + continue; - for (part = 0; part < SLogNumPartitions; part++) + if (count >= capacity) { - SLogFlatPartition *fp = SLogGetPartitionByIndex(part); - bool has_keys_for_part = false; - - /* Check if any keys belong to this partition */ - for (i = 0; i < nkeys; i++) - { - if (SLogFlatHashPartitionIndex(&keys_to_evict[i]) == part) - { - has_keys_for_part = true; - break; - } - } - if (!has_keys_for_part) - continue; - - LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); - - for (i = 0; i < nkeys; i++) - { - SLogFlatOp flat_op; - - if (SLogFlatHashPartitionIndex(&keys_to_evict[i]) != part) - continue; - - memset(&flat_op, 0, sizeof(flat_op)); - flat_op.kind = SLOG_FLAT_OP_REMOVE_ENTRY; - flat_op.key = keys_to_evict[i]; - SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); - } - LWLockRelease(&fp->writer_lock.lock); + capacity *= 2; + arr = (SLogTrackedKeyInfo *) + repalloc_huge(arr, sizeof(SLogTrackedKeyInfo) * (Size) capacity); } - } - pfree(keys_to_evict); + arr[count].key = tk->key; + arr[count].relnumber = tk->relnumber; + arr[count].xid = tk->xid; + arr[count].subxid = tk->subxid; + arr[count].op_type = tk->op_type; + count++; + } - return nkeys; + *out_keys = arr; + return count; } -/* ---------------------------------------------------------------- - * Core Tuple sLog API - * ---------------------------------------------------------------- - */ - /* - * SLogTupleInsert - * Record a tuple operation in the sLog. - * - * Inserts into the seqlock-protected flat hash (wait-free reads). - * Performs overflow handling before failing. - * - * Also adds the key to the backend-private tracking list for cleanup. + * Forget an irreversibly truncated storage incarnation. The caller holds + * AccessExclusiveLock and has already truncated the file: no old tuple can + * survive, even when TRUNCATE reused the same relfilenumber, and its TIDs + * will be reused. Forget the tracked inserts in it, lest commit stamp the + * new occupants, and our row locks on it. (No other transaction can hold + * any.) */ -bool -SLogTupleInsert(Oid relid, ItemPointer tid, TransactionId xid, - SLogOpType op_type, TransactionId subxid, - CommandId cid, TimestampTz commit_ts, - uint32 spec_token, LockTupleMode lock_mode) +void +SLogTupleForgetStorage(Oid relid, RelFileNumber relnumber) { - SLogTupleKey key; - SLogFlatOp flat_op; - SLogFlatPartition *fp; - int entries_before; - int entries_after; - - Assert(SLogState != NULL); - Assert(TransactionIdIsValid(xid)); - Assert(ItemPointerIsValid(tid)); - - /* Zero for deterministic hashing (ItemPointerData is 6 bytes) */ - memset(&key, 0, sizeof(key)); - key.relid = relid; - ItemPointerCopy(tid, &key.tid); + SLogTrackedKey **link = &slog_tracked_keys; - fp = SLogGetPartition(&key); - - /* - * Retained UPDATE markers are never evicted by the on-overflow path -- - * SLogTupleEvictCommitted() leaves any op whose xid is in-progress or not - * yet committed in CLOG. They are cleaned up by - * SLogTupleCleanupRetained(), driven by the UNDO background worker or, - * when it is disabled, by the access method calling - * SLogTupleMaybeCleanupRetained() outside any buffer-locked section. This - * function never triggers that sweep itself (see note below). - * - * Per-TID ops array overflow (SLOG_MAX_TUPLE_OPS=32 slots all full of - * retained entries on hot rows) is handled by flat_hash_apply_insert() - * which reclaims the oldest retained entry when no free slot exists. - */ + slog_tl_forget_storage(relid, relnumber); + while (*link != NULL) { - /* Build the flat hash op */ - memset(&flat_op, 0, sizeof(flat_op)); - flat_op.kind = SLOG_FLAT_OP_INSERT; - flat_op.key = key; - flat_op.xid = xid; - flat_op.subxid = subxid; + SLogTrackedKey *tk = *link; - /* - * The xid reclaim horizon is computed LAZILY, not on every insert. It - * is only consumed by flat_hash_apply_insert to reclaim a slot when a - * hot row's per-TID ops array is completely full; the common path - * (free slot, same-xid overwrite, or a slot freed by coalescing) - * never reads it. Computing it here would run a ProcArrayLock-shared - * xid-horizon scan on every CAS-update -- a measured hot-path cost -- - * for a value used only in the rare full-array case. - * - * So the fast path passes InvalidTransactionId, which now fail-safe - * DISABLES reclamation (see flat_hash_apply_insert): an invalid - * horizon frees NOTHING. This preserves the original safety - * invariant -- a stale or absent horizon must never cause a lost - * update -- in the most conservative direction possible. If the op - * is then dropped because the array was genuinely full, the caller - * (below) recomputes the real, authoritative horizon and retries; - * reclamation is thus attempted with a fresh horizon exactly when, - * and only when, it is actually needed. - */ - flat_op.reclaim_xid_horizon = InvalidTransactionId; - flat_op.tuple_op.xid = xid; - flat_op.tuple_op.subxid = subxid; - flat_op.tuple_op.op_type = op_type; - flat_op.tuple_op.cid = cid; - flat_op.tuple_op.commit_ts = commit_ts; - flat_op.tuple_op.spec_token = spec_token; - flat_op.tuple_op.lock_mode = lock_mode; - flat_op.tuple_op.in_use = true; + if (tk->key.relid == relid && tk->key.relnumber == relnumber) + { + *link = tk->next; + pfree(tk); + } + else + link = &tk->next; } - - /* Apply to flat hash via seqlock writer path (partition-local) */ - LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); - - /* Check capacity before insert (writer lock held: fp->hash is stable) */ - entries_before = fp->hash->num_entries; - SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); - - /* Check if insert succeeded */ - entries_after = fp->hash->num_entries; - - LWLockRelease(&fp->writer_lock.lock); - - /* - * Verify the op was actually stored. We must probe for THIS xid's op, - * not merely the bucket: on a hot row the bucket pre-exists (it holds - * other TIDs' / xids' markers), so num_entries is unchanged and the - * bucket is present even when flat_hash_apply_insert silently dropped our - * op because the per-TID ops array was full with nothing reclaimable. - * Testing only bucket presence (the old SLogFlatHashProbe != NULL) - * reports success for a dropped op, so no marker exists to stamp at - * PRE_COMMIT and the next concurrent writer clobbers this update -- a - * lost update. Probe the ops array for our xid instead. - */ - if (entries_after == entries_before) + if (slog_insert_tids != NULL) { - bool op_stored; - - LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); - op_stored = SLogFlatHashHasOpForXid(fp->hash, &key, xid); - LWLockRelease(&fp->writer_lock.lock); + sloginsert_iterator it; + SLogInsertTidEntry *ie; - if (!op_stored) + sloginsert_start_iterate(slog_insert_tids, &it); + while ((ie = sloginsert_iterate(slog_insert_tids, &it)) != NULL) { - /* - * The op was dropped: the per-TID ops array was full and the fast - * path passed an invalid horizon, which disables reclamation. On - * a hot row the array is full of THIS relation's own - * below-horizon UPDATE markers, which the reclaim path can free - * WITHOUT the cross-partition SLogTupleEvictCommitted() sweep. So - * recompute the real, authoritative horizon now (the - * ProcArrayLock scan we skipped on the fast path) and retry: - * flat_hash_apply_insert can now reclaim a settled marker and - * store our op in place. - */ - TransactionId horizon = GetOldestNonRemovableTransactionId(NULL); - - flat_op.reclaim_xid_horizon = horizon; - - LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); - SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); - op_stored = SLogFlatHashHasOpForXid(fp->hash, &key, xid); - LWLockRelease(&fp->writer_lock.lock); + if (ie->key.relid == relid && ie->key.relnumber == relnumber) + sloginsert_delete(slog_insert_tids, ie->key); } + } +} - if (!op_stored) - { - /* Table or per-TID array was full; try eviction */ - int evicted = SLogTupleEvictCommitted(); - - if (evicted > 0) - { - /* Retry */ - LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); - SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); - op_stored = SLogFlatHashHasOpForXid(fp->hash, &key, xid); - LWLockRelease(&fp->writer_lock.lock); - } - if (!op_stored) - { - slog_overflow_warning_count++; - { - TimestampTz now = GetCurrentTimestamp(); - - if (slog_overflow_warning_count == 1 || - TimestampDifferenceExceeds(slog_overflow_last_warning, - now, 1000)) - { - elog(WARNING, "sLog tuple hash partition full " - "(%d entries); %d overflow(s) this transaction " - "on rel %u (visibility relies on UNCOMMITTED " - "flag + UNDO replay)", - SLogTupleNumEntries() / SLogNumPartitions, - slog_overflow_warning_count, relid); - slog_overflow_last_warning = now; - } - } - - SLogTupleTrackLocalOnly(relid, tid, xid, subxid); - return false; - } - } - } +/* ---------------------------------------------------------------- + * Row locks (see the file header) + * ---------------------------------------------------------------- + */ +/* one entry per strength at most; strengths strictly increase with depth */ +#define SLOG_TL_MAX_DEPTH 4 - SLogTupleTrackKey(key, xid, subxid, op_type); - return true; -} +#define SLOG_TL_VALUE(xid, mode) (((uint64) (xid) << 8) | (uint64) (mode)) +#define SLOG_TL_XID(v) ((TransactionId) ((v) >> 8)) +#define SLOG_TL_MODE(v) ((LockTupleMode) ((v) & 0xFF)) /* - * SLogTupleInsertRecovery - * Record a tuple operation during WAL replay (recovery-safe). - * - * This is a simplified variant of SLogTupleInsert() designed for use during - * WAL redo on hot standbys. Key differences: - * - Does NOT call SLogTupleTrackKey() (no backend-local tracking needed) - * - Returns false silently if the hash is full (instead of ERROR/PANIC) - * - No retries with pg_usleep (would delay WAL replay) - * - * Used by an AM's WAL redo path to register UNCOMMITTED tuples in the - * per-tuple sLog so that the AM's visibility check can correctly determine - * visibility on standbys (where the sLog is otherwise never populated). + * An entry of a lock table: key slog_tl_key(tid, depth), and the lock's + * value, 0 once the level is gone. Key 0 (offset 0, never a TID's) marks a + * free entry. */ -bool -SLogTupleInsertRecovery(Oid relid, ItemPointer tid, TransactionId xid, - SLogOpType op_type) +typedef struct SLogTupleLockEntry { - SLogTupleKey key; - SLogFlatOp clear_op; - SLogFlatOp flat_op; + uint64 key; + uint64 val; +} SLogTupleLockEntry; - if (SLogState == NULL) - return false; - - memset(&key, 0, sizeof(key)); - key.relid = relid; - ItemPointerCopy(tid, &key.tid); +/* + * A relation storage a slot holds row locks in, beyond its inline records: + * an open-addressing table of 'size' entries (a power of two), 'used' of + * them with a key. Entries past the slot's nrels are not in use; they keep + * their (empty) tables for the slot's next transactions. + */ +typedef struct SLogTupleLockRel +{ + Oid relid; + RelFileNumber relnumber; + dsa_pointer table; /* SLogTupleLockEntry[size], or invalid */ + uint64 size; + uint64 used; +} SLogTupleLockRel; - /* - * Clear any pre-existing entry at this exact TID before registering the - * new op. A physical INSERT always targets a free line pointer, so any - * sLog entry already present at this TID belongs to a dead prior occupant - * (deleted and VACUUM-recycled). On the primary the prior occupant's - * sLog entry is removed by its commit/abort xact callback, but the - * standby redo path only ever inserts -- it never removes -- so without - * this the stale entry survives. flat_hash_apply_insert() only - * overwrites a same-xid op, so a stale op from the prior occupant's - * (different) xid would persist and make the AM's visibility check trip - * on TransactionIdDidAbort()/IsInProgress() for that dead xid, wrongly - * hiding the freshly inserted live tuple. - * - * Tombstoning the entry is safe and self-healing: - * flat_hash_apply_insert() reuses tombstoned buckets, so the immediately - * following INSERT recreates a clean entry holding only this insert's op. - */ - memset(&clear_op, 0, sizeof(clear_op)); - clear_op.kind = SLOG_FLAT_OP_REMOVE_ENTRY; - clear_op.key = key; - - /* Apply to flat hash */ - memset(&flat_op, 0, sizeof(flat_op)); - flat_op.kind = SLOG_FLAT_OP_INSERT; - flat_op.key = key; - flat_op.xid = xid; - flat_op.tuple_op.xid = xid; - flat_op.tuple_op.subxid = InvalidTransactionId; - flat_op.tuple_op.op_type = op_type; - flat_op.tuple_op.cid = InvalidCommandId; - flat_op.tuple_op.commit_ts = 0; - flat_op.tuple_op.spec_token = 0; - flat_op.tuple_op.in_use = true; - - LWLockAcquire(SLOG_PART_WRITER_LOCK(&key), LW_EXCLUSIVE); - SLogFlatHashApply(SLogGetPartition(&key)->hash, &clear_op, sizeof(clear_op)); - SLogFlatHashApply(SLogGetPartition(&key)->hash, &flat_op, sizeof(flat_op)); - LWLockRelease(SLOG_PART_WRITER_LOCK(&key)); +/* a table is grown before it is more than 3/4 full */ +#define SLOG_TL_MIN_ENTRIES 16 - return true; -} +/* + * At the end of its transaction a slot keeps its rels array if it has at + * most SLOG_TL_KEEP_RELS entries, and in it the tables of at most + * SLOG_TL_KEEP_ENTRIES entries, emptied, for its next transactions (4 kB + * each); bigger ones are freed, so that one huge transaction does not keep + * its memory. What a slot keeps stays with it for the next backend that + * uses its PGPROC (at most 4 x 4 kB of tables and a 4-entry array a slot). + * + * ponytail: a transaction that locks more than 192 rows of a relation past + * its inline records allocates its table again each time; raise the limit + * if that shows in profiles. + */ +#define SLOG_TL_KEEP_RELS 4 +#define SLOG_TL_KEEP_ENTRIES 256 /* - * Restore a prepared row lock without replacing other holders at this TID. - * Unlike physical INSERT replay, the tuple has not acquired a new occupant. - * Recovery must fail rather than silently lose a lock if the sLog is full. + * A row lock kept in its holder's slot: one TID's entries, outermost first, + * as a table keeps them at depths 0 .. nlev - 1. An invalid relid marks a + * free record. */ -void -SLogTupleRestoreLock(Oid relid, ItemPointer tid, TransactionId xid, - LockTupleMode mode) +typedef struct SLogTupleLockInline { - SLogFlatOp op = {0}; - SLogFlatPartition *fp; - bool stored; - - op.kind = SLOG_FLAT_OP_INSERT; - op.key.relid = relid; - ItemPointerCopy(tid, &op.key.tid); - op.xid = xid; - op.tuple_op.xid = xid; - op.tuple_op.op_type = mode <= LockTupleShare ? - SLOG_OP_LOCK_SHARE : SLOG_OP_LOCK_EXCL; - op.tuple_op.cid = InvalidCommandId; - op.tuple_op.lock_mode = mode; - op.tuple_op.in_use = true; - fp = SLogGetPartition(&op.key); - - LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); - SLogFlatHashApply(fp->hash, &op, sizeof(op)); - stored = SLogFlatHashHasOpForXid(fp->hash, &op.key, xid); - LWLockRelease(&fp->writer_lock.lock); - - if (!stored) - ereport(ERROR, - (errcode(ERRCODE_OUT_OF_MEMORY), - errmsg("could not restore prepared row lock in sLog"))); -} + Oid relid; + RelFileNumber relnumber; + BlockNumber blkno; + OffsetNumber offnum; + uint16 nlev; + uint64 lev[SLOG_TL_MAX_DEPTH]; +} SLogTupleLockInline; /* - * SLogTupleLookup - * Look up a tuple's sLog entry (copy semantics). + * TIDs a slot keeps inline; the transaction's further TIDs go to its tables. * - * Returns true if found, copying the full entry into *entry_out. - * WAIT-FREE: uses the seqlock read-side retry loop. + * ponytail: fixed; enough for transactions that lock a few rows, which then + * never touch the shared DSA or a slot LWLock. Raise it if lock counts per + * transaction show the tables on a hot path. */ -bool -SLogTupleLookup(Oid relid, ItemPointer tid, SLogTupleEntry *entry_out) +#define SLOG_TL_NINLINE 8 + +typedef struct SLogTupleLockSlot { - SLogTupleKey key; - SLogFlatPartition *fp; - bool found; + LWLock lock; /* protects rels and the tables */ + pg_atomic_uint32 changecount; /* odd while inline/ninline/nrels change */ + TransactionId xid; /* top-level holder, or invalid */ + int ninline; /* inline records in use (or freed) */ + bool indexed; /* may have bits in the holder index */ + int nrels; /* rels in use */ + int maxrels; + dsa_pointer rels; /* SLogTupleLockRel[maxrels] */ + SLogTupleLockInline inl[SLOG_TL_NINLINE]; +} SLogTupleLockSlot; + +/* + * The holder index: bucket b is a bitset of slots, bit i set while slot i + * may hold a lock on a TID whose hash (slog_tl_bucket) is b. A locker sets + * its bit under the buffer lock of the tuple before it records the lock, + * and a probe reads the bucket under that buffer lock, so a probe cannot + * miss a lock on its tuple. A bit outlives the locks it stands for (until + * the holder's transaction ends), and buckets are shared by many TIDs: + * the probe then visits a slot for nothing, which costs time, not + * correctness. A backend notes the buckets it set (slog_tl_listed), so + * that the end of its transaction clears only those; the slot of a prepared + * transaction is cleared by a scan of every bucket. + * + * A lock bit in the tuple header cannot serve as the filter: an UPDATE + * compatible with a KEY SHARE lock may write a new image without it. + * + * ponytail: a fixed bucket count, 512 bytes per slot; a probe visits about + * (live locked TIDs / 4096) slots for nothing. Size it from max_connections + * if more concurrent row locks make that show in profiles. + */ +#define SLOG_TL_INDEX_NBUCKETS 4096 +#define SLOG_TL_NWORDS(nslots) (((nslots) + 63) / 64) - memset(&key, 0, sizeof(key)); - key.relid = relid; - ItemPointerCopy(tid, &key.tid); +typedef struct SLogTupleLockShared +{ + int tranche_id; + int nslots; + int nwords; /* index words per bucket */ + pg_atomic_uint64 index[FLEXIBLE_ARRAY_MEMBER]; /* [bucket][word] */ + /* followed, cache-line aligned, by nslots padded SLogTupleLockSlots */ +} SLogTupleLockShared; + +#define SLOG_TL_SLOT_SIZE \ + TYPEALIGN(PG_CACHE_LINE_SIZE, sizeof(SLogTupleLockSlot)) +#define SLOG_TL_SLOTS_OFFSET(nslots) \ + TYPEALIGN(PG_CACHE_LINE_SIZE, \ + offsetof(SLogTupleLockShared, index) + \ + sizeof(pg_atomic_uint64) * SLOG_TL_INDEX_NBUCKETS * \ + SLOG_TL_NWORDS(nslots)) + +static void slog_tl_free_slot(int slotno, bool mine); + +static SLogTupleLockShared *slog_tl_dir = NULL; +static dsa_area *slog_tl_area = NULL; + +/* Does this backend's slot hold anything of its current transaction? */ +static bool slog_tl_used = false; + +/* The index buckets in which this backend set its slot's bit */ +static uint16 *slog_tl_listed = NULL; +static int slog_tl_nlisted = 0; + +static const LOCKMODE slog_tl_lockmode[] = { + [LockTupleKeyShare] = AccessShareLock, + [LockTupleShare] = RowShareLock, + [LockTupleNoKeyExclusive] = ExclusiveLock, + [LockTupleExclusive] = AccessExclusiveLock, +}; + +static inline uint64 +slog_tl_key(ItemPointer tid, int depth) +{ + return ((uint64) depth << 48) | + ((uint64) ItemPointerGetBlockNumber(tid) << 16) | + ItemPointerGetOffsetNumber(tid); +} - fp = SLogGetPartition(&key); +/* The holder-index bucket of a TID */ +static inline uint32 +slog_tl_bucket(Oid relid, RelFileNumber relnumber, ItemPointer tid) +{ + uint64 h; - SLOG_SEQ_READ_BEGIN(fp) - { - const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + h = hash_combine64(murmurhash64(((uint64) relid << 32) | relnumber), + murmurhash64(((uint64) ItemPointerGetBlockNumber(tid) << 16) | + ItemPointerGetOffsetNumber(tid))); + return (uint32) h & (SLOG_TL_INDEX_NBUCKETS - 1); +} - found = (bucket != NULL); - if (found && entry_out) - memcpy(entry_out, &bucket->entry, sizeof(SLogTupleEntry)); - } - SLOG_SEQ_READ_END(fp); +static inline pg_atomic_uint64 * +slog_tl_index_word(SLogTupleLockShared *dir, uint32 bucket, int slotno) +{ + return &dir->index[(Size) bucket * dir->nwords + slotno / 64]; +} + +static inline uint64 +slog_tl_index_bit(int slotno) +{ + return UINT64CONST(1) << (slotno % 64); +} - return found; +static inline SLogTupleLockSlot * +slog_tl_slot(SLogTupleLockShared *dir, int slotno) +{ + return (SLogTupleLockSlot *) ((char *) dir + + SLOG_TL_SLOTS_OFFSET(dir->nslots) + + (Size) slotno * SLOG_TL_SLOT_SIZE); } /* - * SLogTupleLookupFiltered - * Find sLog entries for a TID, optionally filtered by xid. - * - * WAIT-FREE: uses the seqlock read-side. If xid_filter is valid, returns - * only ops for that xid. If InvalidTransactionId, returns all active - * ops for this TID. - * - * Returns the number of ops written to ops_out. + * A slot's inline records and table count change between these two (the + * slot has one writer at a time: its owner, or for a prepared transaction's + * dummy slot whoever prepares, restores or finishes it). Readers retry + * while the count is odd or has moved, as for PgBackendStatus. */ -int -SLogTupleLookupFiltered(Oid relid, ItemPointer tid, - TransactionId xid_filter, - SLogTupleOp *ops_out, int max_ops) +static inline void +slog_tl_change_begin(SLogTupleLockSlot *slot) { - SLogTupleKey key; - SLogFlatPartition *fp; - int nfound = 0; - - memset(&key, 0, sizeof(key)); - key.relid = relid; - ItemPointerCopy(tid, &key.tid); + pg_atomic_fetch_add_u32(&slot->changecount, 1); +} - fp = SLogGetPartition(&key); +static inline void +slog_tl_change_end(SLogTupleLockSlot *slot) +{ + pg_atomic_fetch_add_u32(&slot->changecount, 1); +} - SLOG_SEQ_READ_BEGIN(fp) +static void +slog_tl_init_dir(void *ptr, void *arg) +{ + SLogTupleLockShared *dir = ptr; + + dir->tranche_id = LWLockNewTrancheId("SLogTupleLock"); + dir->nslots = *(int *) arg; + dir->nwords = SLOG_TL_NWORDS(dir->nslots); + for (int w = 0; w < SLOG_TL_INDEX_NBUCKETS * dir->nwords; w++) + pg_atomic_init_u64(&dir->index[w], 0); + for (int i = 0; i < dir->nslots; i++) { - const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + SLogTupleLockSlot *slot = slog_tl_slot(dir, i); + + slot->indexed = false; + LWLockInitialize(&slot->lock, dir->tranche_id); + pg_atomic_init_u32(&slot->changecount, 0); + slot->xid = InvalidTransactionId; + slot->ninline = 0; + slot->nrels = 0; + slot->maxrels = 0; + slot->rels = InvalidDsaPointer; + } +} - if (bucket != NULL) - { - const SLogTupleEntry *entry = &bucket->entry; - int i; +static SLogTupleLockShared * +slog_tl_directory(void) +{ + if (slog_tl_dir == NULL) + { + /* every PGPROC, including prepared transactions' dummy procs */ + int nslots = FIRST_PREPARED_XACT_PROC_NUMBER + max_prepared_xacts; + bool found; + Size size; - for (i = 0; i < SLOG_MAX_TUPLE_OPS && nfound < max_ops; i++) - { - if (!entry->ops[i].in_use) - continue; + size = add_size(SLOG_TL_SLOTS_OFFSET(nslots), + mul_size(SLOG_TL_SLOT_SIZE, nslots)); + slog_tl_dir = GetNamedDSMSegment("slog_tuple_locks", size, + slog_tl_init_dir, &found, &nslots); + } + return slog_tl_dir; +} - if (TransactionIdIsValid(xid_filter) && - !TransactionIdEquals(entry->ops[i].xid, xid_filter)) - continue; +static dsa_area * +slog_tl_dsa(void) +{ + if (slog_tl_area == NULL) + { + bool found; - memcpy(&ops_out[nfound], &entry->ops[i], sizeof(SLogTupleOp)); - nfound++; - } - } + slog_tl_area = GetNamedDSA("slog_tuple_lock_tables", &found); } - SLOG_SEQ_READ_END(fp); - - return nfound; + return slog_tl_area; } /* - * SLogTupleRemove - * Remove operations for a specific xid from a tuple entry. - * - * Uses the seqlock writer path with external (writer_lock) serialization. + * Mark slot slotno as possibly holding a lock on a TID of the given bucket + * (see the holder index). The slot's writer calls it before it records the + * lock. In this backend's own slot, the bit is set only here, and noted. */ -void -SLogTupleRemove(Oid relid, ItemPointer tid, TransactionId xid) +static void +slog_tl_index_set(SLogTupleLockShared *dir, int slotno, uint32 bucket) { - SLogTupleKey key; - SLogFlatOp op; + pg_atomic_uint64 *word = slog_tl_index_word(dir, bucket, slotno); + uint64 bit = slog_tl_index_bit(slotno); - memset(&key, 0, sizeof(key)); - key.relid = relid; - ItemPointerCopy(tid, &key.tid); + if (pg_atomic_read_u64(word) & bit) + return; + if (slotno == MyProcNumber) + { + Assert(slog_tl_nlisted < SLOG_TL_INDEX_NBUCKETS); + slog_tl_listed[slog_tl_nlisted++] = bucket; + } + slog_tl_slot(dir, slotno)->indexed = true; + pg_atomic_fetch_or_u64(word, bit); +} - memset(&op, 0, sizeof(op)); - op.kind = SLOG_FLAT_OP_REMOVE_XID; - op.key = key; - op.xid = xid; +/* Clear this backend's slot from the buckets it noted. */ +static void +slog_tl_index_clear_mine(SLogTupleLockShared *dir) +{ + uint64 bit = slog_tl_index_bit(MyProcNumber); - SLogPartApplyOne(SLogGetPartition(&key), &op); + for (int i = 0; i < slog_tl_nlisted; i++) + pg_atomic_fetch_and_u64(slog_tl_index_word(dir, slog_tl_listed[i], + MyProcNumber), ~bit); + slog_tl_nlisted = 0; } -/* - * Width of the touched-partition bitmap, in uint64 words. Sized to cover - * SLOG_FLAT_MAX_PARTITIONS so the bitmap is a fixed on-stack array regardless - * of the runtime partition count. - */ -#define SLOG_PART_BITMAP_WORDS ((SLOG_FLAT_MAX_PARTITIONS + 63) / 64) - -/* - * SLogCollectTrackedPartitions - * Build a bitmap of the partitions touched by xid's shared tracked keys. - * - * Walks slog_tracked_keys once, setting one bit per distinct partition that - * holds a non-local_only entry for xid. Callers then iterate only the set - * partitions instead of sweeping all SLogNumPartitions, turning the batch - * apply from O(num_partitions * tracked_keys) into - * O(touched_partitions * tracked_keys) -- optimal for the common single-row, - * single-partition transaction. - * - * Returns the number of distinct partitions marked. - */ -static int -SLogCollectTrackedPartitions(TransactionId xid, - uint64 *bitmap) +/* Clear slot slotno from every bucket. */ +static void +slog_tl_index_clear_all(SLogTupleLockShared *dir, int slotno) { - SLogTrackedKey *tk; - int nparts = 0; - - memset(bitmap, 0, sizeof(uint64) * SLOG_PART_BITMAP_WORDS); + uint64 bit = slog_tl_index_bit(slotno); - for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + for (uint32 b = 0; b < SLOG_TL_INDEX_NBUCKETS; b++) { - int part; - uint64 mask; - - if (!TransactionIdEquals(tk->xid, xid) || tk->local_only) - continue; + pg_atomic_uint64 *word = slog_tl_index_word(dir, b, slotno); - part = SLogFlatHashPartitionIndex(&tk->key); - mask = UINT64CONST(1) << (part & 63); - if (!(bitmap[part >> 6] & mask)) - { - bitmap[part >> 6] |= mask; - nparts++; - } + if (pg_atomic_read_u64(word) & bit) + pg_atomic_fetch_and_u64(word, ~bit); } - - return nparts; } /* - * SLogTupleRemoveByXid - * Remove all tuple sLog entries for a transaction. - * - * Uses the backend-local tracking list. Applies REMOVE_XID ops to the - * flat hash in a single writer batch. + * The inline record of a TID in a slot, or -1. For the slot's writer, which + * needs no protection against itself. */ -void -SLogTupleRemoveByXid(TransactionId xid) +static int +slog_tl_find_inline(SLogTupleLockSlot *slot, Oid relid, + RelFileNumber relnumber, ItemPointer tid) { - SLogTrackedKey *tk; - uint64 touched[SLOG_PART_BITMAP_WORDS] = {0}; - int part; - - if (SLogState == NULL) - return; - - /* - * Collect the partitions xid actually touches in one pass. Walking only - * those avoids an O(num_partitions * tracked_keys) sweep over every - * partition for what is usually a single-row transaction. - */ - if (SLogCollectTrackedPartitions(xid, touched) == 0) - return; + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); - /* Batch apply REMOVE_XID ops grouped by partition */ - for (part = 0; part < SLogNumPartitions; part++) + for (int r = 0; r < slot->ninline; r++) { - SLogFlatPartition *fp; - - if (!(touched[part >> 6] & (UINT64CONST(1) << (part & 63)))) - continue; - - fp = SLogGetPartitionByIndex(part); - - LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + SLogTupleLockInline *in = &slot->inl[r]; - for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) - { - SLogFlatOp flat_op; + if (in->relid == relid && in->relnumber == relnumber && + in->blkno == blkno && in->offnum == offnum) + return r; + } + return -1; +} - if (!TransactionIdEquals(tk->xid, xid) || tk->local_only) - continue; - if (SLogFlatHashPartitionIndex(&tk->key) != part) - continue; +/* A free inline record of a slot, or -1 */ +static int +slog_tl_free_inline(SLogTupleLockSlot *slot) +{ + for (int r = 0; r < slot->ninline; r++) + if (!OidIsValid(slot->inl[r].relid)) + return r; + return slot->ninline < SLOG_TL_NINLINE ? slot->ninline : -1; +} - memset(&flat_op, 0, sizeof(flat_op)); - flat_op.kind = SLOG_FLAT_OP_REMOVE_XID; - flat_op.key = tk->key; - flat_op.xid = xid; - SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); - } - LWLockRelease(&fp->writer_lock.lock); - } +/* Write a TID's entries to inline record r of a slot. */ +static void +slog_tl_write_inline(SLogTupleLockSlot *slot, int r, Oid relid, + RelFileNumber relnumber, ItemPointer tid, + int nlev, const uint64 *lev) +{ + SLogTupleLockInline *in = &slot->inl[r]; + + Assert(nlev > 0 && nlev <= SLOG_TL_MAX_DEPTH); + slog_tl_change_begin(slot); + in->relid = relid; + in->relnumber = relnumber; + in->blkno = ItemPointerGetBlockNumber(tid); + in->offnum = ItemPointerGetOffsetNumber(tid); + in->nlev = nlev; + memcpy(in->lev, lev, sizeof(uint64) * nlev); + if (r >= slot->ninline) + slot->ninline = r + 1; + slog_tl_change_end(slot); } /* - * SLogTupleCommitByXid - * Handle commit for tuple sLog: remove ALL of the committing xid's ops - * (INSERT/DELETE/LOCK and UPDATE alike). - * - * Committed-UPDATE markers are NOT retained on the flat hash. - * Snapshot-isolation readers reconstruct the visible version by walking the - * durable UNDO log chain (via the AM's version-reconstruction walk), so at - * commit every op of the xid is removed -- identical to INSERT/DELETE/LOCK -- - * which also drains bucket table_full pressure. - * - * Uses the backend-local tracking list. Applies COMMIT_XID ops to the - * flat hash in batch. + * A probe's read of another slot: the entries of the TID's inline record + * that conflict with lockmode go to cand, and *has_tables says whether the + * TID may be in one of the slot's tables instead. The caller holds the + * TID's buffer lock, under which the owner wrote the record, so the record + * itself cannot change; the counter guards against the owner's concurrent + * changes to its other records (another TID, or the end of its + * transaction). */ -void -SLogTupleCommitByXid(TransactionId xid) +static int +slog_tl_read_inline(SLogTupleLockSlot *slot, Oid relid, + RelFileNumber relnumber, ItemPointer tid, + LOCKMODE lockmode, uint64 *cand, bool *has_tables) { - SLogTrackedKey *tk; - uint64 touched[SLOG_PART_BITMAP_WORDS] = {0}; - - if (SLogState == NULL) - return; - - /* Fast path: INSERT-only transactions never touch the shared hash */ - if (!slog_has_shared_entries) - return; - - /* - * Collect the partitions xid touches in one pass; the COMMIT_XID batch - * below iterates only those rather than sweeping all SLogNumPartitions. - */ - if (SLogCollectTrackedPartitions(xid, touched) == 0) - return; - - /* - * Committed-UPDATE cross-backend before-images are not published to - * shared DSA, and committed-UPDATE markers are not retained on the flat - * hash. Snapshot-isolation readers reconstruct the visible version by - * walking the durable UNDO log chain (via the AM's version-reconstruction - * walk); the write-write conflict probe reads the head verptr on the - * on-page tuple and resolves it in the same chain. - * - * flat_hash_apply_commit_xid therefore removes every op of xid at commit - * -- identical to INSERT/DELETE/LOCK -- which drains bucket table_full. - */ + volatile SLogTupleLockSlot *vslot = slot; + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + SpinDelayStatus delay; + int n; - /* Batch apply COMMIT_XID ops grouped by partition */ + init_local_spin_delay(&delay); + for (;;) { - int part; + uint32 before = pg_atomic_read_u32(&slot->changecount); + int ninline; + bool found = false; - for (part = 0; part < SLogNumPartitions; part++) + if (before & 1) { - SLogFlatPartition *fp; - - if (!(touched[part >> 6] & (UINT64CONST(1) << (part & 63)))) - continue; - - fp = SLogGetPartitionByIndex(part); + perform_spin_delay(&delay); + continue; + } + pg_read_barrier(); - LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + n = 0; + ninline = Min(vslot->ninline, SLOG_TL_NINLINE); + for (int r = 0; r < ninline; r++) + { + volatile SLogTupleLockInline *in = &vslot->inl[r]; + int nlev; - for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + if (in->relid != relid || in->relnumber != relnumber || + in->blkno != blkno || in->offnum != offnum) + continue; + nlev = Min(in->nlev, SLOG_TL_MAX_DEPTH); + for (int d = 0; d < nlev; d++) { - SLogFlatOp flat_op; - - if (!TransactionIdEquals(tk->xid, xid) || tk->local_only) - continue; - if (SLogFlatHashPartitionIndex(&tk->key) != part) - continue; - - memset(&flat_op, 0, sizeof(flat_op)); - flat_op.kind = SLOG_FLAT_OP_COMMIT_XID; - flat_op.key = tk->key; - flat_op.xid = xid; - SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + uint64 v = in->lev[d]; + + /* (a torn read is retried below; just stay in bounds) */ + if (SLOG_TL_MODE(v) <= LockTupleExclusive && + DoLockModesConflict(lockmode, + slog_tl_lockmode[SLOG_TL_MODE(v)])) + cand[n++] = v; } - LWLockRelease(&fp->writer_lock.lock); + found = true; + break; + } + /* a TID's entries are in its record or in a table, not both */ + *has_tables = !found && vslot->nrels > 0; + + pg_read_barrier(); + if (pg_atomic_read_u32(&slot->changecount) == before) + { + finish_spin_delay(&delay); + return n; } + perform_spin_delay(&delay); } } /* - * SLogTupleRemoveByXidSingle - * Remove the sLog entry for a single tuple identified by (relid, tid, xid). - * - * Used by an AM's two-phase postcommit callback to clean up sLog entries - * one at a time (since the local tracking list is unavailable in the - * resolving backend). + * Every shared allocation of the row-lock store, zeroed. Test-only + * injection points fail it as the DSA running out would: + * slog-tuple-lock-oom any of them, slog-tuple-lock-table-oom only a lock + * table's, so that a test can fail the second allocation of a step. */ -void -SLogTupleRemoveByXidSingle(Oid relid, ItemPointer tid, TransactionId xid) +static dsa_pointer +slog_tl_alloc(Size size, bool table) { - SLogTupleKey key; - SLogFlatOp flat_op; - - if (SLogState == NULL) - return; + if (IS_INJECTION_POINT_ATTACHED("slog-tuple-lock-oom") || + (table && IS_INJECTION_POINT_ATTACHED("slog-tuple-lock-table-oom"))) + ereport(ERROR, + (errcode(ERRCODE_OUT_OF_MEMORY), + errmsg("out of memory"), + errdetail("Injected failure of a row-lock store allocation."))); + return dsa_allocate_extended(slog_tl_dsa(), size, + DSA_ALLOC_ZERO | DSA_ALLOC_HUGE); +} - memset(&key, 0, sizeof(key)); - key.relid = relid; - ItemPointerCopy(tid, &key.tid); +/* A slot's rels array, or NULL (for its writer, or under its lock) */ +static inline SLogTupleLockRel * +slog_tl_rels(SLogTupleLockSlot *slot) +{ + if (!DsaPointerIsValid(slot->rels)) + return NULL; + return (SLogTupleLockRel *) dsa_get_address(slog_tl_dsa(), slot->rels); +} - /* Apply to flat hash */ - memset(&flat_op, 0, sizeof(flat_op)); - flat_op.kind = SLOG_FLAT_OP_REMOVE_XID; - flat_op.key = key; - flat_op.xid = xid; +/* The rels entry of a relation storage in a slot, or NULL */ +static SLogTupleLockRel * +slog_tl_find_rel(SLogTupleLockSlot *slot, Oid relid, RelFileNumber relnumber) +{ + SLogTupleLockRel *rels; + + if (slot->nrels == 0) + return NULL; + rels = slog_tl_rels(slot); + for (int i = 0; i < slot->nrels; i++) + if (rels[i].relid == relid && rels[i].relnumber == relnumber) + return &rels[i]; + return NULL; +} - SLogPartApplyOne(SLogGetPartition(&key), &flat_op); +static inline SLogTupleLockEntry * +slog_tl_table(SLogTupleLockRel *rel) +{ + return (SLogTupleLockEntry *) dsa_get_address(slog_tl_dsa(), rel->table); } /* - * SLogTupleMarkAbortedSingle - * Mark the sLog entry for a single tuple as ABORTED. - * - * Used by an AM's two-phase postabort callback to mark sLog entries - * one at a time (since the local tracking list is unavailable in the - * resolving backend). Only operates on tuples that already have a shared - * sLog entry (DELETE/UPDATE operations). + * The entry of key in a table of size entries, or the free entry where it + * would go. A table is never more than 3/4 full, so there is one. */ -void -SLogTupleMarkAbortedSingle(Oid relid, ItemPointer tid, TransactionId xid) +static inline SLogTupleLockEntry * +slog_tl_entry(SLogTupleLockEntry *tab, uint64 size, uint64 key) { - SLogTupleKey key; - SLogFlatOp flat_op; - - if (SLogState == NULL) - return; + uint64 i = murmurhash64(key) & (size - 1); - memset(&key, 0, sizeof(key)); - key.relid = relid; - ItemPointerCopy(tid, &key.tid); + while (tab[i].key != key && tab[i].key != 0) + i = (i + 1) & (size - 1); + return &tab[i]; +} - /* Apply to flat hash */ - memset(&flat_op, 0, sizeof(flat_op)); - flat_op.kind = SLOG_FLAT_OP_MARK_ABORTED; - flat_op.key = key; - flat_op.xid = xid; +/* The value of key in a rel's table, or 0 */ +static inline uint64 +slog_tl_lookup(SLogTupleLockRel *rel, uint64 key) +{ + SLogTupleLockEntry *e = slog_tl_entry(slog_tl_table(rel), rel->size, key); - SLogPartApplyOne(SLogGetPartition(&key), &flat_op); + return e->key == key ? e->val : 0; } /* - * SLogTupleRemoveBySubXid - * Handle subtransaction abort for tuple sLog. - * - * For entries that have a shared sLog entry, marks matching ops as ABORTED. - * For entries that only have local tracking (from SLogTupleTrackLocalOnly, - * used by INSERT), creates a shared ABORTED entry so visibility code can - * find it. + * The rels entry of a relation storage in slot slotno, made if there is + * none. For the slot's writer. The allocations come before any change and + * each is the slot's as soon as it is made: a bigger rels array replaces + * the old one at once (holding the same tables), and a new table goes into + * the first entry past the ones in use, where the slot keeps the empty + * tables of its earlier transactions and where its release finds it. So + * running out of memory leaves nothing unowned, and changes nothing a probe + * can see. */ -void -SLogTupleRemoveBySubXid(TransactionId xid, TransactionId subxid) +static SLogTupleLockRel * +slog_tl_add_rel(int slotno, Oid relid, RelFileNumber relnumber) { - SLogTrackedKey *tk; + SLogTupleLockSlot *slot = slog_tl_slot(slog_tl_dir, slotno); + SLogTupleLockRel *rels; + SLogTupleLockRel *rel; - if (SLogState == NULL) - return; + rel = slog_tl_find_rel(slot, relid, relnumber); + if (rel != NULL) + return rel; - for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + rels = slog_tl_rels(slot); + if (slot->nrels == slot->maxrels) { - SLogFlatOp flat_op; - - if (!TransactionIdEquals(tk->xid, xid)) - continue; - if (tk->subxid != subxid) - continue; + int newmax = Max(SLOG_TL_KEEP_RELS, slot->maxrels * 2); + dsa_pointer newrels; + dsa_pointer oldrels = slot->rels; + + newrels = slog_tl_alloc(sizeof(SLogTupleLockRel) * newmax, false); + if (slot->maxrels > 0) + memcpy(dsa_get_address(slog_tl_dsa(), newrels), rels, + sizeof(SLogTupleLockRel) * slot->maxrels); + LWLockAcquire(&slot->lock, LW_EXCLUSIVE); + slot->rels = newrels; + slot->maxrels = newmax; + LWLockRelease(&slot->lock); + if (DsaPointerIsValid(oldrels)) + dsa_free(slog_tl_dsa(), oldrels); + rels = slog_tl_rels(slot); + } - memset(&flat_op, 0, sizeof(flat_op)); + rel = &rels[slot->nrels]; + if (!DsaPointerIsValid(rel->table)) + { + rel->table = slog_tl_alloc(sizeof(SLogTupleLockEntry) * + SLOG_TL_MIN_ENTRIES, true); + rel->size = SLOG_TL_MIN_ENTRIES; + rel->used = 0; + } + Assert(rel->used == 0); + rel->relid = relid; + rel->relnumber = relnumber; + + LWLockAcquire(&slot->lock, LW_EXCLUSIVE); + slog_tl_change_begin(slot); + slot->nrels++; + slog_tl_change_end(slot); + LWLockRelease(&slot->lock); + return rel; +} - if (tk->local_only) - { - /* - * INSERT elision: no shared entry exists yet. Create one with - * SLOG_OP_ABORTED so visibility code can find it. - */ - flat_op.kind = SLOG_FLAT_OP_CREATE_ABORTED; - flat_op.key = tk->key; - flat_op.xid = xid; - flat_op.subxid = subxid; - } - else +/* + * Give a rel's table room for need keys: a bigger table, filled before it + * replaces the old one. For the slot's writer; the one allocation is the + * only step that can fail, and it changes nothing. + */ +static void +slog_tl_grow_table(SLogTupleLockSlot *slot, SLogTupleLockRel *rel, uint64 need) +{ + SLogTupleLockEntry *old = slog_tl_table(rel); + SLogTupleLockEntry *tab; + dsa_pointer oldtable = rel->table; + dsa_pointer newtable; + uint64 size = rel->size * 2; + uint64 used = 0; + + while (need > size / 4 * 3) + size *= 2; + newtable = slog_tl_alloc(sizeof(SLogTupleLockEntry) * size, true); + tab = (SLogTupleLockEntry *) dsa_get_address(slog_tl_dsa(), newtable); + for (uint64 i = 0; i < rel->size; i++) + { + if (old[i].key != 0 && old[i].val != 0) { - /* Shared entry exists -- mark matching ops ABORTED */ - flat_op.kind = SLOG_FLAT_OP_MARK_ABORTED; - flat_op.key = tk->key; - flat_op.xid = xid; + *slog_tl_entry(tab, size, old[i].key) = old[i]; + used++; } - - LWLockAcquire(SLOG_PART_WRITER_LOCK(&tk->key), LW_EXCLUSIVE); - SLogFlatHashApply(SLogGetPartition(&tk->key)->hash, &flat_op, - sizeof(flat_op)); - LWLockRelease(SLOG_PART_WRITER_LOCK(&tk->key)); - - /* - * Mark the backend-local tracked key as aborted so the AM's - * commit-time flag clearing skips it. Without this, a local-only - * INSERT tracked key still reads as a live INSERT at top-level - * commit, and the AM's uncommitted-flag clearing would clear the - * tuple's UNCOMMITTED flag and stamp a commit marker -- resurrecting - * a tuple that the savepoint rollback was supposed to discard. - */ - tk->op_type = SLOG_OP_ABORTED; } + LWLockAcquire(&slot->lock, LW_EXCLUSIVE); + rel->table = newtable; + rel->size = size; + rel->used = used; + LWLockRelease(&slot->lock); + dsa_free(slog_tl_dsa(), oldtable); } /* - * SLogTupleUpdateSubXid - * Re-parent ops on subtransaction commit. - * - * When a subtransaction commits, its entries' subxid is updated to the - * parent's subxid so they survive subtransaction commit but are cleaned - * up at top-level commit. + * Write a TID's entries, depths 0 .. n - 1, to the table of its relation + * storage in slot slotno, and drop its entries at depths n .. oldn - 1. + * For the slot's writer. Whatever can fail comes first and changes + * nothing a probe can see; then the writes, which cannot fail, under the + * slot's lock. */ -void -SLogTupleUpdateSubXid(TransactionId xid, - TransactionId old_subxid, - TransactionId new_subxid) +static void +slog_tl_table_write(int slotno, Oid relid, RelFileNumber relnumber, + ItemPointer tid, int n, const uint64 *lev, int oldn) { - SLogTrackedKey *tk; + SLogTupleLockSlot *slot = slog_tl_slot(slog_tl_dir, slotno); + SLogTupleLockRel *rel = slog_tl_add_rel(slotno, relid, relnumber); + SLogTupleLockEntry *tab; - if (SLogState == NULL) - return; + if (rel->used + n > rel->size / 4 * 3) + slog_tl_grow_table(slot, rel, rel->used + n); + tab = slog_tl_table(rel); - for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + LWLockAcquire(&slot->lock, LW_EXCLUSIVE); + for (int d = 0; d < n; d++) { - if (!TransactionIdEquals(tk->xid, xid)) - continue; - if (tk->subxid != old_subxid) - continue; + uint64 key = slog_tl_key(tid, d); + SLogTupleLockEntry *e = slog_tl_entry(tab, rel->size, key); - /* Re-parent the local entry */ - tk->subxid = new_subxid; - - /* Also re-parent in the shared sLog if an entry exists */ - if (!tk->local_only) + if (e->key == 0) { - SLogFlatOp flat_op; - - memset(&flat_op, 0, sizeof(flat_op)); - flat_op.kind = SLOG_FLAT_OP_UPDATE_OP; - flat_op.key = tk->key; - flat_op.xid = xid; - flat_op.subxid = new_subxid; - flat_op.tuple_op.subxid = old_subxid; - - LWLockAcquire(SLOG_PART_WRITER_LOCK(&tk->key), LW_EXCLUSIVE); - SLogFlatHashApply(SLogGetPartition(&tk->key)->hash, &flat_op, - sizeof(flat_op)); - LWLockRelease(SLOG_PART_WRITER_LOCK(&tk->key)); + e->key = key; + rel->used++; } + e->val = lev[d]; + } + for (int d = n; d < oldn; d++) + { + SLogTupleLockEntry *e = slog_tl_entry(tab, rel->size, + slog_tl_key(tid, d)); + + Assert(e->key == slog_tl_key(tid, d)); + e->val = 0; } + LWLockRelease(&slot->lock); } /* - * SLogTupleMarkAborted - * Mark all ops for a transaction as SLOG_OP_ABORTED. - * - * Called at transaction abort. Entries remain so visibility checks can - * distinguish "committed (no entry)" from "aborted (UNDO pending)". - * - * For local-only entries (INSERT elision), we CREATE a shared ABORTED - * entry at abort time. This is safe because: - * (a) Abort is uncommon (vast majority of transactions commit) - * (b) The UNDO worker removes both the sLog entry and the page tuple, - * bounding the lifetime of these entries - * (c) If the hash is full, we log a warning; the UNDO worker will - * eventually remove the tuple physically, resolving the anomaly + * Empty a slot. With mine, the slot is this backend's own, and it keeps a + * small rels array and its small tables, emptied, for the backend's next + * transactions; otherwise everything is freed. This allocates nothing, as + * transaction abort needs. */ -void -SLogTupleMarkAborted(TransactionId xid) +static void +slog_tl_free_slot(int slotno, bool mine) { - SLogTrackedKey *tk; - int part; - int ops_since_publish; + SLogTupleLockShared *dir = slog_tl_directory(); + SLogTupleLockSlot *slot = slog_tl_slot(dir, slotno); - if (SLogState == NULL) - return; - - /* - * Process all entries grouped by partition. For each partition, acquire - * its writer lock once, apply all relevant ops, then release. - */ - for (part = 0; part < SLogNumPartitions; part++) + if (DsaPointerIsValid(slot->rels)) { - SLogFlatPartition *fp = SLogGetPartitionByIndex(part); - bool has_entries = false; - - /* Quick check: any local-only INSERT entries at all? */ - if (slog_insert_tids != NULL && slog_insert_tids->members > 0) - has_entries = true; /* conservative; filtered per-entry below */ - - /* Check linked-list entries */ - if (!has_entries) - { - for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) - { - if (!TransactionIdEquals(tk->xid, xid)) - continue; - if (SLogFlatHashPartitionIndex(&tk->key) == part) - { - has_entries = true; - break; - } - } - } - - if (!has_entries && - (slog_insert_tids == NULL || slog_insert_tids->members == 0)) - continue; - - LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); - - /* - * Bound the seqlock odd-hold window on a very large rollback - * (hundreds of thousands of tuples). While seq is odd every - * wait-free reader of this partition spins; ending and re-beginning - * the seqlock every SLOG_ABORT_PUBLISH_BATCH ops lets those readers - * make progress. Correctness across the boundary: each tuple's abort - * visibility is resolved independently and each seq cycle leaves - * fp->hash consistent, so a reader that observes the even counter at - * a boundary sees a valid partial state. - */ - ops_since_publish = 0; - - /* Process backend-local INSERT-hash entries for this partition */ - if (slog_insert_tids != NULL) + SLogTupleLockRel *rels = slog_tl_rels(slot); + bool keep = mine && slot->maxrels <= SLOG_TL_KEEP_RELS; + int nrels = slot->nrels; + + LWLockAcquire(&slot->lock, LW_EXCLUSIVE); + slog_tl_change_begin(slot); + slot->nrels = 0; + slog_tl_change_end(slot); + for (int i = 0; i < slot->maxrels; i++) { - sloginsert_iterator it; - SLogInsertTidEntry *ie; + SLogTupleLockRel *rel = &rels[i]; - sloginsert_start_iterate(slog_insert_tids, &it); - while ((ie = sloginsert_iterate(slog_insert_tids, &it)) != NULL) - { - SLogFlatOp flat_op; - SLogTupleKey smkey; - - memset(&smkey, 0, sizeof(smkey)); - smkey.relid = ie->key.relid; - ItemPointerSet(&smkey.tid, - SLOG_DECODE_BLKNO(ie->key.encoded_tid), - SLOG_DECODE_OFFNUM(ie->key.encoded_tid)); - - /* Only process if this key belongs to current partition */ - if (SLogFlatHashPartitionIndex(&smkey) == part) - { - memset(&flat_op, 0, sizeof(flat_op)); - flat_op.kind = SLOG_FLAT_OP_CREATE_ABORTED; - flat_op.key = smkey; - flat_op.xid = xid; - flat_op.subxid = InvalidTransactionId; - SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); - - if (++ops_since_publish >= SLOG_ABORT_PUBLISH_BATCH) - { - ops_since_publish = 0; - } - } - } - } - - /* Process linked-list entries for this partition */ - for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) - { - SLogFlatOp flat_op; - - if (!TransactionIdEquals(tk->xid, xid)) - continue; - if (SLogFlatHashPartitionIndex(&tk->key) != part) + if (!DsaPointerIsValid(rel->table)) continue; - - memset(&flat_op, 0, sizeof(flat_op)); - if (tk->local_only) - { - flat_op.kind = SLOG_FLAT_OP_CREATE_ABORTED; - flat_op.key = tk->key; - flat_op.xid = xid; - flat_op.subxid = tk->subxid; - } - else - { - flat_op.kind = SLOG_FLAT_OP_MARK_ABORTED; - flat_op.key = tk->key; - flat_op.xid = xid; - } - - SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); - - if (++ops_since_publish >= SLOG_ABORT_PUBLISH_BATCH) - { - ops_since_publish = 0; - } - } - - LWLockRelease(&fp->writer_lock.lock); - } -} - -/* - * SLogTupleRemoveByXidGlobal - * Remove ALL ops for a transaction by scanning the shared flat hash. - * - * Unlike SLogTupleRemoveByXid, this does not use the backend-local tracking - * list (which doesn't exist in the UNDO worker process). Used by the UNDO - * worker to clean up ABORTED entries after UNDO has been applied. - */ -void -SLogTupleRemoveByXidGlobal(TransactionId xid) -{ - SLogTupleKey *collected_keys; - int max_keys; - int part; - - if (SLogState == NULL) - return; - - max_keys = SLogTupleNumEntries(); - if (max_keys <= 0) - return; - - collected_keys = (SLogTupleKey *) - palloc(sizeof(SLogTupleKey) * max_keys); - - /* - * Process each partition while holding its exclusive writer lock. The - * writer lock serializes all writers for the partition, so the single - * copy is stable while we scan it. We apply REMOVE_XID inside one - * seqlock cycle. - */ - for (part = 0; part < SLogNumPartitions; part++) - { - SLogFlatPartition *fp = SLogGetPartitionByIndex(part); - SLogFlatHashScanState scan; - const SLogFlatBucket *bucket; - int nkeys = 0; - int i; - - LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); - - /* Scan the single copy; stable because we hold the writer lock. */ - SLogFlatHashScanInit(&scan); - while ((bucket = SLogFlatHashScanNext(fp->hash, &scan)) != NULL) - { - const SLogTupleEntry *entry = &bucket->entry; - - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + if (keep && rel->size <= SLOG_TL_KEEP_ENTRIES) { - if (entry->ops[i].in_use && - TransactionIdEquals(entry->ops[i].xid, xid)) - { - if (nkeys < max_keys) - collected_keys[nkeys++] = bucket->key; - break; - } + /* (those past the ones in use are empty already) */ + if (i < nrels) + memset(slog_tl_table(rel), 0, + sizeof(SLogTupleLockEntry) * rel->size); + rel->used = 0; + continue; } + dsa_free(slog_tl_dsa(), rel->table); + rel->table = InvalidDsaPointer; + rel->size = 0; + rel->used = 0; } - - /* Drop the slots in one seq cycle. */ - if (nkeys > 0) + if (!keep) { - for (i = 0; i < nkeys; i++) - { - SLogFlatOp flat_op; - - memset(&flat_op, 0, sizeof(flat_op)); - flat_op.kind = SLOG_FLAT_OP_REMOVE_XID; - flat_op.key = collected_keys[i]; - flat_op.xid = xid; - SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); - } + dsa_free(slog_tl_dsa(), slot->rels); + slot->rels = InvalidDsaPointer; + slot->maxrels = 0; } - - LWLockRelease(&fp->writer_lock.lock); + LWLockRelease(&slot->lock); } - - pfree(collected_keys); -} - -/* - * SLogTupleIterateByTid - * Call a callback for each active operation on a tuple. - * - * WAIT-FREE: uses the seqlock read-side. The in-use ops are copied into a - * local array under the seqlock retry loop; the callback then runs after a - * consistent read, so it may have side effects and receives pointers into - * the local copy (valid for the duration of this call). - */ -void -SLogTupleIterateByTid(Oid relid, ItemPointer tid, - SLogTupleIterCallback callback, void *arg) -{ - SLogTupleKey key; - SLogFlatPartition *fp; - SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; - int nops = 0; - int i; - - memset(&key, 0, sizeof(key)); - key.relid = relid; - ItemPointerCopy(tid, &key.tid); - - fp = SLogGetPartition(&key); - - SLOG_SEQ_READ_BEGIN(fp) + if (slot->ninline > 0) { - const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); - - if (bucket != NULL) - { - const SLogTupleEntry *entry = &bucket->entry; - - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) - if (entry->ops[i].in_use) - ops[nops++] = entry->ops[i]; - } + slog_tl_change_begin(slot); + slot->ninline = 0; + slog_tl_change_end(slot); } - SLOG_SEQ_READ_END(fp); + slot->xid = InvalidTransactionId; - /* Consistent read complete; run the (side-effecting) callback. */ - for (i = 0; i < nops; i++) - if (!callback(&ops[i], arg)) - break; + /* + * This backend's noted buckets are all its transaction set; anything else + * (a prepared transaction's slot, or one a backend left) needs a scan. + */ + if (slotno == MyProcNumber && slog_tl_nlisted > 0) + slog_tl_index_clear_mine(dir); + else if (slot->indexed) + slog_tl_index_clear_all(dir, slotno); + slot->indexed = false; } -/* ---------------------------------------------------------------- - * Convenience wrappers - * ---------------------------------------------------------------- - */ - -/* - * SLogTupleHasEntry -- quick probe: does ANY active entry exist for this TID? - * WAIT-FREE: uses the seqlock read-side. - */ -bool -SLogTupleHasEntry(Oid relid, ItemPointer tid) +/* The first row lock of this backend's transaction: take its slot. */ +static SLogTupleLockSlot * +slog_tl_my_slot(void) { - SLogTupleKey key; - SLogFlatPartition *fp; - bool has_entry = false; - - memset(&key, 0, sizeof(key)); - key.relid = relid; - ItemPointerCopy(tid, &key.tid); + SLogTupleLockShared *dir = slog_tl_directory(); + SLogTupleLockSlot *slot = slog_tl_slot(dir, MyProcNumber); - fp = SLogGetPartition(&key); - - SLOG_SEQ_READ_BEGIN(fp) + if (!slog_tl_used) { - const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + if (slog_tl_listed == NULL) + slog_tl_listed = MemoryContextAlloc(TopMemoryContext, + sizeof(uint16) * SLOG_TL_INDEX_NBUCKETS); - has_entry = (bucket != NULL && bucket->entry.nops > 0); + /* + * A backend that exited without cleaning up may have left it. (The + * empty tables kept for reuse stay.) + */ + if (slot->ninline > 0 || slot->nrels > 0 || slot->indexed) + slog_tl_free_slot(MyProcNumber, false); + slot->xid = GetTopTransactionId(); + slog_tl_used = true; } - SLOG_SEQ_READ_END(fp); - - return has_entry; + return slot; } /* - * SLogTupleIsInsertedByMe -- check if current transaction inserted this tuple. + * SLogTupleLockAcquire -- record that the current subtransaction holds a row + * lock of the given strength on a tuple. * - * Checks both the shared hash (normal case) and the backend-local tracking - * list (for local-only INSERTs that have no shared entry). + * Call it under the tuple's buffer lock, before changing the page: running + * out of shared memory ERRORs here, with nothing changed. * - * Uses the top-level XID because SLogTupleTrackLocalOnly() always stores - * GetTopTransactionId(). This ensures correct results even when called - * from within a subtransaction (savepoint). + * A TID's entries live in one place in the slot: in an inline record while + * there is one free when the TID is first locked, else in the table of its + * relation storage. */ -bool -SLogTupleIsInsertedByMe(Oid relid, ItemPointer tid) +void +SLogTupleLockAcquire(Oid relid, RelFileNumber relnumber, ItemPointer tid, + LockTupleMode mode) { - SLogTupleOp op; - int nfound; - SLogTrackedKey *tk; - TransactionId myxid = GetTopTransactionIdIfAny(); - - if (!TransactionIdIsValid(myxid)) - return false; - - /* - * Special-marker TIDs (SpecTokenOffsetNumber 0xFFFE for speculative - * insertions, MovedPartitionsOffsetNumber 0xFFFD for cross-partition - * moves) can appear in a tuple's on-page t_ctid. All sLog entries are - * keyed by an on-page (block, offnum) with offnum <= MaxOffsetNumber, so - * this backend cannot have "inserted" the tuple under such a key. Bail - * out early to avoid tripping the encode-guard elog(ERROR) below. - */ - if (ItemPointerGetOffsetNumber(tid) > MaxOffsetNumber) - return false; - - /* Check shared hash first (normal case) */ - nfound = SLogTupleLookupFiltered(relid, tid, myxid, &op, 1); - if (nfound > 0 && op.op_type == SLOG_OP_INSERT) - return true; - - /* - * Check the backend-local INSERT hash (top-level local-only INSERTs). - */ + TransactionId cur = GetCurrentTransactionId(); + SLogTupleLockSlot *slot = slog_tl_my_slot(); + SLogTupleLockRel *rel = NULL; + uint64 old[SLOG_TL_MAX_DEPTH]; + uint64 stack[SLOG_TL_MAX_DEPTH]; + int n = 0; + int oldn = 0; + int r; + + r = slog_tl_find_inline(slot, relid, relnumber, tid); + if (r >= 0) { - BlockNumber blkno = ItemPointerGetBlockNumber(tid); - OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); - SLogInsertTidKey ikey; + oldn = slot->inl[r].nlev; + memcpy(old, slot->inl[r].lev, sizeof(uint64) * oldn); + } + else if ((rel = slog_tl_find_rel(slot, relid, relnumber)) != NULL) + { + for (int d = 0; d < SLOG_TL_MAX_DEPTH; d++) + { + uint64 v = slog_tl_lookup(rel, slog_tl_key(tid, d)); - if (offnum > MaxOffsetNumber) - elog(ERROR, "offset %u exceeds page item limit %d in sLog TID encoding", - offnum, MaxOffsetNumber); - ikey.relid = relid; - ikey.encoded_tid = SLOG_ENCODE_TID(blkno, offnum); - if (slog_insert_tids != NULL && - sloginsert_lookup(slog_insert_tids, ikey) != NULL) - return true; + if (v == 0) + break; + old[oldn++] = v; + } } /* - * Fall back to backend-local tracking list. This handles the overflow - * case where SLogTupleInsert returned false (hash full) but the INSERT - * was tracked locally via SLogTupleTrackLocalOnly or SLogTupleTrackKey. - * Also handles subtransaction local-only entries (which still use the - * linked list). + * Every live entry (one whose subtransaction is still current) lives at + * least as long as the current subtransaction: it was taken by this one, + * by an enclosing one, or by an inner one already committed into an + * enclosing one. So a live entry at least as strong covers the request, + * and the new lock replaces the current subtransaction's weaker ones. + * Entries of aborted subtransactions are dropped. */ - for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + for (int d = 0; d < oldn; d++) { - if (!TransactionIdEquals(tk->xid, myxid)) + if (!TransactionIdIsCurrentTransactionId(SLOG_TL_XID(old[d]))) continue; - if (tk->key.relid != relid) + if (SLOG_TL_MODE(old[d]) >= mode) + return; + if (TransactionIdEquals(SLOG_TL_XID(old[d]), cur)) continue; - if (ItemPointerEquals(&tk->key.tid, tid)) - return true; + stack[n++] = old[d]; } - - return false; + Assert(n < SLOG_TL_MAX_DEPTH); + stack[n++] = SLOG_TL_VALUE(cur, mode); + + slog_tl_index_set(slog_tl_dir, MyProcNumber, + slog_tl_bucket(relid, relnumber, tid)); + if (r < 0 && oldn == 0) + r = slog_tl_free_inline(slot); + if (r >= 0) + slog_tl_write_inline(slot, r, relid, relnumber, tid, n, stack); + else + slog_tl_table_write(MyProcNumber, relid, relnumber, tid, n, stack, + oldn); } /* - * SLogTupleIsDeletedByMe -- check if current transaction deleted this tuple. + * SLogTupleLockHeldByMe -- does the current transaction (any live + * subtransaction) hold a row lock on the tuple? If so, *mode (when not + * NULL) is the strongest one. */ bool -SLogTupleIsDeletedByMe(Oid relid, ItemPointer tid) +SLogTupleLockHeldByMe(Oid relid, RelFileNumber relnumber, ItemPointer tid, + LockTupleMode *mode) { - SLogTupleOp op; - int nfound; - TransactionId myxid = GetCurrentTransactionIdIfAny(); + SLogTupleLockSlot *slot; + SLogTupleLockRel *rel; + int best = -1; + int r; - if (!TransactionIdIsValid(myxid)) + if (!slog_tl_used) return false; - - nfound = SLogTupleLookupFiltered(relid, tid, myxid, &op, 1); - return (nfound > 0 && - (op.op_type == SLOG_OP_DELETE || - op.op_type == SLOG_OP_UPDATE)); -} - -/* - * SLogTupleGetDirtyXid -- for SNAPSHOT_DIRTY, get the xid of the in-progress - * transaction operating on this tuple. - * - * Returns the xid of the first in-progress INSERT or DELETE/UPDATE entry - * found (excluding our own), or InvalidTransactionId if none. - * - * WAIT-FREE: uses the seqlock read-side. The ops are snapshotted into a - * local array inside the retry loop; the (LWLock-taking) transaction-status - * checks then run after a consistent read, never inside the retry loop. - */ -TransactionId -SLogTupleGetDirtyXid(Oid relid, ItemPointer tid, bool *is_insert) -{ - SLogTupleKey key; - SLogFlatPartition *fp; - SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; - int nops = 0; - TransactionId result = InvalidTransactionId; - int i; - - memset(&key, 0, sizeof(key)); - key.relid = relid; - ItemPointerCopy(tid, &key.tid); - - fp = SLogGetPartition(&key); - - SLOG_SEQ_READ_BEGIN(fp) + slot = slog_tl_slot(slog_tl_dir, MyProcNumber); + r = slog_tl_find_inline(slot, relid, relnumber, tid); + if (r >= 0) { - const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); - - if (bucket != NULL) + for (int d = 0; d < slot->inl[r].nlev; d++) { - const SLogTupleEntry *entry = &bucket->entry; + uint64 v = slot->inl[r].lev[d]; - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) - if (entry->ops[i].in_use) - ops[nops++] = entry->ops[i]; + if (TransactionIdIsCurrentTransactionId(SLOG_TL_XID(v))) + best = Max(best, (int) SLOG_TL_MODE(v)); } } - SLOG_SEQ_READ_END(fp); - - /* Consistent snapshot taken; resolve status outside the shared lock. */ - for (i = 0; i < nops; i++) + else if ((rel = slog_tl_find_rel(slot, relid, relnumber)) != NULL) { - TransactionId xid = ops[i].xid; - SLogOpType op = ops[i].op_type; - - if (TransactionIdIsCurrentTransactionId(xid)) - continue; - if (!TransactionIdIsInProgress(xid)) - continue; + for (int d = 0; d < SLOG_TL_MAX_DEPTH; d++) + { + uint64 v = slog_tl_lookup(rel, slog_tl_key(tid, d)); - if (is_insert) - *is_insert = (op == SLOG_OP_INSERT); - result = xid; - break; + if (v == 0) + break; + if (TransactionIdIsCurrentTransactionId(SLOG_TL_XID(v))) + best = Max(best, (int) SLOG_TL_MODE(v)); + } } - - return result; + if (best < 0) + return false; + if (mode) + *mode = (LockTupleMode) best; + return true; } /* - * SLogTupleGetDirtyWriterXid -- like SLogTupleGetDirtyXid, but returns only - * the xid of an in-progress *writer* (INSERT/UPDATE/DELETE), ignoring - * lock-only markers (LOCK_SHARE/LOCK_EXCL) and aborted markers. - * - * Used at the write-wait decision in the UPDATE/DELETE paths. An updater - * already holds the heavyweight LOCKTAG_TUPLE lock (LockTupleNoKeyExclusive -> - * ExclusiveLock), which serializes against conflicting lockers via the - * standard lock matrix: a key-share locker (AccessShareLock) is compatible and - * does not block, while a share/exclusive locker conflicts and blocks the - * updater on the lock manager. XactLockTableWait must therefore fire only for - * an actual in-progress writer -- never for a pure locker. Waiting on a - * locker's xid here while that locker is queued behind us for the same tuple - * ExclusiveLock manufactures a deadlock cycle that heap avoids via - * HEAP_XMAX_IS_LOCKED_ONLY. - * - * WAIT-FREE: uses the seqlock read-side, identical to SLogTupleGetDirtyXid. + * One holder slot of SLogTupleLockConflictXid: the xid to wait for, or + * InvalidTransactionId. */ -TransactionId -SLogTupleGetDirtyWriterXid(Oid relid, ItemPointer tid, bool *is_insert) +static TransactionId +slog_tl_probe_slot(SLogTupleLockShared *dir, int i, Oid relid, + RelFileNumber relnumber, ItemPointer tid, LOCKMODE lockmode) { - SLogTupleKey key; - SLogFlatPartition *fp; - SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; - int nops = 0; - TransactionId result = InvalidTransactionId; - int i; - - memset(&key, 0, sizeof(key)); - key.relid = relid; - ItemPointerCopy(tid, &key.tid); - - fp = SLogGetPartition(&key); + SLogTupleLockSlot *slot; + uint64 cand[SLOG_TL_MAX_DEPTH]; + int n; + bool has_tables; - SLOG_SEQ_READ_BEGIN(fp) + slot = slog_tl_slot(dir, i); + n = slog_tl_read_inline(slot, relid, relnumber, tid, lockmode, cand, + &has_tables); + if (n == 0 && has_tables) { - const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + SLogTupleLockRel *rel; - if (bucket != NULL) + (void) slog_tl_dsa(); /* attach before taking the lock */ + LWLockAcquire(&slot->lock, LW_SHARED); + rel = slog_tl_find_rel(slot, relid, relnumber); + for (int d = 0; rel != NULL && d < SLOG_TL_MAX_DEPTH; d++) { - const SLogTupleEntry *entry = &bucket->entry; + uint64 v = slog_tl_lookup(rel, slog_tl_key(tid, d)); - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) - if (entry->ops[i].in_use) - ops[nops++] = entry->ops[i]; + if (v == 0) + break; + if (DoLockModesConflict(lockmode, + slog_tl_lockmode[SLOG_TL_MODE(v)])) + cand[n++] = v; } + LWLockRelease(&slot->lock); } - SLOG_SEQ_READ_END(fp); - - /* Consistent snapshot taken; resolve status outside the shared lock. */ - for (i = 0; i < nops; i++) - { - TransactionId xid; - SLogOpType op = ops[i].op_type; - - /* Only real writers block another writer. */ - if (op != SLOG_OP_INSERT && - op != SLOG_OP_UPDATE && - op != SLOG_OP_DELETE) - continue; - - xid = ops[i].xid; - - if (TransactionIdIsCurrentTransactionId(xid)) - continue; - if (!TransactionIdIsInProgress(xid)) - continue; - - if (is_insert) - *is_insert = (op == SLOG_OP_INSERT); - result = xid; - break; - } - - return result; -} -/* - * slog_tuplock_to_lockmode -- map a LockTupleMode to its heavyweight LOCKMODE. - * - * The four tuple-lock strengths MUST map to four distinct LOCKMODEs so the - * real conflict matrix (DoLockModesConflict) can distinguish a compatible - * KeyShare FK locker from a conflicting Share/Exclusive locker. - */ -static LOCKMODE -slog_tuplock_to_lockmode(LockTupleMode mode) -{ - switch (mode) - { - case LockTupleKeyShare: - return AccessShareLock; - case LockTupleShare: - return RowShareLock; - case LockTupleNoKeyExclusive: - return ExclusiveLock; - case LockTupleExclusive: - return AccessExclusiveLock; - } - elog(ERROR, "invalid tuple lock mode: %d", (int) mode); - return NoLock; /* keep compiler quiet */ + /* (a parallel worker's leader holds our own locks) */ + for (int c = 0; c < n; c++) + if (!TransactionIdIsCurrentTransactionId(SLOG_TL_XID(cand[c])) && + TransactionIdIsInProgress(SLOG_TL_XID(cand[c]))) + return SLOG_TL_XID(cand[c]); + return InvalidTransactionId; } /* - * SLogTupleGetWriteConflictXid -- find an in-progress transaction whose marker - * conflicts with a writer (UPDATE/DELETE) acquiring tuple lock my_mode. - * - * Unlike SLogTupleGetDirtyWriterXid (which only ever reports writers and - * silently ignores lock-only markers), this also reports a *locker* whose - * recorded LockTupleMode conflicts with my_mode under the standard heavyweight - * matrix. That is required for correctness: a SELECT ... FOR UPDATE locker - * leaves only a LOCK_EXCL marker (no on-page writer state), and an updater that - * consults a writer-only probe sails past it and clobbers the row the locker is - * protecting. The four-way mapping keeps a KeyShare FK locker (AccessShareLock) - * compatible with a NoKeyExclusive UPDATE (ExclusiveLock) while making FOR SHARE - * (RowShareLock) and FOR UPDATE (AccessExclusiveLock) correctly block it. + * SLogTupleLockConflictXid -- find another transaction's row lock on the tuple + * that conflicts with mode. Returns the xid to wait for (the locking + * subtransaction's), or InvalidTransactionId. Call it under the tuple's + * buffer lock. * - * Writers take priority over lockers in the returned xid so the caller's - * is_insert handling (TM_Invisible for an in-progress INSERT) is preserved; a - * conflicting locker is returned only when no in-progress writer exists. + * Within one holder the outermost conflicting lock is returned, since it + * lives longest. * - * Returns the conflicting xid, or InvalidTransactionId if none. *is_insert is - * set true only when the returned xid is an in-progress INSERT writer. - * - * WAIT-FREE: uses the seqlock read-side, identical to SLogTupleGetDirtyWriterXid. + * Only the slots in the TID's bucket of the holder index are visited, in + * slot order, and the bucket is read again after each visit for the slots + * after it: see SLogTupleLockAtPrepare. */ TransactionId -SLogTupleGetWriteConflictXid(Oid relid, ItemPointer tid, - LockTupleMode my_mode, bool *is_insert) +SLogTupleLockConflictXid(Oid relid, RelFileNumber relnumber, ItemPointer tid, + LockTupleMode mode) { - SLogTupleKey key; - SLogFlatPartition *fp; - SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; - int nops = 0; - TransactionId writer_xid = InvalidTransactionId; - bool writer_is_insert = false; - TransactionId locker_xid = InvalidTransactionId; - LOCKMODE my_lockmode = slog_tuplock_to_lockmode(my_mode); - int i; - - memset(&key, 0, sizeof(key)); - key.relid = relid; - ItemPointerCopy(tid, &key.tid); - - fp = SLogGetPartition(&key); - - SLOG_SEQ_READ_BEGIN(fp) - { - const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); - - if (bucket != NULL) - { - const SLogTupleEntry *entry = &bucket->entry; - - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) - if (entry->ops[i].in_use) - ops[nops++] = entry->ops[i]; - } - } - SLOG_SEQ_READ_END(fp); + SLogTupleLockShared *dir = slog_tl_directory(); + uint32 bucket = slog_tl_bucket(relid, relnumber, tid); + LOCKMODE lockmode = slog_tl_lockmode[mode]; - /* Consistent snapshot taken; resolve status outside the shared lock. */ - for (i = 0; i < nops; i++) + for (int w = 0; w < dir->nwords; w++) { - TransactionId xid; - SLogOpType op = ops[i].op_type; + pg_atomic_uint64 *word = slog_tl_index_word(dir, bucket, w * 64); + uint64 bits = pg_atomic_read_u64(word); - xid = ops[i].xid; - - if (TransactionIdIsCurrentTransactionId(xid)) - continue; - if (!TransactionIdIsInProgress(xid)) - continue; - - if (op == SLOG_OP_INSERT || - op == SLOG_OP_UPDATE || - op == SLOG_OP_DELETE) + while (bits != 0) { - /* A real writer: highest priority, stop scanning. */ - writer_xid = xid; - writer_is_insert = (op == SLOG_OP_INSERT); - break; - } + int j = pg_rightmost_one_pos64(bits); + int i = w * 64 + j; + TransactionId xwait; - if (op == SLOG_OP_LOCK_SHARE || op == SLOG_OP_LOCK_EXCL) - { - LOCKMODE locker_lockmode = - slog_tuplock_to_lockmode(ops[i].lock_mode); - - if (DoLockModesConflict(my_lockmode, locker_lockmode)) - locker_xid = xid; /* candidate; keep seeking a writer */ + pg_read_barrier(); + if (i != MyProcNumber) + { + xwait = slog_tl_probe_slot(dir, i, relid, relnumber, tid, + lockmode); + if (TransactionIdIsValid(xwait)) + return xwait; + } + pg_read_barrier(); + /* the slots after i, read again */ + bits = pg_atomic_read_u64(word) & ~((UINT64CONST(2) << j) - 1); } } - - if (TransactionIdIsValid(writer_xid)) - { - if (is_insert) - *is_insert = writer_is_insert; - return writer_xid; - } - - if (is_insert) - *is_insert = false; - return locker_xid; + return InvalidTransactionId; } -/* - * SLogTupleHasLockConflict -- check if any active lock entries on this TID - * conflict with the requested lock mode. - */ -bool -SLogTupleHasLockConflict(Oid relid, ItemPointer tid, - TransactionId my_xid, - SLogOpType requested_lock) -{ - SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; - int nfound; - int i; - - nfound = SLogTupleLookupFiltered(relid, tid, InvalidTransactionId, - ops, SLOG_MAX_TUPLE_OPS); - - for (i = 0; i < nfound; i++) - { - if (TransactionIdEquals(ops[i].xid, my_xid)) - continue; - if (!TransactionIdIsInProgress(ops[i].xid)) - continue; - /* Only lock/mutating entries can conflict */ - if (ops[i].op_type != SLOG_OP_LOCK_SHARE && - ops[i].op_type != SLOG_OP_LOCK_EXCL && - ops[i].op_type != SLOG_OP_DELETE && - ops[i].op_type != SLOG_OP_UPDATE) - continue; - - /* - * Lock compatibility matrix: SHARE vs SHARE: compatible SHARE vs - * EXCL/DELETE/UPDATE: conflict EXCL vs anything: conflict - */ - if (requested_lock == SLOG_OP_LOCK_SHARE) - { - if (ops[i].op_type == SLOG_OP_LOCK_EXCL || - ops[i].op_type == SLOG_OP_DELETE || - ops[i].op_type == SLOG_OP_UPDATE) - return true; - } - else if (requested_lock == SLOG_OP_LOCK_EXCL) - { - return true; - } - } - - return false; -} - -/* - * SLogTupleGetLockConflictXid -- like SLogTupleHasLockConflict, but also - * returns the xid of the *conflicting* transaction (the one whose marker - * actually conflicts with the requested lock). - * - * The waiter must XactLockTableWait on the conflicting xid specifically. - * Waiting on the first in-progress xid found on the TID (as a broad - * SLogTupleGetDirtyXid probe would return) can pick a compatible peer -- e.g. - * another KeyShare locker -- which is not blocking us and may itself be queued - * behind us, manufacturing a spurious mutual-wait deadlock cycle. - * - * my_mode is the precise LockTupleMode being acquired. Conflicts are decided - * with the real heavyweight matrix (DoLockModesConflict) over the four - * distinct tuple-lock strengths, exactly as SLogTupleGetWriteConflictXid does. - * The coarse two-value SLOG_OP_LOCK_SHARE/EXCL classification is not - * sufficient here: it made every in-progress UPDATE/DELETE marker conflict - * with a SHARE request, so an FK check's KeyShare probe (AccessShareLock) - * blocked behind a NoKeyExclusive UPDATE (ExclusiveLock) that heap treats as - * COMPATIBLE. Two concurrent FK inserts referencing the same parent row then - * waited on each other's xid with no lock cycle for the deadlock detector to - * break, hanging the backend forever (seen in the fk-deadlock isolation test). - * - * Returns true and sets *xid_out if a conflicting in-progress transaction - * exists; returns false otherwise (xid_out is set to InvalidTransactionId). - */ -bool -SLogTupleGetLockConflictXid(Oid relid, ItemPointer tid, - TransactionId my_xid, - LockTupleMode my_mode, - TransactionId *xid_out) +/* Top-level commit or abort: release every row lock of this backend. */ +void +SLogTupleLockReleaseAll(void) { - SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; - LOCKMODE my_lockmode = slog_tuplock_to_lockmode(my_mode); - int nfound; - int i; - - *xid_out = InvalidTransactionId; - - nfound = SLogTupleLookupFiltered(relid, tid, InvalidTransactionId, - ops, SLOG_MAX_TUPLE_OPS); - - for (i = 0; i < nfound; i++) - { - if (TransactionIdEquals(ops[i].xid, my_xid)) - continue; - if (!TransactionIdIsInProgress(ops[i].xid)) - continue; - - switch (ops[i].op_type) - { - case SLOG_OP_LOCK_SHARE: - case SLOG_OP_LOCK_EXCL: - - /* - * A locker records its precise LockTupleMode, so compare the - * two strengths directly. - */ - if (DoLockModesConflict(my_lockmode, - slog_tuplock_to_lockmode(ops[i].lock_mode))) - { - *xid_out = ops[i].xid; - return true; - } - break; - - case SLOG_OP_UPDATE: - case SLOG_OP_DELETE: - - /* - * A writer holds at least NoKeyExclusive (ExclusiveLock); a - * DELETE holds Exclusive (AccessExclusiveLock). KeyShare - * remains compatible with a NoKeyExclusive UPDATE, which is - * what lets concurrent FK checks proceed. - */ - if (DoLockModesConflict(my_lockmode, - ops[i].op_type == SLOG_OP_DELETE ? - AccessExclusiveLock : ExclusiveLock)) - { - *xid_out = ops[i].xid; - return true; - } - break; - - default: - /* INSERT / ABORTED markers never block a lock request. */ - break; - } - } - - return false; + if (!slog_tl_used) + return; + slog_tl_free_slot(MyProcNumber, true); + slog_tl_used = false; } -/* - * SLogTupleHasAbortedEntry -- check if any aborted sLog op exists for a TID. - */ -bool -SLogTupleHasAbortedEntry(Oid relid, ItemPointer tid) +/* Row locks of SLogTupleForgetStorage */ +static void +slog_tl_forget_storage(Oid relid, RelFileNumber relnumber) { - SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; - int nfound; - int i; - - nfound = SLogTupleLookupFiltered(relid, tid, InvalidTransactionId, - ops, SLOG_MAX_TUPLE_OPS); + SLogTupleLockSlot *slot; + SLogTupleLockRel *rel; + bool changing = false; - for (i = 0; i < nfound; i++) + if (!slog_tl_used) + return; + slot = slog_tl_slot(slog_tl_dir, MyProcNumber); + for (int r = 0; r < slot->ninline; r++) { - /* Explicitly marked ABORTED */ - if (ops[i].op_type == SLOG_OP_ABORTED) - return true; - - /* Skip our own transaction's entries for CLOG fallback */ - if (TransactionIdIsCurrentTransactionId(ops[i].xid)) + if (slot->inl[r].relid != relid || slot->inl[r].relnumber != relnumber) continue; - - /* CLOG fallback: completed but did not commit => aborted */ - if (!TransactionIdIsInProgress(ops[i].xid) && - TransactionIdDidAbort(ops[i].xid)) - return true; + if (!changing) + slog_tl_change_begin(slot); + changing = true; + slot->inl[r].relid = InvalidOid; } - - return false; -} - -/* ---------------------------------------------------------------- - * Backend-private tracking for tuple sLog cleanup - * ---------------------------------------------------------------- - */ - -/* - * SLogTupleTrackKey - * Remember a tuple key for cleanup at commit/abort. - * - * Allocated in TopTransactionContext so it's automatically freed - * when the transaction ends. - */ -void -SLogTupleTrackKey(SLogTupleKey key, TransactionId xid, TransactionId subxid, - SLogOpType op_type) -{ - MemoryContext oldcxt; - SLogTrackedKey *tk; - - oldcxt = MemoryContextSwitchTo(TopTransactionContext); - - tk = (SLogTrackedKey *) palloc(sizeof(SLogTrackedKey)); - memcpy(&tk->key, &key, sizeof(SLogTupleKey)); - tk->xid = xid; - tk->subxid = subxid; - tk->local_only = false; - slog_has_shared_entries = true; - tk->op_type = op_type; - tk->next = slog_tracked_keys; - slog_tracked_keys = tk; - - MemoryContextSwitchTo(oldcxt); -} - -/* - * SLogTupleTrackLocalOnly - * Lightweight local-only tracking (INSERTs only). - * - * Records (relid, tid, xid, subxid) in the per-backend local list WITHOUT - * creating a shared sLog entry. On subtransaction abort, - * SLogTupleRemoveBySubXid will create a shared ABORTED entry for visibility. - * - * OOM optimization: When not inside a subtransaction, records the TID in a - * backend-local open-addressing hash (simplehash) keyed by (relid, - * encoded_tid) instead of a 136-byte linked-list node, giving O(1) insert - * and probe and bounded per-backend memory under bulk INSERT. Subtransaction - * entries still use the linked list because subtxn abort needs per-entry - * subxid filtering. - */ -void -SLogTupleTrackLocalOnly(Oid relid, ItemPointer tid, - TransactionId xid, TransactionId subxid) -{ - MemoryContext oldcxt; + if (changing) + slog_tl_change_end(slot); /* - * Fast path: top-level transaction with no savepoint -> use the hash. The - * subxid is InvalidTransactionId in this case (top-level INSERTs always - * pass the top xid as both xid and subxid=Invalid). + * Empty its table and move it past the ones in use, where the slot's + * release finds it. */ - if (!IsSubTransaction()) + rel = slog_tl_find_rel(slot, relid, relnumber); + if (rel != NULL) { - BlockNumber blkno = ItemPointerGetBlockNumber(tid); - OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); - SLogInsertTidKey ikey; - bool found; - - if (offnum > MaxOffsetNumber) - elog(ERROR, "offset %u exceeds page item limit %d in sLog TID encoding", - offnum, MaxOffsetNumber); - ikey.relid = relid; - ikey.encoded_tid = SLOG_ENCODE_TID(blkno, offnum); - - /* - * Record the TID in the backend-local hash. Insert is amortized - * O(1); the table and its entries live in TopTransactionContext and - * are destroyed at transaction end. - */ - (void) sloginsert_insert(slog_insert_tids_ensure(), ikey, &found); - return; - } - - { - SLogTrackedKey *tk; - SLogTupleKey key; - - memset(&key, 0, sizeof(key)); - key.relid = relid; - ItemPointerCopy(tid, &key.tid); - - oldcxt = MemoryContextSwitchTo(TopTransactionContext); - - tk = (SLogTrackedKey *) palloc(sizeof(SLogTrackedKey)); - memcpy(&tk->key, &key, sizeof(SLogTupleKey)); - tk->xid = xid; - tk->subxid = subxid; - tk->local_only = true; - tk->op_type = SLOG_OP_INSERT; - tk->next = slog_tracked_keys; - slog_tracked_keys = tk; - - MemoryContextSwitchTo(oldcxt); + SLogTupleLockRel *last = &slog_tl_rels(slot)[slot->nrels - 1]; + SLogTupleLockRel gone; + + LWLockAcquire(&slot->lock, LW_EXCLUSIVE); + memset(slog_tl_table(rel), 0, sizeof(SLogTupleLockEntry) * rel->size); + gone = *rel; + gone.used = 0; + *rel = *last; + *last = gone; + slog_tl_change_begin(slot); + slot->nrels--; + slog_tl_change_end(slot); + LWLockRelease(&slot->lock); } } /* - * SLogTupleUntrackLocalOnly - * Remove local-only INSERT tracking for (relid, tid). - * - * Counterpart to SLogTupleTrackLocalOnly. Used when a freshly inserted tuple - * must NOT be stamped with the inserting transaction's commit marker at commit - * time -- e.g. VACUUM FULL / CLUSTER copies a recently-dead tombstone into the - * new relation and rewrites it with its ORIGINAL delete timestamp. Leaving the - * INSERT tracked would let the AM's commit-time stamping clobber the tuple's - * commit metadata with the rewrite transaction's commit marker, resurrecting - * the deleted row for any reader whose snapshot predates that commit. - * - * Clears the backend-local INSERT-hash entry on the top-level fast path and - * also drops any matching linked-list node (the savepoint/fallback path). + * Call cb for each live row lock of the current transaction, with the + * relation storage it is in. A TID may be reported more than once (one per + * live subtransaction level); the strongest wins. */ void -SLogTupleUntrackLocalOnly(Oid relid, ItemPointer tid) +SLogTupleLockForEachMine(SLogTupleLockCallback cb, void *arg) { - BlockNumber blkno = ItemPointerGetBlockNumber(tid); - OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); - SLogTrackedKey **link; + SLogTupleLockSlot *slot; + SLogTupleLockRel *rels; - if (offnum > MaxOffsetNumber) - elog(ERROR, "offset %u exceeds page item limit %d in sLog TID encoding", - offnum, MaxOffsetNumber); - - /* Fast path: drop the backend-local INSERT-hash entry if present. */ - if (slog_insert_tids != NULL) + if (!slog_tl_used) + return; + slot = slog_tl_slot(slog_tl_dir, MyProcNumber); + for (int r = 0; r < slot->ninline; r++) { - SLogInsertTidKey ikey; + SLogTupleLockInline *in = &slot->inl[r]; + ItemPointerData tid; - ikey.relid = relid; - ikey.encoded_tid = SLOG_ENCODE_TID(blkno, offnum); - (void) sloginsert_delete(slog_insert_tids, ikey); + if (!OidIsValid(in->relid)) + continue; + ItemPointerSet(&tid, in->blkno, in->offnum); + for (int d = 0; d < in->nlev; d++) + if (TransactionIdIsCurrentTransactionId(SLOG_TL_XID(in->lev[d]))) + cb(in->relid, in->relnumber, &tid, SLOG_TL_MODE(in->lev[d]), + arg); } - /* Fallback/subtxn path: unlink any matching local-only INSERT node. */ - link = &slog_tracked_keys; - while (*link != NULL) + rels = slog_tl_rels(slot); + for (int i = 0; i < slot->nrels; i++) { - SLogTrackedKey *tk = *link; + SLogTupleLockEntry *tab = slog_tl_table(&rels[i]); - if (tk->local_only && - tk->key.relid == relid && - ItemPointerGetBlockNumber(&tk->key.tid) == blkno && - ItemPointerGetOffsetNumber(&tk->key.tid) == offnum) + for (uint64 k = 0; k < rels[i].size; k++) { - *link = tk->next; - pfree(tk); - continue; + ItemPointerData tid; + + if (tab[k].key == 0 || tab[k].val == 0 || + !TransactionIdIsCurrentTransactionId(SLOG_TL_XID(tab[k].val))) + continue; + ItemPointerSet(&tid, + (BlockNumber) ((tab[k].key >> 16) & 0xFFFFFFFF), + (OffsetNumber) (tab[k].key & 0xFFFF)); + cb(rels[i].relid, rels[i].relnumber, &tid, + SLOG_TL_MODE(tab[k].val), arg); } - link = &tk->next; } } /* - * SLogTupleCleanupRetained - * Free retained sLog entries that are no longer visible to any - * active snapshot. - * - * Committed UPDATE markers are not retained (commit_xid - * apply removes them), so under steady-state this routine primarily - * cleans up other retained state. Reclamation gates on the xid horizon - * (GetOldestNonRemovableTransactionId) so a marker is reclaimable only - * once its committing xid precedes the oldest active snapshot's xmin. - * - * Scans the flat hash under read-side, then applies CLEANUP_RETAINED ops. - * Walks every partition taking each writer lock LW_EXCLUSIVE, so callers MUST - * NOT hold a buffer content lock or other page-level critical section across - * this call. Driven by the UNDO background worker, or by - * SLogTupleMaybeCleanupRetained() when the worker is disabled. The - * reclamation decision is the xid horizon (GetOldestNonRemovableTransactionId) - * computed below. + * PREPARE: hand this backend's row locks to the prepared transaction, whose + * dummy proc's slot keeps them until COMMIT/ROLLBACK PREPARED. Called after + * the dummy proc took over the xid. any_live says whether a live lock was + * written to the AM's two-phase state; without one, COMMIT PREPARED would + * not call back to free the slot, so the (dead) locks are dropped now. */ void -SLogTupleCleanupRetained(void) +SLogTupleLockAtPrepare(FullTransactionId fxid, bool any_live) { - SLogTupleKey *collected_keys; - int max_keys = 256; - int part; - int i; - TransactionId reclaim_xid_horizon; + SLogTupleLockShared *dir; + SLogTupleLockSlot *src; + SLogTupleLockSlot *dst; + int dstno; + uint64 dstbit; - if (SLogState == NULL) - return; - - /* - * Compute the xid horizon once for this pass. The read-side eligibility - * scan and the CLEANUP_RETAINED apply gate on the same horizon so the set - * of slots reclaimed here matches the set the apply drops. - */ - reclaim_xid_horizon = GetOldestNonRemovableTransactionId(NULL); - if (!TransactionIdIsValid(reclaim_xid_horizon)) + if (!slog_tl_used) return; - - collected_keys = (SLogTupleKey *) - palloc(max_keys * sizeof(SLogTupleKey)); - - /* - * Process each partition independently while holding its exclusive writer - * lock. The writer lock serializes all writers (other cleanup runs and - * forward-path inserts) for the partition, so the single copy is stable - * to scan. We apply the CLEANUP_RETAINED ops inside seqlock cycles. - */ - for (part = 0; part < SLogNumPartitions; part++) + if (!any_live) { - SLogFlatPartition *fp = SLogGetPartitionByIndex(part); - SLogFlatHashScanState scan; - const SLogFlatBucket *bucket; - int nkeys = 0; - - LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); - - /* - * Scan the single copy to collect expired keys. Stable because we - * hold the writer lock. - */ - SLogFlatHashScanInit(&scan); - while ((bucket = SLogFlatHashScanNext(fp->hash, &scan)) != NULL) - { - const SLogTupleEntry *entry = &bucket->entry; - bool has_expired = false; - - for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) - { - if (!entry->ops[i].in_use) - continue; - if (entry->ops[i].op_type != SLOG_OP_UPDATE) - continue; - if (!TransactionIdIsValid(entry->ops[i].xid)) - continue; - - /* - * Reclaim once the xid precedes the oldest active snapshot's - * xmin (xid authority -- see the function header). This is - * the authoritative, self-healing gate: a below-horizon - * marker is reclaimable whether or not it was ever committed - * in CLOG, and an in-progress xid can never precede the - * horizon. - */ - if (!TransactionIdPrecedes(entry->ops[i].xid, - reclaim_xid_horizon)) - continue; - - if (!has_expired) - { - if (nkeys >= max_keys) - { - max_keys *= 2; - collected_keys = (SLogTupleKey *) - repalloc(collected_keys, - max_keys * sizeof(SLogTupleKey)); - } - collected_keys[nkeys++] = bucket->key; - has_expired = true; - } - } - } - - /* Null the now-dangling pointers and drop the slots. */ - if (nkeys > 0) - { - int ops_since_publish = 0; - - for (i = 0; i < nkeys; i++) - { - SLogFlatOp flat_op; - - memset(&flat_op, 0, sizeof(flat_op)); - flat_op.kind = SLOG_FLAT_OP_CLEANUP_RETAINED; - flat_op.key = collected_keys[i]; - flat_op.reclaim_xid_horizon = reclaim_xid_horizon; - SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); - - /* - * Bound the seqlock odd-hold window so wait-free readers of - * this partition make progress. On a hot row many retained - * UPDATE markers expire at once, so nkeys can be large; a - * single uninterrupted odd window would spin every reader for - * its whole duration. Each key's reclamation is independent - * and each seq cycle leaves fp->hash consistent, so ending - * and re-beginning at a batch boundary is safe -- the - * partition LWLock still serializes us against other - * before-image freers. - */ - if (++ops_since_publish >= SLOG_ABORT_PUBLISH_BATCH) - { - ops_since_publish = 0; - } - } - } - - LWLockRelease(&fp->writer_lock.lock); + SLogTupleLockReleaseAll(); + return; } - pfree(collected_keys); -} + dir = slog_tl_dir; + src = slog_tl_slot(dir, MyProcNumber); + dstno = TwoPhaseGetDummyProcNumber(fxid, false); + dst = slog_tl_slot(dir, dstno); + Assert(dstno > MyProcNumber); -/* - * SLogTupleMaybeCleanupRetained - * Throttled, self-clocking entry point for retained-entry cleanup. - * - * Intended for access methods to call from their DML paths when the UNDO - * background worker is disabled (max_logical_revert_workers = 0), so the DSA - * before-image area does not fill up under sustained high-TPS load. Unlike - * SLogTupleCleanupRetained(), the heavy global sweep here runs at most once - * every 5 seconds per backend; the common call is a counter increment that - * returns immediately. - * - * CRITICAL: the caller MUST NOT hold any buffer content lock or other - * page-level critical section across this call. When the throttle fires this - * walks every sLog partition taking each writer lock LW_EXCLUSIVE; running it - * under a buffer lock would serialize all writers to a hot page behind the - * sweep (the c>=2 hot-row UPDATE convoy this throttle was introduced to avoid). - */ -void -SLogTupleMaybeCleanupRetained(void) -{ - static TimestampTz slog_last_cleanup = 0; - static uint32 slog_cleanup_clock = 0; - TimestampTz now_ts; - - if (SLogState == NULL) - return; + /* The dummy proc is ours now: whatever its slot holds is stale. */ + if (dst->ninline > 0 || dst->maxrels > 0 || dst->indexed) + slog_tl_free_slot(dstno, false); /* - * Sample the wall clock only once every SLOG_INSERT_CLOCK_PERIOD calls. - * The throttle below already bounds the heavy sweep to once per 5s; this - * just keeps the common-case cost down to a counter increment so the - * post-unlock call on every UPDATE does not add a clock_gettime() syscall - * to the hot path (matters for the >=HEAP perf target). + * A probe visits the slots of a bucket in slot order, reading the bucket + * again after each, and ours comes first. So filling the dummy slot + * (index bits, then contents) before emptying ours (contents, then index + * bits) keeps the locks in flight visible to it: if it finds ours empty, + * it reads the bucket after that and finds the dummy's bit. The slot + * locks keep table probes out meanwhile. The tables go with the rels + * array, including the empty ones kept for reuse. */ - if ((slog_cleanup_clock++ & (SLOG_INSERT_CLOCK_PERIOD - 1)) != 0) - return; - - now_ts = GetCurrentTimestamp(); - - if (now_ts - slog_last_cleanup > 5000000) /* 5 seconds */ - { - slog_last_cleanup = now_ts; - SLogTupleCleanupRetained(); - } -} - -/* - * SLogTupleAnyTracked - * True iff the current backend has tracked any tuple key (INSERT, - * UPDATE, or DELETE) for the current transaction -- i.e. this backend - * touched at least one tracked tuple since the last - * SLogTupleResetTracking(). - * - * Used by the AM's xact callback to decide whether a transaction that - * touched no tracked tuples should skip writing a durable commit-map entry. - */ -bool -SLogTupleAnyTracked(void) -{ - return slog_tracked_keys != NULL || - (slog_insert_tids != NULL && slog_insert_tids->members > 0); + LWLockAcquire(&src->lock, LW_EXCLUSIVE); + LWLockAcquire(&dst->lock, LW_EXCLUSIVE); + dst->indexed = true; + dstbit = slog_tl_index_bit(dstno); + for (int i = 0; i < slog_tl_nlisted; i++) + pg_atomic_fetch_or_u64(slog_tl_index_word(dir, slog_tl_listed[i], + dstno), dstbit); + slog_tl_change_begin(dst); + dst->xid = XidFromFullTransactionId(fxid); + dst->ninline = src->ninline; + memcpy(dst->inl, src->inl, sizeof(SLogTupleLockInline) * src->ninline); + dst->nrels = src->nrels; + dst->maxrels = src->maxrels; + dst->rels = src->rels; + slog_tl_change_end(dst); + slog_tl_change_begin(src); + src->xid = InvalidTransactionId; + src->ninline = 0; + src->nrels = 0; + src->maxrels = 0; + src->rels = InvalidDsaPointer; + slog_tl_change_end(src); + slog_tl_index_clear_mine(dir); + src->indexed = false; + LWLockRelease(&dst->lock); + LWLockRelease(&src->lock); + slog_tl_used = false; } /* - * SLogTupleResetTracking - * Clear the backend-private tracking list and reset overflow state. - * - * Also frees the backend-local INSERT hash. The table lives in - * TopTransactionContext, so it would be freed at transaction end anyway. - * Explicit cleanup here allows earlier memory reclaim and makes the state - * consistent for any subsequent operations within the same backend lifetime. + * End of recovery: restore one row lock of a prepared transaction from the + * AM's two-phase state. The waiter sleeps on the top-level xid. */ void -SLogTupleResetTracking(void) +SLogTupleLockRestore(FullTransactionId fxid, Oid relid, RelFileNumber relnumber, + ItemPointer tid, LockTupleMode mode) { - /* Destroy the backend-local INSERT hash */ - if (slog_insert_tids != NULL) + TransactionId xid = XidFromFullTransactionId(fxid); + int slotno = TwoPhaseGetDummyProcNumber(fxid, false); + SLogTupleLockShared *dir = slog_tl_directory(); + SLogTupleLockSlot *slot = slog_tl_slot(dir, slotno); + SLogTupleLockRel *rel; + uint64 v = 0; + uint64 val = SLOG_TL_VALUE(xid, mode); + int r; + + slot->xid = xid; + slog_tl_index_set(dir, slotno, slog_tl_bucket(relid, relnumber, tid)); + r = slog_tl_find_inline(slot, relid, relnumber, tid); + if (r >= 0) { - sloginsert_destroy(slog_insert_tids); - slog_insert_tids = NULL; + if (SLOG_TL_MODE(slot->inl[r].lev[0]) < mode) + slog_tl_write_inline(slot, r, relid, relnumber, tid, 1, &val); + return; } - slog_tracked_keys = NULL; - slog_has_shared_entries = false; - slog_overflow_warning_count = 0; - slog_overflow_last_warning = 0; + rel = slot->nrels > 0 ? slog_tl_find_rel(slot, relid, relnumber) : NULL; + if (rel != NULL) + v = slog_tl_lookup(rel, slog_tl_key(tid, 0)); + if (v == 0 && (r = slog_tl_free_inline(slot)) >= 0) + slog_tl_write_inline(slot, r, relid, relnumber, tid, 1, &val); + else if (v == 0 || SLOG_TL_MODE(v) < mode) + slog_tl_table_write(slotno, relid, relnumber, tid, 1, &val, 0); } /* - * SLogTupleIterateTrackedKeys - * Iterate over tracked keys for a given xid. - * - * Calls the callback for each tracked key matching the given xid. - * If the callback returns false, iteration stops early. - * Used by AM-specific pre-commit callbacks that need to touch pages. - * - * Iterates both hash-based INSERT entries (top-level) and - * linked-list entries. + * COMMIT/ROLLBACK PREPARED: release the prepared transaction's row locks. + * Called with TwoPhaseStateLock held, once per row lock the AM wrote to its + * two-phase state; the first call frees them all. */ void -SLogTupleIterateTrackedKeys(TransactionId xid, - SLogTrackedKeyCallback callback, - void *arg) +SLogTupleLockReleasePrepared(FullTransactionId fxid) { - SLogTrackedKey *tk; - - /* Iterate backend-local INSERT-hash entries (top-level local-only) */ - if (slog_insert_tids != NULL) - { - sloginsert_iterator it; - SLogInsertTidEntry *ie; - - sloginsert_start_iterate(slog_insert_tids, &it); - while ((ie = sloginsert_iterate(slog_insert_tids, &it)) != NULL) - { - SLogTupleKey key; - - memset(&key, 0, sizeof(key)); - key.relid = ie->key.relid; - ItemPointerSet(&key.tid, - SLOG_DECODE_BLKNO(ie->key.encoded_tid), - SLOG_DECODE_OFFNUM(ie->key.encoded_tid)); - - if (!callback(&key, xid, InvalidTransactionId, true, arg)) - return; - } - } - - /* Iterate linked-list entries */ - for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) - { - if (!TransactionIdEquals(tk->xid, xid)) - continue; - - if (!callback(&tk->key, tk->xid, tk->subxid, tk->local_only, arg)) - break; - } -} + int slotno = TwoPhaseGetDummyProcNumber(fxid, true); + SLogTupleLockShared *dir = slog_tl_directory(); -/* - * SLogTupleCollectTrackedKeys - * Collect all tracked keys for a given xid into a palloc'd array. - * - * Returns the number of collected keys. The caller can then sort the - * array for batch processing (e.g. by relid/blockno to amortize buffer - * I/O at commit time). - * - * The returned array is allocated in the current memory context; the caller - * is responsible for pfree(). - */ -int -SLogTupleCollectTrackedKeys(TransactionId xid, SLogTrackedKeyInfo **out_keys) -{ - SLogTrackedKey *tk; - int count = 0; - int capacity = 64; - SLogTrackedKeyInfo *arr; - - /* - * A single very large transaction can touch tens of millions of tracked - * keys; at 48 bytes each the array crosses MaxAllocSize (1 GB) past ~22M - * entries. Use the huge-allocation API so bulk loads do not fail at - * commit. This array is transient and pfree'd by the caller. - */ - arr = (SLogTrackedKeyInfo *) MemoryContextAllocHuge(CurrentMemoryContext, - sizeof(SLogTrackedKeyInfo) * (Size) capacity); - - /* Collect backend-local INSERT-hash entries (top-level local-only) */ - if (slog_insert_tids != NULL) - { - sloginsert_iterator it; - SLogInsertTidEntry *ie; - - sloginsert_start_iterate(slog_insert_tids, &it); - while ((ie = sloginsert_iterate(slog_insert_tids, &it)) != NULL) - { - if (count >= capacity) - { - capacity *= 2; - arr = (SLogTrackedKeyInfo *) - repalloc_huge(arr, sizeof(SLogTrackedKeyInfo) * (Size) capacity); - } - - memset(&arr[count].key, 0, sizeof(SLogTupleKey)); - arr[count].key.relid = ie->key.relid; - ItemPointerSet(&arr[count].key.tid, - SLOG_DECODE_BLKNO(ie->key.encoded_tid), - SLOG_DECODE_OFFNUM(ie->key.encoded_tid)); - arr[count].xid = xid; - arr[count].subxid = InvalidTransactionId; - arr[count].local_only = true; - arr[count].op_type = SLOG_OP_INSERT; - count++; - } - } - - /* Collect linked-list entries */ - for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) - { - if (!TransactionIdEquals(tk->xid, xid)) - continue; - - if (count >= capacity) - { - capacity *= 2; - arr = (SLogTrackedKeyInfo *) - repalloc_huge(arr, sizeof(SLogTrackedKeyInfo) * (Size) capacity); - } - - arr[count].key = tk->key; - arr[count].xid = tk->xid; - arr[count].subxid = tk->subxid; - arr[count].local_only = tk->local_only; - arr[count].op_type = tk->op_type; - count++; - } - - *out_keys = arr; - return count; + if (TransactionIdEquals(slog_tl_slot(dir, slotno)->xid, + XidFromFullTransactionId(fxid))) + slog_tl_free_slot(slotno, false); } diff --git a/src/backend/access/undo/undo.c b/src/backend/access/undo/undo.c index 78e10ad477a9c..e28cb767169b0 100644 --- a/src/backend/access/undo/undo.c +++ b/src/backend/access/undo/undo.c @@ -156,14 +156,14 @@ UndoShmemRequest_internal(void *arg) * The per-backend UNDO engine's discard worker reclaims undo log * space, and its undo launcher applies rollbacks deferred by * crash recovery. Neither is optional: without the discard - * worker the undo segment files of any FLUX table grow - * without bound. Both hibernate when there is no per-backend UNDO - * to process, so a cluster that never uses those AMs pays only - * for two idle processes on top of the cluster-wide UNDO worker - * and logical revert launcher. The default max_worker_processes - * is sized to leave parallel query its full budget alongside - * these. Registered here (request_fn phase) for the same reason - * as UndoWorkerRegister(): a static background worker cannot be + * worker the undo segment files of any FLUX table grow without + * bound. Both hibernate when there is no per-backend UNDO to + * process, so a cluster that never uses those AMs pays only for + * two idle processes on top of the cluster-wide UNDO worker and + * logical revert launcher. The default max_worker_processes is + * sized to leave parallel query its full budget alongside these. + * Registered here (request_fn phase) for the same reason as + * UndoWorkerRegister(): a static background worker cannot be * registered after BackgroundWorkerShmemInit(). */ DiscardWorkerRegister(); diff --git a/src/backend/access/undo/undo_xlog.c b/src/backend/access/undo/undo_xlog.c index 26d36393016ad..36ff401584b53 100644 --- a/src/backend/access/undo/undo_xlog.c +++ b/src/backend/access/undo/undo_xlog.c @@ -37,6 +37,8 @@ #include "access/atm.h" #include "access/bufmask.h" +#include "access/flux.h" +#include "access/flux_xlog.h" #include "access/hash.h" #include "access/hash_xlog.h" #include "access/htup_details.h" @@ -1087,7 +1089,8 @@ UndoResetBatchReader(void) * table AM or index AM that applied the UNDO record, so hand the page to that * AM's mask function, telling them apart by the special space the way * pg_filedump does: hash pages end their 16-byte special space with - * HASHO_PAGE_ID, which no btree cycle ID can equal (MAX_BT_CYCLE_ID). + * HASHO_PAGE_ID, which no btree cycle ID can equal (MAX_BT_CYCLE_ID), and + * FLUX pages have an 8-byte one. */ void undo_mask(char *pagedata, BlockNumber blkno) @@ -1100,6 +1103,10 @@ undo_mask(char *pagedata, BlockNumber blkno) hash_mask(pagedata, blkno); else if (special == MAXALIGN(sizeof(BTPageOpaqueData))) btree_mask(pagedata, blkno); +#ifdef USE_FLUX + else if (special == MAXALIGN(sizeof(FluxPageOpaqueData))) + flux_mask(pagedata, blkno); +#endif else { mask_page_lsn_and_checksum(page); diff --git a/src/backend/access/undo/xactundo.c b/src/backend/access/undo/xactundo.c index 4834375cd1e81..e2309c1be7cea 100644 --- a/src/backend/access/undo/xactundo.c +++ b/src/backend/access/undo/xactundo.c @@ -395,7 +395,13 @@ PrepareXactUndoData(XactUndoContext *ctx, char persistence, XactUndoSubTransactionState *cur; UndoRecPtr *sub_start_location; - /* Remember that we've done something undo-related. */ + /* + * Remember that we've done something undo-related. The first time, make + * sure the ATM exists, as aborting may defer this transaction's UNDO to + * it and must not allocate. + */ + if (!XactUndo.has_undo) + ATMEnsureCreated(); XactUndo.has_undo = true; if (rmid != UNDO_RMID_NBTREE && rmid != UNDO_RMID_HASH) XactUndo.needs_inline_abort = true; @@ -497,7 +503,13 @@ PrepareXactUndoDataParts(XactUndoContext *ctx, char persistence, XactUndoSubTransactionState *cur; UndoRecPtr *sub_start_location; - /* Remember that we've done something undo-related. */ + /* + * Remember that we've done something undo-related. The first time, make + * sure the ATM exists, as aborting may defer this transaction's UNDO to + * it and must not allocate. + */ + if (!XactUndo.has_undo) + ATMEnsureCreated(); XactUndo.has_undo = true; if (rmid != UNDO_RMID_NBTREE && rmid != UNDO_RMID_HASH) XactUndo.needs_inline_abort = true; @@ -1139,13 +1151,12 @@ AtAbort_XactUndo(void) { /* * Already inside AbortTransaction: we cannot abort - * again or rethrow. Repeat its early cleanup and - * fall back to ATM; its ResourceOwnerRelease will - * release pins and other resources later. - * Release any content locks the failed chain left - * held before unwinding. The error reset - * InterruptHoldoffCount, but the abort that runs this - * still holds interrupts. + * again or rethrow. Repeat its early cleanup and fall + * back to ATM; its ResourceOwnerRelease will release + * pins and other resources later. Release any content + * locks the failed chain left held before unwinding. + * The error reset InterruptHoldoffCount, but the + * abort that runs this still holds interrupts. */ InterruptHoldoffCount = saved_holdoff; BufferLockReleaseAll(); @@ -1454,10 +1465,10 @@ AtSubAbort_XactUndo(int level) /* * Already inside AbortSubTransaction, so do not abort * again or rethrow. Repeat its early cleanup; its - * ResourceOwnerRelease will release the rest. - * Release any content locks the failed subxact UNDO chain - * left held before unwinding, and hold interrupts again - * as the subtransaction abort did (see AtAbort_XactUndo). + * ResourceOwnerRelease will release the rest. Release any + * content locks the failed subxact UNDO chain left held + * before unwinding, and hold interrupts again as the + * subtransaction abort did (see AtAbort_XactUndo). */ InterruptHoldoffCount = saved_holdoff; BufferLockReleaseAll(); diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c index 2bfe90d1aaf39..9f43f4c9b47e6 100644 --- a/src/backend/commands/tablecmds.c +++ b/src/backend/commands/tablecmds.c @@ -688,6 +688,7 @@ static ObjectAddress ATExecClusterOn(Relation rel, const char *indexName, LOCKMODE lockmode); static void ATExecDropCluster(Relation rel, LOCKMODE lockmode); static void ATPrepSetAccessMethod(AlteredTableInfo *tab, Relation rel, const char *amname); +static void CheckFluxUserCatalogTable(Oid accessMethodId, Datum reloptions); static void ATExecSetAccessMethodNoStorage(Relation rel, Oid newAccessMethodId); static void ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel, bool toLogged); @@ -1075,6 +1076,9 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId, accessMethodId = get_table_am_oid(default_table_access_method, false); } + /* An access method without historic-snapshot reads cannot be a catalog. */ + CheckFluxUserCatalogTable(accessMethodId, reloptions); + /* * Create the relation. Inherited defaults and CHECK constraints are * passed in for immediate handling --- since they don't need parsing, @@ -17161,6 +17165,40 @@ ATExecDropCluster(Relation rel, LOCKMODE lockmode) mark_index_clustered(rel, InvalidOid, false); } +/* + * Refuse user_catalog_table on an access method that cannot serve logical + * decoding's historic-snapshot reads. + * + * A user_catalog_table is read during logical decoding under a historic + * snapshot that reconstructs a past catalog state from older row versions. + * The FLUX access method stores old versions in undo and reuses the tuple + * slot in place, so it cannot answer those reads; marking a FLUX relation as + * a user_catalog_table would silently break any output plugin that depends on + * it. Refuse the combination here, where both the chosen access method and + * the proposed reloptions are known, so CREATE TABLE and ALTER TABLE ... SET + * are covered by one check. (SET ACCESS METHOD to FLUX on an already-marked + * heap table is refused separately, in ATPrepSetAccessMethod.) + * + * accessMethodId may be InvalidOid (partitioned tables, which have no storage + * and never carry undo); such relations are left to core's own validation. + */ +static void +CheckFluxUserCatalogTable(Oid accessMethodId, Datum reloptions) +{ + StdRdOptions *rdopts; + + if (accessMethodId != FLUX_TABLE_AM_OID) + return; + + rdopts = (StdRdOptions *) heap_reloptions(RELKIND_RELATION, reloptions, + false); + if (rdopts != NULL && rdopts->user_catalog_table) + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("user_catalog_table is not supported for the \"flux\" access method"), + errhint("Put logical-decoding plugin metadata in a table that uses the \"heap\" access method."))); +} + /* * Preparation phase for SET ACCESS METHOD * @@ -17188,6 +17226,19 @@ ATPrepSetAccessMethod(AlteredTableInfo *tab, Relation rel, const char *amname) if (rel->rd_rel->relam == amoid) return; + /* + * A user_catalog_table cannot live under an access method that cannot + * serve logical decoding's historic-snapshot reads (see + * CheckFluxUserCatalogTable). Refuse the conversion here with the + * access-method-specific message; core would otherwise refuse the rewrite + * anyway, but less helpfully. + */ + if (amoid == FLUX_TABLE_AM_OID && RelationIsUsedAsCatalogTable(rel)) + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("user_catalog_table is not supported for the \"flux\" access method"), + errhint("Put logical-decoding plugin metadata in a table that uses the \"heap\" access method."))); + /* Save info for Phase 3 to do the real work */ tab->rewrite |= AT_REWRITE_ACCESS_METHOD; tab->newAccessMethod = amoid; @@ -17398,6 +17449,9 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation, break; } + /* An access method without historic-snapshot reads cannot be a catalog. */ + CheckFluxUserCatalogTable(rel->rd_rel->relam, newOptions); + /* Special-case validation of view options */ if (rel->rd_rel->relkind == RELKIND_VIEW) { diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c index aab6010ab0d88..c11579d92f125 100644 --- a/src/backend/optimizer/util/plancat.c +++ b/src/backend/optimizer/util/plancat.c @@ -299,9 +299,9 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent, * Suppress index-only scans for indexes on a delete-marking * table: a delete-marked entry can carry a stale key that no * longer matches the live heap tuple, so an IOS that trusted - * the index key without visiting the heap could return a wrong - * result. We do this here (not in btcanreturn) because we - * hold the fully-built heap relcache entry, so + * the index key without visiting the heap could return a + * wrong result. We do this here (not in btcanreturn) because + * we hold the fully-built heap relcache entry, so * RelationSupportsDeleteMarking() is safe -- reaching for the * table AM from inside btcanreturn crashes on half-built * relcache entries during bootstrap. diff --git a/src/backend/replication/logical/decode.c b/src/backend/replication/logical/decode.c index 4a7392302645e..c52a72c903e72 100644 --- a/src/backend/replication/logical/decode.c +++ b/src/backend/replication/logical/decode.c @@ -27,6 +27,7 @@ #include "postgres.h" #include "access/heapam_xlog.h" +#include "access/flux_xlog.h" #include "access/transam.h" #include "access/xact.h" #include "access/xlog_internal.h" @@ -68,6 +69,8 @@ static inline bool FilterPrepare(LogicalDecodingContext *ctx, static bool DecodeTXNNeedSkip(LogicalDecodingContext *ctx, XLogRecordBuffer *buf, Oid txn_dbid, ReplOriginId origin_id); +static inline bool FilterByOrigin(LogicalDecodingContext *ctx, + ReplOriginId origin_id); /* * Take every XLogReadRecord()ed record and perform the actions required to @@ -564,6 +567,520 @@ heap_decode(LogicalDecodingContext *ctx, XLogRecordBuffer *buf) } } +/* + * Parse XLOG_FLUX_INSERT from wal into proper tuplebufs. + * + * Inserts contain the new tuple in FLUX format. + */ +static void +DecodeFluxInsert(LogicalDecodingContext *ctx, XLogRecordBuffer *buf) +{ + XLogReaderState *r = buf->record; + xl_flux_insert *xlrec; + ReorderBufferChange *change; + RelFileLocator target_locator; + + xlrec = (xl_flux_insert *) XLogRecGetData(r); + + /* + * Ignore an insert logged without a logical image, as heap does: the + * relation was not logically logged, or TABLE_INSERT_NO_LOGICAL, as for + * the insert half of an UPDATE that moved the row, which its + * XLOG_FLUX_SUPERSEDE record decodes. A speculative insertion's + * confirmation carries no tuple, so it is identified by its own flag. + */ + if (!(xlrec->flags & (FLUX_WAL_LOGICAL_TUPLE | FLUX_WAL_SPECULATIVE | + FLUX_WAL_SPEC_CONFIRM))) + return; + + /* only interested in our database */ + XLogRecGetBlockTag(r, 0, &target_locator, NULL, NULL); + if (target_locator.dbOid != ctx->slot->data.database) + return; + + /* output plugin doesn't look for this origin, no need to queue */ + if (FilterByOrigin(ctx, XLogRecGetOrigin(r))) + return; + + change = ReorderBufferAllocChange(ctx->reorder); + if (xlrec->flags & FLUX_WAL_SPEC_CONFIRM) + change->action = REORDER_BUFFER_CHANGE_INTERNAL_SPEC_CONFIRM; + else if (xlrec->flags & FLUX_WAL_SPECULATIVE) + change->action = REORDER_BUFFER_CHANGE_INTERNAL_SPEC_INSERT; + else + change->action = REORDER_BUFFER_CHANGE_INSERT; + change->origin_id = XLogRecGetOrigin(r); + + memcpy(&change->data.tp.rlocator, &target_locator, sizeof(RelFileLocator)); + + /* + * When FLUX_WAL_LOGICAL_TUPLE is set, the write path appended a + * heap-format image at the END of the main WAL data: ... [heap bytes] + * [uint32 heap_len] Read heap_len from the last 4 bytes, then back up + * heap_len more bytes to find the heap tuple payload. This is immune to + * whatever compression data precedes it. + */ + if (xlrec->flags & FLUX_WAL_SPEC_CONFIRM) + { + /* Confirmation carries no tuple; the speculative insert did. */ + } + else if (xlrec->flags & FLUX_WAL_LOGICAL_TUPLE) + { + Size full_len = XLogRecGetDataLen(r); + char *end = (char *) xlrec + full_len; + uint32 heap_len; + char *heap_data; + + memcpy(&heap_len, end - sizeof(uint32), sizeof(uint32)); + elog(DEBUG1, "FLUX DecodeInsert: full_len=%zu heap_len=%u action=INSERT lsn=%X/%X", + full_len, heap_len, + (uint32) (buf->origptr >> 32), (uint32) buf->origptr); + heap_data = end - sizeof(uint32) - heap_len; + + change->data.tp.newtuple = + ReorderBufferAllocTupleBuf(ctx->reorder, + heap_len - SizeofHeapTupleHeader); + change->data.tp.newtuple->t_len = heap_len; + ItemPointerSetInvalid(&change->data.tp.newtuple->t_self); + change->data.tp.newtuple->t_tableOid = InvalidOid; + memcpy(change->data.tp.newtuple->t_data, heap_data, heap_len); + } + else + { + /* + * A speculative insertion into a relation that was not logically + * logged: its body is in FLUX format, which no output plugin can + * read. + */ + elog(ERROR, "FLUX record without logical image"); + } + + change->data.tp.clear_toast_afterwards = true; + + ReorderBufferQueueChange(ctx->reorder, XLogRecGetXid(r), buf->origptr, + change, false); +} + +/* + * Parse XLOG_FLUX_MULTI_INSERT from wal into proper tuplebufs. + * + * A batched insert carries N tuples on one page. The record layout is: + * + * xl_flux_multi_insert -- header + * N * { xl_flux_multi_insert_tuple | FLUX body }-- per-tuple region + * N * { heap body | uint32 len } -- when FLUX_WAL_LOGICAL_TUPLE + * + * We queue one REORDER_BUFFER_CHANGE_INSERT per tuple, preferring the + * trailing heap-format image when present (mirrors DecodeFluxInsert). The + * heap images are appended in tuple order, so the i-th image from the front + * of the trailing region corresponds to the i-th per-tuple entry. + */ +static void +DecodeFluxMultiInsert(LogicalDecodingContext *ctx, XLogRecordBuffer *buf) +{ + XLogReaderState *r = buf->record; + xl_flux_multi_insert *xlrec; + RelFileLocator target_locator; + char *data; + char *cursor; + Size total_len; + int ntuples; + int i; + bool has_logical; + char **image_data = NULL; + uint32 *image_len = NULL; + + xlrec = (xl_flux_multi_insert *) XLogRecGetData(r); + ntuples = xlrec->ntuples; + + /* only interested in our database */ + XLogRecGetBlockTag(r, 0, &target_locator, NULL, NULL); + if (target_locator.dbOid != ctx->slot->data.database) + return; + + /* output plugin doesn't look for this origin, no need to queue */ + if (FilterByOrigin(ctx, XLogRecGetOrigin(r))) + return; + + data = XLogRecGetData(r); + total_len = XLogRecGetDataLen(r); + has_logical = (xlrec->flags & FLUX_WAL_LOGICAL_TUPLE) != 0; + + /* + * When heap-format images are present, locate each one up front by + * walking the trailing image region from the record tail backward (each + * region is "[heap bytes][uint32 len]"). The images were appended in + * tuple order, so walking back collects them in reverse; we store them + * indexed by tuple so the queue loop can read them front-to-back. + */ + if (has_logical) + { + const char *end = data + total_len; + + image_data = (char **) palloc(ntuples * sizeof(char *)); + image_len = (uint32 *) palloc(ntuples * sizeof(uint32)); + + for (i = ntuples - 1; i >= 0; i--) + { + uint32 heap_len; + + memcpy(&heap_len, end - sizeof(uint32), sizeof(uint32)); + end -= sizeof(uint32); + end -= heap_len; + image_data[i] = (char *) end; + image_len[i] = heap_len; + } + } + + /* Walk the per-tuple region forward, queuing one INSERT per tuple */ + cursor = data + SizeOfFluxMultiInsert; + for (i = 0; i < ntuples; i++) + { + xl_flux_multi_insert_tuple *tuphdr; + ReorderBufferChange *change; + + cursor = (char *) SHORTALIGN(cursor); + tuphdr = (xl_flux_multi_insert_tuple *) cursor; + cursor += SizeOfFluxMultiInsertTuple; + cursor += tuphdr->datalen; + + change = ReorderBufferAllocChange(ctx->reorder); + change->action = REORDER_BUFFER_CHANGE_INSERT; + change->origin_id = XLogRecGetOrigin(r); + memcpy(&change->data.tp.rlocator, &target_locator, + sizeof(RelFileLocator)); + + if (has_logical) + { + uint32 heap_len = image_len[i]; + + change->data.tp.newtuple = + ReorderBufferAllocTupleBuf(ctx->reorder, + heap_len - SizeofHeapTupleHeader); + change->data.tp.newtuple->t_len = heap_len; + ItemPointerSetInvalid(&change->data.tp.newtuple->t_self); + change->data.tp.newtuple->t_tableOid = InvalidOid; + memcpy(change->data.tp.newtuple->t_data, image_data[i], heap_len); + } + else + { + /* + * The relation was not logically logged when the record was + * written, so the body is in FLUX format, which no output plugin + * can read. + */ + elog(ERROR, "FLUX record without logical image"); + } + + change->data.tp.clear_toast_afterwards = (i == ntuples - 1); + + ReorderBufferQueueChange(ctx->reorder, XLogRecGetXid(r), buf->origptr, + change, false); + } + + if (has_logical) + { + pfree(image_data); + pfree(image_len); + } +} + +/* + * Parse XLOG_FLUX_UPDATE_INPLACE from wal into proper tuplebufs. + * + * Updates contain both the old and new tuple in FLUX format. + * + * Also parse an XLOG_FLUX_SUPERSEDE record that carries logical images: it + * links the old version of a row an UPDATE moved to the new one, and is the + * UPDATE's logical change. Its images have the same layout. + */ +static void +DecodeFluxUpdate(LogicalDecodingContext *ctx, XLogRecordBuffer *buf) +{ + XLogReaderState *r = buf->record; + xl_flux_update *xlrec; + ReorderBufferChange *change; + RelFileLocator target_locator; + bool has_images; + + xlrec = (xl_flux_update *) XLogRecGetData(r); + if ((XLogRecGetInfo(r) & XLOG_FLUX_OPMASK) == XLOG_FLUX_SUPERSEDE) + has_images = true; /* checked by the caller */ + else + { + /* + * Skip an UPDATE that was marked as ignorable at origin, as + * DecodeUpdate skips heap's UPDATE without a new tuple (REPACK + * (CONCURRENTLY) replaying onto its transient table). + */ + if (xlrec->flags & FLUX_WAL_NO_LOGICAL) + return; + has_images = (xlrec->flags & FLUX_WAL_LOGICAL_TUPLE) != 0; + } + + /* only interested in our database */ + XLogRecGetBlockTag(r, 0, &target_locator, NULL, NULL); + if (target_locator.dbOid != ctx->slot->data.database) + return; + + /* output plugin doesn't look for this origin, no need to queue */ + if (FilterByOrigin(ctx, XLogRecGetOrigin(r))) + return; + + change = ReorderBufferAllocChange(ctx->reorder); + change->action = REORDER_BUFFER_CHANGE_UPDATE; + change->origin_id = XLogRecGetOrigin(r); + memcpy(&change->data.tp.rlocator, &target_locator, sizeof(RelFileLocator)); + + /* + * Get old and new tuple data from WAL record. The structure is: + * xl_flux_update | old_tuple | new_tuple [| logical images] + */ + if (has_images) + { + /* + * Two heap-format images appended at the END of the record, each with + * trailing length: + * + * ... [old_heap bytes] [uint32 old_heap_len] [new_heap bytes] [uint32 + * new_heap_len] + * + * Walk backwards from end: read new_heap_len last, then old_heap_len + * before the new bytes + its length. + */ + Size full_len = XLogRecGetDataLen(r); + char *end = (char *) xlrec + full_len; + uint32 old_heap_len; + uint32 new_heap_len; + char *old_heap_data; + char *new_heap_data; + + memcpy(&new_heap_len, end - sizeof(uint32), sizeof(uint32)); + new_heap_data = end - sizeof(uint32) - new_heap_len; + memcpy(&old_heap_len, + new_heap_data - sizeof(uint32), sizeof(uint32)); + old_heap_data = new_heap_data - sizeof(uint32) - old_heap_len; + + if (old_heap_len > 0) + { + change->data.tp.oldtuple = + ReorderBufferAllocTupleBuf(ctx->reorder, + old_heap_len - SizeofHeapTupleHeader); + change->data.tp.oldtuple->t_len = old_heap_len; + ItemPointerSetInvalid(&change->data.tp.oldtuple->t_self); + change->data.tp.oldtuple->t_tableOid = InvalidOid; + memcpy(change->data.tp.oldtuple->t_data, old_heap_data, old_heap_len); + } + + change->data.tp.newtuple = + ReorderBufferAllocTupleBuf(ctx->reorder, + new_heap_len - SizeofHeapTupleHeader); + change->data.tp.newtuple->t_len = new_heap_len; + ItemPointerSetInvalid(&change->data.tp.newtuple->t_self); + change->data.tp.newtuple->t_tableOid = InvalidOid; + memcpy(change->data.tp.newtuple->t_data, new_heap_data, new_heap_len); + } + else + { + /* + * The relation was not logically logged when the record was written, + * so the bodies are in FLUX format, which no output plugin can read. + */ + elog(ERROR, "FLUX record without logical image"); + } + + change->data.tp.clear_toast_afterwards = true; + + ReorderBufferQueueChange(ctx->reorder, XLogRecGetXid(r), buf->origptr, + change, false); +} + +/* + * Parse XLOG_FLUX_DELETE from wal into proper tuplebufs. + * + * Deletes contain the old tuple for UNDO purposes. + */ +static void +DecodeFluxDelete(LogicalDecodingContext *ctx, XLogRecordBuffer *buf) +{ + XLogReaderState *r = buf->record; + xl_flux_delete *xlrec; + ReorderBufferChange *change; + RelFileLocator target_locator; + + xlrec = (xl_flux_delete *) XLogRecGetData(r); + + /* + * Ignore a delete logged without a logical image: + * TABLE_DELETE_NO_LOGICAL, as for the delete half of an UPDATE that moved + * the row, which its XLOG_FLUX_SUPERSEDE record decodes. + */ + if (!(xlrec->flags & (FLUX_WAL_LOGICAL_TUPLE | FLUX_WAL_SPECULATIVE))) + return; + + /* only interested in our database */ + XLogRecGetBlockTag(r, 0, &target_locator, NULL, NULL); + if (target_locator.dbOid != ctx->slot->data.database) + return; + + /* output plugin doesn't look for this origin, no need to queue */ + if (FilterByOrigin(ctx, XLogRecGetOrigin(r))) + return; + + change = ReorderBufferAllocChange(ctx->reorder); + change->action = (xlrec->flags & FLUX_WAL_SPECULATIVE) ? + REORDER_BUFFER_CHANGE_INTERNAL_SPEC_ABORT : REORDER_BUFFER_CHANGE_DELETE; + change->origin_id = XLogRecGetOrigin(r); + memcpy(&change->data.tp.rlocator, &target_locator, sizeof(RelFileLocator)); + + if (xlrec->flags & FLUX_WAL_SPECULATIVE) + { + /* An aborted speculative insertion has no old identity. */ + } + else + { + /* + * Heap image is the last region of the WAL record: ... [heap bytes] + * [uint32 heap_len] + */ + Size full_len = XLogRecGetDataLen(r); + char *end = (char *) xlrec + full_len; + uint32 heap_len; + char *heap_data; + + memcpy(&heap_len, end - sizeof(uint32), sizeof(uint32)); + heap_data = end - sizeof(uint32) - heap_len; + + if (heap_len > 0) + { + change->data.tp.oldtuple = + ReorderBufferAllocTupleBuf(ctx->reorder, + heap_len - SizeofHeapTupleHeader); + change->data.tp.oldtuple->t_len = heap_len; + ItemPointerSetInvalid(&change->data.tp.oldtuple->t_self); + change->data.tp.oldtuple->t_tableOid = InvalidOid; + memcpy(change->data.tp.oldtuple->t_data, heap_data, heap_len); + } + } + + change->data.tp.clear_toast_afterwards = true; + + ReorderBufferQueueChange(ctx->reorder, XLogRecGetXid(r), buf->origptr, + change, false); +} + + +/* + * Handle rmgr FLUX records for LogicalDecodingProcessRecord(). + * + * FLUX's WAL record layout and the XLOG_FLUX_* opcodes are defined in + * flux_xlog.h; the FLUX decode helpers above interpret them directly. + */ +void +flux_decode(LogicalDecodingContext *ctx, XLogRecordBuffer *buf) +{ + uint8 info = XLogRecGetInfo(buf->record) & XLOG_FLUX_OPMASK; + TransactionId xid = XLogRecGetXid(buf->record); + SnapBuild *builder = ctx->snapshot_builder; + + ReorderBufferProcessXid(ctx->reorder, xid, buf->origptr); + + if (SnapBuildCurrentState(builder) < SNAPBUILD_FULL_SNAPSHOT) + return; + + switch (info) + { + case XLOG_FLUX_INSERT: + if (SnapBuildProcessChange(builder, xid, buf->origptr) && + !ctx->fast_forward && + !change_useless_for_repack(buf)) + DecodeFluxInsert(ctx, buf); + break; + + case XLOG_FLUX_MULTI_INSERT: + if (SnapBuildProcessChange(builder, xid, buf->origptr) && + !ctx->fast_forward && + !change_useless_for_repack(buf)) + DecodeFluxMultiInsert(ctx, buf); + break; + + case XLOG_FLUX_UPDATE_INPLACE: + if (SnapBuildProcessChange(builder, xid, buf->origptr) && + !ctx->fast_forward && + !change_useless_for_repack(buf)) + DecodeFluxUpdate(ctx, buf); + break; + + case XLOG_FLUX_DELETE: + if (SnapBuildProcessChange(builder, xid, buf->origptr) && + !ctx->fast_forward && + !change_useless_for_repack(buf)) + DecodeFluxDelete(ctx, buf); + break; + + case XLOG_FLUX_FREEZE_PAGE: + case XLOG_FLUX_DEFRAG: + case XLOG_FLUX_COMPRESS: + case XLOG_FLUX_OVERFLOW_WRITE: + case XLOG_FLUX_INIT_PAGE: + case XLOG_FLUX_LOCK: + case XLOG_FLUX_VM_SET: + case XLOG_FLUX_VM_CLEAR: + case XLOG_FLUX_WRITE_DICT: + /* No row change to decode: page maintenance, locking, VM, dict. */ + break; + + case XLOG_FLUX_SUPERSEDE: + /* The UPDATE that moved a row, unless logged without images */ + if ((((xl_flux_supersede *) XLogRecGetData(buf->record))->flags & + FLUX_WAL_LOGICAL_TUPLE) && + SnapBuildProcessChange(builder, xid, buf->origptr) && + !ctx->fast_forward && + !change_useless_for_repack(buf)) + DecodeFluxUpdate(ctx, buf); + break; + + case XLOG_FLUX_CAS_UPDATE: + case XLOG_FLUX_CAS_UPDATE_UNDO: + + /* + * Same-size in-place UPDATE fast path. These records carry only + * a byte-diff, not a full tuple, so they cannot be decoded into a + * logical change. flux_update() refuses the CAS path for a + * logically-logged relation (see flux_operations.c), routing such + * UPDATEs through XLOG_FLUX_UPDATE_INPLACE instead, so a record + * of a relation that was not logically logged is ignored. The + * emitter marks one that was with FLUX_WAL_LOGICAL_REL; skipping + * that would silently lose a change. + */ + { + uint16 flags; + RelFileLocator target_locator; + + if (info == XLOG_FLUX_CAS_UPDATE) + flags = ((xl_flux_cas_update *) + XLogRecGetData(buf->record))->flags; + else + flags = ((xl_flux_cas_update_undo *) + XLogRecGetData(buf->record))->flags; + XLogRecGetBlockTag(buf->record, 0, &target_locator, NULL, NULL); + if ((flags & FLUX_WAL_LOGICAL_REL) && + target_locator.dbOid == ctx->slot->data.database && + SnapBuildProcessChange(builder, xid, buf->origptr) && + !ctx->fast_forward && + !change_useless_for_repack(buf)) + elog(ERROR, "FLUX CAS update of a logically logged relation at %X/%08X cannot be decoded", + LSN_FORMAT_ARGS(buf->origptr)); + } + break; + + default: + elog(ERROR, "unexpected RM_FLUX_ID record type: %u", info); + break; + } +} + + /* * Ask output plugin whether we want to skip this PREPARE and send * this transaction as a regular commit later. diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index e2dd236a8ab59..043ea9fe6c481 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -1097,6 +1097,12 @@ options => 'file_extend_method_options', }, +{ name => 'flux_lazy_uncommitted_clear', type => 'bool', context => 'PGC_USERSET', group => 'CLIENT_CONN_STATEMENT', + short_desc => 'Skip commit-time page re-visits for FLUX UNCOMMITTED flag clearing.', + flags => 'GUC_NOT_IN_SAMPLE', + variable => 'flux_lazy_uncommitted_clear', + boot_val => 'false', +}, { 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.', @@ -2787,15 +2793,6 @@ max => '16384', }, -{ name => 'slog_num_partitions', type => 'int', context => 'PGC_POSTMASTER', group => 'WAL_SETTINGS', - short_desc => 'Number of sLog flat hash partitions (0 = auto from CPU count).', - long_desc => 'Controls the number of partitions for the optional sLog tuple tracking hash. More partitions reduce writer lock contention. Set 0 for auto-sizing (4x CPUs, clamped 16-256, power of 2). Requires restart.', - variable => 'slog_num_partitions', - boot_val => '0', - min => '0', - max => '256', -}, - { name => 'ssl', type => 'bool', context => 'PGC_SIGHUP', group => 'CONN_AUTH_SSL', short_desc => 'Enables SSL connections.', variable => 'EnableSSL', diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c index 59c9221cf592f..0dc61771478f6 100644 --- a/src/backend/utils/misc/guc_tables.c +++ b/src/backend/utils/misc/guc_tables.c @@ -30,6 +30,7 @@ #endif #include "access/commit_ts.h" +#include "access/flux.h" #include "access/gin.h" #include "access/logical_revert_worker.h" #include "access/slog.h" diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index d69d90bce9248..01431970204cf 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -925,7 +925,6 @@ #undo_rollback_overflow_size = 16MB # rollbacks larger than this go to the # size-ordered undo worker queue #slog_dsa_max_size_mb = 256 # MB; max sLog DSA area size (aborted-txn tree) -#slog_num_partitions = 0 # 0 = auto (4x CPUs); requires restart #max_logical_revert_workers = 2 # 0 disables the logical revert launcher #logical_revert_naptime = 1s # time between ATM scan cycles diff --git a/src/bin/pg_waldump/rmgrdesc.c b/src/bin/pg_waldump/rmgrdesc.c index a79a07c772922..d5b6134bb9f87 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/flux_xlog.h" #include "access/fileops_xlog.h" #include "access/nbtxlog.h" #include "access/rmgr.h" diff --git a/src/bin/pg_waldump/t/001_basic.pl b/src/bin/pg_waldump/t/001_basic.pl index db68ddf1febec..7a75e37120406 100644 --- a/src/bin/pg_waldump/t/001_basic.pl +++ b/src/bin/pg_waldump/t/001_basic.pl @@ -99,6 +99,7 @@ XLOG2 Undo ATM +FLUX FileOps UndoLog UndoAction$/, diff --git a/src/include/access/amapi.h b/src/include/access/amapi.h index f404fe99d3718..e50f2ce056752 100644 --- a/src/include/access/amapi.h +++ b/src/include/access/amapi.h @@ -226,7 +226,10 @@ typedef bool (*amdeletemark_function) (Relation indexRelation, * clear_mark == true : the (key, TID) entry is a delete-marked tombstone of * the OLD key; demote it back to a plain live entry. * clear_mark == false: the (key, TID) entry is the NEW key's live entry that - * the aborted update inserted; kill it (LP_DEAD). + * the aborted update inserted; physically remove it. + * With probe=true, only test whether the exact entry is delete-marked; the + * caller is about to revive it in a forward UPDATE, so the AM must report the + * serializable rw-conflict an insert of the entry would (it may ERROR). * Returns true if the entry was found and reversed, false otherwise (already * gone -- not an error). */ @@ -235,7 +238,7 @@ typedef bool (*amundomark_function) (Relation indexRelation, Datum *values, bool *isnull, ItemPointer heap_t_ctid, - bool clear_mark); + bool clear_mark, bool probe); /* end index scan */ typedef void (*amendscan_function) (IndexScanDesc scan); diff --git a/src/include/access/atm.h b/src/include/access/atm.h index 1fafbef036bcf..5fe31e6a9e2ff 100644 --- a/src/include/access/atm.h +++ b/src/include/access/atm.h @@ -28,6 +28,7 @@ /* Shared memory sizing and initialization */ extern Size ATMShmemSize(void); +extern void ATMEnsureCreated(void); extern void ATMShmemInit(void); /* Core API */ diff --git a/src/include/access/flux.h b/src/include/access/flux.h new file mode 100644 index 0000000000000..e0ec7a5c7e778 --- /dev/null +++ b/src/include/access/flux.h @@ -0,0 +1,1095 @@ +/*------------------------------------------------------------------------- + * + * flux.h + * FLUX table access method definitions + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/flux.h + * + *------------------------------------------------------------------------- + */ +#ifndef FLUX_H +#define FLUX_H + +#include "postgres.h" + +#include "access/heapam.h" +#include "access/undodefs.h" /* UndoRecPtr (per-backend version pointer) */ +#include "access/toast_helper.h" +#include "storage/shmem.h" +#include "access/relscan.h" +#include "access/sdir.h" +#include "access/tableam.h" +#include "access/xact.h" +#include "executor/tuptable.h" +#include "port/atomics.h" +#include "storage/buf.h" +#include "storage/bufpage.h" +#include "storage/procnumber.h" +#include "utils/rel.h" +#include "utils/snapshot.h" + +/* + * FLUX special space structure - stored in page special space (8 bytes) + * + * Packs the page-level commit timestamp and 3 flag bits into a single + * uint64. The top 3 bits (63-61) store page flags; the lower 61 bits + * store the commit timestamp (sufficient for 73,000+ years of microseconds). + * + * pd_free_space was removed -- use PageGetFreeSpace() directly (same as + * heap). pd_flags was removed -- flags are packed into the timestamp word. + */ +typedef struct FluxPageOpaqueData +{ + uint64 pd_commit_ts_and_flags; /* bits 63-61: flags, bits 60-0: ts */ +} FluxPageOpaqueData; + +typedef FluxPageOpaqueData *FluxPageOpaque; + +/* Page flags (stored in top 3 bits of pd_commit_ts_and_flags) */ +#define FLUX_PAGE_FLAG_SHIFT 61 +#define FLUX_PAGE_FLAG_MASK (UINT64CONST(0x7) << FLUX_PAGE_FLAG_SHIFT) +#define FLUX_PAGE_TS_MASK (~FLUX_PAGE_FLAG_MASK) + +#define FLUX_PAGE_OVERFLOW (UINT64CONST(1) << 61) +#define FLUX_PAGE_DEFRAG_NEEDED (UINT64CONST(1) << 62) +#define FLUX_PAGE_FULL (UINT64CONST(1) << 63) + +/* Accessor macros for page opaque */ +#define FluxPageGetOpaque(page) \ + ((FluxPageOpaque) PageGetSpecialPointer(page)) + +#define FluxPageGetCommitTs(opaque) \ + ((opaque)->pd_commit_ts_and_flags & FLUX_PAGE_TS_MASK) + +#define FluxPageSetCommitTs(opaque, ts) \ + ((opaque)->pd_commit_ts_and_flags = \ + ((opaque)->pd_commit_ts_and_flags & FLUX_PAGE_FLAG_MASK) | \ + ((uint64)(ts) & FLUX_PAGE_TS_MASK)) + +#define FluxPageGetFlags(opaque) \ + ((opaque)->pd_commit_ts_and_flags & FLUX_PAGE_FLAG_MASK) + +#define FluxPageSetFlag(opaque, flag) \ + ((opaque)->pd_commit_ts_and_flags |= (flag)) + +#define FluxPageClearFlag(opaque, flag) \ + ((opaque)->pd_commit_ts_and_flags &= ~(flag)) + +/* + * FLUX tuple header structure (v3 -- heap-compatible xmin/xmax MVCC) + * + * Visibility is ordinary heap-shaped xmin/xmax + CLOG + snapshot (the same + * model HeapTupleSatisfiesMVCC uses). A tuple is visible to a snapshot iff + * its inserter (t_xmin) is committed-and-visible-to-the-snapshot AND its + * deleter/updater (t_xmax) is either invalid, not committed, or not visible to + * the snapshot. + * + * - t_xmin: the inserting transaction's XID. Always valid once the tuple + * exists on the page (it rides through WAL redo verbatim in the logged + * tuple body, so it needs no separate redo reconstruction). + * - t_xmax: the deleting/updating transaction's XID, or InvalidTransactionId + * for a live never-superseded tuple. Physically stored in the low 32 + * bits of the t_commit_ts word (see FluxTupleGetXmax below). + * + * In-place UPDATE keeps the newest version on the page (new t_xmin) and pushes + * the pre-update image to the UNDO log via t_verptr. A snapshot that predates + * the update reads the old version back from the UNDO log with + * FluxReconstructVisibleVersion(), which walks t_verptr and stops at the + * version whose producing xid is visible (FluxWriterVisibleToSnapshot: + * committed and not in the snapshot). + * + * Transient operation state (who is inserting/deleting/locking concurrently) + * is still tracked in the sLog for write-write conflict serialization and SSI, + * but it is no longer consulted for read visibility -- CLOG is authoritative. + */ +typedef struct FluxTupleHeader +{ + uint64 t_commit_ts; /* 8B low 32 bits = t_xmax (deleter/updater + * XID); high 32 bits reserved. Accessed via + * FluxTupleGetXmax/SetXmax. */ + UndoRecPtr t_verptr; /* 8B per-backend UNDO version-chain head: an + * UndoRecPtr returned by PrepareUndoInsert. */ + + /* + * t_cmin: the command id of the writer identified by t_xmin, while that + * writer is still uncommitted. Heap keeps the same information in t_cid. + * + * INVARIANT: t_cmin always describes the SAME writer as t_xmin. A FLUX + * in-place UPDATE re-stamps t_xmin to the updater, so it must re-stamp + * t_cmin to the updater's command id too. Carrying the original INSERT's + * cid across an in-place UPDATE pairs a new xmin with an old cid and + * makes the self-visibility test below compare our own curcid against a + * different transaction's cid, which hides legitimately-written rows + * (measured: six isolation tests, incl. freeze-the-dead "unexpected + * table_tuple_lock status: 1" and a stale SUM in total-cash). + * + * REQUIRED FOR CORRECTNESS, not an optimisation. Without it a statement + * cannot distinguish its own output rows from its input rows, which is + * the Halloween problem: an UPDATE that relocates a row to a new TID + * (FLUX's out-of-place path) can have the very same scan re-find the row + * it just wrote and update it again. Measured before this field existed: + * `UPDATE hh SET v = v+1, pad = pad||repeat('y',500)` over 2000 rows + * reported UPDATE 3194 with 1194 rows left holding v=2, where heap + * reports UPDATE 2000 and every row v=1. + * + * Read only while FLUX_TUPLE_UNCOMMITTED is set and t_xmin is our own + * transaction (FluxTupleSatisfiesMVCC). After commit it is meaningless + * and nothing clears it, exactly as heap leaves a stale t_cid behind. + * + * This occupies the word formerly reserved as the unused t_gen, so tuple + * geometry, on-disk layout and WAL byte layout are all unchanged (the + * tuple body, including this field, is logged verbatim). + */ + CommandId t_cmin; /* 4B Command id of t_xmin's writer */ + + /* + * t_xmin: the inserting transaction's XID (heap xmin semantics). Always + * valid once the tuple exists. Visibility resolves it against CLOG and + * the reader's snapshot exactly like HeapTupleSatisfiesMVCC, so no sLog + * lookup is needed on the common read path. The tuple body (including + * this field) is WAL-logged verbatim, so t_xmin survives redo without + * separate reconstruction. + * + * (Formerly t_xid_hint, which was "valid only while UNCOMMITTED"; the + * heap-shaped model makes it permanently authoritative as xmin.) + */ + TransactionId t_xmin; /* 4B Inserter XID (heap xmin) */ + + /* + * t_verptr is the head of this tuple's persistent version chain in the + * UNDO log. Every tuple carries it from birth, so the first UPDATE does + * not grow the tuple. InvalidUndoRecPtr means "never updated -- no + * history"; readers treat that as "on-page image is current". + * + * t_cid removed: command ID is now obtained from the sLog entry + * (FluxSLogEntry.cid) when FLUX_TUPLE_UNCOMMITTED is set. This saves 4 + * bytes per tuple. The sLog lookup is mandatory for uncommitted + * DELETE/UPDATE visibility anyway, so fetching the cid from there adds + * zero extra overhead. (INSERT visibility no longer needs the sLog at + * all in the common case -- see t_xmin above.) + */ + ItemPointerData t_ctid; /* 6B Own TID, or the new version's TID if + * FLUX_TUPLE_SUPERSEDED */ + uint16 t_natts; /* 2B Number of attributes */ + uint16 t_flags; /* 2B Tuple flags */ + uint8 t_infomask; /* 1B HASNULL, HASVARWIDTH, etc. */ + uint8 t_pad; /* 1B padding */ + uint8 t_attrs_bitmap[FLEXIBLE_ARRAY_MEMBER]; +} FluxTupleHeader; + +/* The fixed header is 36 bytes; the null bitmap follows without padding. */ + +/* Tuple flags (uint16) */ +#define FLUX_TUPLE_COMPRESSED 0x0001 +#define FLUX_TUPLE_HAS_OVERFLOW 0x0002 +#define FLUX_TUPLE_DELETED 0x0004 +#define FLUX_TUPLE_UPDATED 0x0008 +#define FLUX_TUPLE_LOCKED 0x0010 +#define FLUX_TUPLE_SPECULATIVE 0x0020 + +/* + * FLUX_TUPLE_KEYS_UPDATED -- this in-place image was produced by an UPDATE + * that changed at least one INDEXED column. It is FLUX's analogue of heap's + * HEAP_KEYS_UPDATED (t_infomask2), and exists for exactly heap's reason: a + * LockTupleKeyShare request (the FK check's parent-row probe) must NOT block + * behind an in-progress UPDATE that left the key intact, but MUST block behind + * one that is changing it. + * + * FLUX cannot infer this from FLUX_TUPLE_UPDATED: unlike heap, FLUX updates + * key-changing rows IN PLACE as well (nbtree delete-marking), so + * FLUX_TUPLE_UPDATED is set for both kinds and cannot distinguish them. Nor + * can the tuple sLog answer it: it records row locks, not writers (the + * on-page header is the authority for those), so its lock-conflict matrix + * (SLogTupleLockConflictXid) is never consulted for an in-progress in-place + * updater. Hence a dedicated header bit. + * + * Safety direction: a STALE-SET bit only costs a wait that heap would have + * skipped (a missed optimization). A STALE-CLEAR bit would hand out a + * KeyShare lock against a row whose key is changing -- an FK-integrity hole. + * So every path that writes an in-place image must set it whenever an indexed + * column changed, and when in doubt it is set. + */ +#define FLUX_TUPLE_KEYS_UPDATED 0x0040 /* in-place UPDATE changed an indexed + * column (heap's HEAP_KEYS_UPDATED + * analogue) */ +#define FLUX_TUPLE_UNCOMMITTED 0x0080 /* Inserted but not yet committed */ +#define FLUX_TUPLE_HAS_VERSION_PTR 0x0100 /* trailing UndoRecPtr + * (version-chain head) follows + * column data */ +#define FLUX_TUPLE_XMIN_COMMITTED 0x0200 /* t_xmin known-committed (CLOG + * hint, like heap + * HEAP_XMIN_COMMITTED) */ +#define FLUX_TUPLE_XMAX_COMMITTED 0x0400 /* t_xmax known-committed (CLOG + * hint, like heap + * HEAP_XMAX_COMMITTED) */ + +/* + * FLUX_TUPLE_MOVED_PARTITION -- this DELETED tuple was deleted by an UPDATE + * that moved the row to another partition (TABLE_DELETE_CHANGING_PARTITION). + * It is heap's ItemPointerSetMovedPartitions(&t_ctid) marker. FLUX cannot + * keep the marker in t_ctid, which is the tuple's own TID: the sLog and the + * own-command cid map are looked up by it. It is set only together with + * FLUX_TUPLE_DELETED and is meaningful only while that is set, so VACUUM and + * freezing treat the tuple as the deleted one it is. The delete's UNDO + * before-image clears it on rollback, and its WAL record carries it + * (FLUX_WAL_MOVED_PARTITION), so flux_mask leaves it to be compared. + * Writers and lockers that find it report TM_Updated with the moved-partitions + * marker in tmfd->ctid, as heap does. + */ +#define FLUX_TUPLE_MOVED_PARTITION 0x0800 + +/* + * FLUX_TUPLE_SUPERSEDED -- this DELETED tuple was deleted by an UPDATE that + * moved the row to a new TID in the same relation (flux_update_out_of_place: + * a key change FLUX cannot index in place, or a row that cannot grow on its + * page), and t_ctid holds that new TID. It is heap's t_ctid forward link. + * Without the flag t_ctid holds the tuple's own TID, so nothing may read + * t_ctid as a link unless the flag is set; the own TID always comes from the + * caller. It is set only together with FLUX_TUPLE_DELETED and is meaningful + * only while that is set, so VACUUM and freezing treat the tuple as the + * deleted one it is. XLOG_FLUX_SUPERSEDE sets it, so flux_mask compares it + * and the forward TID. The delete's UNDO before-image clears it and restores + * the own TID on rollback. Writers and lockers that find it report + * TM_Updated with the new TID in tmfd->ctid, and a lock that follows the row + * to its latest version locks the new one, as heap does. + */ +#define FLUX_TUPLE_SUPERSEDED 0x1000 + +/* The superseding UPDATE left every key column unchanged. */ +#define FLUX_TUPLE_KEY_INTACT 0x2000 + +/* + * flux_tuple_insert option, used only by the CLUSTER / VACUUM FULL rewrite + * (flux_relation_copy_for_cluster): store the row with the inserter and + * deleter of the version it copies, from FluxRewriteVersion, rather than as + * an insert by the current transaction. The rewrite holds AccessExclusiveLock + * and copies only versions whose writers committed (or are the rewriting + * transaction itself), so a snapshot taken before the rewrite sees exactly + * the rows it saw before, as with heap's rewriteheap.c. A table AM option + * bit above the core TABLE_INSERT_* ones. + */ +#define FLUX_INSERT_REWRITE 0x1000 + +/* + * flux_tuple_insert and flux_tuple_delete option, used by an out-of-place + * UPDATE (flux_update_out_of_place): do not count the insert or the delete in + * the cumulative statistics, the caller counts the UPDATE once. Above the + * core TABLE_INSERT_* and TABLE_DELETE_* bits. + */ +#define FLUX_DML_NO_STATS 0x2000 + +typedef struct FluxRewriteVersion +{ + TransactionId xmin; /* inserter of the copied version */ + CommandId cmin; /* its command id, if it is our own */ + bool xmin_committed; /* xmin is known committed */ + TransactionId xmax; /* deleter, or InvalidTransactionId */ + bool xmax_committed; /* xmax is known committed */ + bool moved_partition; /* deleted by a cross-partition UPDATE */ +} FluxRewriteVersion; + +extern void FluxSetRewriteVersion(const FluxRewriteVersion *v); + +/* Tuple infomask bits (uint8 -- reduced from uint16) */ +#define FLUX_INFOMASK_HASNULL 0x01 +#define FLUX_INFOMASK_HASVARWIDTH 0x02 +#define FLUX_INFOMASK_HASEXTERNAL 0x04 +#define FLUX_INFOMASK_COMPRESSED 0x08 +#define FLUX_INFOMASK_HASOVERFLOW 0x10 + +/* + * FLUX tuple structure + */ +typedef struct FluxTupleData +{ + uint32 t_len; /* Length of tuple */ + ItemPointerData t_self; /* TID of this tuple */ + Oid t_tableOid; /* Table OID */ + FluxTupleHeader *t_data; /* Tuple header and data */ +} FluxTupleData; + +typedef FluxTupleData *FluxTuple; + +/* + * Column-level overflow + * + * When an individual column value is too large to store inline in the main + * tuple, it is stored as one or more "overflow records" on normal FLUX data + * pages. The main tuple stores a compact overflow pointer (FluxOverflowPtr) + * wrapped in a varlena, optionally preceded by an inline prefix of the + * original data for efficient prefix matching. + * + * Overflow records use a lightweight header (FluxOverflowRecordHeader) + * without MVCC fields -- they share the visibility of the parent tuple. + * Each overflow record holds a chunk of the column data and a continuation + * pointer to the next chunk (or InvalidBlockNumber if this is the last). + * + * This approach stores overflow data on regular pages that can also hold + * normal tuples, within the same relation -- no separate out-of-line + * relation is created. + */ + +/* + * Overflow pointer stored inline in the main tuple (wrapped as varlena). + * + * On-disk layout of an overflowed column in the main tuple: + * [varlena header][FluxOverflowPtr][inline_prefix_bytes...] + * + * The FLUX_OVERFLOW_PTR_MAGIC sentinel distinguishes this from a normal + * varlena value during deform. + */ +#define FLUX_OVERFLOW_PTR_MAGIC 0x52564F50 /* "RVOP" */ + +typedef struct FluxOverflowPtr +{ + uint32 ov_magic; /* FLUX_OVERFLOW_PTR_MAGIC */ + BlockNumber ov_first_block; /* First overflow record's page */ + OffsetNumber ov_first_offset; /* First overflow record's offset on page */ + uint16 ov_inline_prefix; /* Bytes of inline prefix stored after ptr */ + uint32 ov_total_length; /* Total uncompressed column data length */ + uint32 ov_content_hash; /* 32-bit content-hash prefilter for COW */ +} FluxOverflowPtr; + +/* + * ov_content_hash reclaims the two formerly-reserved uint16 fields + * (ov_padding, ov_flags), so the struct stays 20 bytes and + * FLUX_OVERFLOW_PTR_SIZE is unchanged. A wider (64-bit) hash would force + * struct growth that inflates FLUX_OVERFLOW_PTR_SIZE and defeats the + * force-shrink UPDATE recovery path, breaking in-place updates on full pages. + * + * The hash is only a cheap prefilter: every COW-reference candidate is + * byte-verified against the fetched old chain before it is accepted, so a + * 32-bit collision merely costs a wasted fetch and falls through to a normal + * re-store. It never produces an incorrect result. + */ + +/* Minimum varlena size for an overflow pointer (no inline prefix) */ +#define FLUX_OVERFLOW_PTR_SIZE (VARHDRSZ + sizeof(FluxOverflowPtr)) + +/* Default inline prefix size (configurable via GUC) */ +#define FLUX_OVERFLOW_DEFAULT_PREFIX 128 + +/* + * Check if a varlena datum is an overflow pointer. + * + * The check requires: correct size range, and magic value match. + */ +static inline bool +FluxIsOverflowPtr(const void *ptr) +{ + Size vsize; + const FluxOverflowPtr *ovp; + + if (ptr == NULL) + return false; + + vsize = VARSIZE_ANY_EXHDR(ptr); + if (vsize < sizeof(FluxOverflowPtr)) + return false; + + ovp = (const FluxOverflowPtr *) VARDATA_ANY(ptr); + return ovp->ov_magic == FLUX_OVERFLOW_PTR_MAGIC; +} + +/* + * Extract overflow pointer from a varlena datum. + */ +static inline const FluxOverflowPtr * +FluxGetOverflowPtr(const void *ptr) +{ + return (const FluxOverflowPtr *) VARDATA_ANY(ptr); +} + +/* + * Lightweight header for overflow records stored on normal data pages. + * + * Overflow records are stored via PageAddItem just like normal tuples, but + * they carry this minimal header instead of a full FluxTupleHeader. The + * ov_magic field lets us distinguish overflow records from normal tuples + * during page scans (e.g., sequential scan must skip these). + */ +#define FLUX_OVERFLOW_RECORD_MAGIC 0x524F5643 /* "ROVC" */ + +typedef struct FluxOverflowRecordHeader +{ + uint32 or_magic; /* FLUX_OVERFLOW_RECORD_MAGIC */ + uint32 or_data_len; /* Bytes of column data in this record */ + BlockNumber or_next_block; /* Next overflow record's page, or Invalid */ + OffsetNumber or_next_offset; /* Next overflow record's offset */ + uint16 or_flags; /* Flags (reserved) */ + /* Column data follows immediately after this header */ +} FluxOverflowRecordHeader; + +/* Maximum column data per overflow record */ +#define FLUX_OVERFLOW_RECORD_OVERHEAD MAXALIGN(sizeof(FluxOverflowRecordHeader)) +#define FLUX_OVERFLOW_MAX_CHUNK_SIZE \ + (FLUX_MAX_TUPLE_SIZE - FLUX_OVERFLOW_RECORD_OVERHEAD) + +/* + * Structure to track overflow buffers for atomic WAL logging. + * + * When creating overflow chains, we keep buffers pinned and collect them + * here so the caller can register them all in a single WAL record with + * the main tuple modification. This ensures atomicity during crash recovery. + */ +#define MAX_OVERFLOW_BUFFERS 32 + +typedef struct FluxOverflowBuffer +{ + Buffer buffer; /* Pinned buffer containing overflow record */ + OffsetNumber offset; /* Offset of overflow record on page */ + char *record_data; /* FluxOverflowRecordHeader + data */ + uint32 record_len; /* Total record length */ + uint16 flags; /* FLUX_OVERFLOW_WAL_NEW_RECORD or + * _LINK_UPDATE */ +} FluxOverflowBuffer; + +typedef struct FluxOverflowBuffers +{ + int count; /* Number of overflow buffers */ + FluxOverflowBuffer buffers[MAX_OVERFLOW_BUFFERS]; +} FluxOverflowBuffers; + +/* + * Compression types + */ +typedef enum FluxCompressionType +{ + FLUX_COMP_NONE, + FLUX_COMP_LZ4, + FLUX_COMP_ZSTD, + FLUX_COMP_DELTA, /* For numeric columns */ + FLUX_COMP_DICTIONARY /* For text columns */ +} FluxCompressionType; + +/* + * Values for the flux_compression_algorithm GUC. AUTO lets + * FluxChooseCompressionType() pick per attribute type; LZ4/ZSTD force that + * codec for compressible varlena attributes; NONE disables compression. + */ +typedef enum FluxCompressionAlgoGuc +{ + FLUX_COMP_ALGO_AUTO, + FLUX_COMP_ALGO_LZ4, + FLUX_COMP_ALGO_ZSTD, + FLUX_COMP_ALGO_OFF +} FluxCompressionAlgoGuc; + +typedef struct FluxCompressionHeader +{ + uint8 comp_type; + uint8 comp_level; + uint16 dict_id; /* trained-dict id, 0 = FLUX_DICT_INVALID_ID */ + uint32 orig_size; + uint32 comp_size; +} FluxCompressionHeader; + +/* + * FLUX timestamp word. + * + * A uint64 wall-clock timestamp (microseconds since the PG epoch) used for + * per-page commit-ts bookkeeping (FluxPageSetCommitTs). It is NOT a + * visibility timestamp: commit visibility comes from CLOG via heap-shaped + * xmin/xmax MVCC. + */ + +/* + * Tuple MVCC field accessors (heap-shaped xmin/xmax). + * + * t_xmax is physically the low 32 bits of the t_commit_ts word; the high 32 + * bits are reserved. This preserves the on-disk/WAL byte layout while giving + * the tuple a heap-compatible deleter/updater XID. InvalidTransactionId (0) + * means "live, never superseded". + */ +#define FluxTupleGetXmax(tup) ((TransactionId) ((tup)->t_commit_ts & 0xFFFFFFFFULL)) +#define FluxTupleSetXmax(tup, xid) \ + ((tup)->t_commit_ts = ((tup)->t_commit_ts & 0xFFFFFFFF00000000ULL) | \ + ((uint64) (TransactionId) (xid))) +#define FluxTupleGetXmin(tup) ((tup)->t_xmin) +#define FluxTupleSetXmin(tup, xid) ((tup)->t_xmin = (TransactionId) (xid)) + +/* + * Transaction state for uncertainty tracking. + * + * The full struct definition lives in flux_mvcc.c (private to that module). + * External code should use the opaque forward declaration below. + */ +typedef struct FluxTransactionState FluxTransactionState; + +/* + * Free space management + */ +typedef struct FluxFreeSpaceMap +{ + uint32 total_pages; + uint32 pages_with_space; + uint8 *fsm_data; /* Bitmap of page utilization */ + uint32 *defrag_queue; /* Pages needing defragmentation */ + uint32 defrag_queue_size; +} FluxFreeSpaceMap; + +/* Free space map levels */ +#define FLUX_FSM_FULL 0 +#define FLUX_FSM_75_PERCENT 1 +#define FLUX_FSM_50_PERCENT 2 +#define FLUX_FSM_25_PERCENT 3 +#define FLUX_FSM_EMPTY 4 + +/* + * Visibility Map support for FLUX + * + * The visibility map tracks two bits per page: + * - ALL_VISIBLE: all tuples on page are visible to all transactions + * - ALL_FROZEN: all tuples on page are frozen (no further VACUUM needed) + * + * This enables: + * - Index-only scans (can skip heap fetch if page is all-visible) + * - VACUUM optimization (can skip pages marked all-visible/frozen) + */ + +/* Visibility map bits */ +#define FLUX_VM_ALL_VISIBLE 0x01 /* All tuples visible to all xacts */ +#define FLUX_VM_ALL_FROZEN 0x02 /* All tuples frozen */ + +/* Combined flags for convenience */ +#define FLUX_VM_VALID_BITS (FLUX_VM_ALL_VISIBLE | FLUX_VM_ALL_FROZEN) + +/* Visibility map fork number (uses PostgreSQL's fork infrastructure) */ +#define FLUX_VM_FORKNUM VISIBILITYMAP_FORKNUM + +/* + * Scan descriptor for FLUX scans + */ +typedef struct FluxScanDescData +{ + TableScanDescData rs_base; /* Base scan descriptor */ + Buffer rs_cbuf; /* Current buffer */ + BlockNumber rs_cblock; /* Current block */ + BlockNumber rs_nblocks; /* Total blocks in relation (cached) */ + BlockNumber rs_startblock; /* Starting block for sample scans */ + int rs_cindex; /* Current candidate index (seqscan), or + * offset */ + ScanDirection rs_dir; /* Read stream direction */ + BlockNumber rs_endblock; /* Exclusive upper scan bound */ + OffsetNumber rs_coffset; /* Current offset number */ + bool rs_inited; /* True after first block is fetched */ + int rs_ntuples; /* Number of tuples on current page */ + OffsetNumber *rs_vistuples; /* Offset numbers of visible tuples */ + uint64 rs_snapshot_ts; /* Snapshot timestamp */ + uint64 rs_xact_ts; /* Transaction timestamp */ + ParallelBlockTableScanWorkerData *rs_parallelworkerdata; /* Parallel scan worker + * state */ + struct ReadStream *rs_read_stream; /* Read stream for sequential + * prefetching */ + BlockNumber rs_prefetch_block; /* Next block for read stream callback */ + + /* Cached visibility map buffer to avoid per-page VM I/O */ + Buffer rs_vm_buffer; /* Pinned VM buffer (or InvalidBuffer) */ + BlockNumber rs_vm_blockno; /* VM block number for rs_vm_buffer */ + + /* + * A non-MVCC scan drops every DELETED tuple. A SnapshotAny scan begun + * with SO_RETURN_DELETED (CLUSTER / VACUUM FULL, the apply worker's + * search for a deleted row), and an index build's, set this to be handed + * every version on the page, deleted ones included, and judge each + * themselves: a row deleted by a transaction some snapshot does not see + * yet is still that snapshot's (heap's RECENTLY_DEAD), and one whose + * deleter aborted with the revert still pending is alive. + */ + bool rs_keep_deleted; + + /* + * ANALYZE dictionary-refresh sample accumulation. During an ANALYZE scan + * the decompressed bytes of the first varlena column are gathered here so + * FluxMaybeRefreshDict() can train a candidate dictionary at scan end. + * All fields stay zero/NULL on non-ANALYZE scans. + */ + char *rs_dict_samplebuf; /* Concatenated sample bytes */ + size_t *rs_dict_sizes; /* Per-sample lengths, in order */ + int rs_dict_nsamples; /* Number of accumulated samples */ + Size rs_dict_total; /* Total bytes in rs_dict_samplebuf */ + Size rs_dict_cap; /* Capacity of rs_dict_samplebuf */ + int rs_dict_maxsamples; /* Capacity of rs_dict_sizes */ + int16 rs_dict_attnum; /* 1-based varlena attr sampled, 0 = none */ +} FluxScanDescData; + +typedef FluxScanDescData *FluxScanDesc; + +/* + * Table AM private state for a FLUX index scan. + * + * Post-ddce1da5b1b (slot-based table-AM index scan interface): the table AM + * keeps its own scan state in IndexScanDesc.xs_table_opaque, set up in + * flux_index_scan_begin. FLUX does in-place, stable-TID updates and never + * walks a HOT-style chain, so all it needs is the relation and the pin held + * across getnext_slot calls. + */ +typedef struct IndexScanFluxData +{ + Relation rel; /* the heap (FLUX) relation being scanned */ + Buffer buffer; /* pin held across getnext_slot calls */ + bool keyless; /* fetch_tid: the index entry is unknown */ + bool keep_deleted; /* SO_RETURN_DELETED: return deleted versions + * too, see FluxScanDescData.rs_keep_deleted */ +} IndexScanFluxData; + +/* + * Constants + */ +#define FLUX_PAGE_OVERHEAD (MAXALIGN(SizeOfPageHeaderData) + MAXALIGN(sizeof(FluxPageOpaqueData))) +#define SizeOfFluxTupleHeader offsetof(FluxTupleHeader, t_attrs_bitmap) +#define FLUX_TUPLE_OVERHEAD SizeOfFluxTupleHeader + +/* Align the header and bitmap together before storing attribute data. */ +StaticAssertDecl(SizeOfFluxTupleHeader == 36, + "unexpected FLUX tuple header layout"); +StaticAssertDecl(offsetof(FluxTupleHeader, t_verptr) == sizeof(uint64), + "FLUX version pointer must remain aligned"); +#define FLUX_MAX_TUPLE_SIZE MAXALIGN_DOWN(BLCKSZ - FLUX_PAGE_OVERHEAD - sizeof(ItemIdData)) +#define FLUX_OVERFLOW_THRESHOLD (FLUX_MAX_TUPLE_SIZE / 4) + +/* + * Hard ceiling on the line-pointer offsets of a FLUX page. FLUX clamps + * offsets exactly like heap (every PageAddItem passes PAI_IS_HEAP), so that + * AM-agnostic code sized by MaxHeapTuplesPerPage -- tidbitmap.c's per-page + * bitmaps in particular -- covers FLUX pages too. Live tuples alone never + * reach the clamp: every item carries a FLUX tuple header, so a page fills + * by space first. Only line pointers left LP_DEAD by pruning, which keep + * their slot until VACUUM, can run the array up to it. + */ +#define MaxFluxItemsPerPage MaxHeapTuplesPerPage +StaticAssertDecl((BLCKSZ - FLUX_PAGE_OVERHEAD) / + (MAXALIGN(SizeOfFluxTupleHeader) + sizeof(ItemIdData)) <= + MaxFluxItemsPerPage, + "FLUX page holds more tuples than MaxFluxItemsPerPage"); + +/* + * Fill factor support. Unlike heap, FLUX has STABLE TIDs and updates rows + * IN PLACE -- a row that grows (e.g. an accumulating numeric like TPC-C + * w_ytd/d_ytd gaining a digit) cannot be relocated to another page the way + * heap moves a grown tuple. It must fit on its home page or the UPDATE fails + * with "does not fit". So FLUX must reserve per-page headroom by default; + * packing pages 100%% full (heap's default) guarantees that any in-place + * growth on a full page aborts. The default reserves ~10%% of each page, + * which comfortably covers digit-growth of numeric columns plus transient + * line-pointer bloat from concurrent updates to a hot page. Users can raise + * it with WITH (fillfactor=N) for append-mostly tables that never grow rows. + */ +#define FLUX_MIN_FILLFACTOR 10 +#define FLUX_DEFAULT_FILLFACTOR 90 + +/* Macros for tuple access */ +#define FluxTupleGetHeader(tuple) ((tuple)->t_data) +#define FluxTupleGetData(tuple) \ + ((char *) (tuple)->t_data + FLUX_TUPLE_OVERHEAD) + +/* Version-chain accessors. */ +static inline UndoRecPtr +FluxTupleGetVersionPtr(const FluxTupleHeader *hdr) +{ + return hdr->t_verptr; +} + +static inline void +FluxTupleSetVersionPtr(FluxTupleHeader *hdr, UndoRecPtr ptr) +{ + hdr->t_verptr = ptr; +} + +/* + * FluxReconstructVisibleVersion + * + * Walk the per-tuple version chain in the UNDO log to find the image + * the reader's MVCC snapshot should see in place of the on-page (newer) + * data. Returns true and populates *out_data / *out_len with a palloc'd + * reconstructed image when a step back was taken; returns false when the + * on-page image is what the reader should see. + * + * See src/backend/access/flux/flux_pvs.c for the algorithm. + */ +extern void FluxSlotCacheSysAttrs(TupleTableSlot *slot, + const FluxTupleHeader *tuple); +extern bool FluxSlotGetVersion(TupleTableSlot *slot, TransactionId *xmin, + CommandId *cmin); +extern void FluxSlotSetVersion(TupleTableSlot *slot, TransactionId xmin, + CommandId cmin); +extern bool FluxFindVersionSuperseder(Relation rel, ItemPointer tid, + const char *onpage_image, + Size onpage_len, + TransactionId xmin, CommandId cmin, + TransactionId *sup_xid, + CommandId *sup_cid); +extern bool FluxReconstructVisibleVersion(Relation rel, ItemPointer tid, + const char *onpage_image, + Size onpage_len, + Snapshot snapshot, + char **out_data, int *out_len); + +/* + * The updates FluxReconstructVisibleVersionEx stepped back over; see + * flux_pvs.c. + */ +typedef struct FluxSkippedUpdates +{ + TransactionId committed_xid; /* newest committed update */ + TransactionId committed_key_xid; /* newest committed key update */ + TransactionId inprogress_key_xid; /* newest in-progress key update */ + bool complete; /* reached the snapshot's version */ +} FluxSkippedUpdates; + +extern bool FluxReconstructVisibleVersionEx(Relation rel, ItemPointer tid, + const char *onpage_image, + Size onpage_len, + Snapshot snapshot, + char **out_data, int *out_len, + FluxSkippedUpdates *skipped); + +/* Slot operations for FLUX tuples */ +extern PGDLLIMPORT const TupleTableSlotOps TTSOpsFluxTuple; +extern void FluxSlotStoreTuple(TupleTableSlot *slot, FluxTupleHeader *tuple, + uint32 tuple_len, Buffer buffer); + +#define TTS_IS_FLUXTUPLE(slot) ((slot)->tts_ops == &TTSOpsFluxTuple) + +/* Function prototypes */ +extern Size FluxComputeDataSize(TupleDesc tupdesc, Datum *values, bool *isnull); +extern void flux_toast_tuple(Relation rel, Datum *values, bool *isnull, + Datum *oldvalues, bool *oldisnull, + ToastTupleContext *ttc, ToastAttrInfo *toast_attr, + bool *changed, uint32 options); +extern void flux_toast_cleanup(ToastTupleContext *ttc); +extern void flux_toast_abandon(ToastTupleContext *ttc); +extern void flux_toast_delete(Relation rel, Datum *values, bool *isnull, + bool is_speculative); +extern FluxTuple FluxFormTuple(TupleDesc tupdesc, Datum *values, bool *isnull, + Relation rel, FluxOverflowBuffers *overflow_buffers); +extern FluxTuple FluxFormTupleForceShrink(TupleDesc tupdesc, Datum *values, + bool *isnull, Relation rel, + FluxOverflowBuffers *overflow_buffers); +extern FluxTuple FluxFormTupleUpdate(TupleDesc tupdesc, Datum *values, + bool *isnull, Relation rel, + FluxOverflowBuffers *overflow_buffers, + const FluxOverflowPtr *old_ovptrs, + const bool *old_ovpresent); +extern void FluxDeformTuple(Relation rel, FluxTuple tuple, TupleDesc tupdesc, Datum *values, bool *isnull); +extern void FluxDeformTupleUpTo(Relation rel, FluxTuple tuple, TupleDesc tupdesc, Datum *values, bool *isnull, int max_natts); +extern void FluxFreeTuple(FluxTuple tuple); +extern bool FluxTupleToSlot(FluxTupleHeader *tuple_header, TupleTableSlot *slot); +extern bool FluxTupleMovedPartition(FluxTupleHeader *tuple_hdr); +extern bool FluxTupleSuperseded(FluxTupleHeader *tuple_hdr); +extern bool FluxTupleToSlotWithOverflow(FluxTupleHeader *tuple_header, + TupleTableSlot *slot, Relation rel); + +/* Page management */ +extern void FluxInitPage(Page page, Size pageSize); +extern OffsetNumber FluxPageAddTuple(Page page, FluxTuple tuple, Size tuple_size); +extern bool FluxPageUpdateTuple(Page page, OffsetNumber offnum, FluxTuple new_tuple, + uint64 old_commit_ts, uint64 new_commit_ts); +extern int FluxPageGetLiveTuples(Page page, uint64 snapshot_ts); +extern void FluxPageDefragment(Page page); +extern void FluxPageIndexTupleDelete(Page page, OffsetNumber offnum); +extern int FluxPagePruneOpt(Relation rel, Buffer buffer); + +/* Overflow handling - column-level overflow */ +extern Datum FluxStoreOverflowColumn(Relation rel, Datum value, int attnum, + Size inline_prefix_size, + FluxOverflowBuffers *overflow_buffers); +extern Datum FluxFetchOverflowColumn(Relation rel, const void *overflow_varlena); +extern void FluxDeleteOverflowChain(Relation rel, BlockNumber first_block, + OffsetNumber first_offset); +extern int FluxCollectOverflowPtrs(FluxTupleHeader *tuple_hdr, + TupleDesc tupdesc, + BlockNumber *blocks, OffsetNumber *offsets, + int max_ptrs); +extern void FluxCollectOverflowPtrsByAttr(FluxTupleHeader *tuple_hdr, + TupleDesc tupdesc, + FluxOverflowPtr *out_ptrs, + bool *out_present, int natts); +extern void FluxDeleteTupleOverflows(Relation rel, FluxTupleHeader *tuple_hdr, + TupleDesc tupdesc); +extern bool FluxIsOverflowRecord(const void *item, Size item_len); + +/* + * Inline version of FluxIsOverflowRecord for hot scan paths. + * Checks whether an item is an overflow continuation record by testing + * the magic number in the header. + */ +static inline bool +FluxIsOverflowRecordInline(const void *item, Size item_len) +{ + if (item_len < sizeof(FluxOverflowRecordHeader)) + return false; + return ((const FluxOverflowRecordHeader *) item)->or_magic == + FLUX_OVERFLOW_RECORD_MAGIC; +} +extern void FluxGetOverflowStats(Relation rel, int64 *total_overflow_records, + int64 *total_overflow_bytes, int64 *avg_chain_length); + +/* Free space management */ +extern Size FluxTargetFreeSpace(Relation rel, Size len); +extern BlockNumber FluxGetPageWithFreeSpace(Relation rel, Size needed); +extern void FluxRecordFreeSpace(Relation rel, BlockNumber page, Size freespace); +extern void FluxVacuumFSM(Relation rel, BlockNumber new_nblocks); + +/* Visibility Map management */ +extern void FluxVMInit(Relation rel); +extern void FluxVMSet(Relation rel, BlockNumber heapBlk, Buffer heapBuf, uint8 flags, TransactionId conflict_xid); +extern void FluxVMClear(Relation rel, BlockNumber heapBlk, Buffer heapBuf, uint8 flags); +extern bool FluxVMCheck(Relation rel, BlockNumber heapBlk, uint8 flags); +extern bool FluxVMCheckCached(Relation rel, BlockNumber heapBlk, uint8 flags, + Buffer *vmbuf, BlockNumber *vm_blockno); +extern void FluxVMPinBuffer(Relation rel, BlockNumber heapBlk, Buffer *vmbuf); +extern void FluxVMExtend(Relation rel, BlockNumber nheapblocks); +extern Size FluxVMGetPageSize(void); +extern BlockNumber FluxVMMapHeapToVM(BlockNumber heapBlk); +extern Buffer FluxVMPinForChange(Relation rel, Buffer heapBuf); +extern bool FluxVMNeedsClear(Relation rel, Buffer heapBuf); +extern void FluxVMClearForChange(Buffer heapBuf, Buffer vmbuf); +extern void FluxVMRegisterClear(uint16 *flags); +extern void FluxVMFinishClear(XLogRecPtr lsn); +extern bool FluxVMClearMapBits(Page vmpage, BlockNumber heapBlk); +extern void FluxVMVacuumPage(Relation rel, Buffer buffer, bool all_visible, bool all_frozen, TransactionId conflict_xid); + +/* MVCC functions */ +extern uint64 FluxGetCommitTimestamp(void); +extern uint64 FluxGetTransactionTimestamp(void); +extern uint64 FluxGetOldestActiveTimestamp(void); +extern Size FluxMvccShmemSize(void); +extern void FluxMvccShmemInit(void); +extern const ShmemCallbacks FluxMvccShmemCallbacks; +extern void FluxCommitTransaction(void); +extern void FluxAbortTransaction(void); +extern uint64 FluxGetSnapshotTimestamp(Snapshot snapshot); +extern void FluxUpdateOldestActiveTimestamp(void); +extern void FluxGetMvccStats(uint64 *current_ts, uint64 *oldest_ts, int *active_xacts); + +/* SSI (Serializable Snapshot Isolation) via predicate.c integration */ +extern void FluxCheckForSerializableConflictOut(bool visible, + Relation relation, + FluxTupleHeader *tuple, + Buffer buffer, + Snapshot snapshot); +extern void FluxSerializableConflictOutXid(Relation relation, + TransactionId xid, + Snapshot snapshot); + +/* MVCC timestamp helpers (page-level bookkeeping; visibility uses xmin/xmax) */ +extern uint64 FluxGetDmlTimestamp(void); + +/* + * Lost-update conflict probe. Reads the head verptr + * from the on-page tuple, resolves it in the UNDO log, and reports whether + * that head record's committer is concurrent-or-later to snapshot (and not + * exclude_xid). If it is, the caller returns TM_Updated to drive EPQ. EPQ + * dedup lives in FluxTransactionState: FluxEpqReconcileMatches skips a + * probe we already bounced on for this exact (relid, tid, cid, head verptr, + * head xid); FluxEpqReconcileMark stamps the identity we just bounced on. + */ +extern bool FluxTupleHasCommittedUpdateAfter(Relation rel, + const FluxTupleHeader *tuple, + Size tuple_len, + Snapshot snapshot, + TransactionId exclude_xid, + UndoRecPtr *out_head_verptr, + TransactionId *out_head_xid, + bool *out_inprogress); +extern bool FluxEpqReconcileMatches(Snapshot snapshot, Oid relid, + ItemPointer tid); +extern void FluxEpqReconcileMark(Snapshot snapshot, Oid relid, + ItemPointer tid, + UndoRecPtr head_verptr, + TransactionId head_xid); +extern bool FluxTupleSatisfiesLatest(FluxTupleHeader *tuple, Oid relid, + CommandId curcid, Buffer buffer, + ItemPointer tid); +extern bool FluxTupleVisibleToSnapshotDual(FluxTupleHeader *tuple, + Snapshot snapshot, + Oid relid, Buffer buffer, + ItemPointer tid); +extern bool FluxWriterVisibleToSnapshot(TransactionId xid, Snapshot snapshot, + bool known_committed); +extern TransactionId FluxGetOldestXminHorizon(Relation rel); +extern TransactionId FluxVersionChainOldestXid(Relation rel, ItemPointer tid, + const char *image, Size len, + TransactionId oldest); + +/* An older version of a row that a rewrite copies; see FluxRewriteChain. */ +typedef struct FluxChainVersion +{ + char *image; /* the version, palloc'd */ + Size len; + TransactionId xmax; /* the writer that replaced it */ +} FluxChainVersion; + +extern List *FluxRewriteChain(Relation rel, ItemPointer tid, + const char *image, Size len, + TransactionId oldest_xmin); + +extern void FluxTupleAdvanceConflictHorizon(FluxTupleHeader *tuple, + TransactionId *horizon); +extern bool FluxTupleDeadToAll(FluxTupleHeader *tuple, + TransactionId oldest_xmin); + +/* How VACUUM and ANALYZE count a tuple, see FluxTupleStatsCount */ +typedef enum FluxStatsCount +{ + FLUX_COUNT_NONE, /* not counted: another xact is inserting it */ + FLUX_COUNT_LIVE, + FLUX_COUNT_DEAD, +} FluxStatsCount; + +extern FluxStatsCount FluxTupleStatsCount(FluxTupleHeader *tuple); + +/* + * FLUX uses no MultiXacts: row lockers are kept in the tuple sLog + * (SLogTupleLock*). + */ + +extern bool flux_lazy_uncommitted_clear; + + +/* Lock operations */ +extern bool FluxLockTuple(Relation rel, ItemPointer tid, LockTupleMode mode, + bool wait, bool *have_tuple_lock); +extern void FluxUnlockTuple(Relation rel, ItemPointer tid, LockTupleMode mode); +extern void FluxLockTupleToWait(Relation rel, ItemPointer tid, + LockTupleMode mode, bool *have_tuple_lock); +extern void FluxLockPage(Relation rel, BlockNumber blkno, LOCKMODE mode); +extern void FluxUnlockPage(Relation rel, BlockNumber blkno, LOCKMODE mode); +extern bool FluxLockMultipleTuples(Relation rel, ItemPointerData *tids, int ntids, + LockTupleMode mode, bool wait); +extern void FluxLockRelationForDDL(Relation rel, LOCKMODE lockmode); +extern bool FluxHoldsTupleLock(Relation rel, ItemPointer tid, LockTupleMode mode); + +/* Table operations */ +extern void flux_tuple_insert(Relation relation, TupleTableSlot *slot, CommandId cid, + uint32 options, BulkInsertState bistate); +extern TM_Result flux_tuple_delete(Relation relation, ItemPointer tid, CommandId cid, + uint32 options, Snapshot snapshot, Snapshot crosscheck, + bool wait, TM_FailureData *tmfd); +extern TM_Result flux_tuple_update(Relation relation, ItemPointer otid, TupleTableSlot *slot, + CommandId cid, uint32 options, + Snapshot snapshot, Snapshot crosscheck, + bool wait, TM_FailureData *tmfd, LockTupleMode *lockmode, + TU_UpdateIndexes *update_indexes); + +/* + * Reverse an in-place indexed-column UPDATE's index changes during + * ROLLBACK. Called from flux_undo.c after the before-image tuple has been + * restored: 'new_hdr' is the current (post-update, k_new) on-page tuple read + * before the restore; 'old_hdr' is the restored before-image (k_old). For each + * index whose key differed it kills the new (k_new,TID) entry and clears the + * delete-mark on the old (k_old,TID) entry. + */ +extern bool flux_inplace_index_undo(Relation relation, ItemPointer tid, + FluxTupleHeader *new_hdr, Size new_len, + FluxTupleHeader *old_hdr, Size old_len, + const char *indexes, uint16 nindexes, + TransactionId xid); +extern void flux_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, + CommandId cid, uint32 options, BulkInsertState bistate); +extern void flux_relation_vacuum(Relation onerel, const VacuumParams *params, + BufferAccessStrategy bstrategy); +extern const TableAmRoutine *GetFluxTableAmRoutine(void); + +/* + * flux_tableam_handler is declared via PG_FUNCTION_INFO_V1 in + * flux_handler.c. Do not redeclare it here: on Windows that emits a + * __declspec(dllimport) prototype that conflicts with the implicit + * dllexport from the V1 info macro. Catalog references go through + * pg_proc by name. + */ + +/* In-place update statistics */ +extern void FluxGetUpdateStats(int64 *in_place, int64 *out_of_place, + int64 *defrag_triggered); + +/* + * FLUX-specific ANALYZE statistics + * + * These statistics capture properties unique to the FLUX storage format + * and are collected during ANALYZE. They are stored in the relation's + * pg_class.reloptions and consumed by the planner to improve cost estimates. + */ +typedef struct FluxRelationStats +{ + /* Compression effectiveness */ + double compression_ratio; /* avg uncompressed/compressed size */ + double pct_compressed; /* fraction of tuples that are compressed */ + + /* Overflow usage */ + double pct_overflow; /* fraction of tuples with overflow attrs */ + double avg_overflow_chain_len; /* avg overflow records per overflow + * tuple */ + int64 total_overflow_bytes; /* total bytes in overflow records */ + + /* Space efficiency */ + double avg_tuple_size; /* average on-disk tuple size (bytes) */ + double avg_live_per_page; /* average live tuples per page */ + double free_space_frac; /* average fraction of free space per page */ + double bloat_factor; /* allocated space / live data ratio */ + + /* Page-level summary */ + int64 total_pages; /* total pages in relation */ + int64 total_live_tuples; /* total live tuples counted */ + int64 total_dead_tuples; /* total dead tuples counted */ + + /* Per-tuple commit-ts word distribution */ + bool commit_ts_stats_valid; /* true if commit-ts word fields are + * populated */ + uint64 commit_ts_min; /* min commit-ts word seen */ + uint64 commit_ts_max; /* max commit-ts word seen */ +} FluxRelationStats; + +/* ANALYZE statistics collection (flux_stats.c) */ +extern void FluxCollectRelationStats(Relation rel, FluxRelationStats *stats); +extern void FluxLogRelationStats(Relation rel, const FluxRelationStats *stats, + int elevel); + +/* sLog transaction callbacks (flux_operations.c) */ +extern void FluxEnsureSLogCallbacks(void); + +/* + * Backend-local command-id map for the current transaction's own in-place + * UPDATE/DELETE ops (replaces the removed on-tuple t_cid and the per-tuple + * sLog UPDATE/DELETE cid lookup). Used by self-visibility (flux_mvcc.c) and + * the cmin/cmax system columns (flux_slot.c). + */ +extern CommandId FluxLocalCidGet(Oid relid, ItemPointer tid); +extern void FluxLocalCidReset(void); + +/* + * Derive the in-progress non-current writer XID for a tuple from its on-page + * header (t_xmin inserter / t_xmax updater-deleter). Caller must hold the + * tuple's buffer lock. Replaces the per-tuple sLog conflict probe for the + * common in-place UPDATE/DELETE conflict-detection paths. + */ +extern bool flux_help_revert(Relation relation, Buffer buffer, + OffsetNumber offnum); +extern TransactionId flux_header_dirty_xid(FluxTupleHeader *tuple_hdr, + bool *is_insert); + +/* + * Was this tuple already modified by OUR OWN transaction in this or a later + * command? This is FLUX's equivalent of the TM_SelfModified verdict heap + * derives from HeapTupleSatisfiesUpdate(); see flux_operations.c. Caller must + * hold the tuple's buffer lock. + */ +extern bool FluxCheckSelfModified(Relation relation, ItemPointer tid, + FluxTupleHeader *tuple_hdr, CommandId cid, + CommandId *cmax_out); + +/* Two-phase commit support (flux_operations.c) */ +extern void AtPrepare_Flux(void); +extern void flux_twophase_postcommit(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); +extern void flux_twophase_postabort(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); +extern void flux_twophase_recover(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); + + +#endif /* FLUX_H */ diff --git a/src/include/access/flux_pbu.h b/src/include/access/flux_pbu.h new file mode 100644 index 0000000000000..23a8c19ecc041 --- /dev/null +++ b/src/include/access/flux_pbu.h @@ -0,0 +1,102 @@ +/*------------------------------------------------------------------------- + * + * flux_pbu.h + * By-prototype interface to the FLUX per-backend UNDO shim (flux_pbu.c). + * + * The rest of the FLUX access method (flux_operations.c, flux_mvcc.c, + * flux_pvs.c, flux_handler.c, flux_xlog.c) + * cannot include the per-backend UNDO engine headers (they conflict). It + * therefore drives the per-backend insert / fetch / redo path through these + * entry points, which are the only interface flux_pbu.c exposes. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * + * src/include/access/flux_pbu.h + * + *------------------------------------------------------------------------- + */ +#ifndef FLUX_PBU_H +#define FLUX_PBU_H + +#include "access/undodefs.h" /* UndoRecPtr, InvalidUndoRecPtr */ +#include "access/xlogdefs.h" +#include "storage/itemptr.h" +#include "utils/relcache.h" /* Relation */ + +/* + * Maximum number of undo pages one FLUX undo record can span, hence the number + * of block ids a FLUX forward-op WAL record can devote to undo. A FLUX + * before-image record can carry a whole tuple and span three pages. Also + * allow for the previous transaction header updated by the insert. + */ +#define FLUX_PBU_MAX_UNDO_BLOCKS 4 + +/* + * WRITE PROTOCOL (see flux_pbu.c for the full choreography). One record per + * DML op: + * urp = FluxPbuPrepareInsert() -- before crit; returns the new t_verptr + * FluxPbuInsertUndo() -- in crit, before the FLUX forward WAL + * FluxPbuRegisterUndoBuffers(bid) -- in crit, after XLogRegisterBuffer/Data + * and before XLogInsert (attach the undo + * pages to the FLUX forward record) + * FluxPbuFinishUndo(lsn) -- after XLogInsert (stamp LSN, release) + * or FluxPbuCancelUndo() -- release without committing the record + */ +extern UndoRecPtr FluxPbuPrepareInsert(uint16 subtype, Relation rel, + ItemPointer tid, + const char *tuple_bytes, + uint32 tuple_len, char relpersistence, + bool reserve_indexes, + TransactionId freeze_conflict, + CommandId cid); +extern void FluxPbuAmendRevivedList(UndoRecPtr urp, const Oid *oids, uint16 n); +extern void FluxPbuInsertUndo(void); +extern int FluxPbuRegisterUndoBuffers(uint8 first_block_id); +extern bool FluxPbuUndoPending(void); +extern void FluxPbuFinishUndo(XLogRecPtr lsn); +extern void FluxPbuCancelUndo(void); + +/* + * REDO: re-run the undo insert carried on a FLUX forward-op WAL record, so the + * undo page content is rebuilt rather than restored from a forced full-page + * image. The record argument is the XLogReaderState* (typed void * so callers + * need not expose the per-backend headers); first_block_id is where the emitter + * started registering undo buffers. The undo region locates itself from there + * (undo blocks are the record's last blocks and the only ones in the reserved + * undo database), so this is a no-op for records that carry no undo. + */ +extern UndoRecPtr FluxPbuRedoUndo(void *record, uint8 first_block_id); + +/* + * True iff the given block reference of a WAL record is a per-backend UNDO page + * rather than a FLUX relation page. The FLUX redo handlers use this to skip the + * undo blocks folded onto a forward-op record when scanning block ids 1..N for + * FLUX overflow pages. + */ +extern void FluxPbuResolveRecoveryConflict(void *record); +extern bool FluxPbuBlockIsUndo(void *record, uint8 block_id); + +/* + * READ: version-chain fetch for FluxReconstructVisibleVersion (full payload), + * header-only xid probe for the lost-update conflict check + ANALYZE proxy, + * and a discarded-or-not existence probe. + */ +extern bool FluxPbuFetchVersion(UndoRecPtr verptr, TransactionId *out_xid, + CommandId *out_cid, + uint16 *out_subtype, char **out_payload, + Size *out_payload_len); +extern bool FluxPbuFetchXid(UndoRecPtr verptr, TransactionId *out_xid); +extern bool FluxPbuFetchForRevert(UndoRecPtr urp, TransactionId *out_top_xid, + uint16 *out_subtype, char **out_payload, + Size *out_payload_len); +extern bool FluxPbuRecordExists(UndoRecPtr verptr); + +/* + * Subtransaction (SAVEPOINT) rollback: physically restore the pre-savepoint + * images by reverse-applying the aborting subxact's per-backend undo records, + * at SUBXACT_EVENT_ABORT_SUB. FluxPbuInSubxactApply() lets flux_undo_apply() + * know to skip its top-xid sLog cleanup during a subxact apply. + */ +extern bool FluxPbuInSubxactApply(void); + +#endif /* FLUX_PBU_H */ diff --git a/src/include/access/flux_undo.h b/src/include/access/flux_undo.h new file mode 100644 index 0000000000000..7043762bfdc32 --- /dev/null +++ b/src/include/access/flux_undo.h @@ -0,0 +1,269 @@ +/*------------------------------------------------------------------------- + * + * flux_undo.h + * Public interface for the FLUX UNDO resource manager + * + * FLUX writes its UNDO to the per-backend UNDO engine + * (access/undo/perbackend) through the flux_pbu.c shim, under its own UNDO + * resource manager (UNDO_RMID_FLUX). The engine dispatches rollback to + * flux_undo_apply() by the rmid stamped into each record. The same records + * are the version chain that readers walk through t_verptr. + * + * Visibility of an aborted transaction's rows does not depend on the UNDO + * having been applied: readers treat a writer that did not commit as + * invisible (CLOG, FluxWriterVisibleToSnapshot) and walk past its versions. + * Applying the UNDO restores the before-images on the page. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * + * src/include/access/flux_undo.h + * + *------------------------------------------------------------------------- + */ +#ifndef FLUX_UNDO_H +#define FLUX_UNDO_H + +#include "common/relpath.h" /* RelFileNumber */ +#include "access/flux.h" +#include "access/undodefs.h" +#include "access/undormgr.h" +#include "nodes/pg_list.h" +#include "storage/buf.h" +#include "storage/itemptr.h" +#include "utils/relcache.h" + +/* + * FLUX's UNDO resource-manager ID. Defined in FLUX's own header, not 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_FLUX 5 + +/* + * FLUX UNDO subtypes. Values occupy the 16-bit urec_info field of + * the UNDO record header and are orthogonal to the FLUX WAL opcodes + * in flux_xlog.h. + */ +#define FLUX_UNDO_UPDATE 0x0002 /* full-tuple before-image */ +#define FLUX_UNDO_DELETE 0x0003 /* restore deleted tuple */ +#define FLUX_UNDO_UPDATE_DELTA 0x0004 /* byte-diff before-image: a + * FluxUndoDelta describing the + * changed middle region of the + * old image relative to the new + * on-page image. Reconstructed to + * a FULL old image at apply/read + * time, then handled by the same + * path as FLUX_UNDO_UPDATE. */ + +/* + * Common fixed-length header for every FLUX UNDO payload. The + * variable-length tuple / diff image (if any) follows immediately + * after the header. + * + * The header is deliberately small and self-describing so the same + * struct is the FLUX before-image payload header + * avoiding an intermediate palloc. + */ +/* + * One entry of the index list area (FLUX_UNDO_FLAG_INDEX_LIST): an index the + * UPDATE's index maintenance may change, and the file it changes. A REINDEX + * later in the transaction gives the index a new file, and rolling it back + * makes this one current again, so the rollback must reverse the entries here. + * Stored unaligned; four 4-byte fields, no padding. + */ +typedef struct FluxUndoIndex +{ + Oid indexoid; + Oid spcoid; /* the file's tablespace */ + RelFileNumber relnumber; + uint32 flags; /* FLUX_UNDO_INDEX_* */ +} FluxUndoIndex; + +StaticAssertDecl(sizeof(FluxUndoIndex) == 16, "FluxUndoIndex has padding"); + +/* Maintenance revived a delete-marked entry instead of inserting one. */ +#define FLUX_UNDO_INDEX_REVIVED 0x0001 + +typedef struct FluxUndoPayloadHeader +{ + ItemPointerData tid; /* target tuple id */ + TransactionId writer_xid; /* xid (possibly a SUBXACT xid) that produced + * this record's HOST image. Distinct from + * the per-backend record's uur_xid, which + * must stay the owning TOP full xid for the + * engine's abort bookkeeping. The MVCC + * version-chain walk and the lost-update + * conflict probe key off THIS xid so + * subtransaction (SAVEPOINT) rollback + * resolves correctly. */ + uint32 tuple_len; /* length of trailing tuple/diff image */ + uint16 flags; + uint16 pad; /* reserved index-list area length */ + RelFileNumber relnumber; /* relation file the change was made in; the + * record does not apply to any other file of + * the same relation (TRUNCATE, rewrite) */ +} FluxUndoPayloadHeader; + +#define SizeOfFluxUndoPayloadHeader (sizeof(FluxUndoPayloadHeader)) + +/* flags bits */ +#define FLUX_UNDO_FLAG_HAS_TUPLE 0x0001 +#define FLUX_UNDO_FLAG_INDEX_LIST 0x0002 + +/* + * FluxUndoDelta -- the payload of a FLUX_UNDO_UPDATE_DELTA record (it follows + * the FluxUndoPayloadHeader, exactly where the full before-image would sit for + * a FLUX_UNDO_UPDATE record). + * + * The old image and the new on-page image are compared as byte strings; the + * common prefix and common suffix are dropped and only the differing middle of + * the OLD image is stored. This is the same prefix/suffix shape FluxXLogUpdate + * computes for redo, and it covers BOTH the same-size CAS fast path and the + * size-changing path (old_len may differ from the current on-page length). + * + * Reconstruction of the full old image from the current on-page image (cur): + * + * middle_len = old_len - prefixlen - suffixlen + * old = cur[0 .. prefixlen) + * ++ old_middle[0 .. middle_len) + * ++ cur[cur_len - suffixlen .. cur_len) + * + * so the result is exactly old_len bytes. Because every in-place UPDATE + * stamps a fresh t_verptr at FIXED offset 8 (see flux.h FluxTupleHeader), the + * changed middle ALWAYS covers offset 8, so the reconstructed image carries + * the OLD t_verptr and the per-tuple version chain continues verbatim. + */ +typedef struct FluxUndoDelta +{ + uint32 old_len; /* length of the reconstructed old image */ + uint16 prefixlen; /* bytes of common prefix kept from current */ + uint16 suffixlen; /* bytes of common suffix kept from current */ + /* old_middle[old_len - prefixlen - suffixlen] follows */ +} FluxUndoDelta; + +#define SizeOfFluxUndoDelta (offsetof(FluxUndoDelta, suffixlen) + sizeof(uint16)) + +/* + * FluxUndoDeltaCompute + * Compute the prefix/suffix delta of old_image (old_len bytes) relative + * to new_image (new_len bytes) into *out and its trailing middle bytes. + * + * out_cap is the size of the out buffer; it must be at least + * SizeOfFluxUndoDelta + old_len for the worst case. Returns the total delta + * record size on success, or 0 if the delta is not strictly smaller than the + * full old image (caller must then emit a full FLUX_UNDO_UPDATE). + * + * force_offset, if in-range, is a byte offset that MUST fall inside the + * recorded middle (prefixlen <= force_offset < old_len - suffixlen). Callers + * pass the t_verptr offset (8) so the middle always covers the version pointer, + * which the version chain relies on even in the rare case the stamped verptr + * happens to equal the old one. Pass a value >= old_len to disable. + */ +static inline Size +FluxUndoDeltaCompute(const char *old_image, uint32 old_len, + const char *new_image, uint32 new_len, + uint32 force_offset, + char *out, Size out_cap) +{ + uint32 minlen = (old_len < new_len) ? old_len : new_len; + uint32 prefixlen = 0; + uint32 suffixlen = 0; + uint32 middle_len; + FluxUndoDelta *d; + Size total; + + /* Common prefix. */ + while (prefixlen < minlen && + old_image[prefixlen] == new_image[prefixlen]) + prefixlen++; + + /* Common suffix, not overlapping the prefix on either image. */ + while (suffixlen < minlen - prefixlen && + old_image[old_len - 1 - suffixlen] == + new_image[new_len - 1 - suffixlen]) + suffixlen++; + + /* + * Force the given offset into the middle so the recorded old bytes always + * include it (used for the fixed t_verptr offset). + */ + if (force_offset < old_len) + { + if (prefixlen > force_offset) + prefixlen = force_offset; + if (old_len - suffixlen <= force_offset) + suffixlen = old_len - 1 - force_offset; + } + + middle_len = old_len - prefixlen - suffixlen; + total = SizeOfFluxUndoDelta + middle_len; + + /* + * Only worthwhile if the delta record is strictly smaller than the full + * before-image would be, and it fits the caller's buffer. + */ + if (total >= old_len || total > out_cap) + return 0; + + d = (FluxUndoDelta *) out; + d->old_len = old_len; + d->prefixlen = (uint16) prefixlen; + d->suffixlen = (uint16) suffixlen; + memcpy(out + SizeOfFluxUndoDelta, old_image + prefixlen, middle_len); + return total; +} + +/* + * FluxUndoDeltaReconstruct + * Reconstruct the full old image from a delta record and the current + * on-page image (cur, cur_len bytes). Writes into out (caller-allocated, + * at least delta->old_len bytes) and returns true on success. + * + * Fails (returns false) if the delta is malformed or the current image is too + * short to supply the kept prefix/suffix. + */ +static inline bool +FluxUndoDeltaReconstruct(const FluxUndoDelta *delta, Size delta_len, + const char *cur, Size cur_len, + char *out) +{ + uint32 prefixlen = delta->prefixlen; + uint32 suffixlen = delta->suffixlen; + uint32 old_len = delta->old_len; + uint32 middle_len; + + if (delta_len < SizeOfFluxUndoDelta) + return false; + if ((Size) prefixlen + suffixlen > old_len) + return false; + middle_len = old_len - prefixlen - suffixlen; + if (delta_len < SizeOfFluxUndoDelta + middle_len) + return false; + if ((Size) prefixlen + suffixlen > cur_len) + return false; + + memcpy(out, cur, prefixlen); + memcpy(out + prefixlen, + (const char *) delta + SizeOfFluxUndoDelta, middle_len); + memcpy(out + prefixlen + middle_len, + cur + cur_len - suffixlen, suffixlen); + return true; +} + +extern UndoApplyResult flux_undo_apply_locked(Relation rel, Buffer buffer, + uint16 info, + TransactionId xid, + const char *payload, + Size payload_len, + UndoRecPtr urec_ptr); +extern bool FluxTupleHasAbortedVersion(FluxTupleHeader *tup); +extern bool FluxRevertAbortedVersions(Relation rel, Buffer buffer, + OffsetNumber offnum); + +/* + * Registration entry point, called once at postmaster startup from + * InitializeUndoSubsystem() alongside HeapUndoRmgrInit and friends. + */ +extern void FluxUndoRmgrInit(void); + +#endif /* FLUX_UNDO_H */ diff --git a/src/include/access/flux_xlog.h b/src/include/access/flux_xlog.h new file mode 100644 index 0000000000000..2f59a0c7bcbdb --- /dev/null +++ b/src/include/access/flux_xlog.h @@ -0,0 +1,619 @@ +/*------------------------------------------------------------------------- + * + * flux_xlog.h + * FLUX table access method WAL definitions + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/flux_xlog.h + * + *------------------------------------------------------------------------- + */ +#ifndef FLUX_XLOG_H +#define FLUX_XLOG_H + +#include "postgres.h" + +#include "access/xlogreader.h" +#include "lib/stringinfo.h" +#include "storage/buf.h" +#include "storage/itemptr.h" +#include "storage/off.h" + +/* Forward declarations */ +typedef struct FluxTupleData *FluxTuple; +typedef struct RelationData *Relation; +typedef struct FluxOverflowBuffers FluxOverflowBuffers; + +/* + * Heap-format tuple image for logical decoding. + * + * Logical decoding consumes a heap-format image of the FLUX tuple that the + * write path appends to the end of the main WAL data channel as + * "[heap bytes][uint32 heap_len]" (see decode.c). Forming that image calls + * heap_form_tuple()/palloc(), which are forbidden inside a WAL critical + * section. Callers therefore prepare the image with + * FluxXLogPrepareLogicalImage() BEFORE entering the critical section; the + * WAL functions only register the prebuilt bytes (an allocation-free + * operation) inside the section. The image must stay alive until after + * XLogInsert() returns; release it with FluxXLogReleaseLogicalImage() after + * END_CRIT_SECTION(). + * + * When the relation is not logically logged, prepare leaves data == NULL and + * the WAL functions skip the append. + */ +typedef struct FluxLogicalImage +{ + char *data; /* palloc'd heap tuple body, or NULL */ + uint32 len; /* heap tuple t_len (also written trailing) */ +} FluxLogicalImage; + +/* + * WAL record types for FLUX + */ +/* + * WAL record types for FLUX. + * + * Each opcode must be unique. The info byte uses bits 0-7 with + * XLR_INFO_MASK occupying the upper bits, so we have the lower + * nibble(s) available for opcodes. + */ +#define XLOG_FLUX_INSERT 0x00 +#define XLOG_FLUX_UPDATE_INPLACE 0x10 +#define XLOG_FLUX_DELETE 0x20 +#define XLOG_FLUX_DEFRAG 0x30 /* single-page defrag */ +#define XLOG_FLUX_OVERFLOW_WRITE 0x40 +#define XLOG_FLUX_COMPRESS 0x50 +#define XLOG_FLUX_FREEZE_PAGE 0x70 +#define XLOG_FLUX_INIT_PAGE 0x60 +#define XLOG_FLUX_VM_SET 0x80 /* Set visibility map bits */ +#define XLOG_FLUX_VM_CLEAR 0x90 /* Clear visibility map bits */ +#define XLOG_FLUX_LOCK 0xA0 /* Tuple lock */ +#define XLOG_FLUX_CAS_UPDATE 0xB0 /* Same-size CAS in-place update */ +#define XLOG_FLUX_WRITE_DICT 0xC0 /* Compression-dictionary fork + * write */ +#define XLOG_FLUX_MULTI_INSERT 0xD0 /* Batched multi-tuple insert */ +#define XLOG_FLUX_CAS_UPDATE_UNDO 0xE0 /* CAS update folded with UNDO + * before-image */ +#define XLOG_FLUX_SUPERSEDE 0xF0 /* Link a moved row's old version to + * its new TID */ +#define XLOG_FLUX_OPMASK 0xF0 + +/* Aliases for backward compatibility / clarity */ +#define XLOG_FLUX_VACUUM XLOG_FLUX_DEFRAG +#define XLOG_FLUX_UPDATE XLOG_FLUX_UPDATE_INPLACE + +/* Flags for xl_flux_overflow_write */ +#define FLUX_OVERFLOW_WAL_NEW_RECORD 0x0000 /* New overflow record */ +#define FLUX_OVERFLOW_WAL_LINK_UPDATE 0x0001 /* Link update only */ + +/* + * Common WAL record flags. + * + * These appear in the 'flags' field of the DML WAL record structures + * (xl_flux_insert, xl_flux_update, xl_flux_delete). + * + * FLUX_WAL_NO_LOGICAL marks an UPDATE the caller asked not to be decoded + * (TABLE_UPDATE_NO_LOGICAL), so that decoding skips it, as it skips heap's + * UPDATE without a new tuple, instead of failing on the missing images. + */ +#define FLUX_WAL_NO_LOGICAL 0x0001 +#define FLUX_WAL_CROSS_PAGE 0x0002 /* Cross-page out-of-place update */ +#define FLUX_WAL_HAS_OVERFLOW_BLK0 0x0004 /* Block 0 buf data has overflow + * records */ +#define FLUX_WAL_PREFIX_SUFFIX 0x0008 /* Update uses prefix/suffix + * compression */ +/* + * Heap-format tuple image is appended to the WAL record for the benefit of + * logical decoding. Set when RelationIsLogicallyLogged(rel) at WAL-emit + * time. The layout of the appended region is: + * + * uint32 logical_len -- bytes of the heap-tuple payload + * bytes[logical_len] HeapTuple t_data bytes + * + * For INSERT / DELETE the record contains exactly one heap-tuple payload. + * For UPDATE it contains two back-to-back payloads (old, then new). + */ +#define FLUX_WAL_LOGICAL_TUPLE 0x0010 +#define FLUX_WAL_SPECULATIVE 0x0020 /* Speculative insert or abort */ +#define FLUX_WAL_MOVED_PARTITION 0x0040 /* DELETE moved the row to another + * partition: set + * FLUX_TUPLE_MOVED_PARTITION */ +#define FLUX_WAL_SPEC_CONFIRM 0x0080 /* INSERT confirms a speculative + * insertion; no tuple body */ +#define FLUX_WAL_LOGICAL_REL 0x0100 /* CAS UPDATE of a relation that was + * logically logged: not decodable */ +#define FLUX_WAL_ALL_VISIBLE_CLEARED 0x0200 /* INSERT, UPDATE or DELETE + * cleared PD_ALL_VISIBLE and + * the visibility map bits */ + +/* + * Block id of the visibility map page an INSERT, UPDATE or DELETE record + * clears bits on (with FLUX_WAL_ALL_VISIBLE_CLEARED). The highest one, so + * that it never takes the place of an overflow, destination or undo block, + * which follow block 0 without gaps. + */ +#define FLUX_WAL_KEY_INTACT 0x0400 /* SUPERSEDE did not change key columns */ + +#define FLUX_VM_BLOCK_ID XLR_MAX_BLOCK_ID + +/* Freeze plans are WAL state, never hint bits. */ +#define FLUX_FREEZE_XMIN 0x01 +#define FLUX_FREEZE_XMAX 0x02 +#define FLUX_FREEZE_DETACH 0x04 + +typedef struct xl_flux_freeze_page +{ + TransactionId conflict_xid; + uint16 nplans; + bool is_catalog_rel; +} xl_flux_freeze_page; + +typedef struct xl_flux_freeze_tuple +{ + uint16 offset; + uint8 actions; +} xl_flux_freeze_tuple; + +#ifndef FRONTEND +/* + * WAL record data structures + */ +typedef struct xl_flux_insert +{ + Oid relid; /* logical identity for tuple sLog replay */ + OffsetNumber offnum; /* Offset number */ + uint16 flags; /* Flags (FLUX_WAL_* bits) */ + uint32 tuple_len; /* Length of tuple data that follows */ + uint64 commit_ts; /* Commit timestamp */ + /* Tuple data follows */ +} xl_flux_insert; + +typedef struct xl_flux_update +{ + OffsetNumber offnum; /* Offset number on source page (block 0) */ + uint16 flags; /* Flags (FLUX_WAL_* bits) */ + uint64 old_commit_ts; /* Old commit timestamp */ + uint64 new_commit_ts; /* New commit timestamp */ + uint16 old_tuple_len; /* Length of old tuple */ + uint16 new_tuple_len; /* Length of new tuple data that follows */ + uint8 dst_block_id; /* Block ID of destination page for cross-page + * updates (only valid when + * FLUX_WAL_CROSS_PAGE is set in flags) */ + uint8 pad[3]; /* Padding for alignment */ + /* New tuple data follows (old tuple data is in UNDO log only) */ +} xl_flux_update; + +/* + * Prefix/suffix compression header for in-place updates. + * + * When FLUX_WAL_PREFIX_SUFFIX is set in xl_flux_update.flags, this header + * immediately follows the xl_flux_update struct and precedes the diff data. + * Only the changed bytes (between prefixlen and len-suffixlen) are logged. + * + * The redo handler reconstructs the full new tuple by: + * 1. Reading the existing tuple from the page (old data) + * 2. Keeping old[0..prefixlen-1] as-is + * 3. Copying the diff data into old[prefixlen..len-suffixlen-1] + * 4. Keeping old[len-suffixlen..len-1] as-is + */ +typedef struct xl_flux_prefix_suffix +{ + uint16 prefixlen; /* Bytes of common prefix */ + uint16 suffixlen; /* Bytes of common suffix */ +} xl_flux_prefix_suffix; + +typedef struct xl_flux_delete +{ + OffsetNumber offnum; /* Offset number */ + uint16 flags; /* Flags (FLUX_WAL_* bits) */ + uint32 tuple_len; /* Length of old tuple (for logical decoding) */ + uint64 commit_ts; /* Commit timestamp */ + /* Old tuple data is in UNDO log only */ +} xl_flux_delete; + +/* + * WAL record for a batched multi-tuple insert (XLOG_FLUX_MULTI_INSERT). + * + * flux_multi_insert packs many tuples onto a single page in one critical + * section. Rather than emit one xl_flux_insert per tuple (and force a + * full-page image to stay crash-safe), we log every tuple body once in a + * single record modelled on heap's xl_heap_multi_insert. + * + * Layout of the record's main data: + * + * xl_flux_multi_insert -- header (ntuples, flags, ts) + * repeated ntuples times: + * xl_flux_multi_insert_tuple -- per-tuple header + * char body[datalen] -- tuple t_data bytes + * + * If flags & FLUX_WAL_LOGICAL_TUPLE, ntuples heap-format logical images + * follow (one per tuple) after the per-tuple region, each as [body][uint32 len]. + */ +typedef struct xl_flux_multi_insert +{ + Oid relid; /* logical identity for tuple sLog replay */ + uint16 ntuples; /* Number of tuples in this batch */ + uint16 flags; /* Flags (FLUX_WAL_* bits) */ + uint64 commit_ts; /* Shared commit timestamp */ + /* xl_flux_multi_insert_tuple entries follow */ +} xl_flux_multi_insert; + +#define SizeOfFluxMultiInsert sizeof(xl_flux_multi_insert) + +typedef struct xl_flux_multi_insert_tuple +{ + OffsetNumber offnum; /* Target offset on the page */ + uint16 datalen; /* Length of tuple t_data bytes that follow */ + /* char body[datalen] follows */ +} xl_flux_multi_insert_tuple; + +#define SizeOfFluxMultiInsertTuple sizeof(xl_flux_multi_insert_tuple) + +typedef struct xl_flux_lock +{ + OffsetNumber offnum; /* Offset number */ + uint16 flags; /* Flags */ + uint8 infomask; /* Infomask bits (uint8) */ + uint8 lock_mode; /* LockTupleMode */ +} xl_flux_lock; + +/* + * XLOG_FLUX_SUPERSEDE: set FLUX_TUPLE_SUPERSEDED on the old version at offnum + * of block 0 and store new_tid in its t_ctid. See flux_link_superseded. + * With FLUX_WAL_LOGICAL_TUPLE in flags, the old replica identity and the new + * row follow as in XLOG_FLUX_UPDATE_INPLACE, and the record decodes as the + * UPDATE that moved the row. + */ +typedef struct xl_flux_supersede +{ + OffsetNumber offnum; /* Old version, on block 0 */ + ItemPointerData new_tid; /* The new version */ + uint16 flags; /* FLUX_WAL_LOGICAL_TUPLE or 0 */ +} xl_flux_supersede; + +/* + * Per-backend UNDO record carried on a FLUX forward-op WAL record, so that + * flux_redo() can re-run the undo insert instead of restoring the undo page + * from a forced full-page image. + * + * This header plus its trailing payload is registered as BLOCK DATA on the + * FIRST undo block of the record (see FluxPbuRegisterUndoBuffers), not on the + * main data channel. That keeps every opcode's main-data layout -- and hence + * both the redo parsers and the tail-anchored logical-decoding images -- exactly + * as they were, and it makes the region self-locating: redo finds the undo + * blocks by their relfilenode (undo pages live in the reserved undo database + * OID) and reads the region with XLogRecGetBlockData(). The undo buffers are + * registered with REGBUF_KEEP_DATA so this region survives even when the undo + * page happens to carry a full-page image. + * + * Fields: + * urec_ptr - the undo location the DO-time PrepareUndoInsert() returned. + * Redo re-derives the location independently and PANICs on a + * mismatch, so a divergence between the recovered undo-log + * metadata and the WAL cannot silently misplace the record. + * reloid - the undo record's uur_reloid. + * subtype - the FLUX_UNDO_* code (uur_type). + * payload_len / trailing bytes - the undo record's uur_payload verbatim + * (FluxUndoPayloadHeader followed by the before-image tuple + * bytes). Carried unmodified so the replayed record is + * byte-identical to the original, which preserves the target TID, + * the producing writer_xid, and the before-image -- hence the + * t_verptr version chain -- exactly as the primary wrote them. + */ +typedef struct xl_flux_undo +{ + TransactionId freeze_conflict; /* DML before-image freeze horizon */ + bool freeze_catalog_rel; /* relation is accessible in logical + * decoding, see + * RelationIsAccessibleInLogicalDecoding */ + uint64 urec_ptr; /* DO-time undo record location */ + Oid reloid; /* relation OID of the undo record */ + uint16 subtype; /* FLUX_UNDO_* code */ + uint16 payload_len; /* bytes of payload that follow */ + TransactionId top_xid; /* top-level xid that owns the undo log */ + uint32 top_xid_epoch; /* epoch of top_xid */ + CommandId cid; /* command id of the writer (uur_cid) */ + /* char payload[payload_len] follows */ +} xl_flux_undo; + +#define SizeOfFluxUndo sizeof(xl_flux_undo) + +/* + * WAL record for same-size CAS in-place update (XLOG_FLUX_CAS_UPDATE). + * + * This is a lightweight record logged by the tuple-level CAS update fast + * path. Only the changed portion of the tuple data is logged (the region + * between data_offset and data_offset+data_len within the on-page tuple). + * The redo handler patches these bytes directly into the tuple on the page. + */ +typedef struct xl_flux_cas_update +{ + OffsetNumber offnum; /* Tuple offset on page */ + uint16 flags; /* FLUX_WAL_* bits */ + uint16 data_offset; /* Byte offset within tuple for patch start */ + uint16 data_len; /* Length of replacement data */ + uint64 new_commit_ts; /* New commit timestamp for the tuple */ + /* char data[data_len] follows */ +} xl_flux_cas_update; + +/* + * WAL record for a CAS in-place update folded with its UNDO before-image + * (XLOG_FLUX_CAS_UPDATE_UNDO). + * + * This is the FOLD variant of the CAS fast path: instead of emitting a + * separate RM_FLUX_ID CAS-update record and a separate per-backend UNDO + * before-image record, one record carries both. Block 0 is the main-fork + * page (same redo byte-diff as xl_flux_cas_update). Block 1+ are the + * per-backend UNDO pages, registered (without a forced image) by + * FluxPbuRegisterUndoBuffers() so they are covered by this record; the undo + * record's own bytes ride in the trailing xl_flux_undo region. + * + * The redo handler replays block 0 like flux_xlog_cas_update_redo, then re-runs + * the undo insert from that region (FluxPbuRedoUndo). + */ +typedef struct xl_flux_cas_update_undo +{ + /* --- redo half (block 0): mirrors xl_flux_cas_update --- */ + OffsetNumber offnum; /* Tuple offset on main-fork page */ + uint16 flags; /* FLUX_WAL_* bits */ + uint16 data_offset; /* Byte offset within tuple for patch start */ + uint16 data_len; /* Length of replacement data */ + uint64 new_commit_ts; /* New commit timestamp for the tuple */ + /* char redo_data[data_len] follows (block 0 byte-diff) */ + /* per-backend undo pages ride as full-page images at block 1+ */ +} xl_flux_cas_update_undo; + +#define SizeOfFluxCasUpdateUndo sizeof(xl_flux_cas_update_undo) + +/* + * A defrag record removes tuples (the page image is always logged), so like + * heap's prune record it carries the newest XID the removal conflicts with on + * a hot standby, see FluxTupleAdvanceConflictHorizon. + */ +typedef struct xl_flux_defrag +{ + uint16 ntuples; /* Number of tuples moved */ + uint64 commit_ts; /* Commit timestamp */ + TransactionId snapshotConflictHorizon; /* or InvalidTransactionId */ + bool isCatalogRel; /* to handle recovery conflict during logical + * decoding on standby */ + /* Array of offset mappings follows */ +} xl_flux_defrag; + +typedef struct xl_flux_overflow_write +{ + OffsetNumber offnum; /* Offset of overflow record on page */ + uint16 flags; /* Flags (0 = new record, 1 = link update) */ + uint32 data_len; /* Length of overflow data chunk */ + uint64 commit_ts; /* Commit timestamp */ + /* FluxOverflowRecordHeader + data follows for new records */ + /* FluxOverflowRecordHeader follows for link updates */ +} xl_flux_overflow_write; + +typedef struct xl_flux_compress +{ + OffsetNumber offnum; /* Offset number */ + uint16 attr_num; /* Attribute number */ + uint8 comp_type; /* Compression type */ + uint8 comp_level; /* Compression level */ + uint32 orig_size; /* Original size */ + uint32 comp_size; /* Compressed size */ + uint64 commit_ts; /* Commit timestamp */ + /* Compressed data follows */ +} xl_flux_compress; + +typedef struct xl_flux_vacuum +{ + uint32 ntuples; /* Number of removed tuples */ +} xl_flux_vacuum; + +typedef struct xl_flux_init_page +{ + uint32 flags; /* Page flags */ + uint64 commit_ts; /* Initial commit timestamp */ +} xl_flux_init_page; + +/* + * Compression-dictionary fork write (XLOG_FLUX_WRITE_DICT). + * + * The dictionary fork (FLUX_DICT_FORKNUM) stores trained ZSTD/LZ4 dictionary + * blobs append-only. Its pages use a non-standard layout (the directory + * metapage keeps FluxDictMeta above pd_lower; data pages keep payload above + * pd_lower), so the redo path cannot reconstruct them from logical deltas. + * Instead each dirtied dict-fork page is logged as a full-page image with + * REGBUF_FORCE_IMAGE and the redo handler simply restores the registered + * block. No record-specific payload is required beyond the block image. + */ +typedef struct xl_flux_write_dict +{ + BlockNumber blkno; /* Dictionary-fork block being written */ +} xl_flux_write_dict; + +/* + * Visibility Map WAL records + */ +typedef struct xl_flux_vm_set +{ + TransactionId conflict_xid; + BlockNumber heapBlk; /* Heap block number */ + uint8 flags; /* VM flags being set */ +} xl_flux_vm_set; + +typedef struct xl_flux_vm_clear +{ + BlockNumber heapBlk; /* Heap block number */ + uint8 flags; /* VM flags being cleared */ +} xl_flux_vm_clear; + +/* + * Offset mapping for defragmentation + */ +typedef struct FluxOffsetMapping +{ + OffsetNumber old_offnum; + OffsetNumber new_offnum; +} FluxOffsetMapping; +#else /* FRONTEND */ + +/* Frontend-safe versions of WAL record structures */ +typedef struct xl_flux_insert +{ + Oid relid; /* logical identity for tuple sLog replay */ + uint16 offnum; /* Offset number */ + uint16 flags; /* Flags */ + uint32 tuple_len; /* Length of tuple data */ + uint64 commit_ts; /* Commit timestamp */ +} xl_flux_insert; + +typedef struct xl_flux_delete +{ + uint16 offnum; /* Offset number */ + uint16 flags; /* Flags */ + uint32 tuple_len; /* Length of old tuple */ + uint64 commit_ts; /* Commit timestamp */ +} xl_flux_delete; + +typedef struct xl_flux_multi_insert +{ + Oid relid; /* logical identity for tuple sLog replay */ + uint16 ntuples; /* Number of tuples in this batch */ + uint16 flags; /* Flags */ + uint64 commit_ts; /* Shared commit timestamp */ +} xl_flux_multi_insert; + +typedef struct xl_flux_multi_insert_tuple +{ + uint16 offnum; /* Target offset on the page */ + uint16 datalen; /* Length of tuple data that follows */ +} xl_flux_multi_insert_tuple; + +typedef struct xl_flux_update +{ + uint16 offnum; /* Offset number */ + uint16 flags; /* Flags */ + uint64 old_commit_ts; /* Old commit timestamp */ + uint64 new_commit_ts; /* New commit timestamp */ + uint16 old_tuple_len; /* Length of old tuple */ + uint16 new_tuple_len; /* Length of new tuple */ +} xl_flux_update; + +typedef struct xl_flux_vacuum +{ + uint32 ntuples; /* Number of removed tuples */ +} xl_flux_vacuum; + +typedef struct xl_flux_compress +{ + uint16 offnum; /* Offset number */ + uint16 attr_num; /* Attribute number */ + uint8 comp_type; /* Compression type */ + uint8 comp_level; /* Compression level */ + uint32 orig_size; /* Original size */ + uint32 comp_size; /* Compressed size */ + uint64 commit_ts; /* Commit timestamp */ +} xl_flux_compress; + +#endif /* !FRONTEND */ + +/* + * Function prototypes + */ + +/* Frontend-safe function prototypes (pg_waldump, etc.) */ +extern void flux_desc(StringInfo buf, XLogReaderState *record); +extern const char *flux_identify(uint8 info); + +#ifndef FRONTEND +/* WAL replay and logging functions - backend only */ +extern void flux_redo(XLogReaderState *record); +extern void flux_mask(char *page, BlockNumber blkno); + +/* + * Prepare/release the heap-format logical-decoding image. Call prepare + * BEFORE START_CRIT_SECTION() and release AFTER END_CRIT_SECTION(). When rel + * is not logically logged, prepare sets img->data = NULL and the WAL + * functions emit no logical image. + */ +extern void FluxXLogPrepareLogicalImage(Relation rel, FluxTuple rtup, + FluxLogicalImage *img); +extern void FluxXLogPrepareReplicaIdentity(Relation rel, FluxTuple oldtup, + FluxTuple newtup, FluxLogicalImage *img); +extern void FluxXLogReleaseLogicalImage(FluxLogicalImage *img); +extern void FluxXLogRegisterLogicalImage(FluxLogicalImage *img, uint16 *flags); + +extern XLogRecPtr FluxXLogInsert(Relation rel, Buffer buffer, OffsetNumber offnum, + FluxTuple tuple, uint64 commit_ts, + FluxOverflowBuffers *overflow_buffers, + FluxLogicalImage *logical_img, + bool force_page_image); +extern XLogRecPtr FluxXLogUpdate(Relation rel, Buffer buffer, OffsetNumber offnum, + FluxTuple old_tuple, FluxTuple new_tuple, + uint64 old_commit_ts, uint64 new_commit_ts, + FluxOverflowBuffers *overflow_buffers, + Buffer new_buffer, + FluxLogicalImage *old_img, + FluxLogicalImage *new_img, + bool no_logical); +extern XLogRecPtr FluxXLogDelete(Relation rel, Buffer buffer, OffsetNumber offnum, + FluxTuple tuple, uint64 commit_ts, + FluxLogicalImage *logical_img); + +/* + * Log a batch of tuples inserted onto a single page by flux_multi_insert. + * Every tuple body is logged once (no forced full-page image), modelled on + * heap's xl_heap_multi_insert. offnums[i] is the on-page offset assigned to + * tuples[i]; logical_imgs may be NULL (no logical decoding) or an array of + * ntuples prepared images. + */ +extern XLogRecPtr FluxXLogMultiInsert(Relation rel, Buffer buffer, + OffsetNumber *offnums, FluxTuple *tuples, + int ntuples, uint64 commit_ts, + FluxLogicalImage *logical_imgs); + +extern void FluxExecuteFreeze(Buffer buffer, xl_flux_freeze_tuple *plans, int nplans); +extern XLogRecPtr FluxXLogFreeze(Relation rel, Buffer buffer, + xl_flux_freeze_tuple *plans, int nplans, + TransactionId conflict_xid); +extern XLogRecPtr FluxXLogDefrag(Relation rel, Buffer buffer, + FluxOffsetMapping *mappings, int nmappings, uint64 commit_ts, + TransactionId snapshotConflictHorizon); +extern XLogRecPtr FluxXLogOverflowWrite(Relation rel, Buffer buffer, + OffsetNumber offnum, char *record_data, + uint32 record_len, uint16 flags, + uint64 commit_ts); +extern XLogRecPtr FluxXLogCompress(Relation rel, Buffer buffer, OffsetNumber offnum, + uint16 attr_num, uint8 comp_type, + uint8 comp_level, char *comp_data, uint32 orig_size, uint32 comp_size, + uint64 commit_ts); +extern XLogRecPtr FluxXLogInitPage(Relation rel, Buffer buffer, uint32 flags, uint64 commit_ts); +extern XLogRecPtr FluxXLogCasUpdate(Relation rel, Buffer buffer, + OffsetNumber offnum, + uint16 data_offset, uint16 data_len, + const char *new_data, + uint64 new_commit_ts); +extern XLogRecPtr FluxXLogCasUpdateUndo(Relation rel, Buffer buffer, + OffsetNumber offnum, + uint16 data_offset, uint16 data_len, + const char *new_data, + uint64 new_commit_ts); + +/* + * Log a compression-dictionary fork page as a full-page image so crash + * recovery and physical replicas reproduce the append-only dict fork. The + * caller must hold the buffer locked and have already modified it inside a + * critical section; this registers the page with REGBUF_FORCE_IMAGE and sets + * the page LSN. + */ +extern XLogRecPtr FluxXLogWriteDict(Relation rel, Buffer buffer); + +/* + * Logical replication decode entry point for FLUX WAL records. + */ +struct LogicalDecodingContext; +struct XLogRecordBuffer; +extern void flux_decode(struct LogicalDecodingContext *ctx, + struct XLogRecordBuffer *buf); +#endif /* !FRONTEND */ +#endif /* FLUX_XLOG_H */ diff --git a/src/include/access/genam.h b/src/include/access/genam.h index 0e64e49d522dd..51cf8823650fc 100644 --- a/src/include/access/genam.h +++ b/src/include/access/genam.h @@ -158,6 +158,8 @@ extern bool index_delete_mark(Relation indexRelation, Datum *values, bool *isnull, ItemPointer heap_t_ctid, Relation heapRelation); +extern bool index_probe_delete_mark(Relation indexRelation, Datum *values, + bool *isnull, ItemPointer tid, Relation heapRelation); extern bool index_undo_mark(Relation indexRelation, Datum *values, bool *isnull, ItemPointer heap_t_ctid, diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h index 772eaae40c964..a5df997849000 100644 --- a/src/include/access/nbtree.h +++ b/src/include/access/nbtree.h @@ -700,9 +700,9 @@ BTreeTupleGetHeapTID(IndexTuple itup) else if (BTreeTupleIsDeleteMarked(itup)) { /* - * Delete-marked leaf tuple: the single heap TID lives in a - * trailer at the tuple end, exactly like a pivot's heap TID. t_tid - * itself holds only alt-TID status metadata here. + * Delete-marked leaf tuple: the single heap TID lives in a trailer at + * the tuple end, exactly like a pivot's heap TID. t_tid itself holds + * only alt-TID status metadata here. */ return (ItemPointer) ((char *) itup + IndexTupleSize(itup) - sizeof(ItemPointerData)); @@ -959,8 +959,8 @@ typedef struct BTInsertStateData * with an existing posting list tuple that has its LP_DEAD bit set. */ int postingoff; - bool nopostingsplit; /* delete-marking index: never - * posting-split on insert */ + bool nopostingsplit; /* delete-marking index: never posting-split + * on insert */ } BTInsertStateData; typedef BTInsertStateData *BTInsertState; @@ -1287,7 +1287,7 @@ extern bool btdeletemark(Relation rel, Relation heapRel, Datum *values, bool *isnull, ItemPointer heap_t_ctid); extern bool btundomark(Relation rel, Relation heapRel, Datum *values, bool *isnull, ItemPointer heap_t_ctid, - bool clear_mark); + bool clear_mark, bool probe); extern void btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys, ScanKey orderbys, int norderbys); extern void btparallelrescan(IndexScanDesc scan); diff --git a/src/include/access/relscan.h b/src/include/access/relscan.h index f53f99ecd72f6..6834ddfeab7d8 100644 --- a/src/include/access/relscan.h +++ b/src/include/access/relscan.h @@ -142,11 +142,11 @@ typedef struct IndexScanDescData /* * A plain index scan on a delete-marking table AM needs the index tuple * (xs_itup) to recheck a delete-marked entry's key against the visible - * heap version, but - * must NOT set xs_want_itup: that flag is now reserved for genuine - * index-only scans (table_index_getnext_slot asserts an IOS uses a - * virtual slot). This separate flag asks the index AM to keep xs_itup - * populated for the recheck during an ordinary (buffer-slot) index scan. + * heap version, but must NOT set xs_want_itup: that flag is now reserved + * for genuine index-only scans (table_index_getnext_slot asserts an IOS + * uses a virtual slot). This separate flag asks the index AM to keep + * xs_itup populated for the recheck during an ordinary (buffer-slot) + * index scan. */ bool xs_want_itup_recheck; bool xs_temp_snap; /* unregister snapshot at scan end? */ diff --git a/src/include/access/rmgrlist.h b/src/include/access/rmgrlist.h index 16cb679171819..7505883c12da4 100644 --- a/src/include/access/rmgrlist.h +++ b/src/include/access/rmgrlist.h @@ -50,6 +50,9 @@ PG_RMGR(RM_LOGICALMSG_ID, "LogicalMessage", logicalmsg_redo, logicalmsg_desc, lo 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, undo_mask, NULL) PG_RMGR(RM_ATM_ID, "ATM", atm_redo, atm_desc, atm_identify, NULL, NULL, NULL, NULL) +#ifdef USE_FLUX +PG_RMGR(RM_FLUX_ID, "FLUX", flux_redo, flux_desc, flux_identify, NULL, NULL, flux_mask, flux_decode) +#endif #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/slog.h b/src/include/access/slog.h index 14f46048618c4..03f4ea6eff4ed 100644 --- a/src/include/access/slog.h +++ b/src/include/access/slog.h @@ -1,30 +1,17 @@ /*------------------------------------------------------------------------- * * slog.h - * Secondary Log (sLog) for shared-memory tracking + * Secondary Log (sLog): shared state of the UNDO subsystem that is not WAL * - * The sLog provides a shared-memory Aborted Transaction Map (ATM) for the - * UNDO subsystem's Constant-Time Recovery: + * The transaction sLog (slog.c) is the Aborted Transaction Map (ATM): the + * aborted transactions whose UNDO has not been applied yet, in a radix tree + * keyed by (xid, reloid) packed into a uint64, so that one xid's entries are + * contiguous. Protected by a single LWLock (modifications are infrequent). * - * Transaction radix tree - Aborted transaction entries keyed by - * (xid, reloid) packed into a uint64, ordered for efficient xid-based - * range operations. Protected by a single LWLock (modifications are - * infrequent). - * - * An optional per-tuple flat-hash extension (the tuple sLog) adds - * bounded-recovery uncommitted-writer tracking for in-place-update table - * AMs. It shares this subsystem's shared-memory segment and initialization - * but is not required by the UNDO core; its API is declared where it is - * defined. - * - * ENGINE-AGNOSTIC: the tuple sLog is independent of which UNDO engine an AM - * uses. It keys purely on physical tuple identity (relid, tid) and holds no - * reference to the cluster-wide or the per-backend UNDO engine. Any - * in-place-MVCC AM may use it, regardless of UNDO engine. Cleanup is driven - * by the consumer AM (or its UNDO worker) at commit/abort via the public - * SLogTupleRemoveByXid* entry points; a per-backend AM drives the same - * removals from the per-backend rollback path (execute_undo_actions) exactly - * as an AM using its transaction hooks would. + * The tuple sLog (slog_tuple.c) is per-tuple state of UNDO-based table AMs: + * who holds row locks on their tuples, and which tuples this transaction + * inserted. It keys on physical tuple identity (relid, relnumber, tid) and + * knows neither any AM's page format nor either UNDO engine. * * WAL: Transaction sLog reuses existing RM_ATM_ID records. * @@ -48,51 +35,13 @@ #include "utils/dsa.h" /* - * Maximum concurrent operations on a single TID. - * - * Under EPQ retry patterns with high concurrency, each backend that sees - * TM_Updated calls table_tuple_lock which adds a LOCK_EXCL sLog entry. - * These entries persist until the owning transaction commits, so with N - * concurrent backends hitting the same hot row, up to N-1 LOCK entries - * can coexist. Additionally, committed in-place UPDATE markers are retained - * (one per committing xid) until the oldest-snapshot horizon advances past - * them, so a hot row updated repeatedly while a long reader holds a snapshot - * accumulates markers on top of the live LOCK entries. 128 slots handles - * realistic OLTP concurrency plus retained-marker headroom; per-TID - * reclamation of the oldest retained marker kicks in before the array fills. - */ -#define SLOG_MAX_TUPLE_OPS 128 - -/* - * Tuple hash auto-sizing constants. - * - * All DML operations (INSERT/DELETE/UPDATE/LOCK) attempt to create shared - * hash entries. On overflow (hash full), the operation proceeds gracefully - * with local-only tracking rather than crashing. Auto-sizing formula: - * MaxBackends * SLOG_TUPLE_PER_BACKEND_SLOTS, clamped. - * - * The per-backend slot count must be large enough to accommodate OLTP - * workloads where UPDATE before-images are retained until eviction. - * With transactions touching ~25 rows, and retained entries from committed - * transactions accumulating between eviction passes, 1024 slots per backend - * provides adequate headroom. - */ -#define SLOG_TUPLE_PER_BACKEND_SLOTS 1024 -#define SLOG_TUPLE_MIN_ENTRIES 4096 -#define SLOG_TUPLE_MAX_ENTRIES 4194304 - -/* - * sLog entry types for tuple operations. + * What an UNDO-based table AM's transaction did to a tuple, as tracked + * backend-locally (SLogTupleTrackLocalOnly). */ typedef enum SLogOpType { - SLOG_ENTRY_ABORTED_TXN = 0, /* Transaction-level abort entry */ SLOG_OP_INSERT = 1, - SLOG_OP_DELETE = 2, - SLOG_OP_UPDATE = 3, - SLOG_OP_LOCK_SHARE = 4, - SLOG_OP_LOCK_EXCL = 5, - SLOG_OP_ABORTED = 6, /* Tuple-level: op was aborted, UNDO pending */ + SLOG_OP_ABORTED = 6, /* its subtransaction rolled back */ } SLogOpType; /* ---------------------------------------------------------------- @@ -122,64 +71,16 @@ typedef struct SLogTxnEntry bool fileops_owned; /* FILEOPS completed or owned by abort WAL */ } SLogTxnEntry; -/* ---------------------------------------------------------------- - * Tuple sLog structures - * ---------------------------------------------------------------- - */ - /* - * SLogTupleKey - Hash key for the tuple flat hash. - * - * Note: ItemPointerData is 6 bytes. Always memset(&key, 0, sizeof(key)) - * before populating to ensure deterministic byte hashing. + * SLogTupleKey - a tuple: relation, its storage, and TID. */ typedef struct SLogTupleKey { Oid relid; + RelFileNumber relnumber; ItemPointerData tid; } SLogTupleKey; -/* - * SLogTupleOp - Single operation on a tuple. - * - * Uses an in_use slot-based model for O(1) removal without array compaction - * under the exclusive lock. - */ -typedef struct SLogTupleOp -{ - TransactionId xid; - TransactionId subxid; /* subtransaction ID, or InvalidTransactionId */ - SLogOpType op_type; - CommandId cid; - TimestampTz commit_ts; /* 0 if not yet committed */ - uint32 spec_token; /* speculative insertion token, or 0 */ - bool in_use; /* slot occupied? */ - - /* - * Precise tuple-lock strength for LOCK_SHARE/LOCK_EXCL markers. The - * SLOG_OP_LOCK_* op_type only distinguishes shared vs. exclusive intent; - * this field preserves the full four-way LockTupleMode so a concurrent - * updater can apply the real heavyweight conflict matrix (a KeyShare FK - * locker is compatible with a NoKeyExclusive UPDATE, but a - * Share/Exclusive locker conflicts and must block it). Ignored for - * non-lock ops. - */ - LockTupleMode lock_mode; -} SLogTupleOp; - -/* - * SLogTupleEntry - Value in the tuple flat hash. - */ -typedef struct SLogTupleEntry -{ - SLogTupleKey key; /* hash key */ - int nops; /* number of active operations */ - SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; -} SLogTupleEntry; - -/* Callback type for tuple iteration */ -typedef bool (*SLogTupleIterCallback) (const SLogTupleOp *op, void *arg); - /* ---------------------------------------------------------------- * Shared state * @@ -220,136 +121,68 @@ extern XLogRecPtr SLogTxnGetOldestUnrevertedLSN(void); extern int SLogTxnSnapshotForCheckpoint(SLogTxnEntry **entries_out); extern void SLogRecoveryFinalize(int *total_out, int *unreverted_out); -/* DSA lifecycle (shared-memory area backing the aborted-txn radix tree) */ -extern void SLogEnsureDsaAttached(void); +/* Create the ATM before a transaction may need to abort into it */ +extern void SLogTxnEnsureCreated(void); /* GUC: maximum DSA size (in MB) */ extern int slog_dsa_max_size_mb; /* ---------------------------------------------------------------- - * API: Tuple sLog (optional per-tuple tracking extension, slog_tuple.c) + * API: Tuple sLog (slog_tuple.c) * ---------------------------------------------------------------- */ -/* Dynamic sizing */ -extern int SLogTupleNumEntries(void); - -/* Core operations */ -extern bool SLogTupleInsert(Oid relid, ItemPointer tid, TransactionId xid, - SLogOpType op_type, TransactionId subxid, - CommandId cid, TimestampTz commit_ts, - uint32 spec_token, LockTupleMode lock_mode); -extern bool SLogTupleInsertRecovery(Oid relid, ItemPointer tid, - TransactionId xid, SLogOpType op_type); -extern void SLogTupleRestoreLock(Oid relid, ItemPointer tid, - TransactionId xid, LockTupleMode mode); -extern bool SLogTupleLookup(Oid relid, ItemPointer tid, - SLogTupleEntry *entry_out); -extern void SLogTupleRemove(Oid relid, ItemPointer tid, TransactionId xid); -extern void SLogTupleRemoveByXid(TransactionId xid); +/* Backend-local tracking of this transaction's inserts */ +extern void SLogTupleTrackLocalOnly(Oid relid, RelFileNumber relnumber, + ItemPointer tid, + TransactionId xid, TransactionId subxid); extern void SLogTupleRemoveBySubXid(TransactionId xid, TransactionId subxid); -extern void SLogTupleIterateByTid(Oid relid, ItemPointer tid, - SLogTupleIterCallback callback, void *arg); - -/* Filtered lookup (xid_filter=InvalidTransactionId means all) */ -extern int SLogTupleLookupFiltered(Oid relid, ItemPointer tid, - TransactionId xid_filter, - SLogTupleOp *ops_out, int max_ops); - -/* Subtransaction re-parenting on subxact commit */ extern void SLogTupleUpdateSubXid(TransactionId xid, TransactionId old_subxid, TransactionId new_subxid); - -/* Mark all ops for xid as SLOG_OP_ABORTED */ -extern void SLogTupleMarkAborted(TransactionId xid); - -/* Global removal for UNDO worker (no backend-local list) */ -extern void SLogTupleRemoveByXidGlobal(TransactionId xid); - -/* Lightweight local-only tracking (INSERTs only) */ -extern void SLogTupleTrackLocalOnly(Oid relid, ItemPointer tid, - TransactionId xid, TransactionId subxid); -extern void SLogTupleUntrackLocalOnly(Oid relid, ItemPointer tid); - -/* Convenience wrappers */ -extern bool SLogTupleHasEntry(Oid relid, ItemPointer tid); -extern bool SLogTupleIsInsertedByMe(Oid relid, ItemPointer tid); -extern bool SLogTupleIsDeletedByMe(Oid relid, ItemPointer tid); -extern TransactionId SLogTupleGetDirtyXid(Oid relid, ItemPointer tid, - bool *is_insert); -extern TransactionId SLogTupleGetDirtyWriterXid(Oid relid, ItemPointer tid, - bool *is_insert); -extern TransactionId SLogTupleGetWriteConflictXid(Oid relid, ItemPointer tid, - LockTupleMode my_mode, - bool *is_insert); -extern bool SLogTupleHasLockConflict(Oid relid, ItemPointer tid, - TransactionId my_xid, - SLogOpType requested_lock); -extern bool SLogTupleGetLockConflictXid(Oid relid, ItemPointer tid, - TransactionId my_xid, - LockTupleMode my_mode, - TransactionId *xid_out); -extern bool SLogTupleHasAbortedEntry(Oid relid, ItemPointer tid); - - -/* Backend-private tracking for cleanup at commit/abort */ -extern void SLogTupleTrackKey(SLogTupleKey key, TransactionId xid, - TransactionId subxid, SLogOpType op_type); +extern void SLogTupleForgetStorage(Oid relid, RelFileNumber relnumber); extern void SLogTupleResetTracking(void); -extern bool SLogTupleAnyTracked(void); /* - * SLogTrackedKeyInfo -- public snapshot of a tracked key for batch processing. - * - * Returned by SLogTupleCollectTrackedKeys() so that callers (e.g. an AM's - * commit-time stamping) can sort and batch-process tuples without knowledge - * of the internal tracked-key linked-list structure. + * SLogTrackedKeyInfo -- a tracked insert, as SLogTupleCollectTrackedKeys + * returns it for the AM's commit-time processing. */ typedef struct SLogTrackedKeyInfo { SLogTupleKey key; + RelFileNumber relnumber; /* the relation's storage the row went to */ TransactionId xid; TransactionId subxid; - bool local_only; SLogOpType op_type; } SLogTrackedKeyInfo; -/* Collect tracked keys into a sortable array (for batch commit processing) */ extern int SLogTupleCollectTrackedKeys(TransactionId xid, SLogTrackedKeyInfo **out_keys); -/* Iterate tracked keys (for AM-specific pre-commit callbacks) */ -typedef bool (*SLogTrackedKeyCallback) (const SLogTupleKey *key, - TransactionId xid, - TransactionId subxid, - bool local_only, - void *arg); -extern void SLogTupleIterateTrackedKeys(TransactionId xid, - SLogTrackedKeyCallback callback, - void *arg); - -/* Commit retention: retain committed UPDATE entries with before-images */ -extern void SLogTupleCommitByXid(TransactionId xid); - -/* Per-tuple operations for two-phase commit resolution */ -extern void SLogTupleRemoveByXidSingle(Oid relid, ItemPointer tid, - TransactionId xid); -extern void SLogTupleMarkAbortedSingle(Oid relid, ItemPointer tid, - TransactionId xid); - -/* Cleanup retained entries when no longer needed by any snapshot */ -extern void SLogTupleCleanupRetained(void); - /* - * Throttled cleanup trigger for access methods to call from DML paths OUTSIDE - * any buffer-locked critical section (drives cleanup when the UNDO worker is - * disabled). Cheap to call repeatedly; fires the global sweep at most once - * every few seconds per backend. - */ -extern void SLogTupleMaybeCleanupRetained(void); - -/* GUC: number of sLog flat hash partitions (0 = auto based on CPU count) */ -extern int slog_num_partitions; + * Row locks on tuples of UNDO-based table AMs, in on-demand shared memory. + * The AM calls SLogTupleLockAcquire under the tuple's buffer lock before it + * changes the page, and SLogTupleLockReleaseAll at top-level commit or + * abort. Subtransaction end needs nothing: a lock whose subtransaction + * aborted is dead. + */ +typedef void (*SLogTupleLockCallback) (Oid relid, RelFileNumber relnumber, + ItemPointer tid, LockTupleMode mode, + void *arg); +extern void SLogTupleLockAcquire(Oid relid, RelFileNumber relnumber, + ItemPointer tid, LockTupleMode mode); +extern bool SLogTupleLockHeldByMe(Oid relid, RelFileNumber relnumber, + ItemPointer tid, LockTupleMode *mode); +extern TransactionId SLogTupleLockConflictXid(Oid relid, + RelFileNumber relnumber, + ItemPointer tid, + LockTupleMode mode); +extern void SLogTupleLockReleaseAll(void); +extern void SLogTupleLockForEachMine(SLogTupleLockCallback cb, void *arg); +extern void SLogTupleLockAtPrepare(FullTransactionId fxid, bool any_live); +extern void SLogTupleLockRestore(FullTransactionId fxid, Oid relid, + RelFileNumber relnumber, ItemPointer tid, + LockTupleMode mode); +extern void SLogTupleLockReleasePrepared(FullTransactionId fxid); #endif /* SLOG_H */ diff --git a/src/include/access/slog_flathash.h b/src/include/access/slog_flathash.h deleted file mode 100644 index 35700fe724a96..0000000000000 --- a/src/include/access/slog_flathash.h +++ /dev/null @@ -1,282 +0,0 @@ -/*------------------------------------------------------------------------- - * - * slog_flathash.h - * LWLock-protected flat open-addressing hash for sLog tuple tracking. - * - * This provides read access to sLog tuple entries under the per-partition - * writer_lock LWLock. The flat hash uses open addressing with linear probing - * and tombstone markers for deletions. - * - * Architecture: A SINGLE copy of the hash lives in shared memory, guarded - * by a per-partition writer_lock LWLock (SLogFlatPartition.writer_lock). - * Writers take the lock LW_EXCLUSIVE and mutate the single copy in place; - * readers take it LW_SHARED for the duration of a read, copy data into local - * variables, and release. Reader/writer exclusion is entirely by the LWLock - * (this replaces an earlier seqlock reader/writer scheme; the measured - * read:write ratio was not read-dominated enough for the seqlock to pay off). - * - * Scan semantics: SLogFlatHashScanInit/ScanNext iterate all occupied - * buckets linearly. Read-side scans must run inside SLOG_SEQ_READ_BEGIN/END - * (which take writer_lock LW_SHARED) and may only copy data into locals; a - * writer holds writer_lock LW_EXCLUSIVE and mutates fp->hash directly. For - * write operations that need global scans (eviction, xid removal), the - * pattern is: scan fp->hash under the writer lock (stable, no reader can tear - * it), collect keys, then apply write ops. - * - * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group - * Portions Copyright (c) 1994, Regents of the University of California - * - * src/include/access/slog_flathash.h - * - *------------------------------------------------------------------------- - */ -#ifndef SLOG_FLATHASH_H -#define SLOG_FLATHASH_H - -#include "access/slog.h" -#include "port/atomics.h" -#include "storage/lwlock.h" -#include "storage/s_lock.h" - -/* - * Bucket states encoded via hash_val: - * 0 = empty (never used) - * UINT32_MAX = tombstone (deleted) - * anything else = occupied with that hash value - */ -#define SLOG_FLAT_EMPTY 0 -#define SLOG_FLAT_TOMBSTONE UINT32_MAX - -/* - * SLogFlatBucket - One bucket in the flat open-addressing hash table. - * - * Layout: hash_val marks the state, key is the lookup key, entry contains - * the full SLogTupleEntry data (ops array etc). - */ -typedef struct SLogFlatBucket -{ - uint32 hash_val; /* 0=empty, TOMBSTONE=deleted, else hash */ - SLogTupleKey key; /* (relid, tid) */ - uint16 padding; /* alignment padding */ - SLogTupleEntry entry; /* nops + ops[SLOG_MAX_TUPLE_OPS] */ -} SLogFlatBucket; - -/* - * SLogFlatHash - The flat hash table header, followed by buckets[]. - * - * A single copy lives in shared memory, guarded by the partition writer_lock. - */ -typedef struct SLogFlatHash -{ - int32 capacity; /* number of buckets (power of 2) */ - int32 num_entries; /* current live entries */ - int32 num_tombstones; /* tombstone count (for load factor) */ - int32 padding; - SLogFlatBucket buckets[FLEXIBLE_ARRAY_MEMBER]; -} SLogFlatHash; - -/* - * Operation kinds for the flat-hash apply dispatcher (SLogFlatHashApply). - */ -typedef enum SLogFlatOpKind -{ - SLOG_FLAT_OP_INSERT, /* Insert/update a single op slot */ - SLOG_FLAT_OP_REMOVE_XID, /* Remove all ops for xid from entry */ - SLOG_FLAT_OP_REMOVE_ENTRY, /* Remove entire entry (tombstone it) */ - SLOG_FLAT_OP_MARK_ABORTED, /* Mark all ops for xid as ABORTED */ - SLOG_FLAT_OP_UPDATE_OP, /* Update a specific op slot in-place */ - SLOG_FLAT_OP_COMMIT_XID, /* Handle commit retention for an entry */ - SLOG_FLAT_OP_CLEANUP_RETAINED, /* Remove old retained entries */ - SLOG_FLAT_OP_CREATE_ABORTED, /* Create a new ABORTED entry (for - * local-only) */ -} SLogFlatOpKind; - -/* - * SLogFlatOp - A single operation to be applied to the flat hash. - * - * Passed to SLogFlatHashApply(), which mutates the single flat-hash copy. - */ -typedef struct SLogFlatOp -{ - SLogFlatOpKind kind; - SLogTupleKey key; /* which entry */ - TransactionId xid; /* target xid */ - TransactionId subxid; /* for subxid operations */ - TransactionId reclaim_xid_horizon; /* INSERT: oldest active snapshot - * xmin; a committed UPDATE marker may - * be reclaimed only if its xid - * precedes this (visible to all - * snapshots) */ - SLogTupleOp tuple_op; /* the op to insert/update (for INSERT) */ -} SLogFlatOp; - -/* ---------------------------------------------------------------- - * Partitioned flat hash: 32-way sharding to reduce writer lock contention. - * - * Each partition has its own single flat-hash copy guarded by a writer_lock - * LWLock (LW_SHARED reads, LW_EXCLUSIVE writes) and its own writer lock. Key - * routing: - * hash(key) % NUM_PARTITIONS. This reduces writer lock contention - * proportionally to the number of partitions. - * - * The partition count is determined at startup by the slog_num_partitions - * GUC (default: 0 = auto-size based on CPU count). The heuristic targets - * 4x the number of CPUs, clamped to [16, 256], rounded to next power of 2. - * This ensures that at peak concurrency each CPU core has ~4 partitions to - * spread writes across, minimizing writer lock wait time. - * ---------------------------------------------------------------- - */ - -/* Default partition count used only before SLogShmemInit sets the real value */ -#define SLOG_FLAT_DEFAULT_PARTITIONS 32 -#define SLOG_FLAT_MIN_PARTITIONS 16 -#define SLOG_FLAT_MAX_PARTITIONS 256 - -/* - * SLogFlatPartition - Per-partition state. - * - * Each partition owns a slice of the total flat hash capacity. Reads take - * writer_lock LW_SHARED; writes take it LW_EXCLUSIVE and - * mutate the single copy (*hash) in place. - */ -typedef struct SLogFlatPartition -{ - SLogFlatHash *hash; /* single flat-hash copy (in shmem) */ - LWLockPadded writer_lock; /* per-partition writer serialization */ -} SLogFlatPartition; - -/* - * Read-side locking helpers -- "copy into locals, then act after". - * - * Usage: - * SLOG_SEQ_READ_BEGIN(fp) - * { - * ... probe fp->hash and memcpy/read into LOCAL variables ONLY ... - * } - * SLOG_SEQ_READ_END(fp); - * ... now act on the locals ... - * - * Reads take the partition writer_lock in LW_SHARED for the duration of the - * body, so the writer (which holds it LW_EXCLUSIVE) cannot mutate fp->hash - * underneath the reader; the body therefore runs exactly once. It must - * only write caller-provided output locals and must NOT return/goto/elog out - * of the block, or the shared lock would leak. - */ -#define SLOG_SEQ_READ_BEGIN(fp) \ - do { \ - LWLockAcquire(&(fp)->writer_lock.lock, LW_SHARED); - -#define SLOG_SEQ_READ_END(fp) \ - LWLockRelease(&(fp)->writer_lock.lock); \ - } while (0) - -/* - * Compute the shared memory size needed for the flat hash data - * (the single copy guarded by the writer_lock). - */ -extern Size SLogFlatHashDataSize(int capacity); - -/* - * Compute the total shared memory needed for one partition's flat hash - * (the single copy; the writer_lock is embedded in - * SLogFlatPartition). - */ -extern Size SLogFlatHashShmemSize(int capacity, int max_backends); - -/* - * Compute the total shared memory needed for all partitions. - */ -extern Size SLogFlatHashPartitionedShmemSize(int total_capacity, - int max_backends); - -/* - * Initialize the flat hash in a pre-allocated data block. - * Called during SLogShmemInit to set up the single copy. - */ -extern void SLogFlatHashInit(void *data, int capacity); - -/* - * Apply one SLogFlatOp to the (single) flat-hash copy. Called by writers - * holding the partition writer_lock LW_EXCLUSIVE. - */ -extern void SLogFlatHashApply(void *data, const void *operation, Size op_size); - -/* - * Hash computation for SLogTupleKey. - */ -extern uint32 SLogFlatHashComputeHash(const SLogTupleKey *key); - -/* - * Runtime partition count; set during SLogShmemInit() based on - * the slog_num_partitions GUC. Declared in slog.c. - */ -extern int SLogNumPartitions; - -/* - * Compute which partition a key belongs to. - */ -static inline int -SLogFlatHashPartitionIndex(const SLogTupleKey *key) -{ - return (int) (SLogFlatHashComputeHash(key) % (uint32) SLogNumPartitions); -} - -/* - * Probe the flat hash for a key. Returns pointer to the bucket if found, - * NULL if not found. Only valid during a read-side or write-side critical - * section. - */ -extern SLogFlatBucket *SLogFlatHashProbe(const SLogFlatHash *ht, - const SLogTupleKey *key); - -/* - * Return true iff the entry for key holds an in-use op for xid. Detects a - * silently-dropped op (per-TID array full) where the bucket itself exists. - * Only valid during a read-side or write-side critical section. - */ -extern bool SLogFlatHashHasOpForXid(const SLogFlatHash *ht, - const SLogTupleKey *key, - TransactionId xid); - -/* - * Find a bucket for insertion (first empty or tombstone slot on probe chain). - * Returns NULL if the table is full (all slots on probe chain occupied). - * Only valid during write-side critical section. - */ -extern SLogFlatBucket *SLogFlatHashProbeForInsert(SLogFlatHash *ht, - const SLogTupleKey *key, - uint32 hash_val); - -/* - * Scan API for iterating all occupied buckets. - * - * Used by global-scan operations (eviction, xid removal, cleanup) that - * need to visit every entry. The scan iterates linearly over the bucket - * array, skipping EMPTY and TOMBSTONE slots. - * - * Usage pattern: - * SLogFlatHashScanState state; - * const SLogFlatBucket *bucket; - * - * SLogFlatHashScanInit(&state); - * while ((bucket = SLogFlatHashScanNext(ht, &state)) != NULL) - * { - * // process bucket->entry - * } - * - * The scan must be performed inside a read-side critical section - * (SLOG_SEQ_READ_BEGIN/END, which take writer_lock LW_SHARED, read into - * locals only) or by a writer holding writer_lock LW_EXCLUSIVE. For write - * operations, scan fp->hash under the writer lock - * (stable) to collect keys, then apply write ops. - */ -typedef struct SLogFlatHashScanState -{ - int32 current_index; -} SLogFlatHashScanState; - -extern void SLogFlatHashScanInit(SLogFlatHashScanState *state); -extern const SLogFlatBucket *SLogFlatHashScanNext(const SLogFlatHash *ht, - SLogFlatHashScanState *state); - -#endif /* SLOG_FLATHASH_H */ diff --git a/src/include/access/slog_internal.h b/src/include/access/slog_internal.h index 74d08db3b7a05..fa62c46ea46cc 100644 --- a/src/include/access/slog_internal.h +++ b/src/include/access/slog_internal.h @@ -3,12 +3,8 @@ * slog_internal.h * Shared-state definitions private to the sLog implementation * - * The sLog is split across two translation units that share one - * shared-memory segment: slog.c (the always-present transaction Aborted - * Transaction Map) and slog_tuple.c (the optional per-tuple flat-hash - * tracking extension). This header exposes the shared state struct and the - * few globals both files touch, so it is deliberately NOT part of the public - * sLog API in access/slog.h. Only the two sLog .c files include it. + * The transaction sLog's shared state (slog.c). Deliberately not part of + * the public sLog API in access/slog.h. * * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group * Portions Copyright (c) 1994, Regents of the University of California @@ -20,49 +16,23 @@ #ifndef SLOG_INTERNAL_H #define SLOG_INTERNAL_H -#include "access/slog_flathash.h" #include "storage/lwlock.h" #include "utils/dsa.h" /* - * Initial size for the sLog DSA area (backs the aborted-txn radix tree). - * Grows dynamically as needed up to slog_dsa_max_size_mb. - */ -#define SLOG_DSA_INIT_SIZE (512 * 1024) /* 512 KB */ - -/* - * Shared state for the whole sLog subsystem. - * - * The transaction ATM fields are always used. The tuple flat-hash fields - * (tuple_partitions, num_partitions) are used only when the tuple sLog - * extension is compiled in and an access method has opted in; they are - * initialized unconditionally in SLogShmemInit() so the two files can share - * one segment without an init-ordering dependency. + * Fixed shared state of the transaction sLog: only its lock and where its + * tree is. The tree lives in a DSA area that the first transaction to write + * UNDO creates. */ typedef struct SLogSharedState { - /* Transaction ATM (adaptive radix tree in the DSA area below) */ - dsa_pointer atm_handle; /* RT handle; InvalidDsaPointer until init */ LWLockPadded txn_lock; /* single LWLock serializing ATM access */ - - /* - * Tuple flat hash: N-way partitioned for reduced writer contention. - * Partition count determined at startup by slog_num_partitions GUC. - */ - SLogFlatPartition *tuple_partitions; /* palloc'd array in shmem */ - int num_partitions; /* actual partition count */ - - /* DSA area backing the aborted-txn radix tree */ - dsa_area *dsa_area; /* set during SLogShmemInit, NULL until then */ - char dsa_space[SLOG_DSA_INIT_SIZE]; + dsa_handle area; /* the ATM's DSA area, or DSA_HANDLE_INVALID */ + dsa_pointer atm_handle; /* the ATM radix tree in it, or + * InvalidDsaPointer */ } SLogSharedState; /* The single shared-state instance (defined in slog.c). */ extern SLogSharedState *SLogState; -/* Tuple-hash shmem sizing/init helpers (defined in slog_tuple.c). */ -extern Size SLogTupleShmemSize(void); -extern void SLogTupleShmemRequest(void); -extern void SLogTupleShmemInit(void); - #endif /* SLOG_INTERNAL_H */ diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h index 0929c3dddaa35..1eccb91351d20 100644 --- a/src/include/access/tableam.h +++ b/src/include/access/tableam.h @@ -625,8 +625,7 @@ typedef struct TableAmRoutine bool *all_dead); /* - * Optional, for am_index_delete_marking AMs; see - * table_index_fetch_tid(). + * Optional, for am_index_delete_marking AMs; see table_index_fetch_tid(). */ bool (*index_fetch_tid) (Relation rel, Relation irel, IndexTuple itup, ItemPointer tid, diff --git a/src/include/access/twophase_rmgr.h b/src/include/access/twophase_rmgr.h index 7cde672320487..96f03a054e5d0 100644 --- a/src/include/access/twophase_rmgr.h +++ b/src/include/access/twophase_rmgr.h @@ -30,7 +30,8 @@ typedef uint8 TwoPhaseRmgrId; #define TWOPHASE_RM_PREDICATELOCK_ID 4 #define TWOPHASE_RM_FILEOPS_ID 5 #define TWOPHASE_RM_PERBACKEND_ID 6 -#define TWOPHASE_RM_MAX_ID TWOPHASE_RM_PERBACKEND_ID +#define TWOPHASE_RM_FLUX_ID 7 +#define TWOPHASE_RM_MAX_ID TWOPHASE_RM_FLUX_ID extern PGDLLIMPORT const TwoPhaseCallback twophase_recover_callbacks[]; extern PGDLLIMPORT const TwoPhaseCallback twophase_postcommit_callbacks[]; diff --git a/src/include/access/undormgrlist.h b/src/include/access/undormgrlist.h index 31a47d9cf850e..d506def55945e 100644 --- a/src/include/access/undormgrlist.h +++ b/src/include/access/undormgrlist.h @@ -36,5 +36,8 @@ UNDO_RMGR_INIT(NbtreeUndoRmgrInit) UNDO_RMGR_INIT(HashUndoRmgrInit) +/* FLUX: UNDO-based heap-replacement table AM UNDO resource manager */ +UNDO_RMGR_INIT(FluxUndoRmgrInit) + /* FILEOPS: transactional filesystem operations UNDO rmgr (UNDO_RMID_FILEOPS=2) */ UNDO_RMGR_INIT(FileopsUndoRmgrInit) diff --git a/src/include/access/xact.h b/src/include/access/xact.h index 2187018c71993..c18a808a596e1 100644 --- a/src/include/access/xact.h +++ b/src/include/access/xact.h @@ -399,8 +399,8 @@ typedef struct xl_xact_prepare /* * Oldest batch of the permanent-level chain. WAL from here on is - * retained while the transaction stays prepared, and handed to the ATM - * by ROLLBACK PREPARED, because the revert worker reads the whole chain. + * retained while the transaction stays prepared, and handed to the ATM by + * ROLLBACK PREPARED, because the revert worker reads the whole chain. */ XLogRecPtr undo_first_batch_lsn; } xl_xact_prepare; @@ -476,7 +476,7 @@ typedef struct xl_xact_parsed_abort XLogRecPtr origin_lsn; TimestampTz origin_timestamp; - uint32 fileops_len; + uint32 fileops_len; const char *fileops; } xl_xact_parsed_abort; @@ -589,6 +589,7 @@ extern bool IsInParallelMode(void); /* UNDO chain management */ extern void SetCurrentTransactionUndoRecPtr(uint64 undo_ptr); extern uint64 GetCurrentTransactionUndoRecPtr(void); + /* * Number of per-backend UNDO persistence levels (permanent, unlogged, temp); * must match UndoPersistenceLevels in access/perbackend/pbu_undolog.h, which diff --git a/src/include/catalog/pg_am.dat b/src/include/catalog/pg_am.dat index 46d361047fe67..708786dce6119 100644 --- a/src/include/catalog/pg_am.dat +++ b/src/include/catalog/pg_am.dat @@ -33,5 +33,8 @@ { oid => '3580', oid_symbol => 'BRIN_AM_OID', descr => 'block range index (BRIN) access method', amname => 'brin', amhandler => 'brinhandler', amtype => 'i' }, +{ oid => '9316', oid_symbol => 'FLUX_TABLE_AM_OID', + descr => 'flux table access method', + amname => 'flux', amhandler => 'flux_tableam_handler', amtype => 't' }, ] diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index 567adf7ad979e..e6b9545999280 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -912,6 +912,10 @@ proname => 'heap_tableam_handler', provolatile => 'v', prorettype => 'table_am_handler', proargtypes => 'internal', prosrc => 'heap_tableam_handler' }, +{ oid => '9402', descr => 'flux table access method handler', + proname => 'flux_tableam_handler', provolatile => 'v', + prorettype => 'table_am_handler', proargtypes => 'internal', + prosrc => 'flux_tableam_handler' }, # Index access method handlers { oid => '330', descr => 'btree index access method handler', diff --git a/src/include/storage/fileops.h b/src/include/storage/fileops.h index 41f757a9ce960..2f1608f3a186d 100644 --- a/src/include/storage/fileops.h +++ b/src/include/storage/fileops.h @@ -410,7 +410,7 @@ typedef struct FileopsUndoWrite uint32 before_len; uint32 after_len; uint16 path_len; -} FileopsUndoWrite; +} FileopsUndoWrite; /* TRUNCATE undo payload: path, followed by the bytes removed by a shrink. */ typedef struct FileopsUndoTruncate diff --git a/src/include/storage/subsystemlist.h b/src/include/storage/subsystemlist.h index f9c2ce9f05c60..538d38490735d 100644 --- a/src/include/storage/subsystemlist.h +++ b/src/include/storage/subsystemlist.h @@ -100,3 +100,6 @@ PG_SHMEM_SUBSYSTEM(UndoShmemCallbacks) * any consumer. See slog.c's SLogShmemCallbacks for details. */ PG_SHMEM_SUBSYSTEM(SLogShmemCallbacks) + +/* FLUX table access method subsystems */ +PG_SHMEM_SUBSYSTEM(FluxMvccShmemCallbacks) diff --git a/src/test/isolation/expected/flux_cluster_sort_snapshot.out b/src/test/isolation/expected/flux_cluster_sort_snapshot.out new file mode 100644 index 0000000000000..42ab04ae1cafc --- /dev/null +++ b/src/test/isolation/expected/flux_cluster_sort_snapshot.out @@ -0,0 +1,311 @@ +Parsed test spec with 2 sessions + +starting permutation: s1b s2d s2cle s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2d: DELETE FROM cs WHERE k > 10; +step s2cle: CLUSTER cs USING cs_e; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 10| 1| 10| 55 +(1 row) + + +starting permutation: s1b s2d s2clb s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2d: DELETE FROM cs WHERE k > 10; +step s2clb: CLUSTER cs USING cs_b; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 10| 1| 10| 55 +(1 row) + + +starting permutation: s1b s2d s2clg s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2d: DELETE FROM cs WHERE k > 10; +step s2clg: CLUSTER cs USING cs_g; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 10| 1| 10| 55 +(1 row) + + +starting permutation: s1b s2d s2vf s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2d: DELETE FROM cs WHERE k > 10; +step s2vf: VACUUM FULL cs; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 10| 1| 10| 55 +(1 row) + + +starting permutation: s1b s2u s2cle s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2u: UPDATE cs SET v = v + 100; +step s2cle: CLUSTER cs USING cs_e; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max| sum +-----+---+---+---- + 20| 1| 20|2210 +(1 row) + + +starting permutation: s1b s2u s2u s2clg s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2u: UPDATE cs SET v = v + 100; +step s2u: UPDATE cs SET v = v + 100; +step s2clg: CLUSTER cs USING cs_g; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max| sum +-----+---+---+---- + 20| 1| 20|4210 +(1 row) + + +starting permutation: s1b s2u s2d s2vf s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2u: UPDATE cs SET v = v + 100; +step s2d: DELETE FROM cs WHERE k > 10; +step s2vf: VACUUM FULL cs; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max| sum +-----+---+---+---- + 10| 1| 10|1055 +(1 row) + + +starting permutation: s1b s2i s2u s2vff s1r s1ri s2flush s2v s2dead s1c s2v s2dead s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2i: INSERT INTO cs VALUES (21, 21, point(21, 21)); +step s2u: UPDATE cs SET v = v + 100; +step s2vff: VACUUM (FULL, FREEZE) cs; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s2flush: SELECT pg_stat_force_next_flush(); +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s2v: VACUUM cs; +step s2dead: SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'cs'; +n_dead_tup +---------- + 21 +(1 row) + +step s1c: COMMIT; +step s2v: VACUUM cs; +step s2dead: SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'cs'; +n_dead_tup +---------- + 0 +(1 row) + +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max| sum +-----+---+---+---- + 21| 1| 21|2331 +(1 row) + + +starting permutation: s1b s2d s2u s1r s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2d: DELETE FROM cs WHERE k > 10; +step s2u: UPDATE cs SET v = v + 100; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max| sum +-----+---+---+---- + 10| 1| 10|1055 +(1 row) + diff --git a/src/test/isolation/expected/flux_conflict.out b/src/test/isolation/expected/flux_conflict.out new file mode 100644 index 0000000000000..e72ba38c9909b --- /dev/null +++ b/src/test/isolation/expected/flux_conflict.out @@ -0,0 +1,42 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_upd1 s2_upd1 s1_commit s2_commit +step s1_upd1: UPDATE flux_cc SET v = 1 WHERE id = 1; +step s2_upd1: UPDATE flux_cc SET v = 2 WHERE id = 1; +step s1_commit: COMMIT; +step s2_upd1: <... completed> +step s2_commit: COMMIT; + +starting permutation: s1_upd1 s2_upd2 s1_commit s2_commit +step s1_upd1: UPDATE flux_cc SET v = 1 WHERE id = 1; +step s2_upd2: UPDATE flux_cc SET v = 2 WHERE id = 2; +step s1_commit: COMMIT; +step s2_commit: COMMIT; + +starting permutation: s1_del1 s2_upd1 s1_commit s2_commit +step s1_del1: DELETE FROM flux_cc WHERE id = 1; +step s2_upd1: UPDATE flux_cc SET v = 2 WHERE id = 1; +step s1_commit: COMMIT; +step s2_upd1: <... completed> +step s2_commit: COMMIT; + +starting permutation: s1_del1 s2_upd1 s1_abort s2_commit +step s1_del1: DELETE FROM flux_cc WHERE id = 1; +step s2_upd1: UPDATE flux_cc SET v = 2 WHERE id = 1; +step s1_abort: ROLLBACK; +step s2_upd1: <... completed> +step s2_commit: COMMIT; + +starting permutation: s1_upd1 s2_upd1 s1_abort s2_commit +step s1_upd1: UPDATE flux_cc SET v = 1 WHERE id = 1; +step s2_upd1: UPDATE flux_cc SET v = 2 WHERE id = 1; +step s1_abort: ROLLBACK; +step s2_upd1: <... completed> +step s2_commit: COMMIT; + +starting permutation: s1_upd1 s2_del1 s1_commit s2_commit +step s1_upd1: UPDATE flux_cc SET v = 1 WHERE id = 1; +step s2_del1: DELETE FROM flux_cc WHERE id = 1; +step s1_commit: COMMIT; +step s2_del1: <... completed> +step s2_commit: COMMIT; diff --git a/src/test/isolation/expected/flux_delete_conflict.out b/src/test/isolation/expected/flux_delete_conflict.out new file mode 100644 index 0000000000000..eb5fa3f819b6f --- /dev/null +++ b/src/test/isolation/expected/flux_delete_conflict.out @@ -0,0 +1,214 @@ +Parsed test spec with 2 sessions + +starting permutation: b1rc b2rc d1 d2 c1 c2 sel +step b1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step b2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step d1: DELETE FROM d WHERE id = 1; +step d2: DELETE FROM d WHERE id = 1 RETURNING v; +step c1: COMMIT; +step d2: <... completed> +v +- +(0 rows) + +step c2: COMMIT; +step sel: SELECT id, v FROM d ORDER BY id; +id| v +--+-- + 2|20 +(1 row) + + +starting permutation: b1rc b2rc d1 d2 a1 c2 sel +step b1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step b2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step d1: DELETE FROM d WHERE id = 1; +step d2: DELETE FROM d WHERE id = 1 RETURNING v; +step a1: ROLLBACK; +step d2: <... completed> + v +-- +10 +(1 row) + +step c2: COMMIT; +step sel: SELECT id, v FROM d ORDER BY id; +id| v +--+-- + 2|20 +(1 row) + + +starting permutation: b1rc b2rc u1 d2 c1 c2 sel +step b1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step b2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step u1: UPDATE d SET v = v + 1 WHERE id = 1; +step d2: DELETE FROM d WHERE id = 1 RETURNING v; +step c1: COMMIT; +step d2: <... completed> + v +-- +11 +(1 row) + +step c2: COMMIT; +step sel: SELECT id, v FROM d ORDER BY id; +id| v +--+-- + 2|20 +(1 row) + + +starting permutation: b1rc b2rc d1 u2 c1 c2 sel +step b1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step b2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step d1: DELETE FROM d WHERE id = 1; +step u2: UPDATE d SET v = v + 100 WHERE id = 1 RETURNING v; +step c1: COMMIT; +step u2: <... completed> +v +- +(0 rows) + +step c2: COMMIT; +step sel: SELECT id, v FROM d ORDER BY id; +id| v +--+-- + 2|20 +(1 row) + + +starting permutation: b1rc b2rc d1 u2 a1 c2 sel +step b1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step b2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step d1: DELETE FROM d WHERE id = 1; +step u2: UPDATE d SET v = v + 100 WHERE id = 1 RETURNING v; +step a1: ROLLBACK; +step u2: <... completed> + v +--- +110 +(1 row) + +step c2: COMMIT; +step sel: SELECT id, v FROM d ORDER BY id; +id| v +--+--- + 1|110 + 2| 20 +(2 rows) + + +starting permutation: b1rr b2rr snap2 d1 d2 c1 c2 sel +step b1rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step b2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap2: SELECT count(*) FROM d; +count +----- + 2 +(1 row) + +step d1: DELETE FROM d WHERE id = 1; +step d2: DELETE FROM d WHERE id = 1 RETURNING v; +step c1: COMMIT; +step d2: <... completed> +ERROR: could not serialize access due to concurrent delete +step c2: COMMIT; +step sel: SELECT id, v FROM d ORDER BY id; +id| v +--+-- + 2|20 +(1 row) + + +starting permutation: b1rr b2rr snap2 d1 d2 a1 c2 sel +step b1rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step b2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap2: SELECT count(*) FROM d; +count +----- + 2 +(1 row) + +step d1: DELETE FROM d WHERE id = 1; +step d2: DELETE FROM d WHERE id = 1 RETURNING v; +step a1: ROLLBACK; +step d2: <... completed> + v +-- +10 +(1 row) + +step c2: COMMIT; +step sel: SELECT id, v FROM d ORDER BY id; +id| v +--+-- + 2|20 +(1 row) + + +starting permutation: b1rr b2rr snap2 u1 d2 c1 c2 sel +step b1rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step b2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap2: SELECT count(*) FROM d; +count +----- + 2 +(1 row) + +step u1: UPDATE d SET v = v + 1 WHERE id = 1; +step d2: DELETE FROM d WHERE id = 1 RETURNING v; +step c1: COMMIT; +step d2: <... completed> +ERROR: could not serialize access due to concurrent update +step c2: COMMIT; +step sel: SELECT id, v FROM d ORDER BY id; +id| v +--+-- + 1|11 + 2|20 +(2 rows) + + +starting permutation: b1rr b2rr snap2 d1 u2 c1 c2 sel +step b1rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step b2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap2: SELECT count(*) FROM d; +count +----- + 2 +(1 row) + +step d1: DELETE FROM d WHERE id = 1; +step u2: UPDATE d SET v = v + 100 WHERE id = 1 RETURNING v; +step c1: COMMIT; +step u2: <... completed> +ERROR: could not serialize access due to concurrent delete +step c2: COMMIT; +step sel: SELECT id, v FROM d ORDER BY id; +id| v +--+-- + 2|20 +(1 row) + + +starting permutation: b1rr b2rr snap2 d1 c1 d2 c2 sel +step b1rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step b2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap2: SELECT count(*) FROM d; +count +----- + 2 +(1 row) + +step d1: DELETE FROM d WHERE id = 1; +step c1: COMMIT; +step d2: DELETE FROM d WHERE id = 1 RETURNING v; +ERROR: could not serialize access due to concurrent delete +step c2: COMMIT; +step sel: SELECT id, v FROM d ORDER BY id; +id| v +--+-- + 2|20 +(1 row) + diff --git a/src/test/isolation/expected/flux_fk_crosscheck.out b/src/test/isolation/expected/flux_fk_crosscheck.out new file mode 100644 index 0000000000000..ee365c657fcb9 --- /dev/null +++ b/src/test/isolation/expected/flux_fk_crosscheck.out @@ -0,0 +1,81 @@ +Parsed test spec with 2 sessions + +starting permutation: s1brr s1ic s2brr s2d s1c s2c s1sel +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1ic: INSERT INTO fkc VALUES (2); +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2d: DELETE FROM pk WHERE a = 2; +step s1c: COMMIT; +step s2d: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s1sel: SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; +t |a +---+- +fkc|2 +pk |2 +(2 rows) + + +starting permutation: s1brr s1in s2brr s2d s1c s2c s1sel +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1in: INSERT INTO fkn VALUES (2); +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2d: DELETE FROM pk WHERE a = 2; +step s1c: COMMIT; +step s2d: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s1sel: SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; +t |a +---+- +fkn|2 +pk |2 +(2 rows) + + +starting permutation: s1brr s1ic s2brr s2u s1c s2c s1sel +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1ic: INSERT INTO fkc VALUES (2); +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2u: UPDATE pk SET a = 3 WHERE a = 2; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s1sel: SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; +t |a +---+- +fkc|2 +pk |2 +(2 rows) + + +starting permutation: s1brr s1ic s2brc s2d s1c s2c s1sel +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1ic: INSERT INTO fkc VALUES (2); +step s2brc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2d: DELETE FROM pk WHERE a = 2; +step s1c: COMMIT; +step s2d: <... completed> +step s2c: COMMIT; +step s1sel: SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; +t|a +-+- +(0 rows) + + +starting permutation: s1brr s1in s2brc s2d s1c s2c s1sel +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1in: INSERT INTO fkn VALUES (2); +step s2brc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2d: DELETE FROM pk WHERE a = 2; +step s1c: COMMIT; +step s2d: <... completed> +step s2c: COMMIT; +step s1sel: SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; +t |a +---+- +fkn| +(1 row) + diff --git a/src/test/isolation/expected/flux_index_aba.out b/src/test/isolation/expected/flux_index_aba.out new file mode 100644 index 0000000000000..97f122b947ed8 --- /dev/null +++ b/src/test/isolation/expected/flux_index_aba.out @@ -0,0 +1,104 @@ +Parsed test spec with 2 sessions + +starting permutation: s1b s2c s2a s1a s1c s1e s2r +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT k FROM fx WHERE id = 12; + k +-- +12 +(1 row) + +step s2c: UPDATE fx SET k = 3000012 WHERE id = 12; UPDATE hp SET k = 3000012 WHERE id = 12; +step s2a: BEGIN; UPDATE fx SET k = 12 WHERE id = 12; UPDATE hp SET k = 12 WHERE id = 12; ROLLBACK; +step s1a: SELECT * FROM aba_rows(12); +via |id| k +------+--+-- +fx-idx|12|12 +hp-idx|12|12 +fx-seq|12|12 +hp-seq|12|12 +(4 rows) + +step s1c: SELECT * FROM aba_rows(3000012); +via|id|k +---+--+- +(0 rows) + +step s1e: COMMIT; +step s2r: SELECT * FROM aba_rows(12) UNION ALL SELECT * FROM aba_rows(3000012); +via |id| k +------+--+------- +fx-idx|12|3000012 +hp-idx|12|3000012 +fx-seq|12|3000012 +hp-seq|12|3000012 +(4 rows) + + +starting permutation: s1b s2c s2x s1a s1c s1e s2r +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT k FROM fx WHERE id = 12; + k +-- +12 +(1 row) + +step s2c: UPDATE fx SET k = 3000012 WHERE id = 12; UPDATE hp SET k = 3000012 WHERE id = 12; +step s2x: BEGIN; + UPDATE fx SET k = 12 WHERE id = 12; UPDATE hp SET k = 12 WHERE id = 12; + UPDATE fx SET k = 3000012 WHERE id = 12; UPDATE hp SET k = 3000012 WHERE id = 12; + UPDATE fx SET k = 12 WHERE id = 12; UPDATE hp SET k = 12 WHERE id = 12; + ROLLBACK; +step s1a: SELECT * FROM aba_rows(12); +via |id| k +------+--+-- +fx-idx|12|12 +hp-idx|12|12 +fx-seq|12|12 +hp-seq|12|12 +(4 rows) + +step s1c: SELECT * FROM aba_rows(3000012); +via|id|k +---+--+- +(0 rows) + +step s1e: COMMIT; +step s2r: SELECT * FROM aba_rows(12) UNION ALL SELECT * FROM aba_rows(3000012); +via |id| k +------+--+------- +fx-idx|12|3000012 +hp-idx|12|3000012 +fx-seq|12|3000012 +hp-seq|12|3000012 +(4 rows) + + +starting permutation: s1b s2b s1a s1e s2r +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT k FROM fx WHERE id = 12; + k +-- +12 +(1 row) + +step s2b: BEGIN; + UPDATE fx SET k = 4000012 WHERE id = 12; UPDATE hp SET k = 4000012 WHERE id = 12; + UPDATE fx SET k = 12 WHERE id = 12; UPDATE hp SET k = 12 WHERE id = 12; + ROLLBACK; +step s1a: SELECT * FROM aba_rows(12); +via |id| k +------+--+-- +fx-idx|12|12 +hp-idx|12|12 +fx-seq|12|12 +hp-seq|12|12 +(4 rows) + +step s1e: COMMIT; +step s2r: SELECT * FROM aba_rows(12) UNION ALL SELECT * FROM aba_rows(3000012); +via |id| k +------+--+-- +fx-idx|12|12 +hp-idx|12|12 +fx-seq|12|12 +hp-seq|12|12 +(4 rows) + diff --git a/src/test/isolation/expected/flux_inplace.out b/src/test/isolation/expected/flux_inplace.out new file mode 100644 index 0000000000000..a9916f99eaf6d --- /dev/null +++ b/src/test/isolation/expected/flux_inplace.out @@ -0,0 +1,34 @@ +Parsed test spec with 3 sessions + +starting permutation: w_update o_old_key o_new_key n_new_key n_old_key o_commit + k|v +---+- +100|a +(1 row) + +step w_update: UPDATE flux_ip SET k = 200, v = 'b' WHERE id = 1; +step o_old_key: SELECT k, v FROM flux_ip WHERE k = 100; + k|v +---+- +100|a +(1 row) + +step o_new_key: SELECT count(*) AS n FROM flux_ip WHERE k = 200; +n +- +0 +(1 row) + +step n_new_key: SELECT k, v FROM flux_ip WHERE k = 200; + k|v +---+- +200|b +(1 row) + +step n_old_key: SELECT count(*) AS n FROM flux_ip WHERE k = 100; +n +- +0 +(1 row) + +step o_commit: COMMIT; diff --git a/src/test/isolation/expected/flux_lock_delete_lockers.out b/src/test/isolation/expected/flux_lock_delete_lockers.out new file mode 100644 index 0000000000000..19ca57d28421b --- /dev/null +++ b/src/test/isolation/expected/flux_lock_delete_lockers.out @@ -0,0 +1,51 @@ +Parsed test spec with 3 sessions + +starting permutation: s1b s1l s2b s2l s3d s1r s2c s3chk +step s1b: BEGIN; +step s1l: INSERT INTO child VALUES (1); +step s2b: BEGIN; +step s2l: INSERT INTO child VALUES (1); +step s3d: DELETE FROM parent; +step s1r: ROLLBACK; +step s2c: COMMIT; +step s3d: <... completed> +ERROR: update or delete on table "parent" violates foreign key constraint "child_i_fkey" on table "child" +step s3chk: SELECT (SELECT count(*) FROM parent) AS parents, (SELECT count(*) FROM child) AS children; +parents|children +-------+-------- + 1| 1 +(1 row) + + +starting permutation: s1b s1l s2b s2l s3d s2r s1c s3chk +step s1b: BEGIN; +step s1l: INSERT INTO child VALUES (1); +step s2b: BEGIN; +step s2l: INSERT INTO child VALUES (1); +step s3d: DELETE FROM parent; +step s2r: ROLLBACK; +step s1c: COMMIT; +step s3d: <... completed> +ERROR: update or delete on table "parent" violates foreign key constraint "child_i_fkey" on table "child" +step s3chk: SELECT (SELECT count(*) FROM parent) AS parents, (SELECT count(*) FROM child) AS children; +parents|children +-------+-------- + 1| 1 +(1 row) + + +starting permutation: s1b s1l s2b s2l s3d s1r s2r s3chk +step s1b: BEGIN; +step s1l: INSERT INTO child VALUES (1); +step s2b: BEGIN; +step s2l: INSERT INTO child VALUES (1); +step s3d: DELETE FROM parent; +step s1r: ROLLBACK; +step s2r: ROLLBACK; +step s3d: <... completed> +step s3chk: SELECT (SELECT count(*) FROM parent) AS parents, (SELECT count(*) FROM child) AS children; +parents|children +-------+-------- + 0| 0 +(1 row) + diff --git a/src/test/isolation/expected/flux_lock_recheck.out b/src/test/isolation/expected/flux_lock_recheck.out new file mode 100644 index 0000000000000..75ea7061b4b0c --- /dev/null +++ b/src/test/isolation/expected/flux_lock_recheck.out @@ -0,0 +1,2626 @@ +Parsed test spec with 5 sessions + +starting permutation: s1b s1u s2rc s2fu s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1c: COMMIT; +step s2fu: <... completed> +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2fnk s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fnk: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR NO KEY UPDATE; +step s1c: COMMIT; +step s2fnk: <... completed> +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2fs s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fs: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR SHARE; +step s1c: COMMIT; +step s2fs: <... completed> +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2fks s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1c: COMMIT; +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1k s2rc s2fks s1c s2c s3r +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +step s1c: COMMIT; +step s2fks: <... completed> +id|v|w +--+-+- +10|1|0 + 2|1|0 +(2 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2sl s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2sl: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE SKIP LOCKED; +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2nw s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2nw: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE NOWAIT; +ERROR: could not obtain lock on row in relation "lr" +step s1c: COMMIT; +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2upd s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2upd: UPDATE lr SET w = w + 1 WHERE v = 1 RETURNING *; +step s1c: COMMIT; +step s2upd: <... completed> +id|v|w +--+-+- + 2|1|1 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|1 +(2 rows) + + +starting permutation: s1b s1d s2rc s2fu s1c s2c s3r +step s1b: BEGIN; +step s1d: DELETE FROM lr WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1c: COMMIT; +step s2fu: <... completed> +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +(1 row) + + +starting permutation: s1b s1u s2rc s2fu s1a s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1a: ROLLBACK; +step s2fu: <... completed> +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2upd s1a s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2upd: UPDATE lr SET w = w + 1 WHERE v = 1 RETURNING *; +step s1a: ROLLBACK; +step s2upd: <... completed> +id|v|w +--+-+- + 1|1|1 + 2|1|1 +(2 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|1 + 2|1|1 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s2fu s1c s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1c: COMMIT; +step s2fu: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2fu s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2fnk s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2fnk: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2fs s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2fs: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2sl s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2sl: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE SKIP LOCKED; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2nw s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2nw: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE NOWAIT; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2upd s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2upd: UPDATE lr SET w = w + 1 WHERE v = 1 RETURNING *; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2fks s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1k s1c s2fks s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s1c: COMMIT; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s2fu s1a s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1a: ROLLBACK; +step s2fu: <... completed> +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1d s2fu s1c s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1d: DELETE FROM lr WHERE id = 1; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1c: COMMIT; +step s2fu: <... completed> +ERROR: could not serialize access due to concurrent delete +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +(1 row) + + +starting permutation: s1b s1pk s2rc s2ci s1c s2c s3c +step s1b: BEGIN; +step s1pk: UPDATE lrp SET id = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1c: COMMIT; +step s2ci: <... completed> +ERROR: insert or update on table "lrc" violates foreign key constraint "lrc_pid_fkey" +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s1b s1pfu s2rc s2ci s1pn s1c s2c s3c +step s1b: BEGIN; +step s1pfu: SELECT id FROM lrp WHERE id = 1 FOR UPDATE; +id +-- + 1 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1pn: UPDATE lrp SET note = 1 WHERE id = 1; +step s1c: COMMIT; +step s2ci: <... completed> +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s1b s1pk s2rc s2ci s1a s2c s3c +step s1b: BEGIN; +step s1pk: UPDATE lrp SET id = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1a: ROLLBACK; +step s2ci: <... completed> +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s1b s1tu s2rc s2tu s1c s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2tu: UPDATE lrt SET w = w + 1 WHERE v = 1 RETURNING *; +step s1c: COMMIT; +step s2tu: <... completed> +id|v|w +--+-+- +(0 rows) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|0 +(1 row) + + +starting permutation: s1b s1tu s2rc s2tu s1a s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2tu: UPDATE lrt SET w = w + 1 WHERE v = 1 RETURNING *; +step s1a: ROLLBACK; +s2: NOTICE: lrt_bu: OLD.v=1 NEW.v=1 +step s2tu: <... completed> +id|v|w +--+-+- + 1|1|1 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|1|1 +(1 row) + + +starting permutation: s2rr s2snap s1b s1tu s2tu s1c s2c s3t +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2tu: UPDATE lrt SET w = w + 1 WHERE v = 1 RETURNING *; +step s1c: COMMIT; +step s2tu: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|0 +(1 row) + + +starting permutation: s1b s1u s2rc s2jfu s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2jfu: SELECT lr.* FROM lr JOIN (VALUES (1), (1), (2)) s(id) USING (id) WHERE lr.v = 1 ORDER BY lr.id FOR UPDATE OF lr; +step s1c: COMMIT; +step s2jfu: <... completed> +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1tu s2rc s2mq s1c s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mq: MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id AND lrt.v = 1 WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; +step s1c: COMMIT; +step s2mq: <... completed> +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|0 +(1 row) + + +starting permutation: s1b s1tu s2rc s2mu s1c s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mu: MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; +step s1c: COMMIT; +s2: NOTICE: lrt_bu: OLD.v=2 NEW.v=2 +step s2mu: <... completed> +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|1 +(1 row) + + +starting permutation: s2rr s2snap s1b s1tu s2mu s1c s2c s3t +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2mu: MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; +step s1c: COMMIT; +step s2mu: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|0 +(1 row) + + +starting permutation: s1b s1u s2rc s2oc s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1c: COMMIT; +step s2oc: <... completed> +id|v|w +--+-+- + 1|2|1 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|1 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s2oc s1c s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1c: COMMIT; +step s2oc: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2oc s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s4pkk s4pn101 s2pks s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s4pn101 s2ci s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2ci: INSERT INTO lrc VALUES (1, 1); +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s2rr s2snap s4pkk s4pn101 s2pnk s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rc s2dks s4pkk s4pn101 s2fc s2ci s2c s3c +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dks: DECLARE c CURSOR FOR SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2fc: FETCH ALL FROM c; +id|note +--+---- +(0 rows) + +step s2ci: INSERT INTO lrc VALUES (1, 1); +ERROR: insert or update on table "lrc" violates foreign key constraint "lrc_pid_fkey" +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s2rc s2dka s4pkk s4pn101 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2fc: FETCH ALL FROM c; + id|note +---+---- +101| 11 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rc s2dnk s4pkk s4pn101 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dnk: DECLARE c CURSOR FOR SELECT * FROM lrp FOR NO KEY UPDATE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2fc: FETCH ALL FROM c; + id|note +---+---- +101| 11 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rc s4pkk s4pn101 s2ci s2c s3c +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2ci: INSERT INTO lrc VALUES (1, 1); +ERROR: insert or update on table "lrc" violates foreign key constraint "lrc_pid_fkey" +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s2rr s2snap s4pn1 s4pkk s2pks s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rc s2dka s4pn1 s4pkk s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2fc: FETCH ALL FROM c; + id|note +---+---- +101| 11 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s4pn1 s2pks s2ci s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +id|note +--+---- + 1| 0 +(1 row) + +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s2rc s2dka s4pn1 s4pn1 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 0 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 20 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s4pkb s4pn1 s2pks s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pkb: UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 12 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s4pkb s4pn1 s2ci s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pkb: UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2ci: INSERT INTO lrc VALUES (1, 1); +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s2rc s2dka s4pkk s4pkb s4pn1 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pkb: UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 12 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 12 +(1 row) + + +starting permutation: s2rc s2dks s4pkk s4pkb s4pn1 s2fc s2ci s2c s3c +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dks: DECLARE c CURSOR FOR SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pkb: UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 12 +(1 row) + +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s4pn101 s4pn101 s2pks s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 21 +(1 row) + + +starting permutation: s2rc s2dka s4pkk s4pn101 s4pn101 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2fc: FETCH ALL FROM c; + id|note +---+---- +101| 21 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 21 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s5b s5pn101 s2pks s5c s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s5c: COMMIT; +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 101 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s5b s5pn101 s2ci s5c s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2ci: INSERT INTO lrc VALUES (1, 1); +ERROR: could not serialize access due to concurrent update +step s5c: COMMIT; +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s2rr s2snap s4pkk s5b s5pn101 s2pnk s5c s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s5c: COMMIT; +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 101 +(1 row) + + +starting permutation: s2rc s2dka s4pkk s5b s5pn101 s2fc s5c s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2fc: FETCH ALL FROM c; +step s5c: COMMIT; +step s2fc: <... completed> + id|note +---+---- +101| 101 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 101 +(1 row) + + +starting permutation: s2rc s2dnk s4pkk s5b s5pn101 s2fc s5c s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dnk: DECLARE c CURSOR FOR SELECT * FROM lrp FOR NO KEY UPDATE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2fc: FETCH ALL FROM c; +step s5c: COMMIT; +step s2fc: <... completed> + id|note +---+---- +101| 101 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 101 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s5b s5pkk s2pks s5c s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s5c: COMMIT; +step s2pks: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s5b s5pkk s2pks s5a s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s5a: ROLLBACK; +step s2pks: <... completed> +id|note +--+---- + 1| 0 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 10 +(1 row) + + +starting permutation: s2rc s2dka s4pn1 s5b s5pkk s2fc s5c s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +step s5c: COMMIT; +step s2fc: <... completed> + id|note +---+---- +101| 11 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rc s2dka s4pn1 s5b s5pkk s2fc s5a s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +step s5a: ROLLBACK; +step s2fc: <... completed> +id|note +--+---- + 1| 0 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 10 +(1 row) + + +starting permutation: s5b s5pkk s5pn101 s2rc s2ci s5c s2c s3c +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s5c: COMMIT; +step s2ci: <... completed> +ERROR: insert or update on table "lrc" violates foreign key constraint "lrc_pid_fkey" +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s5b s5pkk s5pn101 s2rc s2ci s5a s2c s3c +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s5a: ROLLBACK; +step s2ci: <... completed> +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s5b s5pkk s5pn101 s2rr s2snap s2pks s5c s2c s3p +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s5c: COMMIT; +step s2pks: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 101 +(1 row) + + +starting permutation: s5b s5pkk s5pn101 s2rr s2snap s2pks s5a s2c s3p +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s5a: ROLLBACK; +step s2pks: <... completed> +id|note +--+---- + 1| 0 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 0 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s5b s5pn1 s2pks s2ci s5c s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pn1: UPDATE lrp SET note = note + 100 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +id|note +--+---- + 1| 0 +(1 row) + +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s5c: COMMIT; +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s2rc s2dka s4pn1 s5b s5pn1 s2fc s5c s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pn1: UPDATE lrp SET note = note + 100 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 0 +(1 row) + +step s5c: COMMIT; +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 110 +(1 row) + + +starting permutation: s4pkk s2rr s2snap s4pn101 s2pks101 s2ci101 s2c s3c +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2pks101: SELECT * FROM lrp WHERE id = 101 FOR KEY SHARE; + id|note +---+---- +101| 1 +(1 row) + +step s2ci101: INSERT INTO lrc VALUES (2, 101); +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 2|101 +(1 row) + + +starting permutation: s4pkk s2rr s2snap s5b s5pn101 s2pks101 s2ci101 s5c s2c s3c +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2pks101: SELECT * FROM lrp WHERE id = 101 FOR KEY SHARE; + id|note +---+---- +101| 1 +(1 row) + +step s2ci101: INSERT INTO lrc VALUES (2, 101); +step s5c: COMMIT; +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 2|101 +(1 row) + + +starting permutation: s2rr s2snap s5b s5pkk s5a s4pn1 s2pks s2ci s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5a: ROLLBACK; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +id|note +--+---- + 1| 0 +(1 row) + +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s2rc s2dka s5b s5pkk s5a s4pn1 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5a: ROLLBACK; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 0 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 10 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s4pn1 s2pnk s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 20 +(1 row) + + +starting permutation: s2rc s2dnk s4pn1 s4pn1 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dnk: DECLARE c CURSOR FOR SELECT * FROM lrp FOR NO KEY UPDATE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 20 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 20 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s5b s5pkk s2pnk s5c s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s5c: COMMIT; +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s5b s5pkk s2pnk s5a s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s5a: ROLLBACK; +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 10 +(1 row) + + +starting permutation: s2rr s2snap s5b s5pkk s5a s4pn1 s2pnk s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5a: ROLLBACK; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 10 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s4pkb s4pn1 s2pnk s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pkb: UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 12 +(1 row) + + +starting permutation: s1b s1tu s2rc s2toc s1c s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +step s1c: COMMIT; +s2: NOTICE: lrt_bu: OLD.v=2 NEW.v=2 +step s2toc: <... completed> +id|v|w +--+-+- + 1|2|1 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|1 +(1 row) + + +starting permutation: s1b s1tu s1tu2 s2rc s2toc s1c s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +s1: NOTICE: lrt_bu: OLD.v=2 NEW.v=3 +step s1tu2: UPDATE lrt SET v = v + 1, w = w + 10 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +step s1c: COMMIT; +s2: NOTICE: lrt_bu: OLD.v=3 NEW.v=3 +step s2toc: <... completed> +id|v| w +--+-+-- + 1|3|11 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v| w +--+-+-- + 1|3|11 +(1 row) + + +starting permutation: s1b s1tu s2rc s2toc s1a s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +step s1a: ROLLBACK; +s2: NOTICE: lrt_bu: OLD.v=1 NEW.v=1 +step s2toc: <... completed> +id|v|w +--+-+- + 1|1|1 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|1|1 +(1 row) + + +starting permutation: s2rr s2snap s1b s1tu s2toc s1c s2c s3t +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +step s1c: COMMIT; +step s2toc: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|0 +(1 row) + + +starting permutation: s1b s1nu s2rc s2noc s1c s2c s3n +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2noc: INSERT INTO lrn VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrn.w + 1 RETURNING *; +step s1c: COMMIT; +s2: NOTICE: lrn_bu UPDATE: OLD=(1,2,100) NEW=(1,2,101) +step s2noc: <... completed> +id|v| w +--+-+--- + 1|2|201 +(1 row) + +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v| w +--+-+--- + 1|2|201 +(1 row) + + +starting permutation: s1b s1nu s2rc s2noc s1a s2c s3n +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2noc: INSERT INTO lrn VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrn.w + 1 RETURNING *; +step s1a: ROLLBACK; +s2: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,1,1) +step s2noc: <... completed> +id|v| w +--+-+--- + 1|1|101 +(1 row) + +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v| w +--+-+--- + 1|1|101 +(1 row) + + +starting permutation: s1b s1lfu s2rc s2oc s1k s1c s2c s3r +step s1b: BEGIN; +step s1lfu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s1c: COMMIT; +step s2oc: <... completed> +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +10|1|0 +(3 rows) + + +starting permutation: s1b s1tfu s2rc s2toc s1tk s1c s2c s3t +step s1b: BEGIN; +step s1tfu: SELECT * FROM lrt WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=1 +step s1tk: UPDATE lrt SET id = 10 WHERE id = 1; +step s1c: COMMIT; +step s2toc: <... completed> +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|1|0 +10|1|0 +(2 rows) + + +starting permutation: s1b s1lfu s2rc s2oc s1u s1c s2c s3r +step s1b: BEGIN; +step s1lfu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2oc: <... completed> +id|v|w +--+-+- + 1|2|1 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|1 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1tfu s2rc s2toc s1tu s1c s2c s3t +step s1b: BEGIN; +step s1tfu: SELECT * FROM lrt WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s1c: COMMIT; +s2: NOTICE: lrt_bu: OLD.v=2 NEW.v=2 +step s2toc: <... completed> +id|v|w +--+-+- + 1|2|1 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|1 +(1 row) + + +starting permutation: s1b s1lfu s2rc s2oc s1c s2c s3r +step s1b: BEGIN; +step s1lfu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1c: COMMIT; +step s2oc: <... completed> +id|v|w +--+-+- + 1|1|1 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|1 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1lfu s2oc s1u s1c s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1lfu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2oc: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2mlu s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mlu: MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id WHEN MATCHED THEN UPDATE SET w = lr.w + 1 RETURNING merge_action(), lr.*; +step s1c: COMMIT; +step s2mlu: <... completed> +merge_action|id|v|w +------------+--+-+- +UPDATE | 1|2|1 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|1 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2mlq s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mlq: MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id AND lr.v = 1 WHEN MATCHED THEN UPDATE SET w = lr.w + 1 RETURNING merge_action(), lr.*; +step s1c: COMMIT; +step s2mlq: <... completed> +merge_action|id|v|w +------------+--+-+- +(0 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2mld s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mld: MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id WHEN MATCHED THEN DELETE RETURNING merge_action(), lr.*; +step s1c: COMMIT; +step s2mld: <... completed> +merge_action|id|v|w +------------+--+-+- +DELETE | 1|2|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +(1 row) + + +starting permutation: s1b s1nu s2rc s2mnu s1c s2c s3n +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mnu: MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; +step s1c: COMMIT; +s2: NOTICE: lrn_bu UPDATE: OLD=(1,2,100) NEW=(1,2,101) +step s2mnu: <... completed> +merge_action|id|v| w +------------+--+-+--- +UPDATE | 1|2|201 +(1 row) + +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v| w +--+-+--- + 1|2|201 +(1 row) + + +starting permutation: s1b s1nu s2rc s2mnq s1c s2c s3n +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mnq: MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id AND lrn.v = 1 WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; +step s1c: COMMIT; +step s2mnq: <... completed> +merge_action|id|v|w +------------+--+-+- +(0 rows) + +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v| w +--+-+--- + 1|2|100 +(1 row) + + +starting permutation: s1b s1nu s2rc s2mnd s1c s2c s3n +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mnd: MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN DELETE RETURNING merge_action(), lrn.*; +step s1c: COMMIT; +s2: NOTICE: lrn_bu DELETE: OLD=(1,2,100) NEW= +step s2mnd: <... completed> +merge_action|id|v| w +------------+--+-+--- +DELETE | 1|2|100 +(1 row) + +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v|w +--+-+- +(0 rows) + + +starting permutation: s2rr s2snap s1b s1nu s2mnu s1c s2c s3n +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2mnu: MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; +step s1c: COMMIT; +step s2mnu: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v| w +--+-+--- + 1|2|100 +(1 row) + + +starting permutation: s1b s1pu s2rc s2pm s1c s2c s3pt +step s1b: BEGIN; +step s1pu: UPDATE lpt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2pm: UPDATE lpt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; +step s1c: COMMIT; +step s2pm: <... completed> +tableoid|id|v|w +--------+--+-+- +lpt2 | 1|2|5 +(1 row) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt2 | 1|2|5 +lptt1 | 1|1|0 +(2 rows) + + +starting permutation: s1b s1pu s2rc s2pmq s1c s2c s3pt +step s1b: BEGIN; +step s1pu: UPDATE lpt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2pmq: UPDATE lpt SET v = 2 WHERE id = 1 AND w = 0 RETURNING tableoid::regclass, *; +step s1c: COMMIT; +step s2pmq: <... completed> +tableoid|id|v|w +--------+--+-+- +(0 rows) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt1 | 1|1|5 +lptt1 | 1|1|0 +(2 rows) + + +starting permutation: s1b s1ptu s2rc s2ptm s1c s2c s3pt +step s1b: BEGIN; +s1: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,0) NEW=(1,1,5) +step s1ptu: UPDATE lptt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ptm: UPDATE lptt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; +step s1c: COMMIT; +s2: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,5) NEW=(1,2,5) +s2: NOTICE: lptt1 BEFORE DELETE: OLD=(1,1,5) NEW= +step s2ptm: <... completed> +tableoid|id|v|w +--------+--+-+- +lptt2 | 1|2|5 +(1 row) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt1 | 1|1|0 +lptt2 | 1|2|5 +(2 rows) + + +starting permutation: s1b s1ptu s2rc s2ptmq s1c s2c s3pt +step s1b: BEGIN; +s1: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,0) NEW=(1,1,5) +step s1ptu: UPDATE lptt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ptmq: UPDATE lptt SET v = 2 WHERE id = 1 AND w = 0 RETURNING tableoid::regclass, *; +step s1c: COMMIT; +step s2ptmq: <... completed> +tableoid|id|v|w +--------+--+-+- +(0 rows) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt1 | 1|1|0 +lptt1 | 1|1|5 +(2 rows) + + +starting permutation: s1b s1pu s2rc s2mpm s1c s2c s3pt +step s1b: BEGIN; +step s1pu: UPDATE lpt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mpm: MERGE INTO lpt USING (VALUES (1)) s(id) ON lpt.id = s.id WHEN MATCHED THEN UPDATE SET v = 2 RETURNING merge_action(), lpt.*; +step s1c: COMMIT; +step s2mpm: <... completed> +merge_action|id|v|w +------------+--+-+- +UPDATE | 1|2|5 +(1 row) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt2 | 1|2|5 +lptt1 | 1|1|0 +(2 rows) + + +starting permutation: s1b s1ptu s2rc s2mptm s1c s2c s3pt +step s1b: BEGIN; +s1: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,0) NEW=(1,1,5) +step s1ptu: UPDATE lptt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mptm: MERGE INTO lptt USING (VALUES (1)) s(id) ON lptt.id = s.id WHEN MATCHED THEN UPDATE SET v = 2 RETURNING merge_action(), lptt.*; +step s1c: COMMIT; +s2: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,5) NEW=(1,2,5) +s2: NOTICE: lptt1 BEFORE DELETE: OLD=(1,1,5) NEW= +step s2mptm: <... completed> +merge_action|id|v|w +------------+--+-+- +UPDATE | 1|2|5 +(1 row) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt1 | 1|1|0 +lptt2 | 1|2|5 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1ptu s2ptm s1c s2c s3pt +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +s1: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,0) NEW=(1,1,5) +step s1ptu: UPDATE lptt SET w = 5 WHERE id = 1; +step s2ptm: UPDATE lptt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; +step s1c: COMMIT; +step s2ptm: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt1 | 1|1|0 +lptt1 | 1|1|5 +(2 rows) + + +starting permutation: s1b s1qcu s2rc s2qd s1c s2c s3q +step s1b: BEGIN; +step s1qcu: SET LOCAL session_replication_role = replica; UPDATE lrqc SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2qd: DELETE FROM lrq WHERE id = 1; +step s1c: COMMIT; +step s2qd: <... completed> +step s2c: COMMIT; +step s3q: SELECT * FROM lrq ORDER BY id; SELECT * FROM lrqc ORDER BY id; +id|note +--+---- +(0 rows) + +id|pid|w +--+---+- +(0 rows) + + +starting permutation: s2rc s2fks s1b s1k s2c s1c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2c: COMMIT; +step s1k: <... completed> +step s1c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s2rc s2fks s1b s1u s1c s2c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rc s2fs s1b s1k s2c s1c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fs: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2c: COMMIT; +step s1k: <... completed> +step s1c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s2rc s2fnk s1b s1k s2c s1c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fnk: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR NO KEY UPDATE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2c: COMMIT; +step s1k: <... completed> +step s1c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s2rc s2fu s1b s1k s2c s1c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2c: COMMIT; +step s1k: <... completed> +step s1c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s2rc s2ci s1b s1pk s2c s1c s3p s3c s3orphans +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1b: BEGIN; +step s1pk: UPDATE lrp SET id = 2 WHERE id = 1; +step s2c: COMMIT; +step s1pk: <... completed> +ERROR: update or delete on table "lrp" violates foreign key constraint "lrc_pid_fkey" on table "lrc" +step s1c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 0 +(1 row) + +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + +step s3orphans: SELECT count(*) AS orphans FROM lrc c WHERE NOT EXISTS (SELECT FROM lrp p WHERE p.id = c.pid); +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2ci s1b s1pk s2a s1c s3p s3c s3orphans +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1b: BEGIN; +step s1pk: UPDATE lrp SET id = 2 WHERE id = 1; +step s2a: ROLLBACK; +step s1pk: <... completed> +step s1c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 2| 0 +(1 row) + +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + +step s3orphans: SELECT count(*) AS orphans FROM lrc c WHERE NOT EXISTS (SELECT FROM lrp p WHERE p.id = c.pid); +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2uci s1b s1uk s2c s1c s3u +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2uci: INSERT INTO lruc VALUES (1); +step s1b: BEGIN; +step s1uk: UPDATE lrup SET id = 2 WHERE id = 1; +step s2c: COMMIT; +step s1uk: <... completed> +step s1c: COMMIT; +step s3u: SELECT * FROM lrup; SELECT * FROM lruc; SELECT count(*) AS orphans FROM lruc c WHERE NOT EXISTS (SELECT FROM lrup p WHERE p.id = c.pid); +id +-- + 2 +(1 row) + +pid +--- + 2 +(1 row) + +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2rci s1b s1rk s2c s1c s3restrict +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rci: INSERT INTO lrrc VALUES (1); +step s1b: BEGIN; +step s1rk: UPDATE lrrp SET id = 2 WHERE id = 1; +step s2c: COMMIT; +step s1rk: <... completed> +ERROR: update or delete on table "lrrp" violates RESTRICT setting of foreign key constraint "lrrc_pid_fkey" on table "lrrc" +step s1c: COMMIT; +step s3restrict: SELECT * FROM lrrp; SELECT * FROM lrrc; SELECT count(*) AS orphans FROM lrrc c WHERE NOT EXISTS (SELECT FROM lrrp p WHERE p.id = c.pid); +id +-- + 1 +(1 row) + +pid +--- + 1 +(1 row) + +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2fks s1b s1sp s1k s2c s1rb s1c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1sp: SAVEPOINT key_update; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2c: COMMIT; +step s1k: <... completed> +step s1rb: ROLLBACK TO key_update; +step s1c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1sp s1pk s2rc s2ci s1rb s2c s1c s3p s3c s3orphans +step s1b: BEGIN; +step s1sp: SAVEPOINT key_update; +step s1pk: UPDATE lrp SET id = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1rb: ROLLBACK TO key_update; +step s2ci: <... completed> +step s2c: COMMIT; +step s1c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 0 +(1 row) + +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + +step s3orphans: SELECT count(*) AS orphans FROM lrc c WHERE NOT EXISTS (SELECT FROM lrp p WHERE p.id = c.pid); +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2mci s1b s1mk s2c s1c s3multi +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mci: INSERT INTO lrmc VALUES (1, 1); +step s1b: BEGIN; +step s1mk: UPDATE lrmp SET b = 2 WHERE a = 1 AND b = 1; +step s2c: COMMIT; +step s1mk: <... completed> +ERROR: update or delete on table "lrmp" violates foreign key constraint "lrmc_a_b_fkey" on table "lrmc" +step s1c: COMMIT; +step s3multi: SELECT * FROM lrmp; SELECT * FROM lrmc; SELECT count(*) AS orphans FROM lrmc c WHERE NOT EXISTS (SELECT FROM lrmp p WHERE p.a = c.a AND p.b = c.b); +a|b +-+- +1|1 +(1 row) + +a|b +-+- +1|1 +(1 row) + +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2vci s1b s1vk s2c s1c s3unique +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2vci: INSERT INTO lrvc VALUES (1); +step s1b: BEGIN; +step s1vk: UPDATE lrvp SET id = 2 WHERE id = 1; +step s2c: COMMIT; +step s1vk: <... completed> +ERROR: update or delete on table "lrvp" violates foreign key constraint "lrvc_pid_fkey" on table "lrvc" +step s1c: COMMIT; +step s3unique: SELECT * FROM lrvp; SELECT * FROM lrvc; SELECT count(*) AS orphans FROM lrvc c WHERE NOT EXISTS (SELECT FROM lrvp p WHERE p.id = c.pid); +id|note +--+---- + 1| 0 +(1 row) + +pid +--- + 1 +(1 row) + +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2vci s1b s1vn s1c s2c s3unique +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2vci: INSERT INTO lrvc VALUES (1); +step s1b: BEGIN; +step s1vn: UPDATE lrvp SET note = note + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +step s3unique: SELECT * FROM lrvp; SELECT * FROM lrvc; SELECT count(*) AS orphans FROM lrvc c WHERE NOT EXISTS (SELECT FROM lrvp p WHERE p.id = c.pid); +id|note +--+---- + 1| 1 +(1 row) + +pid +--- + 1 +(1 row) + +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2vci s4vn s1b s1vk s2c s1c s3unique +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2vci: INSERT INTO lrvc VALUES (1); +step s4vn: UPDATE lrvp SET note = note + 1 WHERE id = 1; +step s1b: BEGIN; +step s1vk: UPDATE lrvp SET id = 2 WHERE id = 1; +step s2c: COMMIT; +step s1vk: <... completed> +ERROR: update or delete on table "lrvp" violates foreign key constraint "lrvc_pid_fkey" on table "lrvc" +step s1c: COMMIT; +step s3unique: SELECT * FROM lrvp; SELECT * FROM lrvc; SELECT count(*) AS orphans FROM lrvc c WHERE NOT EXISTS (SELECT FROM lrvp p WHERE p.id = c.pid); +id|note +--+---- + 1| 1 +(1 row) + +pid +--- + 1 +(1 row) + +orphans +------- + 0 +(1 row) + diff --git a/src/test/isolation/expected/flux_lock_update_lockers.out b/src/test/isolation/expected/flux_lock_update_lockers.out new file mode 100644 index 0000000000000..222e432460395 --- /dev/null +++ b/src/test/isolation/expected/flux_lock_update_lockers.out @@ -0,0 +1,33 @@ +Parsed test spec with 3 sessions + +starting permutation: s1b s1l s2b s2l s3u s1r s2chk s2c s3chk +step s1b: BEGIN; +step s1l: SELECT v FROM lu WHERE id = 1 FOR SHARE; +v +- +0 +(1 row) + +step s2b: BEGIN; +step s2l: SELECT v FROM lu WHERE id = 1 FOR SHARE; +v +- +0 +(1 row) + +step s3u: UPDATE lu SET v = v + 1 WHERE id = 1; +step s1r: ROLLBACK; +step s2chk: SELECT v FROM lu WHERE id = 1; +v +- +0 +(1 row) + +step s2c: COMMIT; +step s3u: <... completed> +step s3chk: SELECT v FROM lu WHERE id = 1; +v +- +1 +(1 row) + diff --git a/src/test/isolation/expected/flux_lock_upgrade_queue.out b/src/test/isolation/expected/flux_lock_upgrade_queue.out new file mode 100644 index 0000000000000..e8d227c700561 --- /dev/null +++ b/src/test/isolation/expected/flux_lock_upgrade_queue.out @@ -0,0 +1,101 @@ +Parsed test spec with 3 sessions + +starting permutation: s1ks s3fu s2ks s1sp s1sh s2nk s1rs s1r s2c s3c +step s1ks: SELECT id FROM luq FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s3fu: SELECT id FROM luq FOR UPDATE; +step s2ks: SELECT id FROM luq FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s1sp: SAVEPOINT a; +step s1sh: SELECT id FROM luq FOR SHARE; +id +-- + 1 +(1 row) + +step s2nk: SELECT id FROM luq FOR NO KEY UPDATE; +step s1rs: ROLLBACK TO a; +step s2nk: <... completed> +id +-- + 1 +(1 row) + +step s1r: ROLLBACK; +step s2c: COMMIT; +step s3fu: <... completed> +id +-- + 1 +(1 row) + +step s3c: COMMIT; + +starting permutation: s1sh s2fu s3sh s3fu s1r s3c s2c +step s1sh: SELECT id FROM luq FOR SHARE; +id +-- + 1 +(1 row) + +step s2fu: SELECT id FROM luq FOR UPDATE; +step s3sh: SELECT id FROM luq FOR SHARE; +id +-- + 1 +(1 row) + +step s3fu: SELECT id FROM luq FOR UPDATE; +step s1r: ROLLBACK; +step s3fu: <... completed> +id +-- + 1 +(1 row) + +step s3c: COMMIT; +step s2fu: <... completed> +id +-- + 1 +(1 row) + +step s2c: COMMIT; + +starting permutation: s1ks s2fu s3ks s3d s1r s3c s2c +step s1ks: SELECT id FROM luq FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s2fu: SELECT id FROM luq FOR UPDATE; +step s3ks: SELECT id FROM luq FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s3d: DELETE FROM luq RETURNING id; +step s1r: ROLLBACK; +step s3d: <... completed> +id +-- + 1 +(1 row) + +step s3c: COMMIT; +step s2fu: <... completed> +id +-- +(0 rows) + +step s2c: COMMIT; diff --git a/src/test/isolation/expected/flux_lostupdate.out b/src/test/isolation/expected/flux_lostupdate.out new file mode 100644 index 0000000000000..5effeaef0e69b --- /dev/null +++ b/src/test/isolation/expected/flux_lostupdate.out @@ -0,0 +1,14 @@ +Parsed test spec with 3 sessions + +starting permutation: s1_upd s2_upd s1_commit s2_commit s3_read +step s1_upd: UPDATE flux_lu SET v = v + 1 WHERE id = 1; +step s2_upd: UPDATE flux_lu SET v = v + 1 WHERE id = 1; +step s1_commit: COMMIT; +step s2_upd: <... completed> +step s2_commit: COMMIT; +step s3_read: SELECT v FROM flux_lu WHERE id = 1; +v +- +2 +(1 row) + diff --git a/src/test/isolation/expected/flux_nonkey_move.out b/src/test/isolation/expected/flux_nonkey_move.out new file mode 100644 index 0000000000000..4f7569f0c800d --- /dev/null +++ b/src/test/isolation/expected/flux_nonkey_move.out @@ -0,0 +1,831 @@ +Parsed test spec with 3 sessions + +starting permutation: rr snap b2 ui ki c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rr snap b2 ui ii c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ii: INSERT INTO ci VALUES (1); +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rr snap b2 ui si c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step si: SELECT id, v FROM fi WHERE id=1 FOR SHARE; +step c2: COMMIT; +step si: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ui di c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step di: DELETE FROM fi WHERE id=1; +step c2: COMMIT; +step di: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ui wi c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step wi: UPDATE fi SET v=v+1 WHERE id=1; +step c2: COMMIT; +step wi: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ug kg c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rr snap b2 ug ig c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step ig: INSERT INTO cg VALUES (1); +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rr snap b2 ug sg c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step sg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR SHARE; +step c2: COMMIT; +step sg: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ug dg c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step dg: DELETE FROM fg WHERE id=1; +step c2: COMMIT; +step dg: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ug wg c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step wg: UPDATE fg SET v=v+1 WHERE id=1; +step c2: COMMIT; +step wg: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rc snap b2 ui ki c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rc snap b2 ui ii c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ii: INSERT INTO ci VALUES (1); +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rc snap b2 ui si c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step si: SELECT id, v FROM fi WHERE id=1 FOR SHARE; +step c2: COMMIT; +step si: <... completed> +id|v +--+- + 1|2 +(1 row) + +step c1: COMMIT; + +starting permutation: rc snap b2 ui di c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step di: DELETE FROM fi WHERE id=1; +step c2: COMMIT; +step di: <... completed> +step c1: COMMIT; + +starting permutation: rc snap b2 ui wi c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step wi: UPDATE fi SET v=v+1 WHERE id=1; +step c2: COMMIT; +step wi: <... completed> +step c1: COMMIT; + +starting permutation: rc snap b2 ug kg c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rc snap b2 ug ig c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step ig: INSERT INTO cg VALUES (1); +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rc snap b2 ug sg c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step sg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR SHARE; +step c2: COMMIT; +step sg: <... completed> +id|v|length +--+-+------ + 1|1| 1920 +(1 row) + +step c1: COMMIT; + +starting permutation: rc snap b2 ug dg c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step dg: DELETE FROM fg WHERE id=1; +step c2: COMMIT; +step dg: <... completed> +step c1: COMMIT; + +starting permutation: rc snap b2 ug wg c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step wg: UPDATE fg SET v=v+1 WHERE id=1; +step c2: COMMIT; +step wg: <... completed> +step c1: COMMIT; + +starting permutation: rr snap b2 ui ki c2 d2i c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c2: COMMIT; +step d2i: DELETE FROM fi WHERE id=1; +step c1: COMMIT; +step d2i: <... completed> + +starting permutation: rr snap b2 ui ki c2 k2i c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c2: COMMIT; +step k2i: UPDATE fi SET id=2 WHERE id=1; +step c1: COMMIT; +step k2i: <... completed> + +starting permutation: rr snap b2 ug kg c2 d2g c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c2: COMMIT; +step d2g: DELETE FROM fg WHERE id=1; +step c1: COMMIT; +step d2g: <... completed> + +starting permutation: rr snap b2 ug kg c2 k2g c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c2: COMMIT; +step k2g: UPDATE fg SET id=100 WHERE id=1; +step c1: COMMIT; +step k2g: <... completed> + +starting permutation: rr snap b2 ui ki r2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step r2: ROLLBACK; +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 ki c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c1: COMMIT; + +starting permutation: rr snap b2 ug kg r2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step r2: ROLLBACK; +step c1: COMMIT; + +starting permutation: rr snap b2 ug c2 kg c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step c2: COMMIT; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c1: COMMIT; + +starting permutation: rr snap b2 ui ki c2 n2i c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c2: COMMIT; +step n2i: UPDATE fi SET v=3 WHERE id=1; +step c1: COMMIT; +step n2i: <... completed> + +starting permutation: rr snap b2 ug kg c2 n2g c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c2: COMMIT; +step n2g: UPDATE fg SET pad=repeat('z',1940) WHERE id=1; +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 b3 k3 c3 ki c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step k3: UPDATE fi SET id=2 WHERE id=1; +step c3: COMMIT; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rc snap b2 ui c2 b3 k3 c3 ki c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step k3: UPDATE fi SET id=2 WHERE id=1; +step c3: COMMIT; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- +(0 rows) + +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 b3 k3 ki c3 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step k3: UPDATE fi SET id=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +step c3: COMMIT; +step ki: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 b3 u3 ki c3 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step u3: UPDATE fi SET v=3 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c3: COMMIT; +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 b3 k3 z3 u3 c3 ki c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step k3: UPDATE fi SET id=2 WHERE id=1; +step z3: UPDATE fi SET id=1 WHERE id=2; +step u3: UPDATE fi SET v=3 WHERE id=1; +step c3: COMMIT; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 b3 k3 z3 u3 ki c3 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step k3: UPDATE fi SET id=2 WHERE id=1; +step z3: UPDATE fi SET id=1 WHERE id=2; +step u3: UPDATE fi SET v=3 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +step c3: COMMIT; +step ki: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; diff --git a/src/test/isolation/expected/flux_outofplace_grow.out b/src/test/isolation/expected/flux_outofplace_grow.out new file mode 100644 index 0000000000000..9f7745dea9bf6 --- /dev/null +++ b/src/test/isolation/expected/flux_outofplace_grow.out @@ -0,0 +1,35 @@ +Parsed test spec with 2 sessions + +starting permutation: s1b s2b s1g s2u s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1g: UPDATE xg SET p1 = 1, p2 = 2, p3 = 3, p4 = 4, p5 = 5, p6 = 6, + p7 = 7, p8 = 8 WHERE id = 1; +step s2u: UPDATE xg SET n = n + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, n, p8, (SELECT count(*) FROM xg) AS nrows + FROM xg WHERE id = 1; +id|n|p8|nrows +--+-+--+----- + 1|1| 8| 400 +(1 row) + + +starting permutation: s1b s2b s1g s2u s1r s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1g: UPDATE xg SET p1 = 1, p2 = 2, p3 = 3, p4 = 4, p5 = 5, p6 = 6, + p7 = 7, p8 = 8 WHERE id = 1; +step s2u: UPDATE xg SET n = n + 1 WHERE id = 1; +step s1r: ROLLBACK; +step s2u: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, n, p8, (SELECT count(*) FROM xg) AS nrows + FROM xg WHERE id = 1; +id|n|p8|nrows +--+-+--+----- + 1|1| | 400 +(1 row) + diff --git a/src/test/isolation/expected/flux_outofplace_update.out b/src/test/isolation/expected/flux_outofplace_update.out new file mode 100644 index 0000000000000..95e470df0dc37 --- /dev/null +++ b/src/test/isolation/expected/flux_outofplace_update.out @@ -0,0 +1,196 @@ +Parsed test spec with 2 sessions + +starting permutation: s1b s2b s1u s2u s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s2u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|2|[1,10)| 1 +(1 row) + + +starting permutation: s1b s2b s1uk s2uk s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s1c: COMMIT; +step s2uk: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|2|[1,12)| 1 +(1 row) + + +starting permutation: s1b s2b s1uk s2u s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|2|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2b s1u s2uk s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s2uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s1c: COMMIT; +step s2uk: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|2|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2b s1uk s2d s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2d: DELETE FROM xt WHERE id = 1; +step s1c: COMMIT; +step s2d: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r|nrows +--+-+-+----- +(0 rows) + + +starting permutation: s1b s2b s1uk s2u s1r s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s1r: ROLLBACK; +step s2u: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|1|[1,10)| 1 +(1 row) + + +starting permutation: s1b s2b s1uk s2uk s1r s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s1r: ROLLBACK; +step s2uk: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|1|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2brr s1uk s2u s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|1|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2brr s2sel s1uk s1c s2d s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2sel: SELECT id, v, r FROM xt; +id|v|r +--+-+------ + 1|0|[1,10) +(1 row) + +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s1c: COMMIT; +step s2d: DELETE FROM xt WHERE id = 1; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|1|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2b s1up s2ins s1c s2c +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1up: UPDATE xp SET valid_at = '[2020-01-01,2026-01-01)' WHERE id = '[1,2)'; +step s2ins: INSERT INTO xc VALUES ('[1,2)', '[2020-01-01,2025-01-01)', '[1,2)'); +step s1c: COMMIT; +step s2ins: <... completed> +step s2c: COMMIT; + +starting permutation: s1b s2brr s1up s2ins s1c s2c +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1up: UPDATE xp SET valid_at = '[2020-01-01,2026-01-01)' WHERE id = '[1,2)'; +step s2ins: INSERT INTO xc VALUES ('[1,2)', '[2020-01-01,2025-01-01)', '[1,2)'); +step s1c: COMMIT; +step s2ins: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; + +starting permutation: s1b s2b s1uk s2fu s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2fu: SELECT id, v, r FROM xt WHERE id = 1 FOR UPDATE; +step s1c: COMMIT; +step s2fu: <... completed> +id|v|r +--+-+------ + 1|1|[1,11) +(1 row) + +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|1|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2brr s2sel s1uk s1c s2fu s2c +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2sel: SELECT id, v, r FROM xt; +id|v|r +--+-+------ + 1|0|[1,10) +(1 row) + +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s1c: COMMIT; +step s2fu: SELECT id, v, r FROM xt WHERE id = 1 FOR UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; diff --git a/src/test/isolation/expected/flux_partition_move.out b/src/test/isolation/expected/flux_partition_move.out new file mode 100644 index 0000000000000..fed6d1384b56b --- /dev/null +++ b/src/test/isolation/expected/flux_partition_move.out @@ -0,0 +1,172 @@ +Parsed test spec with 2 sessions + +starting permutation: s2b s1u s2d s1c s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2d: DELETE FROM pm WHERE a = 1; +step s1c: COMMIT; +step s2d: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2sel: SELECT * FROM pm ORDER BY a; +ERROR: current transaction is aborted, commands ignored until end of transaction block +step s2c: COMMIT; + +starting permutation: s2b s1u s2u s1c s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2u: UPDATE pm SET b = 'EFG' WHERE a = 1; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2sel: SELECT * FROM pm ORDER BY a; +ERROR: current transaction is aborted, commands ignored until end of transaction block +step s2c: COMMIT; + +starting permutation: s2b s1u s2l s1c s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2l: SELECT * FROM pm WHERE a = 1 FOR UPDATE; +step s1c: COMMIT; +step s2l: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2c: COMMIT; + +starting permutation: s2b s1u s2ks s1c s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2ks: SELECT * FROM pm WHERE a = 1 FOR KEY SHARE; +step s1c: COMMIT; +step s2ks: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2c: COMMIT; + +starting permutation: s2b s1u s2m s1c s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2m: MERGE INTO pm t USING (SELECT 1 AS a) s ON t.a = s.a + WHEN MATCHED THEN UPDATE SET b = 'merged'; +step s1c: COMMIT; +step s2m: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2sel: SELECT * FROM pm ORDER BY a; +ERROR: current transaction is aborted, commands ignored until end of transaction block +step s2c: COMMIT; + +starting permutation: s2b s1upr s2i s1c s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1upr: UPDATE pr SET a = 11 WHERE a = 7; +step s2i: INSERT INTO prc VALUES (7); +step s1c: COMMIT; +step s2i: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2c: COMMIT; + +starting permutation: s2brr s2sel s1u s1c s2d s2c +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+--- +1|ABC +(1 row) + +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s1c: COMMIT; +step s2d: DELETE FROM pm WHERE a = 1; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; + +starting permutation: s2brr s2sel s1u s1c s2l s2c +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+--- +1|ABC +(1 row) + +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s1c: COMMIT; +step s2l: SELECT * FROM pm WHERE a = 1 FOR UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; + +starting permutation: s2b s1u s2u s1r s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2u: UPDATE pm SET b = 'EFG' WHERE a = 1; +step s1r: ROLLBACK; +step s2u: <... completed> +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+--- +1|EFG +(1 row) + +step s2c: COMMIT; + +starting permutation: s2b s1u s2d s1r s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2d: DELETE FROM pm WHERE a = 1; +step s1r: ROLLBACK; +step s2d: <... completed> +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+- +(0 rows) + +step s2c: COMMIT; + +starting permutation: s2b s1u s2l s1r s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2l: SELECT * FROM pm WHERE a = 1 FOR UPDATE; +step s1r: ROLLBACK; +step s2l: <... completed> +a|b +-+--- +1|ABC +(1 row) + +step s2c: COMMIT; + +starting permutation: s2b s1upr s2i s1r s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1upr: UPDATE pr SET a = 11 WHERE a = 7; +step s2i: INSERT INTO prc VALUES (7); +step s1r: ROLLBACK; +step s2i: <... completed> +step s2c: COMMIT; + +starting permutation: s1sp s1u s1rs s2b s2u s2sel s2c s1c +step s1sp: SAVEPOINT sp; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s1rs: ROLLBACK TO sp; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2u: UPDATE pm SET b = 'EFG' WHERE a = 1; +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+--- +1|EFG +(1 row) + +step s2c: COMMIT; +step s1c: COMMIT; + +starting permutation: s2b s1u s1r s2l s2u s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s1r: ROLLBACK; +step s2l: SELECT * FROM pm WHERE a = 1 FOR UPDATE; +a|b +-+--- +1|ABC +(1 row) + +step s2u: UPDATE pm SET b = 'EFG' WHERE a = 1; +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+--- +1|EFG +(1 row) + +step s2c: COMMIT; diff --git a/src/test/isolation/expected/flux_pvs_deep.out b/src/test/isolation/expected/flux_pvs_deep.out new file mode 100644 index 0000000000000..8c14d9a452d7c --- /dev/null +++ b/src/test/isolation/expected/flux_pvs_deep.out @@ -0,0 +1,35 @@ +Parsed test spec with 2 sessions + +starting permutation: snap upd read done now +step snap: SELECT 'heap' AS am, * FROM pvs_h UNION ALL + SELECT 'flux', * FROM pvs_f ORDER BY 1, 2; +am |id|v +----+--+- +flux| 1|0 +flux| 2|0 +heap| 1|0 +heap| 2|0 +(4 rows) + +step upd: CALL pvs_churn(10010); +step read: SELECT 'heap' AS am, * FROM pvs_h UNION ALL + SELECT 'flux', * FROM pvs_f ORDER BY 1, 2; +am |id|v +----+--+- +flux| 1|0 +flux| 2|0 +heap| 1|0 +heap| 2|0 +(4 rows) + +step done: COMMIT; +step now: SELECT 'heap' AS am, * FROM pvs_h UNION ALL + SELECT 'flux', * FROM pvs_f ORDER BY 1, 2; +am |id| v +----+--+----- +flux| 1|10010 +flux| 2| 0 +heap| 1|10010 +heap| 2| 0 +(4 rows) + diff --git a/src/test/isolation/expected/flux_recheck_after_wait.out b/src/test/isolation/expected/flux_recheck_after_wait.out new file mode 100644 index 0000000000000..60bf09b298fff --- /dev/null +++ b/src/test/isolation/expected/flux_recheck_after_wait.out @@ -0,0 +1,165 @@ +Parsed test spec with 4 sessions + +starting permutation: s1sh s2d s3d s1r s2r s3c s4sel +step s1sh: SELECT id FROM rw FOR SHARE; +id +-- + 1 +(1 row) + +step s2d: DELETE FROM rw RETURNING id, v; +step s3d: DELETE FROM rw WHERE v = 0 RETURNING id, v; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2r: ROLLBACK; +step s3d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id|v +--+- +(0 rows) + + +starting permutation: s1sh s2d s3u s1r s2r s3c s4sel +step s1sh: SELECT id FROM rw FOR SHARE; +id +-- + 1 +(1 row) + +step s2d: DELETE FROM rw RETURNING id, v; +step s3u: UPDATE rw SET v = v + 10 RETURNING id, v; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2r: ROLLBACK; +step s3u: <... completed> +id| v +--+-- + 1|10 +(1 row) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id| v +--+-- + 1|10 +(1 row) + + +starting permutation: s1u s2d s3d s1r s2r s3c s4sel +step s1u: UPDATE rw SET v = 5; +step s2d: DELETE FROM rw RETURNING id, v; +step s3d: DELETE FROM rw WHERE v = 0 RETURNING id, v; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2r: ROLLBACK; +step s3d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id|v +--+- +(0 rows) + + +starting permutation: s1u s2d s3u s1r s2r s3c s4sel +step s1u: UPDATE rw SET v = 5; +step s2d: DELETE FROM rw RETURNING id, v; +step s3u: UPDATE rw SET v = v + 10 RETURNING id, v; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2r: ROLLBACK; +step s3u: <... completed> +id| v +--+-- + 1|10 +(1 row) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id| v +--+-- + 1|10 +(1 row) + + +starting permutation: s1sh s2d s3d s1r s2c s3c s4sel +step s1sh: SELECT id FROM rw FOR SHARE; +id +-- + 1 +(1 row) + +step s2d: DELETE FROM rw RETURNING id, v; +step s3d: DELETE FROM rw WHERE v = 0 RETURNING id, v; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2c: COMMIT; +step s3d: <... completed> +id|v +--+- +(0 rows) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id|v +--+- +(0 rows) + + +starting permutation: s1sh s3d s1u s1c s3c s4sel +step s1sh: SELECT id FROM rw FOR SHARE; +id +-- + 1 +(1 row) + +step s3d: DELETE FROM rw WHERE v = 0 RETURNING id, v; +step s1u: UPDATE rw SET v = 5; +step s1c: COMMIT; +step s3d: <... completed> +id|v +--+- +(0 rows) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id|v +--+- + 1|5 +(1 row) + diff --git a/src/test/isolation/expected/flux_relstats.out b/src/test/isolation/expected/flux_relstats.out new file mode 100644 index 0000000000000..8f9cd74c9677f --- /dev/null +++ b/src/test/isolation/expected/flux_relstats.out @@ -0,0 +1,242 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_dml s2_vacuum_heap s2_vacuum_flux s2_reltuples s1_commit s2_pgstat +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s1_dml: + BEGIN; + DELETE FROM st_heap WHERE a <= 1000; + DELETE FROM st_flux WHERE a <= 1000; + INSERT INTO st_heap SELECT g, g, g FROM generate_series(20001, 22000) g; + INSERT INTO st_flux SELECT g, g, g FROM generate_series(20001, 22000) g; + +step s2_vacuum_heap: VACUUM st_heap; +step s2_vacuum_flux: VACUUM st_flux; +step s2_reltuples: + SELECT relname, abs(reltuples - 10000) <= 500 AS committed_rows_only + FROM pg_class WHERE relname IN ('st_heap', 'st_flux') ORDER BY relname; + +relname|committed_rows_only +-------+------------------- +st_flux|t +st_heap|t +(2 rows) + +step s1_commit: COMMIT; SELECT pg_stat_force_next_flush(); +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s2_pgstat: + SELECT pg_stat_clear_snapshot(); + SELECT relname, abs(n_live_tup - st_count(relid)) <= 500 AS live_ok, + n_dead_tup = 1000 AS dead_ok + FROM pg_stat_user_tables WHERE relname IN ('st_heap', 'st_flux') + ORDER BY relname; + +pg_stat_clear_snapshot +---------------------- + +(1 row) + +relname|live_ok|dead_ok +-------+-------+------- +st_flux|t |t +st_heap|t |t +(2 rows) + + +starting permutation: s1_dml s2_analyze s2_reltuples s1_commit s2_pgstat +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s1_dml: + BEGIN; + DELETE FROM st_heap WHERE a <= 1000; + DELETE FROM st_flux WHERE a <= 1000; + INSERT INTO st_heap SELECT g, g, g FROM generate_series(20001, 22000) g; + INSERT INTO st_flux SELECT g, g, g FROM generate_series(20001, 22000) g; + +step s2_analyze: ANALYZE st_heap, st_flux; +step s2_reltuples: + SELECT relname, abs(reltuples - 10000) <= 500 AS committed_rows_only + FROM pg_class WHERE relname IN ('st_heap', 'st_flux') ORDER BY relname; + +relname|committed_rows_only +-------+------------------- +st_flux|t +st_heap|t +(2 rows) + +step s1_commit: COMMIT; SELECT pg_stat_force_next_flush(); +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s2_pgstat: + SELECT pg_stat_clear_snapshot(); + SELECT relname, abs(n_live_tup - st_count(relid)) <= 500 AS live_ok, + n_dead_tup = 1000 AS dead_ok + FROM pg_stat_user_tables WHERE relname IN ('st_heap', 'st_flux') + ORDER BY relname; + +pg_stat_clear_snapshot +---------------------- + +(1 row) + +relname|live_ok|dead_ok +-------+-------+------- +st_flux|t |t +st_heap|t |t +(2 rows) + + +starting permutation: s2_update_nonkey s2_update_counts s2_analyze s2_dead +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s2_update_nonkey: + UPDATE st_heap SET c = c + 1; + UPDATE st_flux SET c = c + 1; + SELECT pg_stat_force_next_flush(); + +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s2_update_counts: + SELECT pg_stat_clear_snapshot(); + SELECT relname, n_tup_upd = 10000 AS upd_ok, + n_tup_ins = 10000 AND n_tup_del = 0 AS not_ins_del, + n_mod_since_analyze = 10000 AS mod_ok, + n_tup_hot_upd = n_tup_upd AS all_hot, + CASE n_dead_tup WHEN 0 THEN 'none' WHEN 10000 THEN 'all' + ELSE 'some' END AS dead + FROM pg_stat_user_tables + WHERE relname IN ('st_heap', 'st_flux', 'sh_heap', 'sh_flux') + AND n_tup_upd + n_tup_del > 0 + ORDER BY relname; + +pg_stat_clear_snapshot +---------------------- + +(1 row) + +relname|upd_ok|not_ins_del|mod_ok|all_hot|dead +-------+------+-----------+------+-------+---- +st_flux|t |t |t |t |none +st_heap|t |t |t |f |all +(2 rows) + +step s2_analyze: ANALYZE st_heap, st_flux; +step s2_dead: + SELECT pg_stat_clear_snapshot(); + SELECT relname, CASE n_dead_tup WHEN 0 THEN 'none' WHEN 10000 THEN 'all' + ELSE 'some' END AS dead + FROM pg_stat_user_tables WHERE relname IN ('st_heap', 'st_flux') + ORDER BY relname; + +pg_stat_clear_snapshot +---------------------- + +(1 row) + +relname|dead +-------+---- +st_flux|none +st_heap|all +(2 rows) + + +starting permutation: s2_update_key s2_update_counts +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s2_update_key: + UPDATE st_heap SET a = a + 100000; + UPDATE st_flux SET a = a + 100000; + SELECT pg_stat_force_next_flush(); + +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s2_update_counts: + SELECT pg_stat_clear_snapshot(); + SELECT relname, n_tup_upd = 10000 AS upd_ok, + n_tup_ins = 10000 AND n_tup_del = 0 AS not_ins_del, + n_mod_since_analyze = 10000 AS mod_ok, + n_tup_hot_upd = n_tup_upd AS all_hot, + CASE n_dead_tup WHEN 0 THEN 'none' WHEN 10000 THEN 'all' + ELSE 'some' END AS dead + FROM pg_stat_user_tables + WHERE relname IN ('st_heap', 'st_flux', 'sh_heap', 'sh_flux') + AND n_tup_upd + n_tup_del > 0 + ORDER BY relname; + +pg_stat_clear_snapshot +---------------------- + +(1 row) + +relname|upd_ok|not_ins_del|mod_ok|all_hot|dead +-------+------+-----------+------+-------+---- +st_flux|t |t |t |f |all +st_heap|t |t |t |f |all +(2 rows) + + +starting permutation: s2_update_hashkey s2_update_counts +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s2_update_hashkey: + UPDATE sh_heap SET a = a + 100000; + UPDATE sh_flux SET a = a + 100000; + SELECT pg_stat_force_next_flush(); + +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s2_update_counts: + SELECT pg_stat_clear_snapshot(); + SELECT relname, n_tup_upd = 10000 AS upd_ok, + n_tup_ins = 10000 AND n_tup_del = 0 AS not_ins_del, + n_mod_since_analyze = 10000 AS mod_ok, + n_tup_hot_upd = n_tup_upd AS all_hot, + CASE n_dead_tup WHEN 0 THEN 'none' WHEN 10000 THEN 'all' + ELSE 'some' END AS dead + FROM pg_stat_user_tables + WHERE relname IN ('st_heap', 'st_flux', 'sh_heap', 'sh_flux') + AND n_tup_upd + n_tup_del > 0 + ORDER BY relname; + +pg_stat_clear_snapshot +---------------------- + +(1 row) + +relname|upd_ok|not_ins_del|mod_ok|all_hot|dead +-------+------+-----------+------+-------+---- +sh_flux|t |t |t |f |all +sh_heap|t |t |t |f |all +(2 rows) + diff --git a/src/test/isolation/expected/flux_savepoint_lock_rollback.out b/src/test/isolation/expected/flux_savepoint_lock_rollback.out new file mode 100644 index 0000000000000..9c17a2099289c --- /dev/null +++ b/src/test/isolation/expected/flux_savepoint_lock_rollback.out @@ -0,0 +1,1945 @@ +Parsed test spec with 3 sessions + +starting permutation: s1b s1sp s1fu s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fnk s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fnk: SELECT * FROM lr WHERE id = 1 FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fs s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fs: SELECT * FROM lr WHERE id = 1 FOR SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fks s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1sp2 s1fu s1rs2 s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs2: ROLLBACK TO b; +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1sp2 s1fu s1rs2 s1rel s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs2: ROLLBACK TO b; +step s1rel: RELEASE a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fu s1rel s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rel: RELEASE a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fu s1rs s2d s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2d: DELETE FROM lr WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 2|0 +(1 row) + +via |id|v +---------+--+- +key share| 2|0 +(1 row) + +via |id|v +----------+--+- +for update| 2|0 +(1 row) + +via |id| v +------+--+--- +update| 2|100 +(1 row) + +via|id|v +---+--+- +(0 rows) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fu s1rs s2b s2u s2r s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2b: BEGIN; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s2r: ROLLBACK; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|0 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|0 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|0 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|100 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|100 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fu s1rs s2b s2u s1c s2c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2b: BEGIN; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fu s1rs s2u s1r s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1r: ROLLBACK; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1r s2u s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1r: ROLLBACK; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1u s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1d s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1d: DELETE FROM lr WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1u s1rs s2b s2u s1c s2c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2b: BEGIN; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1d s1rs s2b s2u s1c s2c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1d: DELETE FROM lr WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2b: BEGIN; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1sp s1fu s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1sp s1fks s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fnk s1sp s1fu s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fnk: SELECT * FROM lr WHERE id = 1 FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1fks s2u s1c s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1sp s1fks s1rel s2u s1c s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1rel: RELEASE a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1sp s1u s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fks s1sp s1u s1rs s2d s1c s3chk +step s1b: BEGIN; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2d: DELETE FROM lr WHERE id = 1; +step s1c: COMMIT; +step s2d: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 2|0 +(1 row) + +via |id|v +---------+--+- +key share| 2|0 +(1 row) + +via |id|v +----------+--+- +for update| 2|0 +(1 row) + +via |id| v +------+--+--- +update| 2|100 +(1 row) + +via|id|v +---+--+- +(0 rows) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1u s1sp s1fu s1rs s1c s3chk +step s1b: BEGIN; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id| v +--+-- + 1|10 +(1 row) + +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id| v +------+--+-- +select| 1|10 +select| 2| 0 +(2 rows) + +via |id| v +---------+--+-- +key share| 1|10 +key share| 2| 0 +(2 rows) + +via |id| v +----------+--+-- +for update| 1|10 +for update| 2| 0 +(2 rows) + +via |id| v +------+--+--- +update| 1|110 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|110 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1u s1sp s1u s1rs s1c s3chk +step s1b: BEGIN; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id| v +------+--+-- +select| 1|10 +select| 2| 0 +(2 rows) + +via |id| v +---------+--+-- +key share| 1|10 +key share| 2| 0 +(2 rows) + +via |id| v +----------+--+-- +for update| 1|10 +for update| 2| 0 +(2 rows) + +via |id| v +------+--+--- +update| 1|110 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|110 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1u s1sp s1fu s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id| v +--+-- + 1|10 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id| v +------+--+-- +select| 1|11 +select| 2| 0 +(2 rows) + +via |id| v +---------+--+-- +key share| 1|11 +key share| 2| 0 +(2 rows) + +via |id| v +----------+--+-- +for update| 1|11 +for update| 2| 0 +(2 rows) + +via |id| v +------+--+--- +update| 1|111 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|111 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1u s1sp s1fks s1rs s2u s1r s3chk +step s1b: BEGIN; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id| v +--+-- + 1|10 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1r: ROLLBACK; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1i s1sp s1fu3 s1rs s1c s3chk +step s1b: BEGIN; +step s1i: INSERT INTO lr VALUES (3, 0); +step s1sp: SAVEPOINT a; +step s1fu3: SELECT * FROM lr WHERE id = 3 FOR UPDATE; +id|v +--+- + 3|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|0 +select| 2|0 +select| 3|0 +(3 rows) + +via |id|v +---------+--+- +key share| 1|0 +key share| 2|0 +key share| 3|0 +(3 rows) + +via |id|v +----------+--+- +for update| 1|0 +for update| 2|0 +for update| 3|0 +(3 rows) + +via |id| v +------+--+--- +update| 1|100 +update| 2|100 +update| 3|100 +(3 rows) + +via |id| v +------+--+--- +delete| 1|100 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +after delete| 3|100 +(2 rows) + + +starting permutation: s1b s1sp s1i s1rs s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1i: INSERT INTO lr VALUES (3, 0); +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|0 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|0 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|0 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|100 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|100 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1i s1sp s1u3 s1rs s1c s3chk +step s1b: BEGIN; +step s1i: INSERT INTO lr VALUES (3, 0); +step s1sp: SAVEPOINT a; +step s1u3: UPDATE lr SET v = v + 10 WHERE id = 3; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|0 +select| 2|0 +select| 3|0 +(3 rows) + +via |id|v +---------+--+- +key share| 1|0 +key share| 2|0 +key share| 3|0 +(3 rows) + +via |id|v +----------+--+- +for update| 1|0 +for update| 2|0 +for update| 3|0 +(3 rows) + +via |id| v +------+--+--- +update| 1|100 +update| 2|100 +update| 3|100 +(3 rows) + +via |id| v +------+--+--- +delete| 1|100 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +after delete| 3|100 +(2 rows) + + +starting permutation: s1b s1sp s1i s1rel s1sp2 s1u3 s1rs2 s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1i: INSERT INTO lr VALUES (3, 0); +step s1rel: RELEASE a; +step s1sp2: SAVEPOINT b; +step s1u3: UPDATE lr SET v = v + 10 WHERE id = 3; +step s1rs2: ROLLBACK TO b; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|0 +select| 2|0 +select| 3|0 +(3 rows) + +via |id|v +---------+--+- +key share| 1|0 +key share| 2|0 +key share| 3|0 +(3 rows) + +via |id|v +----------+--+- +for update| 1|0 +for update| 2|0 +for update| 3|0 +(3 rows) + +via |id| v +------+--+--- +update| 1|100 +update| 2|100 +update| 3|100 +(3 rows) + +via |id| v +------+--+--- +delete| 1|100 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +after delete| 3|100 +(2 rows) + + +starting permutation: s1b s1fks s1sp s1fu s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fks s1sp s1sp2 s1fu s1rs2 s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs2: ROLLBACK TO b; +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fks s1sp s1sp2 s1fu s1rel s2u s1c s3chk +step s1b: BEGIN; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rel: RELEASE a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + diff --git a/src/test/isolation/expected/flux_savepoint_lock_wake.out b/src/test/isolation/expected/flux_savepoint_lock_wake.out new file mode 100644 index 0000000000000..7629daf724334 --- /dev/null +++ b/src/test/isolation/expected/flux_savepoint_lock_wake.out @@ -0,0 +1,173 @@ +Parsed test spec with 2 sessions + +starting permutation: s1ks s1sp s1nk s2nk s1rs s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1nk: SELECT * FROM lw FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2nk: SELECT * FROM lw FOR NO KEY UPDATE; +step s1rs: ROLLBACK TO a; +step s2nk: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; + +starting permutation: s1ks s1sp s1nk s2u s1rs s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1nk: SELECT * FROM lw FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2u: UPDATE lw SET v = v + 1 RETURNING *; +step s1rs: ROLLBACK TO a; +step s2u: <... completed> +id|v +--+- + 1|1 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; + +starting permutation: s1ks s1sp s1fu s2fs s1rs s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lw FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2fs: SELECT * FROM lw FOR SHARE; +step s1rs: ROLLBACK TO a; +step s2fs: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; + +starting permutation: s1sp s1fu s2u s1rs s1c s2c +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lw FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2u: UPDATE lw SET v = v + 1 RETURNING *; +step s1rs: ROLLBACK TO a; +step s2u: <... completed> +id|v +--+- + 1|1 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; + +starting permutation: s1ks s1sp s1sp2 s1nk s1rel2 s2nk s1rs s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1nk: SELECT * FROM lw FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rel2: RELEASE b; +step s2nk: SELECT * FROM lw FOR NO KEY UPDATE; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s2nk: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2c: COMMIT; + +starting permutation: s1ks s1sp s1sp2 s1nk s2nk s1rs2 s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1nk: SELECT * FROM lw FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2nk: SELECT * FROM lw FOR NO KEY UPDATE; +step s1rs2: ROLLBACK TO b; +step s2nk: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; + +starting permutation: s1ks s1sp s1nk s2d s1rs s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1nk: SELECT * FROM lw FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2d: DELETE FROM lw RETURNING *; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2c: COMMIT; diff --git a/src/test/isolation/expected/flux_scan_history.out b/src/test/isolation/expected/flux_scan_history.out new file mode 100644 index 0000000000000..9acc7ea6d423d --- /dev/null +++ b/src/test/isolation/expected/flux_scan_history.out @@ -0,0 +1,149 @@ +Parsed test spec with 3 sessions + +starting permutation: tplan lock tread upd unlock tdone +step tplan: EXPLAIN (COSTS OFF) SELECT v FROM t WHERE ctid >= '(0,1)'; +QUERY PLAN +---------------------------------- +Tid Range Scan on t + TID Cond: (ctid >= '(0,1)'::tid) +(2 rows) + +step lock: SELECT pg_advisory_lock(881); +pg_advisory_lock +---------------- + +(1 row) + +step tread: SELECT id, v, CASE WHEN id = 1 THEN pg_advisory_lock(881) END IS NULL AS waited + FROM t WHERE ctid >= '(0,1)'; +step upd: UPDATE t SET v = CASE id WHEN 1 THEN 'bbbb' ELSE 'dddd' END; +step unlock: SELECT pg_advisory_unlock(881); +pg_advisory_unlock +------------------ +t +(1 row) + +step tread: <... completed> +id|v |waited +--+----+------ + 1|aaaa|f + 2|cccc|t +(2 rows) + +step tdone: SELECT pg_advisory_unlock_all(); +pg_advisory_unlock_all +---------------------- + +(1 row) + + +starting permutation: tplan lock tread del unlock tdone +step tplan: EXPLAIN (COSTS OFF) SELECT v FROM t WHERE ctid >= '(0,1)'; +QUERY PLAN +---------------------------------- +Tid Range Scan on t + TID Cond: (ctid >= '(0,1)'::tid) +(2 rows) + +step lock: SELECT pg_advisory_lock(881); +pg_advisory_lock +---------------- + +(1 row) + +step tread: SELECT id, v, CASE WHEN id = 1 THEN pg_advisory_lock(881) END IS NULL AS waited + FROM t WHERE ctid >= '(0,1)'; +step del: DELETE FROM t WHERE id = 2; +step unlock: SELECT pg_advisory_unlock(881); +pg_advisory_unlock +------------------ +t +(1 row) + +step tread: <... completed> +id|v |waited +--+----+------ + 1|aaaa|f + 2|cccc|t +(2 rows) + +step tdone: SELECT pg_advisory_unlock_all(); +pg_advisory_unlock_all +---------------------- + +(1 row) + + +starting permutation: splan lock sread upd unlock sdone +step splan: EXPLAIN (COSTS OFF) SELECT v FROM t TABLESAMPLE SYSTEM (100); +QUERY PLAN +-------------------------------- +Sample Scan on t + Sampling: system ('100'::real) +(2 rows) + +step lock: SELECT pg_advisory_lock(881); +pg_advisory_lock +---------------- + +(1 row) + +step sread: SELECT id, v, CASE WHEN id = 1 THEN pg_advisory_lock(881) END IS NULL AS waited + FROM t TABLESAMPLE SYSTEM (100); +step upd: UPDATE t SET v = CASE id WHEN 1 THEN 'bbbb' ELSE 'dddd' END; +step unlock: SELECT pg_advisory_unlock(881); +pg_advisory_unlock +------------------ +t +(1 row) + +step sread: <... completed> +id|v |waited +--+----+------ + 1|aaaa|f + 2|cccc|t +(2 rows) + +step sdone: SELECT pg_advisory_unlock_all(); +pg_advisory_unlock_all +---------------------- + +(1 row) + + +starting permutation: splan lock sread del unlock sdone +step splan: EXPLAIN (COSTS OFF) SELECT v FROM t TABLESAMPLE SYSTEM (100); +QUERY PLAN +-------------------------------- +Sample Scan on t + Sampling: system ('100'::real) +(2 rows) + +step lock: SELECT pg_advisory_lock(881); +pg_advisory_lock +---------------- + +(1 row) + +step sread: SELECT id, v, CASE WHEN id = 1 THEN pg_advisory_lock(881) END IS NULL AS waited + FROM t TABLESAMPLE SYSTEM (100); +step del: DELETE FROM t WHERE id = 2; +step unlock: SELECT pg_advisory_unlock(881); +pg_advisory_unlock +------------------ +t +(1 row) + +step sread: <... completed> +id|v |waited +--+----+------ + 1|aaaa|f + 2|cccc|t +(2 rows) + +step sdone: SELECT pg_advisory_unlock_all(); +pg_advisory_unlock_all +---------------------- + +(1 row) + diff --git a/src/test/isolation/expected/flux_scan_stable.out b/src/test/isolation/expected/flux_scan_stable.out new file mode 100644 index 0000000000000..f9064bddef9e2 --- /dev/null +++ b/src/test/isolation/expected/flux_scan_stable.out @@ -0,0 +1,264 @@ +Parsed test spec with 5 sessions + +starting permutation: seq_plan w_lock seq_f w_upd w_unlock seq_unlock +step seq_plan: EXPLAIN (COSTS OFF) SELECT v FROM ft; +QUERY PLAN +-------------- +Seq Scan on ft +(1 row) + +step w_lock: SELECT pg_advisory_lock(1); +pg_advisory_lock +---------------- + +(1 row) + +step seq_f: SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ft; +step w_upd: UPDATE ft SET v = 'bbbb' WHERE id = 1; + UPDATE ht SET v = 'bbbb' WHERE id = 1; +step w_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + +step seq_f: <... completed> +v |waited +----+------ +aaaa|t +(1 row) + +step seq_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + + +starting permutation: w_lock seq_h w_upd w_unlock seq_unlock +step w_lock: SELECT pg_advisory_lock(1); +pg_advisory_lock +---------------- + +(1 row) + +step seq_h: SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ht; +step w_upd: UPDATE ft SET v = 'bbbb' WHERE id = 1; + UPDATE ht SET v = 'bbbb' WHERE id = 1; +step w_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + +step seq_h: <... completed> +v |waited +----+------ +aaaa|t +(1 row) + +step seq_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + + +starting permutation: bmp_plan w_lock bmp_f w_upd w_unlock bmp_unlock +step bmp_plan: EXPLAIN (COSTS OFF) SELECT v FROM ft WHERE id = 1; +QUERY PLAN +-------------------------------- +Bitmap Heap Scan on ft + Recheck Cond: (id = 1) + -> Bitmap Index Scan on ft_id + Index Cond: (id = 1) +(4 rows) + +step w_lock: SELECT pg_advisory_lock(1); +pg_advisory_lock +---------------- + +(1 row) + +step bmp_f: SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ft WHERE id = 1; +step w_upd: UPDATE ft SET v = 'bbbb' WHERE id = 1; + UPDATE ht SET v = 'bbbb' WHERE id = 1; +step w_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + +step bmp_f: <... completed> +v |waited +----+------ +aaaa|t +(1 row) + +step bmp_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + + +starting permutation: w_lock bmp_h w_upd w_unlock bmp_unlock +step w_lock: SELECT pg_advisory_lock(1); +pg_advisory_lock +---------------- + +(1 row) + +step bmp_h: SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ht WHERE id = 1; +step w_upd: UPDATE ft SET v = 'bbbb' WHERE id = 1; + UPDATE ht SET v = 'bbbb' WHERE id = 1; +step w_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + +step bmp_h: <... completed> +v |waited +----+------ +aaaa|t +(1 row) + +step bmp_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + + +starting permutation: tid_plan w_lock tid_f w_upd w_unlock tid_unlock +step tid_plan: EXPLAIN (COSTS OFF) SELECT v FROM ft WHERE ctid >= '(0,1)'; +QUERY PLAN +---------------------------------- +Tid Range Scan on ft + TID Cond: (ctid >= '(0,1)'::tid) +(2 rows) + +step w_lock: SELECT pg_advisory_lock(1); +pg_advisory_lock +---------------- + +(1 row) + +step tid_f: SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ft + WHERE ctid >= '(0,1)'; +step w_upd: UPDATE ft SET v = 'bbbb' WHERE id = 1; + UPDATE ht SET v = 'bbbb' WHERE id = 1; +step w_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + +step tid_f: <... completed> +v |waited +----+------ +aaaa|t +(1 row) + +step tid_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + + +starting permutation: w_lock tid_h w_upd w_unlock tid_unlock +step w_lock: SELECT pg_advisory_lock(1); +pg_advisory_lock +---------------- + +(1 row) + +step tid_h: SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ht + WHERE ctid >= '(0,1)'; +step w_upd: UPDATE ft SET v = 'bbbb' WHERE id = 1; + UPDATE ht SET v = 'bbbb' WHERE id = 1; +step w_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + +step tid_h: <... completed> +v |waited +----+------ +aaaa|t +(1 row) + +step tid_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + + +starting permutation: idx_plan w_lock idx_f w_upd w_unlock idx_unlock +step idx_plan: EXPLAIN (COSTS OFF) SELECT v FROM ft WHERE id = 1; +QUERY PLAN +---------------------------- +Index Scan using ft_id on ft + Index Cond: (id = 1) +(2 rows) + +step w_lock: SELECT pg_advisory_lock(1); +pg_advisory_lock +---------------- + +(1 row) + +step idx_f: SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ft WHERE id = 1; +step w_upd: UPDATE ft SET v = 'bbbb' WHERE id = 1; + UPDATE ht SET v = 'bbbb' WHERE id = 1; +step w_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + +step idx_f: <... completed> +v |waited +----+------ +aaaa|t +(1 row) + +step idx_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + + +starting permutation: w_lock idx_h w_upd w_unlock idx_unlock +step w_lock: SELECT pg_advisory_lock(1); +pg_advisory_lock +---------------- + +(1 row) + +step idx_h: SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ht WHERE id = 1; +step w_upd: UPDATE ft SET v = 'bbbb' WHERE id = 1; + UPDATE ht SET v = 'bbbb' WHERE id = 1; +step w_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + +step idx_h: <... completed> +v |waited +----+------ +aaaa|t +(1 row) + +step idx_unlock: SELECT pg_advisory_unlock(1); +pg_advisory_unlock +------------------ +t +(1 row) + diff --git a/src/test/isolation/expected/flux_seqscan_stats.out b/src/test/isolation/expected/flux_seqscan_stats.out new file mode 100644 index 0000000000000..cc6194dfaec38 --- /dev/null +++ b/src/test/isolation/expected/flux_seqscan_stats.out @@ -0,0 +1,55 @@ +Parsed test spec with 1 sessions + +starting permutation: s1stats s1scan s1ff s1stats s1where s1ff s1stats +step s1stats: + SELECT pg_stat_get_numscans('ss'::regclass) AS seq_scan, + pg_stat_get_tuples_returned('ss'::regclass) AS seq_tup_read; + +seq_scan|seq_tup_read +--------+------------ + 0| 0 +(1 row) + +step s1scan: SELECT count(*) FROM ss; +count +----- + 5 +(1 row) + +step s1ff: SELECT pg_stat_force_next_flush(); +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s1stats: + SELECT pg_stat_get_numscans('ss'::regclass) AS seq_scan, + pg_stat_get_tuples_returned('ss'::regclass) AS seq_tup_read; + +seq_scan|seq_tup_read +--------+------------ + 1| 5 +(1 row) + +step s1where: SELECT k FROM ss WHERE v > 3 ORDER BY k; +k +- +4 +5 +(2 rows) + +step s1ff: SELECT pg_stat_force_next_flush(); +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s1stats: + SELECT pg_stat_get_numscans('ss'::regclass) AS seq_scan, + pg_stat_get_tuples_returned('ss'::regclass) AS seq_tup_read; + +seq_scan|seq_tup_read +--------+------------ + 2| 10 +(1 row) + diff --git a/src/test/isolation/expected/flux_slot_tuple_context.out b/src/test/isolation/expected/flux_slot_tuple_context.out new file mode 100644 index 0000000000000..365ba45132067 --- /dev/null +++ b/src/test/isolation/expected/flux_slot_tuple_context.out @@ -0,0 +1,18 @@ +Parsed test spec with 2 sessions + +starting permutation: upd validate check abort +step upd: BEGIN; UPDATE fv SET v = v + 100, pad = pad || 'x' WHERE id IN (3, 17); +step validate: ALTER TABLE fv VALIDATE CONSTRAINT fv_v; +step check: SELECT convalidated FROM pg_constraint WHERE conname = 'fv_v'; + SELECT count(*), sum(v) FROM fv; +convalidated +------------ +t +(1 row) + +count|sum +-----+--- + 30|465 +(1 row) + +step abort: ROLLBACK; diff --git a/src/test/isolation/expected/flux_specconflict_toast.out b/src/test/isolation/expected/flux_specconflict_toast.out new file mode 100644 index 0000000000000..ca3cff122a3e6 --- /dev/null +++ b/src/test/isolation/expected/flux_specconflict_toast.out @@ -0,0 +1,62 @@ +Parsed test spec with 3 sessions + +starting permutation: c_locks s1_insert s2_insert c_unlock_1_1 c_unlock_2_1 c_unlock_1_3 c_unlock_2_3 c_unlock_1_2 c_unlock_2_2 c_show +step c_locks: SELECT pg_advisory_lock(sess, lock), sess, lock FROM generate_series(1, 2) a(sess), generate_series(1, 3) b(lock); +pg_advisory_lock|sess|lock +----------------+----+---- + | 1| 1 + | 1| 2 + | 1| 3 + | 2| 1 + | 2| 2 + | 2| 3 +(6 rows) + +step s1_insert: INSERT INTO flux_sct VALUES ('k', flux_sct_val()) ON CONFLICT DO NOTHING; +step s2_insert: INSERT INTO flux_sct VALUES ('k', flux_sct_val()) ON CONFLICT DO NOTHING; +step c_unlock_1_1: SELECT pg_advisory_unlock(1, 1); +pg_advisory_unlock +------------------ +t +(1 row) + +step c_unlock_2_1: SELECT pg_advisory_unlock(2, 1); +pg_advisory_unlock +------------------ +t +(1 row) + +step c_unlock_1_3: SELECT pg_advisory_unlock(1, 3); +pg_advisory_unlock +------------------ +t +(1 row) + +step c_unlock_2_3: SELECT pg_advisory_unlock(2, 3); +pg_advisory_unlock +------------------ +t +(1 row) + +step c_unlock_1_2: SELECT pg_advisory_unlock(1, 2); +pg_advisory_unlock +------------------ +t +(1 row) + +step s1_insert: <... completed> +step c_unlock_2_2: SELECT pg_advisory_unlock(2, 2); +pg_advisory_unlock +------------------ +t +(1 row) + +step s2_insert: <... completed> +step c_show: SELECT count(*) AS nrows, flux_sct_chunks() AS nvalues, + bool_and(md5(data) = md5(flux_sct_val())) AS ok + FROM flux_sct; +nrows|nvalues|ok +-----+-------+-- + 1| 1|t +(1 row) + diff --git a/src/test/isolation/expected/flux_ssi_conflict_out.out b/src/test/isolation/expected/flux_ssi_conflict_out.out new file mode 100644 index 0000000000000..928e9f515b7e3 --- /dev/null +++ b/src/test/isolation/expected/flux_ssi_conflict_out.out @@ -0,0 +1,130 @@ +Parsed test spec with 9 sessions + +starting permutation: foo_begin bar_begin trouble_begin foo_select bar_insert foo_insert foo_commit trouble_update bar_select bar_commit trouble_abort +step foo_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step bar_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step trouble_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step foo_select: SELECT * FROM txn0 WHERE id = 42; +id|val +--+--- +(0 rows) + +step bar_insert: INSERT INTO txn0 SELECT 42, 'bar_insert'; +step foo_insert: INSERT INTO txn1 SELECT 7, 'foo_insert'; +step foo_commit: COMMIT; +step trouble_update: UPDATE txn1 SET val = 'add physical version for "bar_select"' WHERE id = 7; +step bar_select: SELECT * FROM txn1 WHERE id = 7; +ERROR: could not serialize access due to read/write dependencies among transactions +step bar_commit: COMMIT; +step trouble_abort: ABORT; + +starting permutation: foo_begin bar_begin trouble_begin foo_select bar_insert foo_insert foo_commit trouble_delete bar_select bar_commit trouble_abort +step foo_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step bar_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step trouble_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step foo_select: SELECT * FROM txn0 WHERE id = 42; +id|val +--+--- +(0 rows) + +step bar_insert: INSERT INTO txn0 SELECT 42, 'bar_insert'; +step foo_insert: INSERT INTO txn1 SELECT 7, 'foo_insert'; +step foo_commit: COMMIT; +step trouble_delete: DELETE FROM txn1 WHERE id = 7; +step bar_select: SELECT * FROM txn1 WHERE id = 7; +ERROR: could not serialize access due to read/write dependencies among transactions +step bar_commit: COMMIT; +step trouble_abort: ABORT; + +starting permutation: w1b w1snap w2b w2r2 w2w1 w2c w1r1 w1w2 w1c +step w1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step w1snap: SELECT v FROM ws WHERE id = 3; +v +- +0 +(1 row) + +step w2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step w2r2: SELECT v FROM ws WHERE id = 2; +v +- +0 +(1 row) + +step w2w1: UPDATE ws SET v = v + 10 WHERE id = 1; +step w2c: COMMIT; +step w1r1: SELECT v FROM ws WHERE id = 1; +v +- +0 +(1 row) + +step w1w2: UPDATE ws SET v = v + 1 WHERE id = 2; +ERROR: could not serialize access due to read/write dependencies among transactions +step w1c: COMMIT; + +starting permutation: w1b w1snap w2b w2r2 w2w1 w2c w3b w3w1 w1r1 w1w2 w1c w3a +step w1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step w1snap: SELECT v FROM ws WHERE id = 3; +v +- +0 +(1 row) + +step w2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step w2r2: SELECT v FROM ws WHERE id = 2; +v +- +0 +(1 row) + +step w2w1: UPDATE ws SET v = v + 10 WHERE id = 1; +step w2c: COMMIT; +step w3b: BEGIN; +step w3w1: UPDATE ws SET v = v + 100 WHERE id = 1; +step w1r1: SELECT v FROM ws WHERE id = 1; +v +- +0 +(1 row) + +step w1w2: UPDATE ws SET v = v + 1 WHERE id = 2; +ERROR: could not serialize access due to read/write dependencies among transactions +step w1c: COMMIT; +step w3a: ABORT; + +starting permutation: s1b s2b s3b rxwy1 wx2 c2 rx3 c1 ry3 c3 +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; +step s3b: BEGIN ISOLATION LEVEL SERIALIZABLE, READ ONLY; +step rxwy1: INSERT INTO receipt VALUES (3, (SELECT deposit_date FROM ctl WHERE k = 'receipt'), 4.00); +step wx2: UPDATE ctl SET deposit_date = DATE '2008-12-23' WHERE k = 'receipt'; +step c2: COMMIT; +step rx3: SELECT * FROM ctl WHERE k = 'receipt'; +k |deposit_date +-------+------------ +receipt| 12-23-2008 +(1 row) + +step c1: COMMIT; +step ry3: SELECT * FROM receipt WHERE deposit_date = DATE '2008-12-22'; +ERROR: could not serialize access due to read/write dependencies among transactions +step c3: COMMIT; + +starting permutation: s1b s2b s3b wx2 rxwy1 c2 rx3 c1 ry3 c3 +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; +step s3b: BEGIN ISOLATION LEVEL SERIALIZABLE, READ ONLY; +step wx2: UPDATE ctl SET deposit_date = DATE '2008-12-23' WHERE k = 'receipt'; +step rxwy1: INSERT INTO receipt VALUES (3, (SELECT deposit_date FROM ctl WHERE k = 'receipt'), 4.00); +step c2: COMMIT; +step rx3: SELECT * FROM ctl WHERE k = 'receipt'; +k |deposit_date +-------+------------ +receipt| 12-23-2008 +(1 row) + +step c1: COMMIT; +step ry3: SELECT * FROM receipt WHERE deposit_date = DATE '2008-12-22'; +ERROR: could not serialize access due to read/write dependencies among transactions +step c3: COMMIT; diff --git a/src/test/isolation/expected/flux_ssi_copy.out b/src/test/isolation/expected/flux_ssi_copy.out new file mode 100644 index 0000000000000..36ee6d84dfce9 --- /dev/null +++ b/src/test/isolation/expected/flux_ssi_copy.out @@ -0,0 +1,22 @@ +Parsed test spec with 2 sessions + +starting permutation: b1 b2 r1 r2 w1 c1 w2 c2 +step b1: BEGIN ISOLATION LEVEL SERIALIZABLE; +step b2: BEGIN ISOLATION LEVEL SERIALIZABLE; +step r1: SELECT count(*) FROM a; +count +----- + 0 +(1 row) + +step r2: SELECT count(*) FROM b; +count +----- + 0 +(1 row) + +step w1: COPY b FROM PROGRAM 'printf "1\n"'; +step c1: COMMIT; +step w2: COPY a FROM PROGRAM 'printf "2\n"'; +ERROR: could not serialize access due to read/write dependencies among transactions +step c2: COMMIT; diff --git a/src/test/isolation/expected/flux_ssi_revive.out b/src/test/isolation/expected/flux_ssi_revive.out new file mode 100644 index 0000000000000..7301e2dcb6f04 --- /dev/null +++ b/src/test/isolation/expected/flux_ssi_revive.out @@ -0,0 +1,145 @@ +Parsed test spec with 3 sessions + +starting permutation: s1b s1r s3a s3b s2b s2r s2w s1w s1c s2c s3chk +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s1r: SELECT count(*) FROM t WHERE k = 1; +count +----- + 0 +(1 row) + +step s3a: UPDATE t SET k = 1 WHERE id = 1; +step s3b: UPDATE t SET k = 2 WHERE id = 1; +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s2r: SELECT v FROM u WHERE id = 1; +v +- +0 +(1 row) + +step s2w: UPDATE t SET k = 1 WHERE id = 1; +step s1w: UPDATE u SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +ERROR: could not serialize access due to read/write dependencies among transactions +step s3chk: SELECT id, k FROM t ORDER BY id; SELECT * FROM u; +id|k +--+- + 1|2 + 2|5 + 3|7 +(3 rows) + +id|v +--+- + 1|1 +(1 row) + + +starting permutation: s1b s1r s3a s3b s2b s2r s2w s2c s1w s1c s3chk +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s1r: SELECT count(*) FROM t WHERE k = 1; +count +----- + 0 +(1 row) + +step s3a: UPDATE t SET k = 1 WHERE id = 1; +step s3b: UPDATE t SET k = 2 WHERE id = 1; +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s2r: SELECT v FROM u WHERE id = 1; +v +- +0 +(1 row) + +step s2w: UPDATE t SET k = 1 WHERE id = 1; +step s2c: COMMIT; +step s1w: UPDATE u SET v = v + 1 WHERE id = 1; +ERROR: could not serialize access due to read/write dependencies among transactions +step s1c: COMMIT; +step s3chk: SELECT id, k FROM t ORDER BY id; SELECT * FROM u; +id|k +--+- + 1|1 + 2|5 + 3|7 +(3 rows) + +id|v +--+- + 1|0 +(1 row) + + +starting permutation: s3a s3b s1b s1r s2b s2r s2w s1w s1c s2c s3chk +step s3a: UPDATE t SET k = 1 WHERE id = 1; +step s3b: UPDATE t SET k = 2 WHERE id = 1; +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s1r: SELECT count(*) FROM t WHERE k = 1; +count +----- + 0 +(1 row) + +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s2r: SELECT v FROM u WHERE id = 1; +v +- +0 +(1 row) + +step s2w: UPDATE t SET k = 1 WHERE id = 1; +step s1w: UPDATE u SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +ERROR: could not serialize access due to read/write dependencies among transactions +step s3chk: SELECT id, k FROM t ORDER BY id; SELECT * FROM u; +id|k +--+- + 1|2 + 2|5 + 3|7 +(3 rows) + +id|v +--+- + 1|1 +(1 row) + + +starting permutation: s3a s3b s1b s1r5 s2b s2r s2w s1w s1c s2c s3chk +step s3a: UPDATE t SET k = 1 WHERE id = 1; +step s3b: UPDATE t SET k = 2 WHERE id = 1; +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s1r5: SELECT count(*) FROM t WHERE k = 5; +count +----- + 1 +(1 row) + +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s2r: SELECT v FROM u WHERE id = 1; +v +- +0 +(1 row) + +step s2w: UPDATE t SET k = 1 WHERE id = 1; +step s1w: UPDATE u SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +ERROR: could not serialize access due to read/write dependencies among transactions +step s3chk: SELECT id, k FROM t ORDER BY id; SELECT * FROM u; +id|k +--+- + 1|2 + 2|5 + 3|7 +(3 rows) + +id|v +--+- + 1|1 +(1 row) + diff --git a/src/test/isolation/expected/flux_tid_fetch_snapshot.out b/src/test/isolation/expected/flux_tid_fetch_snapshot.out new file mode 100644 index 0000000000000..e2c2d4a67f13b --- /dev/null +++ b/src/test/isolation/expected/flux_tid_fetch_snapshot.out @@ -0,0 +1,103 @@ +Parsed test spec with 2 sessions + +starting permutation: s2b s2u s1b s1lock s2c s1c +step s2b: BEGIN; +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1lock: SELECT * FROM acc a1, acc a2 WHERE a1.id = a2.id ORDER BY a1.id FOR UPDATE OF a1; +step s2c: COMMIT; +step s1lock: <... completed> +id | bal|id |bal +--------+----+--------+--- +checking|1050|checking|600 +savings | 600|savings |600 +(2 rows) + +step s1c: COMMIT; + +starting permutation: s2b s2u s2uo s1b s1join s2c s1c +step s2b: BEGIN; +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s2uo: UPDATE oth SET v = v + 10 WHERE id = 'checking'; +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1join: SELECT * FROM acc a JOIN oth o USING (id) ORDER BY id FOR UPDATE OF a; +step s2c: COMMIT; +step s1join: <... completed> +id | bal|v +--------+----+- +checking|1050|1 +savings | 600|1 +(2 rows) + +step s1c: COMMIT; + +starting permutation: s2b s2u s2do s1b s1join s2c s1c +step s2b: BEGIN; +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s2do: DELETE FROM oth WHERE id = 'checking'; +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1join: SELECT * FROM acc a JOIN oth o USING (id) ORDER BY id FOR UPDATE OF a; +step s2c: COMMIT; +step s1join: <... completed> +id | bal|v +--------+----+- +checking|1050|1 +savings | 600|1 +(2 rows) + +step s1c: COMMIT; + +starting permutation: s1brr s1sel s2u s1tid s1c +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1sel: SELECT * FROM acc ORDER BY id; +id |bal +--------+--- +checking|600 +savings |600 +(2 rows) + +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s1tid: SELECT * FROM acc WHERE ctid = '(0,1)'; +id |bal +--------+--- +checking|600 +(1 row) + +step s1c: COMMIT; + +starting permutation: s1brr s1sel s2d s1tid s1c +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1sel: SELECT * FROM acc ORDER BY id; +id |bal +--------+--- +checking|600 +savings |600 +(2 rows) + +step s2d: DELETE FROM acc WHERE id = 'checking'; +step s1tid: SELECT * FROM acc WHERE ctid = '(0,1)'; +id |bal +--------+--- +checking|600 +(1 row) + +step s1c: COMMIT; + +starting permutation: s1brr s1sel s2u s2u s1tid s1c +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1sel: SELECT * FROM acc ORDER BY id; +id |bal +--------+--- +checking|600 +savings |600 +(2 rows) + +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s1tid: SELECT * FROM acc WHERE ctid = '(0,1)'; +id |bal +--------+--- +checking|600 +(1 row) + +step s1c: COMMIT; diff --git a/src/test/isolation/expected/flux_update_deadlock.out b/src/test/isolation/expected/flux_update_deadlock.out new file mode 100644 index 0000000000000..be458774fea7b --- /dev/null +++ b/src/test/isolation/expected/flux_update_deadlock.out @@ -0,0 +1,83 @@ +Parsed test spec with 4 sessions + +starting permutation: s1u1 s2u2 s1u2 s2u1 s1c s2c s3c chk +step s1u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); +step s2u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); +step s1u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); +step s2u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); +step s1u2: <... completed> +step s2u1: <... completed> +ERROR: deadlock detected +step s1c: COMMIT; +step s2c: COMMIT; +step s3c: COMMIT; +step chk: SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; +id|v|committed +--+-+--------- + 1|1| 1 + 2|1| 1 +(2 rows) + + +starting permutation: s1u1 s2u2 s3u1 s1u2 s2u1 s1c s2c s3c chk +step s1u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); +step s2u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); +step s3u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's3'); +step s1u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); +step s2u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); +step s1u2: <... completed> +step s2u1: <... completed> +ERROR: deadlock detected +step s1c: COMMIT; +step s3u1: <... completed> +step s2c: COMMIT; +step s3c: COMMIT; +step chk: SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; +id|v|committed +--+-+--------- + 1|2| 2 + 2|1| 1 +(2 rows) + + +starting permutation: s2u1 s1u2 s3u1 s2u2 s1u1 s3c s1c s2c chk +step s2u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); +step s1u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); +step s3u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's3'); +step s2u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); +step s1u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); +step s3u1: <... completed> +step s2u2: <... completed> +ERROR: deadlock detected +step s3c: COMMIT; +step s1u1: <... completed> +step s1c: COMMIT; +step s2c: COMMIT; +step chk: SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; +id|v|committed +--+-+--------- + 1|2| 2 + 2|1| 1 +(2 rows) + + +starting permutation: s1u1 s2u2 s3u2 s1u2 s2u1 s3c s1c s2c chk +step s1u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); +step s2u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); +step s3u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's3'); +step s1u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); +step s2u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); +step s3u2: <... completed> +step s2u1: <... completed> +ERROR: deadlock detected +step s3c: COMMIT; +step s1u2: <... completed> +step s1c: COMMIT; +step s2c: COMMIT; +step chk: SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; +id|v|committed +--+-+--------- + 1|1| 1 + 2|2| 2 +(2 rows) + diff --git a/src/test/isolation/expected/flux_update_key_waiter.out b/src/test/isolation/expected/flux_update_key_waiter.out new file mode 100644 index 0000000000000..364ab4a152367 --- /dev/null +++ b/src/test/isolation/expected/flux_update_key_waiter.out @@ -0,0 +1,259 @@ +Parsed test spec with 3 sessions + +starting permutation: s1b s1k s2b s2k s1r s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1r: ROLLBACK; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|0 +(1 row) + +id| k|v +--+---+- + 3|100|0 +(1 row) + + +starting permutation: s1b s1k s2b s2k s1c s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1c: COMMIT; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|0 +(1 row) + +id| k|v +--+---+- + 3|100|0 +(1 row) + + +starting permutation: s1b s1k s2b s2k2 s1r s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2k2: UPDATE kw SET k = 200 WHERE id = 3; +step s1r: ROLLBACK; +step s2k2: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|200|0 +(1 row) + +id| k|v +--+---+- + 3|200|0 +(1 row) + + +starting permutation: s1b s1k s2b s2k2 s1c s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2k2: UPDATE kw SET k = 200 WHERE id = 3; +step s1c: COMMIT; +step s2k2: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|200|0 +(1 row) + +id| k|v +--+---+- + 3|200|0 +(1 row) + + +starting permutation: s1b s1k s2b s2v s1r s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2v: UPDATE kw SET v = 2 WHERE id = 3; +step s1r: ROLLBACK; +step s2v: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id|k|v +--+-+- + 3|3|2 +(1 row) + +id|k|v +--+-+- + 3|3|2 +(1 row) + + +starting permutation: s1b s1k s2b s2v s1c s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2v: UPDATE kw SET v = 2 WHERE id = 3; +step s1c: COMMIT; +step s2v: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|2 +(1 row) + +id| k|v +--+---+- + 3|100|2 +(1 row) + + +starting permutation: s1b s1v s2b s2k s1r s2c chk +step s1b: BEGIN; +step s1v: UPDATE kw SET v = 1 WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1r: ROLLBACK; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|0 +(1 row) + +id| k|v +--+---+- + 3|100|0 +(1 row) + + +starting permutation: s1b s1v s2b s2k s1c s2c chk +step s1b: BEGIN; +step s1v: UPDATE kw SET v = 1 WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1c: COMMIT; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|1 +(1 row) + +id| k|v +--+---+- + 3|100|1 +(1 row) + + +starting permutation: s1b s1d s2b s2k s1r s2c chk +step s1b: BEGIN; +step s1d: DELETE FROM kw WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1r: ROLLBACK; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|0 +(1 row) + +id| k|v +--+---+- + 3|100|0 +(1 row) + + +starting permutation: s1b s1d s2b s2k s1c s2c chk +step s1b: BEGIN; +step s1d: DELETE FROM kw WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1c: COMMIT; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id|k|v +--+-+- +(0 rows) + +id|k|v +--+-+- +(0 rows) + diff --git a/src/test/isolation/expected/flux_update_serialize.out b/src/test/isolation/expected/flux_update_serialize.out new file mode 100644 index 0000000000000..f448ed79acc5b --- /dev/null +++ b/src/test/isolation/expected/flux_update_serialize.out @@ -0,0 +1,169 @@ +Parsed test spec with 3 sessions + +starting permutation: s1rc s2rc s1u s2u s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s2rc s1u s2u s1a s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1a: ROLLBACK; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rr s2snap s1u s2u s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT v FROM fus WHERE id = 1; +v +- +0 +(1 row) + +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rr s2snap s1u s2u s1a s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT v FROM fus WHERE id = 1; +v +- +0 +(1 row) + +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1a: ROLLBACK; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rr s2snap s1u s1c s2u s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT v FROM fus WHERE id = 1; +v +- +0 +(1 row) + +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rc s1ks s2u s1u s2c s1c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1ks: SELECT id FROM fus WHERE id = 1 FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2c: COMMIT; +step s1u: <... completed> +step s1c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s2rc s1fu s2u s1u s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1fu: SELECT id FROM fus WHERE id = 1 FOR UPDATE; +id +-- + 1 +(1 row) + +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s2rc s1oc s2oc s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1oc: INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; +step s2oc: INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; +step s1c: COMMIT; +step s2oc: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s1big s1locks s1c s1bigsum +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1big: UPDATE fus_big SET v = v + 1; +step s1locks: SELECT count(*) AS tuple_locks FROM pg_locks WHERE locktype = 'tuple' AND pid = pg_backend_pid(); +tuple_locks +----------- + 0 +(1 row) + +step s1c: COMMIT; +step s1bigsum: SELECT sum(v) FROM fus_big; + sum +----- +20000 +(1 row) + diff --git a/src/test/isolation/expected/flux_update_victim.out b/src/test/isolation/expected/flux_update_victim.out new file mode 100644 index 0000000000000..7932687a2cc9c --- /dev/null +++ b/src/test/isolation/expected/flux_update_victim.out @@ -0,0 +1,63 @@ +Parsed test spec with 4 sessions + +starting permutation: s2u2 s1u1 s2u1 s1u2 s1c s2c chk +step s2u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s2u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s1u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u2: <... completed> +ERROR: deadlock detected +step s2u1: <... completed> +step s1c: COMMIT; +step s2c: COMMIT; +step chk: SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; +id|v|committed +--+-+--------- + 1|1| 1 + 2|1| 1 +(2 rows) + + +starting permutation: s2u2 s1u1 s3u2 s2u1 s1u2 s1c s2c s3c chk +s3: WARNING: there is already a transaction in progress +step s2u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s3u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s2u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s1u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u2: <... completed> +ERROR: deadlock detected +step s3u2: <... completed> +ERROR: deadlock detected +step s2u1: <... completed> +step s1c: COMMIT; +step s2c: COMMIT; +step s3c: COMMIT; +step chk: SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; +id|v|committed +--+-+--------- + 1|1| 1 + 2|1| 1 +(2 rows) + + +starting permutation: s1u1 s3u2 s2u1 s1c s2u2 s3u1 s2c s3c chk +step s1u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s3u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s2u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s1c: COMMIT; +step s2u1: <... completed> +step s2u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s3u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s2u2: <... completed> +step s3u1: <... completed> +ERROR: deadlock detected +step s2c: COMMIT; +step s3c: COMMIT; +step chk: SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; +id|v|committed +--+-+--------- + 1|2| 2 + 2|1| 1 +(2 rows) + diff --git a/src/test/isolation/expected/flux_update_waiter_abort.out b/src/test/isolation/expected/flux_update_waiter_abort.out new file mode 100644 index 0000000000000..5662a22502426 --- /dev/null +++ b/src/test/isolation/expected/flux_update_waiter_abort.out @@ -0,0 +1,338 @@ +Parsed test spec with 4 sessions + +starting permutation: s1b s1w s2b s2u s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +v| w|v| w +-+--+-+-- +0|10|1|10 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v| w +--+-+-- + 1|1|10 + 2|0| 0 +(2 rows) + + +starting permutation: s1b s1w s2b s2u s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1w s2rr s2u s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v| w +--+-+-- + 1|0|10 + 2|0| 0 +(2 rows) + + +starting permutation: s1b s1w s2rr s2u s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1sp s1w s2b s2u s1rs s1c s2c chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1rs: ROLLBACK TO a; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1w s1sp s1w2 s2b s2u s1rs s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1w2: UPDATE vh SET w = 20 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s2u: <... completed> +v| w|v| w +-+--+-+-- +0|10|1|10 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v| w +--+-+-- + 1|1|10 + 2|0| 0 +(2 rows) + + +starting permutation: s1b s1w s2b s2d s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2d: DELETE FROM vh WHERE id = 1 RETURNING *; +step s1c: COMMIT; +step s2d: <... completed> +id|v| w +--+-+-- + 1|0|10 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 2|0|0 +(1 row) + + +starting permutation: s1b s1w s2b s2d s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2d: DELETE FROM vh WHERE id = 1 RETURNING *; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v|w +--+-+- + 1|0|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 2|0|0 +(1 row) + + +starting permutation: s1b s1w s2b s2l s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2l: SELECT * FROM vh WHERE id = 1 FOR UPDATE; +step s1r: ROLLBACK; +step s2l: <... completed> +id|v|w +--+-+- + 1|0|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|0|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1w s2b s3b s2u s3u s1c s2c s3c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s3b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s3u: UPDATE vh SET v = v + 10 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +v| w|v| w +-+--+-+-- +0|10|1|10 +(1 row) + +step s2c: COMMIT; +step s3u: <... completed> +v| w| v| w +-+--+--+-- +1|10|11|10 +(1 row) + +step s3c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id| v| w +--+--+-- + 1|11|10 + 2| 0| 0 +(2 rows) + + +starting permutation: s1b s1w s2b s3b s2u s3u s1r s2c s3c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s3b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s3u: UPDATE vh SET v = v + 10 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step s3u: <... completed> +v|w| v|w +-+-+--+- +1|0|11|0 +(1 row) + +step s3c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id| v|w +--+--+- + 1|11|0 + 2| 0|0 +(2 rows) + + +starting permutation: s1b s1k s2b s2u s1c s2c chk +step s1b: BEGIN; +step s1k: UPDATE vh SET id = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +v|w|v|w +-+-+-+- +(0 rows) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 2|0|0 +10|0|0 +(2 rows) + + +starting permutation: s1b s1k s2b s2u s1r s2c chk +step s1b: BEGIN; +step s1k: UPDATE vh SET id = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s2b s2w5 s2u s2c chk +step s2b: BEGIN; +step s2w5: UPDATE vh SET w = 5 WHERE id = 1; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +v|w|v|w +-+-+-+- +0|5|1|5 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|5 + 2|0|0 +(2 rows) + + +starting permutation: s1b s2b s3b s1u1 s3u2 s2u s3u1 s1u2 s1c s2h s2c s3c chkh +step s1b: BEGIN; +step s2b: BEGIN; +step s3b: BEGIN; +step s1u1: UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); +step s3u2: UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s3u1: UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); +step s1u2: UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); +step s1u2: <... completed> +ERROR: deadlock detected +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s1c: COMMIT; +step s2h: INSERT INTO vh_hist VALUES (1); +step s2c: COMMIT; +step s3u1: <... completed> +step s3c: COMMIT; +step chkh: SELECT f.id, f.v, f.w, count(h.id) AS committed FROM vh f LEFT JOIN vh_hist h USING (id) GROUP BY f.id, f.v, f.w ORDER BY f.id; +id|v|w|committed +--+-+-+--------- + 1|2|0| 2 + 2|1|0| 1 +(2 rows) + diff --git a/src/test/isolation/expected/flux_vacuum_move.out b/src/test/isolation/expected/flux_vacuum_move.out new file mode 100644 index 0000000000000..1c7b9f343bfaf --- /dev/null +++ b/src/test/isolation/expected/flux_vacuum_move.out @@ -0,0 +1,55 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_upd s2_vac s2_ins s1_idx s1_bmp s1_rollback s2_check +step s1_upd: BEGIN; UPDATE fvmove SET v = 7 WHERE id = 400; +step s2_vac: VACUUM fvmove; +step s2_ins: INSERT INTO fvmove SELECT g, 100, repeat('n', 200) FROM generate_series(9000, 9800) g; +step s1_idx: SELECT id, v FROM fvmove WHERE id = 400; + id|v +---+- +400|7 +(1 row) + +step s1_bmp: SET enable_indexscan = off; SET enable_bitmapscan = on; SELECT id, v FROM fvmove WHERE id = 400; + id|v +---+- +400|7 +(1 row) + +step s1_rollback: ROLLBACK; +step s2_check: + SET enable_indexscan = off; SET enable_bitmapscan = off; + SELECT count(*) AS n, count(DISTINCT id) AS ids, sum(v) AS sumv, + count(*) FILTER (WHERE id = 400) AS n400, + max(v) FILTER (WHERE id = 400) AS v400 + FROM fvmove; + + n|ids| sumv|n400|v400 +---+---+-----+----+---- +901|901|80100| 1| 0 +(1 row) + + +starting permutation: s1_upd s2_vac s2_ins s1_idx s1_commit s2_check +step s1_upd: BEGIN; UPDATE fvmove SET v = 7 WHERE id = 400; +step s2_vac: VACUUM fvmove; +step s2_ins: INSERT INTO fvmove SELECT g, 100, repeat('n', 200) FROM generate_series(9000, 9800) g; +step s1_idx: SELECT id, v FROM fvmove WHERE id = 400; + id|v +---+- +400|7 +(1 row) + +step s1_commit: COMMIT; +step s2_check: + SET enable_indexscan = off; SET enable_bitmapscan = off; + SELECT count(*) AS n, count(DISTINCT id) AS ids, sum(v) AS sumv, + count(*) FILTER (WHERE id = 400) AS n400, + max(v) FILTER (WHERE id = 400) AS v400 + FROM fvmove; + + n|ids| sumv|n400|v400 +---+---+-----+----+---- +901|901|80107| 1| 7 +(1 row) + diff --git a/src/test/isolation/expected/heap_cluster_sort_snapshot.out b/src/test/isolation/expected/heap_cluster_sort_snapshot.out new file mode 100644 index 0000000000000..42ab04ae1cafc --- /dev/null +++ b/src/test/isolation/expected/heap_cluster_sort_snapshot.out @@ -0,0 +1,311 @@ +Parsed test spec with 2 sessions + +starting permutation: s1b s2d s2cle s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2d: DELETE FROM cs WHERE k > 10; +step s2cle: CLUSTER cs USING cs_e; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 10| 1| 10| 55 +(1 row) + + +starting permutation: s1b s2d s2clb s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2d: DELETE FROM cs WHERE k > 10; +step s2clb: CLUSTER cs USING cs_b; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 10| 1| 10| 55 +(1 row) + + +starting permutation: s1b s2d s2clg s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2d: DELETE FROM cs WHERE k > 10; +step s2clg: CLUSTER cs USING cs_g; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 10| 1| 10| 55 +(1 row) + + +starting permutation: s1b s2d s2vf s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2d: DELETE FROM cs WHERE k > 10; +step s2vf: VACUUM FULL cs; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 10| 1| 10| 55 +(1 row) + + +starting permutation: s1b s2u s2cle s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2u: UPDATE cs SET v = v + 100; +step s2cle: CLUSTER cs USING cs_e; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max| sum +-----+---+---+---- + 20| 1| 20|2210 +(1 row) + + +starting permutation: s1b s2u s2u s2clg s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2u: UPDATE cs SET v = v + 100; +step s2u: UPDATE cs SET v = v + 100; +step s2clg: CLUSTER cs USING cs_g; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max| sum +-----+---+---+---- + 20| 1| 20|4210 +(1 row) + + +starting permutation: s1b s2u s2d s2vf s1r s1ri s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2u: UPDATE cs SET v = v + 100; +step s2d: DELETE FROM cs WHERE k > 10; +step s2vf: VACUUM FULL cs; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max| sum +-----+---+---+---- + 10| 1| 10|1055 +(1 row) + + +starting permutation: s1b s2i s2u s2vff s1r s1ri s2flush s2v s2dead s1c s2v s2dead s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2i: INSERT INTO cs VALUES (21, 21, point(21, 21)); +step s2u: UPDATE cs SET v = v + 100; +step s2vff: VACUUM (FULL, FREEZE) cs; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1ri: + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; + +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s2flush: SELECT pg_stat_force_next_flush(); +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s2v: VACUUM cs; +step s2dead: SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'cs'; +n_dead_tup +---------- + 21 +(1 row) + +step s1c: COMMIT; +step s2v: VACUUM cs; +step s2dead: SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'cs'; +n_dead_tup +---------- + 0 +(1 row) + +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max| sum +-----+---+---+---- + 21| 1| 21|2331 +(1 row) + + +starting permutation: s1b s2d s2u s1r s1c s2chk +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; +count +----- + 0 +(1 row) + +step s2d: DELETE FROM cs WHERE k > 10; +step s2u: UPDATE cs SET v = v + 100; +step s1r: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max|sum +-----+---+---+--- + 20| 1| 20|210 +(1 row) + +step s1c: COMMIT; +step s2chk: SELECT count(*), min(k), max(k), sum(v) FROM cs; +count|min|max| sum +-----+---+---+---- + 10| 1| 10|1055 +(1 row) + diff --git a/src/test/isolation/expected/heap_fk_crosscheck.out b/src/test/isolation/expected/heap_fk_crosscheck.out new file mode 100644 index 0000000000000..ee365c657fcb9 --- /dev/null +++ b/src/test/isolation/expected/heap_fk_crosscheck.out @@ -0,0 +1,81 @@ +Parsed test spec with 2 sessions + +starting permutation: s1brr s1ic s2brr s2d s1c s2c s1sel +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1ic: INSERT INTO fkc VALUES (2); +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2d: DELETE FROM pk WHERE a = 2; +step s1c: COMMIT; +step s2d: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s1sel: SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; +t |a +---+- +fkc|2 +pk |2 +(2 rows) + + +starting permutation: s1brr s1in s2brr s2d s1c s2c s1sel +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1in: INSERT INTO fkn VALUES (2); +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2d: DELETE FROM pk WHERE a = 2; +step s1c: COMMIT; +step s2d: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s1sel: SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; +t |a +---+- +fkn|2 +pk |2 +(2 rows) + + +starting permutation: s1brr s1ic s2brr s2u s1c s2c s1sel +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1ic: INSERT INTO fkc VALUES (2); +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2u: UPDATE pk SET a = 3 WHERE a = 2; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s1sel: SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; +t |a +---+- +fkc|2 +pk |2 +(2 rows) + + +starting permutation: s1brr s1ic s2brc s2d s1c s2c s1sel +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1ic: INSERT INTO fkc VALUES (2); +step s2brc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2d: DELETE FROM pk WHERE a = 2; +step s1c: COMMIT; +step s2d: <... completed> +step s2c: COMMIT; +step s1sel: SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; +t|a +-+- +(0 rows) + + +starting permutation: s1brr s1in s2brc s2d s1c s2c s1sel +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1in: INSERT INTO fkn VALUES (2); +step s2brc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2d: DELETE FROM pk WHERE a = 2; +step s1c: COMMIT; +step s2d: <... completed> +step s2c: COMMIT; +step s1sel: SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; +t |a +---+- +fkn| +(1 row) + diff --git a/src/test/isolation/expected/heap_lock_delete_lockers.out b/src/test/isolation/expected/heap_lock_delete_lockers.out new file mode 100644 index 0000000000000..19ca57d28421b --- /dev/null +++ b/src/test/isolation/expected/heap_lock_delete_lockers.out @@ -0,0 +1,51 @@ +Parsed test spec with 3 sessions + +starting permutation: s1b s1l s2b s2l s3d s1r s2c s3chk +step s1b: BEGIN; +step s1l: INSERT INTO child VALUES (1); +step s2b: BEGIN; +step s2l: INSERT INTO child VALUES (1); +step s3d: DELETE FROM parent; +step s1r: ROLLBACK; +step s2c: COMMIT; +step s3d: <... completed> +ERROR: update or delete on table "parent" violates foreign key constraint "child_i_fkey" on table "child" +step s3chk: SELECT (SELECT count(*) FROM parent) AS parents, (SELECT count(*) FROM child) AS children; +parents|children +-------+-------- + 1| 1 +(1 row) + + +starting permutation: s1b s1l s2b s2l s3d s2r s1c s3chk +step s1b: BEGIN; +step s1l: INSERT INTO child VALUES (1); +step s2b: BEGIN; +step s2l: INSERT INTO child VALUES (1); +step s3d: DELETE FROM parent; +step s2r: ROLLBACK; +step s1c: COMMIT; +step s3d: <... completed> +ERROR: update or delete on table "parent" violates foreign key constraint "child_i_fkey" on table "child" +step s3chk: SELECT (SELECT count(*) FROM parent) AS parents, (SELECT count(*) FROM child) AS children; +parents|children +-------+-------- + 1| 1 +(1 row) + + +starting permutation: s1b s1l s2b s2l s3d s1r s2r s3chk +step s1b: BEGIN; +step s1l: INSERT INTO child VALUES (1); +step s2b: BEGIN; +step s2l: INSERT INTO child VALUES (1); +step s3d: DELETE FROM parent; +step s1r: ROLLBACK; +step s2r: ROLLBACK; +step s3d: <... completed> +step s3chk: SELECT (SELECT count(*) FROM parent) AS parents, (SELECT count(*) FROM child) AS children; +parents|children +-------+-------- + 0| 0 +(1 row) + diff --git a/src/test/isolation/expected/heap_lock_recheck.out b/src/test/isolation/expected/heap_lock_recheck.out new file mode 100644 index 0000000000000..75ea7061b4b0c --- /dev/null +++ b/src/test/isolation/expected/heap_lock_recheck.out @@ -0,0 +1,2626 @@ +Parsed test spec with 5 sessions + +starting permutation: s1b s1u s2rc s2fu s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1c: COMMIT; +step s2fu: <... completed> +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2fnk s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fnk: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR NO KEY UPDATE; +step s1c: COMMIT; +step s2fnk: <... completed> +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2fs s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fs: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR SHARE; +step s1c: COMMIT; +step s2fs: <... completed> +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2fks s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1c: COMMIT; +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1k s2rc s2fks s1c s2c s3r +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +step s1c: COMMIT; +step s2fks: <... completed> +id|v|w +--+-+- +10|1|0 + 2|1|0 +(2 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2sl s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2sl: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE SKIP LOCKED; +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2nw s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2nw: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE NOWAIT; +ERROR: could not obtain lock on row in relation "lr" +step s1c: COMMIT; +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2upd s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2upd: UPDATE lr SET w = w + 1 WHERE v = 1 RETURNING *; +step s1c: COMMIT; +step s2upd: <... completed> +id|v|w +--+-+- + 2|1|1 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|1 +(2 rows) + + +starting permutation: s1b s1d s2rc s2fu s1c s2c s3r +step s1b: BEGIN; +step s1d: DELETE FROM lr WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1c: COMMIT; +step s2fu: <... completed> +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +(1 row) + + +starting permutation: s1b s1u s2rc s2fu s1a s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1a: ROLLBACK; +step s2fu: <... completed> +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2upd s1a s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2upd: UPDATE lr SET w = w + 1 WHERE v = 1 RETURNING *; +step s1a: ROLLBACK; +step s2upd: <... completed> +id|v|w +--+-+- + 1|1|1 + 2|1|1 +(2 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|1 + 2|1|1 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s2fu s1c s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1c: COMMIT; +step s2fu: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2fu s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2fnk s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2fnk: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2fs s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2fs: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2sl s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2sl: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE SKIP LOCKED; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2nw s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2nw: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE NOWAIT; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2upd s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2upd: UPDATE lr SET w = w + 1 WHERE v = 1 RETURNING *; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2fks s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1k s1c s2fks s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s1c: COMMIT; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s2fu s1a s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1a: ROLLBACK; +step s2fu: <... completed> +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1d s2fu s1c s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1d: DELETE FROM lr WHERE id = 1; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +step s1c: COMMIT; +step s2fu: <... completed> +ERROR: could not serialize access due to concurrent delete +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +(1 row) + + +starting permutation: s1b s1pk s2rc s2ci s1c s2c s3c +step s1b: BEGIN; +step s1pk: UPDATE lrp SET id = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1c: COMMIT; +step s2ci: <... completed> +ERROR: insert or update on table "lrc" violates foreign key constraint "lrc_pid_fkey" +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s1b s1pfu s2rc s2ci s1pn s1c s2c s3c +step s1b: BEGIN; +step s1pfu: SELECT id FROM lrp WHERE id = 1 FOR UPDATE; +id +-- + 1 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1pn: UPDATE lrp SET note = 1 WHERE id = 1; +step s1c: COMMIT; +step s2ci: <... completed> +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s1b s1pk s2rc s2ci s1a s2c s3c +step s1b: BEGIN; +step s1pk: UPDATE lrp SET id = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1a: ROLLBACK; +step s2ci: <... completed> +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s1b s1tu s2rc s2tu s1c s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2tu: UPDATE lrt SET w = w + 1 WHERE v = 1 RETURNING *; +step s1c: COMMIT; +step s2tu: <... completed> +id|v|w +--+-+- +(0 rows) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|0 +(1 row) + + +starting permutation: s1b s1tu s2rc s2tu s1a s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2tu: UPDATE lrt SET w = w + 1 WHERE v = 1 RETURNING *; +step s1a: ROLLBACK; +s2: NOTICE: lrt_bu: OLD.v=1 NEW.v=1 +step s2tu: <... completed> +id|v|w +--+-+- + 1|1|1 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|1|1 +(1 row) + + +starting permutation: s2rr s2snap s1b s1tu s2tu s1c s2c s3t +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2tu: UPDATE lrt SET w = w + 1 WHERE v = 1 RETURNING *; +step s1c: COMMIT; +step s2tu: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|0 +(1 row) + + +starting permutation: s1b s1u s2rc s2jfu s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2jfu: SELECT lr.* FROM lr JOIN (VALUES (1), (1), (2)) s(id) USING (id) WHERE lr.v = 1 ORDER BY lr.id FOR UPDATE OF lr; +step s1c: COMMIT; +step s2jfu: <... completed> +id|v|w +--+-+- + 2|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1tu s2rc s2mq s1c s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mq: MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id AND lrt.v = 1 WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; +step s1c: COMMIT; +step s2mq: <... completed> +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|0 +(1 row) + + +starting permutation: s1b s1tu s2rc s2mu s1c s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mu: MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; +step s1c: COMMIT; +s2: NOTICE: lrt_bu: OLD.v=2 NEW.v=2 +step s2mu: <... completed> +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|1 +(1 row) + + +starting permutation: s2rr s2snap s1b s1tu s2mu s1c s2c s3t +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2mu: MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; +step s1c: COMMIT; +step s2mu: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|0 +(1 row) + + +starting permutation: s1b s1u s2rc s2oc s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1c: COMMIT; +step s2oc: <... completed> +id|v|w +--+-+- + 1|2|1 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|1 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s2oc s1c s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1c: COMMIT; +step s2oc: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1u s1c s2oc s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s4pkk s4pn101 s2pks s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s4pn101 s2ci s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2ci: INSERT INTO lrc VALUES (1, 1); +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s2rr s2snap s4pkk s4pn101 s2pnk s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rc s2dks s4pkk s4pn101 s2fc s2ci s2c s3c +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dks: DECLARE c CURSOR FOR SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2fc: FETCH ALL FROM c; +id|note +--+---- +(0 rows) + +step s2ci: INSERT INTO lrc VALUES (1, 1); +ERROR: insert or update on table "lrc" violates foreign key constraint "lrc_pid_fkey" +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s2rc s2dka s4pkk s4pn101 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2fc: FETCH ALL FROM c; + id|note +---+---- +101| 11 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rc s2dnk s4pkk s4pn101 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dnk: DECLARE c CURSOR FOR SELECT * FROM lrp FOR NO KEY UPDATE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2fc: FETCH ALL FROM c; + id|note +---+---- +101| 11 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rc s4pkk s4pn101 s2ci s2c s3c +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2ci: INSERT INTO lrc VALUES (1, 1); +ERROR: insert or update on table "lrc" violates foreign key constraint "lrc_pid_fkey" +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s2rr s2snap s4pn1 s4pkk s2pks s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rc s2dka s4pn1 s4pkk s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2fc: FETCH ALL FROM c; + id|note +---+---- +101| 11 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s4pn1 s2pks s2ci s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +id|note +--+---- + 1| 0 +(1 row) + +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s2rc s2dka s4pn1 s4pn1 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 0 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 20 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s4pkb s4pn1 s2pks s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pkb: UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 12 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s4pkb s4pn1 s2ci s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pkb: UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2ci: INSERT INTO lrc VALUES (1, 1); +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s2rc s2dka s4pkk s4pkb s4pn1 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pkb: UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 12 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 12 +(1 row) + + +starting permutation: s2rc s2dks s4pkk s4pkb s4pn1 s2fc s2ci s2c s3c +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dks: DECLARE c CURSOR FOR SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pkb: UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 12 +(1 row) + +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s4pn101 s4pn101 s2pks s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 21 +(1 row) + + +starting permutation: s2rc s2dka s4pkk s4pn101 s4pn101 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2fc: FETCH ALL FROM c; + id|note +---+---- +101| 21 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 21 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s5b s5pn101 s2pks s5c s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step s5c: COMMIT; +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 101 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s5b s5pn101 s2ci s5c s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2ci: INSERT INTO lrc VALUES (1, 1); +ERROR: could not serialize access due to concurrent update +step s5c: COMMIT; +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s2rr s2snap s4pkk s5b s5pn101 s2pnk s5c s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s5c: COMMIT; +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 101 +(1 row) + + +starting permutation: s2rc s2dka s4pkk s5b s5pn101 s2fc s5c s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2fc: FETCH ALL FROM c; +step s5c: COMMIT; +step s2fc: <... completed> + id|note +---+---- +101| 101 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 101 +(1 row) + + +starting permutation: s2rc s2dnk s4pkk s5b s5pn101 s2fc s5c s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dnk: DECLARE c CURSOR FOR SELECT * FROM lrp FOR NO KEY UPDATE; +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2fc: FETCH ALL FROM c; +step s5c: COMMIT; +step s2fc: <... completed> + id|note +---+---- +101| 101 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 101 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s5b s5pkk s2pks s5c s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s5c: COMMIT; +step s2pks: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s5b s5pkk s2pks s5a s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s5a: ROLLBACK; +step s2pks: <... completed> +id|note +--+---- + 1| 0 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 10 +(1 row) + + +starting permutation: s2rc s2dka s4pn1 s5b s5pkk s2fc s5c s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +step s5c: COMMIT; +step s2fc: <... completed> + id|note +---+---- +101| 11 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rc s2dka s4pn1 s5b s5pkk s2fc s5a s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +step s5a: ROLLBACK; +step s2fc: <... completed> +id|note +--+---- + 1| 0 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 10 +(1 row) + + +starting permutation: s5b s5pkk s5pn101 s2rc s2ci s5c s2c s3c +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s5c: COMMIT; +step s2ci: <... completed> +ERROR: insert or update on table "lrc" violates foreign key constraint "lrc_pid_fkey" +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + + +starting permutation: s5b s5pkk s5pn101 s2rc s2ci s5a s2c s3c +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s5a: ROLLBACK; +step s2ci: <... completed> +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s5b s5pkk s5pn101 s2rr s2snap s2pks s5c s2c s3p +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s5c: COMMIT; +step s2pks: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 101 +(1 row) + + +starting permutation: s5b s5pkk s5pn101 s2rr s2snap s2pks s5a s2c s3p +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +step s5a: ROLLBACK; +step s2pks: <... completed> +id|note +--+---- + 1| 0 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 0 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s5b s5pn1 s2pks s2ci s5c s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pn1: UPDATE lrp SET note = note + 100 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +id|note +--+---- + 1| 0 +(1 row) + +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s5c: COMMIT; +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s2rc s2dka s4pn1 s5b s5pn1 s2fc s5c s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pn1: UPDATE lrp SET note = note + 100 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 0 +(1 row) + +step s5c: COMMIT; +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 110 +(1 row) + + +starting permutation: s4pkk s2rr s2snap s4pn101 s2pks101 s2ci101 s2c s3c +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn101: UPDATE lrp SET note = note + 10 WHERE id = 101; +step s2pks101: SELECT * FROM lrp WHERE id = 101 FOR KEY SHARE; + id|note +---+---- +101| 1 +(1 row) + +step s2ci101: INSERT INTO lrc VALUES (2, 101); +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 2|101 +(1 row) + + +starting permutation: s4pkk s2rr s2snap s5b s5pn101 s2pks101 s2ci101 s5c s2c s3c +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s5b: BEGIN; +step s5pn101: UPDATE lrp SET note = note + 100 WHERE id = 101; +step s2pks101: SELECT * FROM lrp WHERE id = 101 FOR KEY SHARE; + id|note +---+---- +101| 1 +(1 row) + +step s2ci101: INSERT INTO lrc VALUES (2, 101); +step s5c: COMMIT; +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 2|101 +(1 row) + + +starting permutation: s2rr s2snap s5b s5pkk s5a s4pn1 s2pks s2ci s2c s3c +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5a: ROLLBACK; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pks: SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; +id|note +--+---- + 1| 0 +(1 row) + +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s2c: COMMIT; +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + + +starting permutation: s2rc s2dka s5b s5pkk s5a s4pn1 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dka: DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5a: ROLLBACK; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 0 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 10 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s4pn1 s2pnk s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 20 +(1 row) + + +starting permutation: s2rc s2dnk s4pn1 s4pn1 s2fc s2c s3p +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2dnk: DECLARE c CURSOR FOR SELECT * FROM lrp FOR NO KEY UPDATE; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2fc: FETCH ALL FROM c; +id|note +--+---- + 1| 20 +(1 row) + +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 20 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s5b s5pkk s2pnk s5c s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s5c: COMMIT; +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; + id|note +---+---- +101| 11 +(1 row) + + +starting permutation: s2rr s2snap s4pn1 s5b s5pkk s2pnk s5a s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s5a: ROLLBACK; +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 10 +(1 row) + + +starting permutation: s2rr s2snap s5b s5pkk s5a s4pn1 s2pnk s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s5b: BEGIN; +step s5pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s5a: ROLLBACK; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 10 +(1 row) + + +starting permutation: s2rr s2snap s4pkk s4pkb s4pn1 s2pnk s2c s3p +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s4pkk: UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; +step s4pkb: UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; +step s4pn1: UPDATE lrp SET note = note + 10 WHERE id = 1; +step s2pnk: SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 12 +(1 row) + + +starting permutation: s1b s1tu s2rc s2toc s1c s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +step s1c: COMMIT; +s2: NOTICE: lrt_bu: OLD.v=2 NEW.v=2 +step s2toc: <... completed> +id|v|w +--+-+- + 1|2|1 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|1 +(1 row) + + +starting permutation: s1b s1tu s1tu2 s2rc s2toc s1c s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +s1: NOTICE: lrt_bu: OLD.v=2 NEW.v=3 +step s1tu2: UPDATE lrt SET v = v + 1, w = w + 10 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +step s1c: COMMIT; +s2: NOTICE: lrt_bu: OLD.v=3 NEW.v=3 +step s2toc: <... completed> +id|v| w +--+-+-- + 1|3|11 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v| w +--+-+-- + 1|3|11 +(1 row) + + +starting permutation: s1b s1tu s2rc s2toc s1a s2c s3t +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +step s1a: ROLLBACK; +s2: NOTICE: lrt_bu: OLD.v=1 NEW.v=1 +step s2toc: <... completed> +id|v|w +--+-+- + 1|1|1 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|1|1 +(1 row) + + +starting permutation: s2rr s2snap s1b s1tu s2toc s1c s2c s3t +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +step s1c: COMMIT; +step s2toc: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|0 +(1 row) + + +starting permutation: s1b s1nu s2rc s2noc s1c s2c s3n +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2noc: INSERT INTO lrn VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrn.w + 1 RETURNING *; +step s1c: COMMIT; +s2: NOTICE: lrn_bu UPDATE: OLD=(1,2,100) NEW=(1,2,101) +step s2noc: <... completed> +id|v| w +--+-+--- + 1|2|201 +(1 row) + +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v| w +--+-+--- + 1|2|201 +(1 row) + + +starting permutation: s1b s1nu s2rc s2noc s1a s2c s3n +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2noc: INSERT INTO lrn VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrn.w + 1 RETURNING *; +step s1a: ROLLBACK; +s2: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,1,1) +step s2noc: <... completed> +id|v| w +--+-+--- + 1|1|101 +(1 row) + +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v| w +--+-+--- + 1|1|101 +(1 row) + + +starting permutation: s1b s1lfu s2rc s2oc s1k s1c s2c s3r +step s1b: BEGIN; +step s1lfu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s1c: COMMIT; +step s2oc: <... completed> +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +10|1|0 +(3 rows) + + +starting permutation: s1b s1tfu s2rc s2toc s1tk s1c s2c s3t +step s1b: BEGIN; +step s1tfu: SELECT * FROM lrt WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=1 +step s1tk: UPDATE lrt SET id = 10 WHERE id = 1; +step s1c: COMMIT; +step s2toc: <... completed> +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|1|0 +10|1|0 +(2 rows) + + +starting permutation: s1b s1lfu s2rc s2oc s1u s1c s2c s3r +step s1b: BEGIN; +step s1lfu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2oc: <... completed> +id|v|w +--+-+- + 1|2|1 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|1 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1tfu s2rc s2toc s1tu s1c s2c s3t +step s1b: BEGIN; +step s1tfu: SELECT * FROM lrt WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2toc: INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; +s1: NOTICE: lrt_bu: OLD.v=1 NEW.v=2 +step s1tu: UPDATE lrt SET v = 2 WHERE id = 1; +step s1c: COMMIT; +s2: NOTICE: lrt_bu: OLD.v=2 NEW.v=2 +step s2toc: <... completed> +id|v|w +--+-+- + 1|2|1 +(1 row) + +step s2c: COMMIT; +step s3t: SELECT * FROM lrt ORDER BY id; +id|v|w +--+-+- + 1|2|1 +(1 row) + + +starting permutation: s1b s1lfu s2rc s2oc s1c s2c s3r +step s1b: BEGIN; +step s1lfu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1c: COMMIT; +step s2oc: <... completed> +id|v|w +--+-+- + 1|1|1 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|1 + 2|1|0 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1lfu s2oc s1u s1c s2c s3r +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +step s1lfu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v|w +--+-+- + 1|1|0 +(1 row) + +step s2oc: INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2oc: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2mlu s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mlu: MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id WHEN MATCHED THEN UPDATE SET w = lr.w + 1 RETURNING merge_action(), lr.*; +step s1c: COMMIT; +step s2mlu: <... completed> +merge_action|id|v|w +------------+--+-+- +UPDATE | 1|2|1 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|1 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2mlq s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mlq: MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id AND lr.v = 1 WHEN MATCHED THEN UPDATE SET w = lr.w + 1 RETURNING merge_action(), lr.*; +step s1c: COMMIT; +step s2mlq: <... completed> +merge_action|id|v|w +------------+--+-+- +(0 rows) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1u s2rc s2mld s1c s2c s3r +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mld: MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id WHEN MATCHED THEN DELETE RETURNING merge_action(), lr.*; +step s1c: COMMIT; +step s2mld: <... completed> +merge_action|id|v|w +------------+--+-+- +DELETE | 1|2|0 +(1 row) + +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +(1 row) + + +starting permutation: s1b s1nu s2rc s2mnu s1c s2c s3n +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mnu: MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; +step s1c: COMMIT; +s2: NOTICE: lrn_bu UPDATE: OLD=(1,2,100) NEW=(1,2,101) +step s2mnu: <... completed> +merge_action|id|v| w +------------+--+-+--- +UPDATE | 1|2|201 +(1 row) + +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v| w +--+-+--- + 1|2|201 +(1 row) + + +starting permutation: s1b s1nu s2rc s2mnq s1c s2c s3n +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mnq: MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id AND lrn.v = 1 WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; +step s1c: COMMIT; +step s2mnq: <... completed> +merge_action|id|v|w +------------+--+-+- +(0 rows) + +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v| w +--+-+--- + 1|2|100 +(1 row) + + +starting permutation: s1b s1nu s2rc s2mnd s1c s2c s3n +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mnd: MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN DELETE RETURNING merge_action(), lrn.*; +step s1c: COMMIT; +s2: NOTICE: lrn_bu DELETE: OLD=(1,2,100) NEW= +step s2mnd: <... completed> +merge_action|id|v| w +------------+--+-+--- +DELETE | 1|2|100 +(1 row) + +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v|w +--+-+- +(0 rows) + + +starting permutation: s2rr s2snap s1b s1nu s2mnu s1c s2c s3n +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +s1: NOTICE: lrn_bu UPDATE: OLD=(1,1,0) NEW=(1,2,0) +step s1nu: UPDATE lrn SET v = 2 WHERE id = 1; +step s2mnu: MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; +step s1c: COMMIT; +step s2mnu: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3n: SELECT * FROM lrn ORDER BY id; +id|v| w +--+-+--- + 1|2|100 +(1 row) + + +starting permutation: s1b s1pu s2rc s2pm s1c s2c s3pt +step s1b: BEGIN; +step s1pu: UPDATE lpt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2pm: UPDATE lpt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; +step s1c: COMMIT; +step s2pm: <... completed> +tableoid|id|v|w +--------+--+-+- +lpt2 | 1|2|5 +(1 row) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt2 | 1|2|5 +lptt1 | 1|1|0 +(2 rows) + + +starting permutation: s1b s1pu s2rc s2pmq s1c s2c s3pt +step s1b: BEGIN; +step s1pu: UPDATE lpt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2pmq: UPDATE lpt SET v = 2 WHERE id = 1 AND w = 0 RETURNING tableoid::regclass, *; +step s1c: COMMIT; +step s2pmq: <... completed> +tableoid|id|v|w +--------+--+-+- +(0 rows) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt1 | 1|1|5 +lptt1 | 1|1|0 +(2 rows) + + +starting permutation: s1b s1ptu s2rc s2ptm s1c s2c s3pt +step s1b: BEGIN; +s1: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,0) NEW=(1,1,5) +step s1ptu: UPDATE lptt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ptm: UPDATE lptt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; +step s1c: COMMIT; +s2: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,5) NEW=(1,2,5) +s2: NOTICE: lptt1 BEFORE DELETE: OLD=(1,1,5) NEW= +step s2ptm: <... completed> +tableoid|id|v|w +--------+--+-+- +lptt2 | 1|2|5 +(1 row) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt1 | 1|1|0 +lptt2 | 1|2|5 +(2 rows) + + +starting permutation: s1b s1ptu s2rc s2ptmq s1c s2c s3pt +step s1b: BEGIN; +s1: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,0) NEW=(1,1,5) +step s1ptu: UPDATE lptt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ptmq: UPDATE lptt SET v = 2 WHERE id = 1 AND w = 0 RETURNING tableoid::regclass, *; +step s1c: COMMIT; +step s2ptmq: <... completed> +tableoid|id|v|w +--------+--+-+- +(0 rows) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt1 | 1|1|0 +lptt1 | 1|1|5 +(2 rows) + + +starting permutation: s1b s1pu s2rc s2mpm s1c s2c s3pt +step s1b: BEGIN; +step s1pu: UPDATE lpt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mpm: MERGE INTO lpt USING (VALUES (1)) s(id) ON lpt.id = s.id WHEN MATCHED THEN UPDATE SET v = 2 RETURNING merge_action(), lpt.*; +step s1c: COMMIT; +step s2mpm: <... completed> +merge_action|id|v|w +------------+--+-+- +UPDATE | 1|2|5 +(1 row) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt2 | 1|2|5 +lptt1 | 1|1|0 +(2 rows) + + +starting permutation: s1b s1ptu s2rc s2mptm s1c s2c s3pt +step s1b: BEGIN; +s1: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,0) NEW=(1,1,5) +step s1ptu: UPDATE lptt SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mptm: MERGE INTO lptt USING (VALUES (1)) s(id) ON lptt.id = s.id WHEN MATCHED THEN UPDATE SET v = 2 RETURNING merge_action(), lptt.*; +step s1c: COMMIT; +s2: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,5) NEW=(1,2,5) +s2: NOTICE: lptt1 BEFORE DELETE: OLD=(1,1,5) NEW= +step s2mptm: <... completed> +merge_action|id|v|w +------------+--+-+- +UPDATE | 1|2|5 +(1 row) + +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt1 | 1|1|0 +lptt2 | 1|2|5 +(2 rows) + + +starting permutation: s2rr s2snap s1b s1ptu s2ptm s1c s2c s3pt +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT count(*) FROM lr; +count +----- + 2 +(1 row) + +step s1b: BEGIN; +s1: NOTICE: lptt1 BEFORE UPDATE: OLD=(1,1,0) NEW=(1,1,5) +step s1ptu: UPDATE lptt SET w = 5 WHERE id = 1; +step s2ptm: UPDATE lptt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; +step s1c: COMMIT; +step s2ptm: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3pt: SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; +tableoid|id|v|w +--------+--+-+- +lpt1 | 1|1|0 +lptt1 | 1|1|5 +(2 rows) + + +starting permutation: s1b s1qcu s2rc s2qd s1c s2c s3q +step s1b: BEGIN; +step s1qcu: SET LOCAL session_replication_role = replica; UPDATE lrqc SET w = 5 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2qd: DELETE FROM lrq WHERE id = 1; +step s1c: COMMIT; +step s2qd: <... completed> +step s2c: COMMIT; +step s3q: SELECT * FROM lrq ORDER BY id; SELECT * FROM lrqc ORDER BY id; +id|note +--+---- +(0 rows) + +id|pid|w +--+---+- +(0 rows) + + +starting permutation: s2rc s2fks s1b s1k s2c s1c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2c: COMMIT; +step s1k: <... completed> +step s1c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s2rc s2fks s1b s1u s1c s2c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1u: UPDATE lr SET v = 2 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|2|0 + 2|1|0 +(2 rows) + + +starting permutation: s2rc s2fs s1b s1k s2c s1c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fs: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2c: COMMIT; +step s1k: <... completed> +step s1c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s2rc s2fnk s1b s1k s2c s1c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fnk: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR NO KEY UPDATE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2c: COMMIT; +step s1k: <... completed> +step s1c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s2rc s2fu s1b s1k s2c s1c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fu: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2c: COMMIT; +step s1k: <... completed> +step s1c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 2|1|0 +10|1|0 +(2 rows) + + +starting permutation: s2rc s2ci s1b s1pk s2c s1c s3p s3c s3orphans +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1b: BEGIN; +step s1pk: UPDATE lrp SET id = 2 WHERE id = 1; +step s2c: COMMIT; +step s1pk: <... completed> +ERROR: update or delete on table "lrp" violates foreign key constraint "lrc_pid_fkey" on table "lrc" +step s1c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 0 +(1 row) + +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + +step s3orphans: SELECT count(*) AS orphans FROM lrc c WHERE NOT EXISTS (SELECT FROM lrp p WHERE p.id = c.pid); +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2ci s1b s1pk s2a s1c s3p s3c s3orphans +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1b: BEGIN; +step s1pk: UPDATE lrp SET id = 2 WHERE id = 1; +step s2a: ROLLBACK; +step s1pk: <... completed> +step s1c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 2| 0 +(1 row) + +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- +(0 rows) + +step s3orphans: SELECT count(*) AS orphans FROM lrc c WHERE NOT EXISTS (SELECT FROM lrp p WHERE p.id = c.pid); +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2uci s1b s1uk s2c s1c s3u +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2uci: INSERT INTO lruc VALUES (1); +step s1b: BEGIN; +step s1uk: UPDATE lrup SET id = 2 WHERE id = 1; +step s2c: COMMIT; +step s1uk: <... completed> +step s1c: COMMIT; +step s3u: SELECT * FROM lrup; SELECT * FROM lruc; SELECT count(*) AS orphans FROM lruc c WHERE NOT EXISTS (SELECT FROM lrup p WHERE p.id = c.pid); +id +-- + 2 +(1 row) + +pid +--- + 2 +(1 row) + +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2rci s1b s1rk s2c s1c s3restrict +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rci: INSERT INTO lrrc VALUES (1); +step s1b: BEGIN; +step s1rk: UPDATE lrrp SET id = 2 WHERE id = 1; +step s2c: COMMIT; +step s1rk: <... completed> +ERROR: update or delete on table "lrrp" violates RESTRICT setting of foreign key constraint "lrrc_pid_fkey" on table "lrrc" +step s1c: COMMIT; +step s3restrict: SELECT * FROM lrrp; SELECT * FROM lrrc; SELECT count(*) AS orphans FROM lrrc c WHERE NOT EXISTS (SELECT FROM lrrp p WHERE p.id = c.pid); +id +-- + 1 +(1 row) + +pid +--- + 1 +(1 row) + +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2fks s1b s1sp s1k s2c s1rb s1c s3r +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2fks: SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + +step s1b: BEGIN; +step s1sp: SAVEPOINT key_update; +step s1k: UPDATE lr SET id = 10 WHERE id = 1; +step s2c: COMMIT; +step s1k: <... completed> +step s1rb: ROLLBACK TO key_update; +step s1c: COMMIT; +step s3r: SELECT * FROM lr ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|1|0 +(2 rows) + + +starting permutation: s1b s1sp s1pk s2rc s2ci s1rb s2c s1c s3p s3c s3orphans +step s1b: BEGIN; +step s1sp: SAVEPOINT key_update; +step s1pk: UPDATE lrp SET id = 2 WHERE id = 1; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2ci: INSERT INTO lrc VALUES (1, 1); +step s1rb: ROLLBACK TO key_update; +step s2ci: <... completed> +step s2c: COMMIT; +step s1c: COMMIT; +step s3p: SELECT * FROM lrp ORDER BY id; +id|note +--+---- + 1| 0 +(1 row) + +step s3c: SELECT * FROM lrc ORDER BY id; +id|pid +--+--- + 1| 1 +(1 row) + +step s3orphans: SELECT count(*) AS orphans FROM lrc c WHERE NOT EXISTS (SELECT FROM lrp p WHERE p.id = c.pid); +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2mci s1b s1mk s2c s1c s3multi +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2mci: INSERT INTO lrmc VALUES (1, 1); +step s1b: BEGIN; +step s1mk: UPDATE lrmp SET b = 2 WHERE a = 1 AND b = 1; +step s2c: COMMIT; +step s1mk: <... completed> +ERROR: update or delete on table "lrmp" violates foreign key constraint "lrmc_a_b_fkey" on table "lrmc" +step s1c: COMMIT; +step s3multi: SELECT * FROM lrmp; SELECT * FROM lrmc; SELECT count(*) AS orphans FROM lrmc c WHERE NOT EXISTS (SELECT FROM lrmp p WHERE p.a = c.a AND p.b = c.b); +a|b +-+- +1|1 +(1 row) + +a|b +-+- +1|1 +(1 row) + +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2vci s1b s1vk s2c s1c s3unique +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2vci: INSERT INTO lrvc VALUES (1); +step s1b: BEGIN; +step s1vk: UPDATE lrvp SET id = 2 WHERE id = 1; +step s2c: COMMIT; +step s1vk: <... completed> +ERROR: update or delete on table "lrvp" violates foreign key constraint "lrvc_pid_fkey" on table "lrvc" +step s1c: COMMIT; +step s3unique: SELECT * FROM lrvp; SELECT * FROM lrvc; SELECT count(*) AS orphans FROM lrvc c WHERE NOT EXISTS (SELECT FROM lrvp p WHERE p.id = c.pid); +id|note +--+---- + 1| 0 +(1 row) + +pid +--- + 1 +(1 row) + +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2vci s1b s1vn s1c s2c s3unique +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2vci: INSERT INTO lrvc VALUES (1); +step s1b: BEGIN; +step s1vn: UPDATE lrvp SET note = note + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +step s3unique: SELECT * FROM lrvp; SELECT * FROM lrvc; SELECT count(*) AS orphans FROM lrvc c WHERE NOT EXISTS (SELECT FROM lrvp p WHERE p.id = c.pid); +id|note +--+---- + 1| 1 +(1 row) + +pid +--- + 1 +(1 row) + +orphans +------- + 0 +(1 row) + + +starting permutation: s2rc s2vci s4vn s1b s1vk s2c s1c s3unique +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2vci: INSERT INTO lrvc VALUES (1); +step s4vn: UPDATE lrvp SET note = note + 1 WHERE id = 1; +step s1b: BEGIN; +step s1vk: UPDATE lrvp SET id = 2 WHERE id = 1; +step s2c: COMMIT; +step s1vk: <... completed> +ERROR: update or delete on table "lrvp" violates foreign key constraint "lrvc_pid_fkey" on table "lrvc" +step s1c: COMMIT; +step s3unique: SELECT * FROM lrvp; SELECT * FROM lrvc; SELECT count(*) AS orphans FROM lrvc c WHERE NOT EXISTS (SELECT FROM lrvp p WHERE p.id = c.pid); +id|note +--+---- + 1| 1 +(1 row) + +pid +--- + 1 +(1 row) + +orphans +------- + 0 +(1 row) + diff --git a/src/test/isolation/expected/heap_lock_update_lockers.out b/src/test/isolation/expected/heap_lock_update_lockers.out new file mode 100644 index 0000000000000..222e432460395 --- /dev/null +++ b/src/test/isolation/expected/heap_lock_update_lockers.out @@ -0,0 +1,33 @@ +Parsed test spec with 3 sessions + +starting permutation: s1b s1l s2b s2l s3u s1r s2chk s2c s3chk +step s1b: BEGIN; +step s1l: SELECT v FROM lu WHERE id = 1 FOR SHARE; +v +- +0 +(1 row) + +step s2b: BEGIN; +step s2l: SELECT v FROM lu WHERE id = 1 FOR SHARE; +v +- +0 +(1 row) + +step s3u: UPDATE lu SET v = v + 1 WHERE id = 1; +step s1r: ROLLBACK; +step s2chk: SELECT v FROM lu WHERE id = 1; +v +- +0 +(1 row) + +step s2c: COMMIT; +step s3u: <... completed> +step s3chk: SELECT v FROM lu WHERE id = 1; +v +- +1 +(1 row) + diff --git a/src/test/isolation/expected/heap_lock_upgrade_queue.out b/src/test/isolation/expected/heap_lock_upgrade_queue.out new file mode 100644 index 0000000000000..e8d227c700561 --- /dev/null +++ b/src/test/isolation/expected/heap_lock_upgrade_queue.out @@ -0,0 +1,101 @@ +Parsed test spec with 3 sessions + +starting permutation: s1ks s3fu s2ks s1sp s1sh s2nk s1rs s1r s2c s3c +step s1ks: SELECT id FROM luq FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s3fu: SELECT id FROM luq FOR UPDATE; +step s2ks: SELECT id FROM luq FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s1sp: SAVEPOINT a; +step s1sh: SELECT id FROM luq FOR SHARE; +id +-- + 1 +(1 row) + +step s2nk: SELECT id FROM luq FOR NO KEY UPDATE; +step s1rs: ROLLBACK TO a; +step s2nk: <... completed> +id +-- + 1 +(1 row) + +step s1r: ROLLBACK; +step s2c: COMMIT; +step s3fu: <... completed> +id +-- + 1 +(1 row) + +step s3c: COMMIT; + +starting permutation: s1sh s2fu s3sh s3fu s1r s3c s2c +step s1sh: SELECT id FROM luq FOR SHARE; +id +-- + 1 +(1 row) + +step s2fu: SELECT id FROM luq FOR UPDATE; +step s3sh: SELECT id FROM luq FOR SHARE; +id +-- + 1 +(1 row) + +step s3fu: SELECT id FROM luq FOR UPDATE; +step s1r: ROLLBACK; +step s3fu: <... completed> +id +-- + 1 +(1 row) + +step s3c: COMMIT; +step s2fu: <... completed> +id +-- + 1 +(1 row) + +step s2c: COMMIT; + +starting permutation: s1ks s2fu s3ks s3d s1r s3c s2c +step s1ks: SELECT id FROM luq FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s2fu: SELECT id FROM luq FOR UPDATE; +step s3ks: SELECT id FROM luq FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s3d: DELETE FROM luq RETURNING id; +step s1r: ROLLBACK; +step s3d: <... completed> +id +-- + 1 +(1 row) + +step s3c: COMMIT; +step s2fu: <... completed> +id +-- +(0 rows) + +step s2c: COMMIT; diff --git a/src/test/isolation/expected/heap_nonkey_move.out b/src/test/isolation/expected/heap_nonkey_move.out new file mode 100644 index 0000000000000..cd91a3ac9bacd --- /dev/null +++ b/src/test/isolation/expected/heap_nonkey_move.out @@ -0,0 +1,830 @@ +Parsed test spec with 3 sessions + +starting permutation: rr snap b2 ui ki c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rr snap b2 ui ii c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ii: INSERT INTO ci VALUES (1); +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rr snap b2 ui si c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step si: SELECT id, v FROM fi WHERE id=1 FOR SHARE; +step c2: COMMIT; +step si: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ui di c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step di: DELETE FROM fi WHERE id=1; +step c2: COMMIT; +step di: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ui wi c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step wi: UPDATE fi SET v=v+1 WHERE id=1; +step c2: COMMIT; +step wi: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ug kg c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rr snap b2 ug ig c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step ig: INSERT INTO cg VALUES (1); +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rr snap b2 ug sg c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step sg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR SHARE; +step c2: COMMIT; +step sg: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ug dg c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step dg: DELETE FROM fg WHERE id=1; +step c2: COMMIT; +step dg: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ug wg c2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step wg: UPDATE fg SET v=v+1 WHERE id=1; +step c2: COMMIT; +step wg: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rc snap b2 ui ki c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rc snap b2 ui ii c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ii: INSERT INTO ci VALUES (1); +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rc snap b2 ui si c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step si: SELECT id, v FROM fi WHERE id=1 FOR SHARE; +step c2: COMMIT; +step si: <... completed> +id|v +--+- + 1|2 +(1 row) + +step c1: COMMIT; + +starting permutation: rc snap b2 ui di c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step di: DELETE FROM fi WHERE id=1; +step c2: COMMIT; +step di: <... completed> +step c1: COMMIT; + +starting permutation: rc snap b2 ui wi c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step wi: UPDATE fi SET v=v+1 WHERE id=1; +step c2: COMMIT; +step wi: <... completed> +step c1: COMMIT; + +starting permutation: rc snap b2 ug kg c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rc snap b2 ug ig c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step ig: INSERT INTO cg VALUES (1); +step c2: COMMIT; +step c1: COMMIT; + +starting permutation: rc snap b2 ug sg c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step sg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR SHARE; +step c2: COMMIT; +step sg: <... completed> +id|v|length +--+-+------ + 1|1| 1920 +(1 row) + +step c1: COMMIT; + +starting permutation: rc snap b2 ug dg c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step dg: DELETE FROM fg WHERE id=1; +step c2: COMMIT; +step dg: <... completed> +step c1: COMMIT; + +starting permutation: rc snap b2 ug wg c2 c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step wg: UPDATE fg SET v=v+1 WHERE id=1; +step c2: COMMIT; +step wg: <... completed> +step c1: COMMIT; + +starting permutation: rr snap b2 ui ki c2 d2i c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c2: COMMIT; +step d2i: DELETE FROM fi WHERE id=1; +step c1: COMMIT; +step d2i: <... completed> + +starting permutation: rr snap b2 ui ki c2 k2i c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c2: COMMIT; +step k2i: UPDATE fi SET id=2 WHERE id=1; +step c1: COMMIT; +step k2i: <... completed> + +starting permutation: rr snap b2 ug kg c2 d2g c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c2: COMMIT; +step d2g: DELETE FROM fg WHERE id=1; +step c1: COMMIT; +step d2g: <... completed> + +starting permutation: rr snap b2 ug kg c2 k2g c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c2: COMMIT; +step k2g: UPDATE fg SET id=100 WHERE id=1; +step c1: COMMIT; +step k2g: <... completed> + +starting permutation: rr snap b2 ui ki r2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step r2: ROLLBACK; +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 ki c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c1: COMMIT; + +starting permutation: rr snap b2 ug kg r2 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step r2: ROLLBACK; +step c1: COMMIT; + +starting permutation: rr snap b2 ug c2 kg c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step c2: COMMIT; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c1: COMMIT; + +starting permutation: rr snap b2 ui ki c2 n2i c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c2: COMMIT; +step n2i: UPDATE fi SET v=3 WHERE id=1; +step c1: COMMIT; + +starting permutation: rr snap b2 ug kg c2 n2g c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ug: UPDATE fg SET pad=repeat('y',1920) WHERE id=1; +step kg: SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; +id|v|length +--+-+------ + 1|1| 640 +(1 row) + +step c2: COMMIT; +step n2g: UPDATE fg SET pad=repeat('z',1940) WHERE id=1; +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 b3 k3 c3 ki c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step k3: UPDATE fi SET id=2 WHERE id=1; +step c3: COMMIT; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rc snap b2 ui c2 b3 k3 c3 ki c1 +step rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step k3: UPDATE fi SET id=2 WHERE id=1; +step c3: COMMIT; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- +(0 rows) + +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 b3 k3 ki c3 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step k3: UPDATE fi SET id=2 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +step c3: COMMIT; +step ki: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 b3 u3 ki c3 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step u3: UPDATE fi SET v=3 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +id|v +--+- + 1|1 +(1 row) + +step c3: COMMIT; +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 b3 k3 z3 u3 c3 ki c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step k3: UPDATE fi SET id=2 WHERE id=1; +step z3: UPDATE fi SET id=1 WHERE id=2; +step u3: UPDATE fi SET v=3 WHERE id=1; +step c3: COMMIT; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; + +starting permutation: rr snap b2 ui c2 b3 k3 z3 u3 ki c3 c1 +step rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step snap: SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; +v +- +1 +(1 row) + +v +- +1 +(1 row) + +step b2: BEGIN; +step ui: UPDATE fi SET v=2 WHERE id=1; +step c2: COMMIT; +step b3: BEGIN; +step k3: UPDATE fi SET id=2 WHERE id=1; +step z3: UPDATE fi SET id=1 WHERE id=2; +step u3: UPDATE fi SET v=3 WHERE id=1; +step ki: SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; +step c3: COMMIT; +step ki: <... completed> +ERROR: could not serialize access due to concurrent update +step c1: COMMIT; diff --git a/src/test/isolation/expected/heap_outofplace_grow.out b/src/test/isolation/expected/heap_outofplace_grow.out new file mode 100644 index 0000000000000..9f7745dea9bf6 --- /dev/null +++ b/src/test/isolation/expected/heap_outofplace_grow.out @@ -0,0 +1,35 @@ +Parsed test spec with 2 sessions + +starting permutation: s1b s2b s1g s2u s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1g: UPDATE xg SET p1 = 1, p2 = 2, p3 = 3, p4 = 4, p5 = 5, p6 = 6, + p7 = 7, p8 = 8 WHERE id = 1; +step s2u: UPDATE xg SET n = n + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, n, p8, (SELECT count(*) FROM xg) AS nrows + FROM xg WHERE id = 1; +id|n|p8|nrows +--+-+--+----- + 1|1| 8| 400 +(1 row) + + +starting permutation: s1b s2b s1g s2u s1r s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1g: UPDATE xg SET p1 = 1, p2 = 2, p3 = 3, p4 = 4, p5 = 5, p6 = 6, + p7 = 7, p8 = 8 WHERE id = 1; +step s2u: UPDATE xg SET n = n + 1 WHERE id = 1; +step s1r: ROLLBACK; +step s2u: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, n, p8, (SELECT count(*) FROM xg) AS nrows + FROM xg WHERE id = 1; +id|n|p8|nrows +--+-+--+----- + 1|1| | 400 +(1 row) + diff --git a/src/test/isolation/expected/heap_outofplace_update.out b/src/test/isolation/expected/heap_outofplace_update.out new file mode 100644 index 0000000000000..95e470df0dc37 --- /dev/null +++ b/src/test/isolation/expected/heap_outofplace_update.out @@ -0,0 +1,196 @@ +Parsed test spec with 2 sessions + +starting permutation: s1b s2b s1u s2u s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s2u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|2|[1,10)| 1 +(1 row) + + +starting permutation: s1b s2b s1uk s2uk s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s1c: COMMIT; +step s2uk: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|2|[1,12)| 1 +(1 row) + + +starting permutation: s1b s2b s1uk s2u s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|2|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2b s1u s2uk s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s2uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s1c: COMMIT; +step s2uk: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|2|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2b s1uk s2d s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2d: DELETE FROM xt WHERE id = 1; +step s1c: COMMIT; +step s2d: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r|nrows +--+-+-+----- +(0 rows) + + +starting permutation: s1b s2b s1uk s2u s1r s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s1r: ROLLBACK; +step s2u: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|1|[1,10)| 1 +(1 row) + + +starting permutation: s1b s2b s1uk s2uk s1r s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s1r: ROLLBACK; +step s2uk: <... completed> +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|1|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2brr s1uk s2u s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2u: UPDATE xt SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|1|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2brr s2sel s1uk s1c s2d s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2sel: SELECT id, v, r FROM xt; +id|v|r +--+-+------ + 1|0|[1,10) +(1 row) + +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s1c: COMMIT; +step s2d: DELETE FROM xt WHERE id = 1; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|1|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2b s1up s2ins s1c s2c +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1up: UPDATE xp SET valid_at = '[2020-01-01,2026-01-01)' WHERE id = '[1,2)'; +step s2ins: INSERT INTO xc VALUES ('[1,2)', '[2020-01-01,2025-01-01)', '[1,2)'); +step s1c: COMMIT; +step s2ins: <... completed> +step s2c: COMMIT; + +starting permutation: s1b s2brr s1up s2ins s1c s2c +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1up: UPDATE xp SET valid_at = '[2020-01-01,2026-01-01)' WHERE id = '[1,2)'; +step s2ins: INSERT INTO xc VALUES ('[1,2)', '[2020-01-01,2025-01-01)', '[1,2)'); +step s1c: COMMIT; +step s2ins: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; + +starting permutation: s1b s2b s1uk s2fu s1c s2c s1sel +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s2fu: SELECT id, v, r FROM xt WHERE id = 1 FOR UPDATE; +step s1c: COMMIT; +step s2fu: <... completed> +id|v|r +--+-+------ + 1|1|[1,11) +(1 row) + +step s2c: COMMIT; +step s1sel: SELECT id, v, r, count(*) OVER () AS nrows FROM xt; +id|v|r |nrows +--+-+------+----- + 1|1|[1,11)| 1 +(1 row) + + +starting permutation: s1b s2brr s2sel s1uk s1c s2fu s2c +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2sel: SELECT id, v, r FROM xt; +id|v|r +--+-+------ + 1|0|[1,10) +(1 row) + +step s1uk: UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; +step s1c: COMMIT; +step s2fu: SELECT id, v, r FROM xt WHERE id = 1 FOR UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; diff --git a/src/test/isolation/expected/heap_partition_move.out b/src/test/isolation/expected/heap_partition_move.out new file mode 100644 index 0000000000000..fed6d1384b56b --- /dev/null +++ b/src/test/isolation/expected/heap_partition_move.out @@ -0,0 +1,172 @@ +Parsed test spec with 2 sessions + +starting permutation: s2b s1u s2d s1c s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2d: DELETE FROM pm WHERE a = 1; +step s1c: COMMIT; +step s2d: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2sel: SELECT * FROM pm ORDER BY a; +ERROR: current transaction is aborted, commands ignored until end of transaction block +step s2c: COMMIT; + +starting permutation: s2b s1u s2u s1c s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2u: UPDATE pm SET b = 'EFG' WHERE a = 1; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2sel: SELECT * FROM pm ORDER BY a; +ERROR: current transaction is aborted, commands ignored until end of transaction block +step s2c: COMMIT; + +starting permutation: s2b s1u s2l s1c s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2l: SELECT * FROM pm WHERE a = 1 FOR UPDATE; +step s1c: COMMIT; +step s2l: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2c: COMMIT; + +starting permutation: s2b s1u s2ks s1c s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2ks: SELECT * FROM pm WHERE a = 1 FOR KEY SHARE; +step s1c: COMMIT; +step s2ks: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2c: COMMIT; + +starting permutation: s2b s1u s2m s1c s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2m: MERGE INTO pm t USING (SELECT 1 AS a) s ON t.a = s.a + WHEN MATCHED THEN UPDATE SET b = 'merged'; +step s1c: COMMIT; +step s2m: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2sel: SELECT * FROM pm ORDER BY a; +ERROR: current transaction is aborted, commands ignored until end of transaction block +step s2c: COMMIT; + +starting permutation: s2b s1upr s2i s1c s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1upr: UPDATE pr SET a = 11 WHERE a = 7; +step s2i: INSERT INTO prc VALUES (7); +step s1c: COMMIT; +step s2i: <... completed> +ERROR: tuple to be locked was already moved to another partition due to concurrent update +step s2c: COMMIT; + +starting permutation: s2brr s2sel s1u s1c s2d s2c +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+--- +1|ABC +(1 row) + +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s1c: COMMIT; +step s2d: DELETE FROM pm WHERE a = 1; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; + +starting permutation: s2brr s2sel s1u s1c s2l s2c +step s2brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+--- +1|ABC +(1 row) + +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s1c: COMMIT; +step s2l: SELECT * FROM pm WHERE a = 1 FOR UPDATE; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; + +starting permutation: s2b s1u s2u s1r s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2u: UPDATE pm SET b = 'EFG' WHERE a = 1; +step s1r: ROLLBACK; +step s2u: <... completed> +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+--- +1|EFG +(1 row) + +step s2c: COMMIT; + +starting permutation: s2b s1u s2d s1r s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2d: DELETE FROM pm WHERE a = 1; +step s1r: ROLLBACK; +step s2d: <... completed> +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+- +(0 rows) + +step s2c: COMMIT; + +starting permutation: s2b s1u s2l s1r s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s2l: SELECT * FROM pm WHERE a = 1 FOR UPDATE; +step s1r: ROLLBACK; +step s2l: <... completed> +a|b +-+--- +1|ABC +(1 row) + +step s2c: COMMIT; + +starting permutation: s2b s1upr s2i s1r s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1upr: UPDATE pr SET a = 11 WHERE a = 7; +step s2i: INSERT INTO prc VALUES (7); +step s1r: ROLLBACK; +step s2i: <... completed> +step s2c: COMMIT; + +starting permutation: s1sp s1u s1rs s2b s2u s2sel s2c s1c +step s1sp: SAVEPOINT sp; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s1rs: ROLLBACK TO sp; +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2u: UPDATE pm SET b = 'EFG' WHERE a = 1; +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+--- +1|EFG +(1 row) + +step s2c: COMMIT; +step s1c: COMMIT; + +starting permutation: s2b s1u s1r s2l s2u s2sel s2c +step s2b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE pm SET a = 2 WHERE a = 1; +step s1r: ROLLBACK; +step s2l: SELECT * FROM pm WHERE a = 1 FOR UPDATE; +a|b +-+--- +1|ABC +(1 row) + +step s2u: UPDATE pm SET b = 'EFG' WHERE a = 1; +step s2sel: SELECT * FROM pm ORDER BY a; +a|b +-+--- +1|EFG +(1 row) + +step s2c: COMMIT; diff --git a/src/test/isolation/expected/heap_recheck_after_wait.out b/src/test/isolation/expected/heap_recheck_after_wait.out new file mode 100644 index 0000000000000..60bf09b298fff --- /dev/null +++ b/src/test/isolation/expected/heap_recheck_after_wait.out @@ -0,0 +1,165 @@ +Parsed test spec with 4 sessions + +starting permutation: s1sh s2d s3d s1r s2r s3c s4sel +step s1sh: SELECT id FROM rw FOR SHARE; +id +-- + 1 +(1 row) + +step s2d: DELETE FROM rw RETURNING id, v; +step s3d: DELETE FROM rw WHERE v = 0 RETURNING id, v; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2r: ROLLBACK; +step s3d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id|v +--+- +(0 rows) + + +starting permutation: s1sh s2d s3u s1r s2r s3c s4sel +step s1sh: SELECT id FROM rw FOR SHARE; +id +-- + 1 +(1 row) + +step s2d: DELETE FROM rw RETURNING id, v; +step s3u: UPDATE rw SET v = v + 10 RETURNING id, v; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2r: ROLLBACK; +step s3u: <... completed> +id| v +--+-- + 1|10 +(1 row) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id| v +--+-- + 1|10 +(1 row) + + +starting permutation: s1u s2d s3d s1r s2r s3c s4sel +step s1u: UPDATE rw SET v = 5; +step s2d: DELETE FROM rw RETURNING id, v; +step s3d: DELETE FROM rw WHERE v = 0 RETURNING id, v; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2r: ROLLBACK; +step s3d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id|v +--+- +(0 rows) + + +starting permutation: s1u s2d s3u s1r s2r s3c s4sel +step s1u: UPDATE rw SET v = 5; +step s2d: DELETE FROM rw RETURNING id, v; +step s3u: UPDATE rw SET v = v + 10 RETURNING id, v; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2r: ROLLBACK; +step s3u: <... completed> +id| v +--+-- + 1|10 +(1 row) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id| v +--+-- + 1|10 +(1 row) + + +starting permutation: s1sh s2d s3d s1r s2c s3c s4sel +step s1sh: SELECT id FROM rw FOR SHARE; +id +-- + 1 +(1 row) + +step s2d: DELETE FROM rw RETURNING id, v; +step s3d: DELETE FROM rw WHERE v = 0 RETURNING id, v; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2c: COMMIT; +step s3d: <... completed> +id|v +--+- +(0 rows) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id|v +--+- +(0 rows) + + +starting permutation: s1sh s3d s1u s1c s3c s4sel +step s1sh: SELECT id FROM rw FOR SHARE; +id +-- + 1 +(1 row) + +step s3d: DELETE FROM rw WHERE v = 0 RETURNING id, v; +step s1u: UPDATE rw SET v = 5; +step s1c: COMMIT; +step s3d: <... completed> +id|v +--+- +(0 rows) + +step s3c: COMMIT; +step s4sel: SELECT id, v FROM rw; +id|v +--+- + 1|5 +(1 row) + diff --git a/src/test/isolation/expected/heap_savepoint_lock_rollback.out b/src/test/isolation/expected/heap_savepoint_lock_rollback.out new file mode 100644 index 0000000000000..9c17a2099289c --- /dev/null +++ b/src/test/isolation/expected/heap_savepoint_lock_rollback.out @@ -0,0 +1,1945 @@ +Parsed test spec with 3 sessions + +starting permutation: s1b s1sp s1fu s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fnk s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fnk: SELECT * FROM lr WHERE id = 1 FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fs s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fs: SELECT * FROM lr WHERE id = 1 FOR SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fks s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1sp2 s1fu s1rs2 s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs2: ROLLBACK TO b; +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1sp2 s1fu s1rs2 s1rel s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs2: ROLLBACK TO b; +step s1rel: RELEASE a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fu s1rel s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rel: RELEASE a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fu s1rs s2d s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2d: DELETE FROM lr WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 2|0 +(1 row) + +via |id|v +---------+--+- +key share| 2|0 +(1 row) + +via |id|v +----------+--+- +for update| 2|0 +(1 row) + +via |id| v +------+--+--- +update| 2|100 +(1 row) + +via|id|v +---+--+- +(0 rows) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fu s1rs s2b s2u s2r s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2b: BEGIN; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s2r: ROLLBACK; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|0 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|0 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|0 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|100 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|100 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fu s1rs s2b s2u s1c s2c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2b: BEGIN; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1fu s1rs s2u s1r s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1r: ROLLBACK; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1r s2u s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1r: ROLLBACK; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1u s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1d s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1d: DELETE FROM lr WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1u s1rs s2b s2u s1c s2c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2b: BEGIN; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1sp s1d s1rs s2b s2u s1c s2c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1d: DELETE FROM lr WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2b: BEGIN; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1sp s1fu s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1sp s1fks s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fnk s1sp s1fu s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fnk: SELECT * FROM lr WHERE id = 1 FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1fks s2u s1c s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1sp s1fks s1rel s2u s1c s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1rel: RELEASE a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fu s1sp s1u s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fks s1sp s1u s1rs s2d s1c s3chk +step s1b: BEGIN; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s2d: DELETE FROM lr WHERE id = 1; +step s1c: COMMIT; +step s2d: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 2|0 +(1 row) + +via |id|v +---------+--+- +key share| 2|0 +(1 row) + +via |id|v +----------+--+- +for update| 2|0 +(1 row) + +via |id| v +------+--+--- +update| 2|100 +(1 row) + +via|id|v +---+--+- +(0 rows) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1u s1sp s1fu s1rs s1c s3chk +step s1b: BEGIN; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id| v +--+-- + 1|10 +(1 row) + +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id| v +------+--+-- +select| 1|10 +select| 2| 0 +(2 rows) + +via |id| v +---------+--+-- +key share| 1|10 +key share| 2| 0 +(2 rows) + +via |id| v +----------+--+-- +for update| 1|10 +for update| 2| 0 +(2 rows) + +via |id| v +------+--+--- +update| 1|110 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|110 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1u s1sp s1u s1rs s1c s3chk +step s1b: BEGIN; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id| v +------+--+-- +select| 1|10 +select| 2| 0 +(2 rows) + +via |id| v +---------+--+-- +key share| 1|10 +key share| 2| 0 +(2 rows) + +via |id| v +----------+--+-- +for update| 1|10 +for update| 2| 0 +(2 rows) + +via |id| v +------+--+--- +update| 1|110 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|110 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1u s1sp s1fu s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id| v +--+-- + 1|10 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id| v +------+--+-- +select| 1|11 +select| 2| 0 +(2 rows) + +via |id| v +---------+--+-- +key share| 1|11 +key share| 2| 0 +(2 rows) + +via |id| v +----------+--+-- +for update| 1|11 +for update| 2| 0 +(2 rows) + +via |id| v +------+--+--- +update| 1|111 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|111 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1u s1sp s1fks s1rs s2u s1r s3chk +step s1b: BEGIN; +step s1u: UPDATE lr SET v = v + 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id| v +--+-- + 1|10 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1r: ROLLBACK; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1i s1sp s1fu3 s1rs s1c s3chk +step s1b: BEGIN; +step s1i: INSERT INTO lr VALUES (3, 0); +step s1sp: SAVEPOINT a; +step s1fu3: SELECT * FROM lr WHERE id = 3 FOR UPDATE; +id|v +--+- + 3|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|0 +select| 2|0 +select| 3|0 +(3 rows) + +via |id|v +---------+--+- +key share| 1|0 +key share| 2|0 +key share| 3|0 +(3 rows) + +via |id|v +----------+--+- +for update| 1|0 +for update| 2|0 +for update| 3|0 +(3 rows) + +via |id| v +------+--+--- +update| 1|100 +update| 2|100 +update| 3|100 +(3 rows) + +via |id| v +------+--+--- +delete| 1|100 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +after delete| 3|100 +(2 rows) + + +starting permutation: s1b s1sp s1i s1rs s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1i: INSERT INTO lr VALUES (3, 0); +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|0 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|0 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|0 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|100 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|100 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1i s1sp s1u3 s1rs s1c s3chk +step s1b: BEGIN; +step s1i: INSERT INTO lr VALUES (3, 0); +step s1sp: SAVEPOINT a; +step s1u3: UPDATE lr SET v = v + 10 WHERE id = 3; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|0 +select| 2|0 +select| 3|0 +(3 rows) + +via |id|v +---------+--+- +key share| 1|0 +key share| 2|0 +key share| 3|0 +(3 rows) + +via |id|v +----------+--+- +for update| 1|0 +for update| 2|0 +for update| 3|0 +(3 rows) + +via |id| v +------+--+--- +update| 1|100 +update| 2|100 +update| 3|100 +(3 rows) + +via |id| v +------+--+--- +delete| 1|100 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +after delete| 3|100 +(2 rows) + + +starting permutation: s1b s1sp s1i s1rel s1sp2 s1u3 s1rs2 s1c s3chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1i: INSERT INTO lr VALUES (3, 0); +step s1rel: RELEASE a; +step s1sp2: SAVEPOINT b; +step s1u3: UPDATE lr SET v = v + 10 WHERE id = 3; +step s1rs2: ROLLBACK TO b; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|0 +select| 2|0 +select| 3|0 +(3 rows) + +via |id|v +---------+--+- +key share| 1|0 +key share| 2|0 +key share| 3|0 +(3 rows) + +via |id|v +----------+--+- +for update| 1|0 +for update| 2|0 +for update| 3|0 +(3 rows) + +via |id| v +------+--+--- +update| 1|100 +update| 2|100 +update| 3|100 +(3 rows) + +via |id| v +------+--+--- +delete| 1|100 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +after delete| 3|100 +(2 rows) + + +starting permutation: s1b s1fks s1sp s1fu s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fks s1sp s1sp2 s1fu s1rs2 s1rs s2u s1c s3chk +step s1b: BEGIN; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rs2: ROLLBACK TO b; +step s1rs: ROLLBACK TO a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + + +starting permutation: s1b s1fks s1sp s1sp2 s1fu s1rel s2u s1c s3chk +step s1b: BEGIN; +step s1fks: SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1fu: SELECT * FROM lr WHERE id = 1 FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rel: RELEASE a; +step s2u: UPDATE lr SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s3chk: + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; + +via |id|v +------+--+- +select| 1|1 +select| 2|0 +(2 rows) + +via |id|v +---------+--+- +key share| 1|1 +key share| 2|0 +(2 rows) + +via |id|v +----------+--+- +for update| 1|1 +for update| 2|0 +(2 rows) + +via |id| v +------+--+--- +update| 1|101 +update| 2|100 +(2 rows) + +via |id| v +------+--+--- +delete| 1|101 +(1 row) + +via |id| v +------------+--+--- +after delete| 2|100 +(1 row) + diff --git a/src/test/isolation/expected/heap_savepoint_lock_wake.out b/src/test/isolation/expected/heap_savepoint_lock_wake.out new file mode 100644 index 0000000000000..7629daf724334 --- /dev/null +++ b/src/test/isolation/expected/heap_savepoint_lock_wake.out @@ -0,0 +1,173 @@ +Parsed test spec with 2 sessions + +starting permutation: s1ks s1sp s1nk s2nk s1rs s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1nk: SELECT * FROM lw FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2nk: SELECT * FROM lw FOR NO KEY UPDATE; +step s1rs: ROLLBACK TO a; +step s2nk: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; + +starting permutation: s1ks s1sp s1nk s2u s1rs s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1nk: SELECT * FROM lw FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2u: UPDATE lw SET v = v + 1 RETURNING *; +step s1rs: ROLLBACK TO a; +step s2u: <... completed> +id|v +--+- + 1|1 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; + +starting permutation: s1ks s1sp s1fu s2fs s1rs s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lw FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2fs: SELECT * FROM lw FOR SHARE; +step s1rs: ROLLBACK TO a; +step s2fs: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; + +starting permutation: s1sp s1fu s2u s1rs s1c s2c +step s1sp: SAVEPOINT a; +step s1fu: SELECT * FROM lw FOR UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2u: UPDATE lw SET v = v + 1 RETURNING *; +step s1rs: ROLLBACK TO a; +step s2u: <... completed> +id|v +--+- + 1|1 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; + +starting permutation: s1ks s1sp s1sp2 s1nk s1rel2 s2nk s1rs s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1nk: SELECT * FROM lw FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s1rel2: RELEASE b; +step s2nk: SELECT * FROM lw FOR NO KEY UPDATE; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s2nk: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2c: COMMIT; + +starting permutation: s1ks s1sp s1sp2 s1nk s2nk s1rs2 s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1sp2: SAVEPOINT b; +step s1nk: SELECT * FROM lw FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2nk: SELECT * FROM lw FOR NO KEY UPDATE; +step s1rs2: ROLLBACK TO b; +step s2nk: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; + +starting permutation: s1ks s1sp s1nk s2d s1rs s1c s2c +step s1ks: SELECT * FROM lw FOR KEY SHARE; +id|v +--+- + 1|0 +(1 row) + +step s1sp: SAVEPOINT a; +step s1nk: SELECT * FROM lw FOR NO KEY UPDATE; +id|v +--+- + 1|0 +(1 row) + +step s2d: DELETE FROM lw RETURNING *; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s2d: <... completed> +id|v +--+- + 1|0 +(1 row) + +step s2c: COMMIT; diff --git a/src/test/isolation/expected/heap_seqscan_stats.out b/src/test/isolation/expected/heap_seqscan_stats.out new file mode 100644 index 0000000000000..cc6194dfaec38 --- /dev/null +++ b/src/test/isolation/expected/heap_seqscan_stats.out @@ -0,0 +1,55 @@ +Parsed test spec with 1 sessions + +starting permutation: s1stats s1scan s1ff s1stats s1where s1ff s1stats +step s1stats: + SELECT pg_stat_get_numscans('ss'::regclass) AS seq_scan, + pg_stat_get_tuples_returned('ss'::regclass) AS seq_tup_read; + +seq_scan|seq_tup_read +--------+------------ + 0| 0 +(1 row) + +step s1scan: SELECT count(*) FROM ss; +count +----- + 5 +(1 row) + +step s1ff: SELECT pg_stat_force_next_flush(); +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s1stats: + SELECT pg_stat_get_numscans('ss'::regclass) AS seq_scan, + pg_stat_get_tuples_returned('ss'::regclass) AS seq_tup_read; + +seq_scan|seq_tup_read +--------+------------ + 1| 5 +(1 row) + +step s1where: SELECT k FROM ss WHERE v > 3 ORDER BY k; +k +- +4 +5 +(2 rows) + +step s1ff: SELECT pg_stat_force_next_flush(); +pg_stat_force_next_flush +------------------------ + +(1 row) + +step s1stats: + SELECT pg_stat_get_numscans('ss'::regclass) AS seq_scan, + pg_stat_get_tuples_returned('ss'::regclass) AS seq_tup_read; + +seq_scan|seq_tup_read +--------+------------ + 2| 10 +(1 row) + diff --git a/src/test/isolation/expected/heap_ssi_conflict_out.out b/src/test/isolation/expected/heap_ssi_conflict_out.out new file mode 100644 index 0000000000000..928e9f515b7e3 --- /dev/null +++ b/src/test/isolation/expected/heap_ssi_conflict_out.out @@ -0,0 +1,130 @@ +Parsed test spec with 9 sessions + +starting permutation: foo_begin bar_begin trouble_begin foo_select bar_insert foo_insert foo_commit trouble_update bar_select bar_commit trouble_abort +step foo_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step bar_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step trouble_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step foo_select: SELECT * FROM txn0 WHERE id = 42; +id|val +--+--- +(0 rows) + +step bar_insert: INSERT INTO txn0 SELECT 42, 'bar_insert'; +step foo_insert: INSERT INTO txn1 SELECT 7, 'foo_insert'; +step foo_commit: COMMIT; +step trouble_update: UPDATE txn1 SET val = 'add physical version for "bar_select"' WHERE id = 7; +step bar_select: SELECT * FROM txn1 WHERE id = 7; +ERROR: could not serialize access due to read/write dependencies among transactions +step bar_commit: COMMIT; +step trouble_abort: ABORT; + +starting permutation: foo_begin bar_begin trouble_begin foo_select bar_insert foo_insert foo_commit trouble_delete bar_select bar_commit trouble_abort +step foo_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step bar_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step trouble_begin: BEGIN ISOLATION LEVEL SERIALIZABLE; +step foo_select: SELECT * FROM txn0 WHERE id = 42; +id|val +--+--- +(0 rows) + +step bar_insert: INSERT INTO txn0 SELECT 42, 'bar_insert'; +step foo_insert: INSERT INTO txn1 SELECT 7, 'foo_insert'; +step foo_commit: COMMIT; +step trouble_delete: DELETE FROM txn1 WHERE id = 7; +step bar_select: SELECT * FROM txn1 WHERE id = 7; +ERROR: could not serialize access due to read/write dependencies among transactions +step bar_commit: COMMIT; +step trouble_abort: ABORT; + +starting permutation: w1b w1snap w2b w2r2 w2w1 w2c w1r1 w1w2 w1c +step w1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step w1snap: SELECT v FROM ws WHERE id = 3; +v +- +0 +(1 row) + +step w2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step w2r2: SELECT v FROM ws WHERE id = 2; +v +- +0 +(1 row) + +step w2w1: UPDATE ws SET v = v + 10 WHERE id = 1; +step w2c: COMMIT; +step w1r1: SELECT v FROM ws WHERE id = 1; +v +- +0 +(1 row) + +step w1w2: UPDATE ws SET v = v + 1 WHERE id = 2; +ERROR: could not serialize access due to read/write dependencies among transactions +step w1c: COMMIT; + +starting permutation: w1b w1snap w2b w2r2 w2w1 w2c w3b w3w1 w1r1 w1w2 w1c w3a +step w1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step w1snap: SELECT v FROM ws WHERE id = 3; +v +- +0 +(1 row) + +step w2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step w2r2: SELECT v FROM ws WHERE id = 2; +v +- +0 +(1 row) + +step w2w1: UPDATE ws SET v = v + 10 WHERE id = 1; +step w2c: COMMIT; +step w3b: BEGIN; +step w3w1: UPDATE ws SET v = v + 100 WHERE id = 1; +step w1r1: SELECT v FROM ws WHERE id = 1; +v +- +0 +(1 row) + +step w1w2: UPDATE ws SET v = v + 1 WHERE id = 2; +ERROR: could not serialize access due to read/write dependencies among transactions +step w1c: COMMIT; +step w3a: ABORT; + +starting permutation: s1b s2b s3b rxwy1 wx2 c2 rx3 c1 ry3 c3 +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; +step s3b: BEGIN ISOLATION LEVEL SERIALIZABLE, READ ONLY; +step rxwy1: INSERT INTO receipt VALUES (3, (SELECT deposit_date FROM ctl WHERE k = 'receipt'), 4.00); +step wx2: UPDATE ctl SET deposit_date = DATE '2008-12-23' WHERE k = 'receipt'; +step c2: COMMIT; +step rx3: SELECT * FROM ctl WHERE k = 'receipt'; +k |deposit_date +-------+------------ +receipt| 12-23-2008 +(1 row) + +step c1: COMMIT; +step ry3: SELECT * FROM receipt WHERE deposit_date = DATE '2008-12-22'; +ERROR: could not serialize access due to read/write dependencies among transactions +step c3: COMMIT; + +starting permutation: s1b s2b s3b wx2 rxwy1 c2 rx3 c1 ry3 c3 +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; +step s3b: BEGIN ISOLATION LEVEL SERIALIZABLE, READ ONLY; +step wx2: UPDATE ctl SET deposit_date = DATE '2008-12-23' WHERE k = 'receipt'; +step rxwy1: INSERT INTO receipt VALUES (3, (SELECT deposit_date FROM ctl WHERE k = 'receipt'), 4.00); +step c2: COMMIT; +step rx3: SELECT * FROM ctl WHERE k = 'receipt'; +k |deposit_date +-------+------------ +receipt| 12-23-2008 +(1 row) + +step c1: COMMIT; +step ry3: SELECT * FROM receipt WHERE deposit_date = DATE '2008-12-22'; +ERROR: could not serialize access due to read/write dependencies among transactions +step c3: COMMIT; diff --git a/src/test/isolation/expected/heap_ssi_copy.out b/src/test/isolation/expected/heap_ssi_copy.out new file mode 100644 index 0000000000000..36ee6d84dfce9 --- /dev/null +++ b/src/test/isolation/expected/heap_ssi_copy.out @@ -0,0 +1,22 @@ +Parsed test spec with 2 sessions + +starting permutation: b1 b2 r1 r2 w1 c1 w2 c2 +step b1: BEGIN ISOLATION LEVEL SERIALIZABLE; +step b2: BEGIN ISOLATION LEVEL SERIALIZABLE; +step r1: SELECT count(*) FROM a; +count +----- + 0 +(1 row) + +step r2: SELECT count(*) FROM b; +count +----- + 0 +(1 row) + +step w1: COPY b FROM PROGRAM 'printf "1\n"'; +step c1: COMMIT; +step w2: COPY a FROM PROGRAM 'printf "2\n"'; +ERROR: could not serialize access due to read/write dependencies among transactions +step c2: COMMIT; diff --git a/src/test/isolation/expected/heap_ssi_revive.out b/src/test/isolation/expected/heap_ssi_revive.out new file mode 100644 index 0000000000000..7301e2dcb6f04 --- /dev/null +++ b/src/test/isolation/expected/heap_ssi_revive.out @@ -0,0 +1,145 @@ +Parsed test spec with 3 sessions + +starting permutation: s1b s1r s3a s3b s2b s2r s2w s1w s1c s2c s3chk +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s1r: SELECT count(*) FROM t WHERE k = 1; +count +----- + 0 +(1 row) + +step s3a: UPDATE t SET k = 1 WHERE id = 1; +step s3b: UPDATE t SET k = 2 WHERE id = 1; +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s2r: SELECT v FROM u WHERE id = 1; +v +- +0 +(1 row) + +step s2w: UPDATE t SET k = 1 WHERE id = 1; +step s1w: UPDATE u SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +ERROR: could not serialize access due to read/write dependencies among transactions +step s3chk: SELECT id, k FROM t ORDER BY id; SELECT * FROM u; +id|k +--+- + 1|2 + 2|5 + 3|7 +(3 rows) + +id|v +--+- + 1|1 +(1 row) + + +starting permutation: s1b s1r s3a s3b s2b s2r s2w s2c s1w s1c s3chk +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s1r: SELECT count(*) FROM t WHERE k = 1; +count +----- + 0 +(1 row) + +step s3a: UPDATE t SET k = 1 WHERE id = 1; +step s3b: UPDATE t SET k = 2 WHERE id = 1; +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s2r: SELECT v FROM u WHERE id = 1; +v +- +0 +(1 row) + +step s2w: UPDATE t SET k = 1 WHERE id = 1; +step s2c: COMMIT; +step s1w: UPDATE u SET v = v + 1 WHERE id = 1; +ERROR: could not serialize access due to read/write dependencies among transactions +step s1c: COMMIT; +step s3chk: SELECT id, k FROM t ORDER BY id; SELECT * FROM u; +id|k +--+- + 1|1 + 2|5 + 3|7 +(3 rows) + +id|v +--+- + 1|0 +(1 row) + + +starting permutation: s3a s3b s1b s1r s2b s2r s2w s1w s1c s2c s3chk +step s3a: UPDATE t SET k = 1 WHERE id = 1; +step s3b: UPDATE t SET k = 2 WHERE id = 1; +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s1r: SELECT count(*) FROM t WHERE k = 1; +count +----- + 0 +(1 row) + +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s2r: SELECT v FROM u WHERE id = 1; +v +- +0 +(1 row) + +step s2w: UPDATE t SET k = 1 WHERE id = 1; +step s1w: UPDATE u SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +ERROR: could not serialize access due to read/write dependencies among transactions +step s3chk: SELECT id, k FROM t ORDER BY id; SELECT * FROM u; +id|k +--+- + 1|2 + 2|5 + 3|7 +(3 rows) + +id|v +--+- + 1|1 +(1 row) + + +starting permutation: s3a s3b s1b s1r5 s2b s2r s2w s1w s1c s2c s3chk +step s3a: UPDATE t SET k = 1 WHERE id = 1; +step s3b: UPDATE t SET k = 2 WHERE id = 1; +step s1b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s1r5: SELECT count(*) FROM t WHERE k = 5; +count +----- + 1 +(1 row) + +step s2b: BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; +step s2r: SELECT v FROM u WHERE id = 1; +v +- +0 +(1 row) + +step s2w: UPDATE t SET k = 1 WHERE id = 1; +step s1w: UPDATE u SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2c: COMMIT; +ERROR: could not serialize access due to read/write dependencies among transactions +step s3chk: SELECT id, k FROM t ORDER BY id; SELECT * FROM u; +id|k +--+- + 1|2 + 2|5 + 3|7 +(3 rows) + +id|v +--+- + 1|1 +(1 row) + diff --git a/src/test/isolation/expected/heap_tid_fetch_snapshot.out b/src/test/isolation/expected/heap_tid_fetch_snapshot.out new file mode 100644 index 0000000000000..e2c2d4a67f13b --- /dev/null +++ b/src/test/isolation/expected/heap_tid_fetch_snapshot.out @@ -0,0 +1,103 @@ +Parsed test spec with 2 sessions + +starting permutation: s2b s2u s1b s1lock s2c s1c +step s2b: BEGIN; +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1lock: SELECT * FROM acc a1, acc a2 WHERE a1.id = a2.id ORDER BY a1.id FOR UPDATE OF a1; +step s2c: COMMIT; +step s1lock: <... completed> +id | bal|id |bal +--------+----+--------+--- +checking|1050|checking|600 +savings | 600|savings |600 +(2 rows) + +step s1c: COMMIT; + +starting permutation: s2b s2u s2uo s1b s1join s2c s1c +step s2b: BEGIN; +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s2uo: UPDATE oth SET v = v + 10 WHERE id = 'checking'; +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1join: SELECT * FROM acc a JOIN oth o USING (id) ORDER BY id FOR UPDATE OF a; +step s2c: COMMIT; +step s1join: <... completed> +id | bal|v +--------+----+- +checking|1050|1 +savings | 600|1 +(2 rows) + +step s1c: COMMIT; + +starting permutation: s2b s2u s2do s1b s1join s2c s1c +step s2b: BEGIN; +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s2do: DELETE FROM oth WHERE id = 'checking'; +step s1b: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1join: SELECT * FROM acc a JOIN oth o USING (id) ORDER BY id FOR UPDATE OF a; +step s2c: COMMIT; +step s1join: <... completed> +id | bal|v +--------+----+- +checking|1050|1 +savings | 600|1 +(2 rows) + +step s1c: COMMIT; + +starting permutation: s1brr s1sel s2u s1tid s1c +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1sel: SELECT * FROM acc ORDER BY id; +id |bal +--------+--- +checking|600 +savings |600 +(2 rows) + +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s1tid: SELECT * FROM acc WHERE ctid = '(0,1)'; +id |bal +--------+--- +checking|600 +(1 row) + +step s1c: COMMIT; + +starting permutation: s1brr s1sel s2d s1tid s1c +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1sel: SELECT * FROM acc ORDER BY id; +id |bal +--------+--- +checking|600 +savings |600 +(2 rows) + +step s2d: DELETE FROM acc WHERE id = 'checking'; +step s1tid: SELECT * FROM acc WHERE ctid = '(0,1)'; +id |bal +--------+--- +checking|600 +(1 row) + +step s1c: COMMIT; + +starting permutation: s1brr s1sel s2u s2u s1tid s1c +step s1brr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1sel: SELECT * FROM acc ORDER BY id; +id |bal +--------+--- +checking|600 +savings |600 +(2 rows) + +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s2u: UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; +step s1tid: SELECT * FROM acc WHERE ctid = '(0,1)'; +id |bal +--------+--- +checking|600 +(1 row) + +step s1c: COMMIT; diff --git a/src/test/isolation/expected/heap_update_deadlock.out b/src/test/isolation/expected/heap_update_deadlock.out new file mode 100644 index 0000000000000..be458774fea7b --- /dev/null +++ b/src/test/isolation/expected/heap_update_deadlock.out @@ -0,0 +1,83 @@ +Parsed test spec with 4 sessions + +starting permutation: s1u1 s2u2 s1u2 s2u1 s1c s2c s3c chk +step s1u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); +step s2u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); +step s1u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); +step s2u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); +step s1u2: <... completed> +step s2u1: <... completed> +ERROR: deadlock detected +step s1c: COMMIT; +step s2c: COMMIT; +step s3c: COMMIT; +step chk: SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; +id|v|committed +--+-+--------- + 1|1| 1 + 2|1| 1 +(2 rows) + + +starting permutation: s1u1 s2u2 s3u1 s1u2 s2u1 s1c s2c s3c chk +step s1u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); +step s2u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); +step s3u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's3'); +step s1u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); +step s2u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); +step s1u2: <... completed> +step s2u1: <... completed> +ERROR: deadlock detected +step s1c: COMMIT; +step s3u1: <... completed> +step s2c: COMMIT; +step s3c: COMMIT; +step chk: SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; +id|v|committed +--+-+--------- + 1|2| 2 + 2|1| 1 +(2 rows) + + +starting permutation: s2u1 s1u2 s3u1 s2u2 s1u1 s3c s1c s2c chk +step s2u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); +step s1u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); +step s3u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's3'); +step s2u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); +step s1u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); +step s3u1: <... completed> +step s2u2: <... completed> +ERROR: deadlock detected +step s3c: COMMIT; +step s1u1: <... completed> +step s1c: COMMIT; +step s2c: COMMIT; +step chk: SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; +id|v|committed +--+-+--------- + 1|2| 2 + 2|1| 1 +(2 rows) + + +starting permutation: s1u1 s2u2 s3u2 s1u2 s2u1 s3c s1c s2c chk +step s1u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); +step s2u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); +step s3u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's3'); +step s1u2: UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); +step s2u1: UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); +step s3u2: <... completed> +step s2u1: <... completed> +ERROR: deadlock detected +step s3c: COMMIT; +step s1u2: <... completed> +step s1c: COMMIT; +step s2c: COMMIT; +step chk: SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; +id|v|committed +--+-+--------- + 1|1| 1 + 2|2| 2 +(2 rows) + diff --git a/src/test/isolation/expected/heap_update_key_waiter.out b/src/test/isolation/expected/heap_update_key_waiter.out new file mode 100644 index 0000000000000..364ab4a152367 --- /dev/null +++ b/src/test/isolation/expected/heap_update_key_waiter.out @@ -0,0 +1,259 @@ +Parsed test spec with 3 sessions + +starting permutation: s1b s1k s2b s2k s1r s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1r: ROLLBACK; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|0 +(1 row) + +id| k|v +--+---+- + 3|100|0 +(1 row) + + +starting permutation: s1b s1k s2b s2k s1c s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1c: COMMIT; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|0 +(1 row) + +id| k|v +--+---+- + 3|100|0 +(1 row) + + +starting permutation: s1b s1k s2b s2k2 s1r s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2k2: UPDATE kw SET k = 200 WHERE id = 3; +step s1r: ROLLBACK; +step s2k2: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|200|0 +(1 row) + +id| k|v +--+---+- + 3|200|0 +(1 row) + + +starting permutation: s1b s1k s2b s2k2 s1c s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2k2: UPDATE kw SET k = 200 WHERE id = 3; +step s1c: COMMIT; +step s2k2: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|200|0 +(1 row) + +id| k|v +--+---+- + 3|200|0 +(1 row) + + +starting permutation: s1b s1k s2b s2v s1r s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2v: UPDATE kw SET v = 2 WHERE id = 3; +step s1r: ROLLBACK; +step s2v: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id|k|v +--+-+- + 3|3|2 +(1 row) + +id|k|v +--+-+- + 3|3|2 +(1 row) + + +starting permutation: s1b s1k s2b s2v s1c s2c chk +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2b: BEGIN; +step s2v: UPDATE kw SET v = 2 WHERE id = 3; +step s1c: COMMIT; +step s2v: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|2 +(1 row) + +id| k|v +--+---+- + 3|100|2 +(1 row) + + +starting permutation: s1b s1v s2b s2k s1r s2c chk +step s1b: BEGIN; +step s1v: UPDATE kw SET v = 1 WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1r: ROLLBACK; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|0 +(1 row) + +id| k|v +--+---+- + 3|100|0 +(1 row) + + +starting permutation: s1b s1v s2b s2k s1c s2c chk +step s1b: BEGIN; +step s1v: UPDATE kw SET v = 1 WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1c: COMMIT; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|1 +(1 row) + +id| k|v +--+---+- + 3|100|1 +(1 row) + + +starting permutation: s1b s1d s2b s2k s1r s2c chk +step s1b: BEGIN; +step s1d: DELETE FROM kw WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1r: ROLLBACK; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id| k|v +--+---+- + 3|100|0 +(1 row) + +id| k|v +--+---+- + 3|100|0 +(1 row) + + +starting permutation: s1b s1d s2b s2k s1c s2c chk +step s1b: BEGIN; +step s1d: DELETE FROM kw WHERE id = 3; +step s2b: BEGIN; +step s2k: UPDATE kw SET k = 100 WHERE id = 3; +step s1c: COMMIT; +step s2k: <... completed> +step s2c: COMMIT; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; + +id|k|v +--+-+- +(0 rows) + +id|k|v +--+-+- +(0 rows) + diff --git a/src/test/isolation/expected/heap_update_serialize.out b/src/test/isolation/expected/heap_update_serialize.out new file mode 100644 index 0000000000000..f448ed79acc5b --- /dev/null +++ b/src/test/isolation/expected/heap_update_serialize.out @@ -0,0 +1,169 @@ +Parsed test spec with 3 sessions + +starting permutation: s1rc s2rc s1u s2u s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s2rc s1u s2u s1a s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1a: ROLLBACK; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rr s2snap s1u s2u s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT v FROM fus WHERE id = 1; +v +- +0 +(1 row) + +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rr s2snap s1u s2u s1a s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT v FROM fus WHERE id = 1; +v +- +0 +(1 row) + +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1a: ROLLBACK; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rr s2snap s1u s1c s2u s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2snap: SELECT v FROM fus WHERE id = 1; +v +- +0 +(1 row) + +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +1 +(1 row) + + +starting permutation: s1rc s2rc s1ks s2u s1u s2c s1c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1ks: SELECT id FROM fus WHERE id = 1 FOR KEY SHARE; +id +-- + 1 +(1 row) + +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s2c: COMMIT; +step s1u: <... completed> +step s1c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s2rc s1fu s2u s1u s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1fu: SELECT id FROM fus WHERE id = 1 FOR UPDATE; +id +-- + 1 +(1 row) + +step s2u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1u: UPDATE fus SET v = v + 1 WHERE id = 1; +step s1c: COMMIT; +step s2u: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s2rc s1oc s2oc s1c s2c s3r +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1oc: INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; +step s2oc: INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; +step s1c: COMMIT; +step s2oc: <... completed> +step s2c: COMMIT; +step s3r: SELECT v FROM fus WHERE id = 1; +v +- +2 +(1 row) + + +starting permutation: s1rc s1big s1locks s1c s1bigsum +step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED; +step s1big: UPDATE fus_big SET v = v + 1; +step s1locks: SELECT count(*) AS tuple_locks FROM pg_locks WHERE locktype = 'tuple' AND pid = pg_backend_pid(); +tuple_locks +----------- + 0 +(1 row) + +step s1c: COMMIT; +step s1bigsum: SELECT sum(v) FROM fus_big; + sum +----- +20000 +(1 row) + diff --git a/src/test/isolation/expected/heap_update_victim.out b/src/test/isolation/expected/heap_update_victim.out new file mode 100644 index 0000000000000..7932687a2cc9c --- /dev/null +++ b/src/test/isolation/expected/heap_update_victim.out @@ -0,0 +1,63 @@ +Parsed test spec with 4 sessions + +starting permutation: s2u2 s1u1 s2u1 s1u2 s1c s2c chk +step s2u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s2u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s1u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u2: <... completed> +ERROR: deadlock detected +step s2u1: <... completed> +step s1c: COMMIT; +step s2c: COMMIT; +step chk: SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; +id|v|committed +--+-+--------- + 1|1| 1 + 2|1| 1 +(2 rows) + + +starting permutation: s2u2 s1u1 s3u2 s2u1 s1u2 s1c s2c s3c chk +s3: WARNING: there is already a transaction in progress +step s2u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s3u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s2u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s1u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s1u2: <... completed> +ERROR: deadlock detected +step s3u2: <... completed> +ERROR: deadlock detected +step s2u1: <... completed> +step s1c: COMMIT; +step s2c: COMMIT; +step s3c: COMMIT; +step chk: SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; +id|v|committed +--+-+--------- + 1|1| 1 + 2|1| 1 +(2 rows) + + +starting permutation: s1u1 s3u2 s2u1 s1c s2u2 s3u1 s2c s3c chk +step s1u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s3u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s2u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s1c: COMMIT; +step s2u1: <... completed> +step s2u2: UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); +step s3u1: UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); +step s2u2: <... completed> +step s3u1: <... completed> +ERROR: deadlock detected +step s2c: COMMIT; +step s3c: COMMIT; +step chk: SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; +id|v|committed +--+-+--------- + 1|2| 2 + 2|1| 1 +(2 rows) + diff --git a/src/test/isolation/expected/heap_update_waiter_abort.out b/src/test/isolation/expected/heap_update_waiter_abort.out new file mode 100644 index 0000000000000..5662a22502426 --- /dev/null +++ b/src/test/isolation/expected/heap_update_waiter_abort.out @@ -0,0 +1,338 @@ +Parsed test spec with 4 sessions + +starting permutation: s1b s1w s2b s2u s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +v| w|v| w +-+--+-+-- +0|10|1|10 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v| w +--+-+-- + 1|1|10 + 2|0| 0 +(2 rows) + + +starting permutation: s1b s1w s2b s2u s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1w s2rr s2u s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +ERROR: could not serialize access due to concurrent update +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v| w +--+-+-- + 1|0|10 + 2|0| 0 +(2 rows) + + +starting permutation: s1b s1w s2rr s2u s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1sp s1w s2b s2u s1rs s1c s2c chk +step s1b: BEGIN; +step s1sp: SAVEPOINT a; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1rs: ROLLBACK TO a; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1w s1sp s1w2 s2b s2u s1rs s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s1sp: SAVEPOINT a; +step s1w2: UPDATE vh SET w = 20 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1rs: ROLLBACK TO a; +step s1c: COMMIT; +step s2u: <... completed> +v| w|v| w +-+--+-+-- +0|10|1|10 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v| w +--+-+-- + 1|1|10 + 2|0| 0 +(2 rows) + + +starting permutation: s1b s1w s2b s2d s1c s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2d: DELETE FROM vh WHERE id = 1 RETURNING *; +step s1c: COMMIT; +step s2d: <... completed> +id|v| w +--+-+-- + 1|0|10 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 2|0|0 +(1 row) + + +starting permutation: s1b s1w s2b s2d s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2d: DELETE FROM vh WHERE id = 1 RETURNING *; +step s1r: ROLLBACK; +step s2d: <... completed> +id|v|w +--+-+- + 1|0|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 2|0|0 +(1 row) + + +starting permutation: s1b s1w s2b s2l s1r s2c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s2l: SELECT * FROM vh WHERE id = 1 FOR UPDATE; +step s1r: ROLLBACK; +step s2l: <... completed> +id|v|w +--+-+- + 1|0|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|0|0 + 2|0|0 +(2 rows) + + +starting permutation: s1b s1w s2b s3b s2u s3u s1c s2c s3c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s3b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s3u: UPDATE vh SET v = v + 10 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +v| w|v| w +-+--+-+-- +0|10|1|10 +(1 row) + +step s2c: COMMIT; +step s3u: <... completed> +v| w| v| w +-+--+--+-- +1|10|11|10 +(1 row) + +step s3c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id| v| w +--+--+-- + 1|11|10 + 2| 0| 0 +(2 rows) + + +starting permutation: s1b s1w s2b s3b s2u s3u s1r s2c s3c chk +step s1b: BEGIN; +step s1w: UPDATE vh SET w = 10 WHERE id = 1; +step s2b: BEGIN; +step s3b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s3u: UPDATE vh SET v = v + 10 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step s3u: <... completed> +v|w| v|w +-+-+--+- +1|0|11|0 +(1 row) + +step s3c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id| v|w +--+--+- + 1|11|0 + 2| 0|0 +(2 rows) + + +starting permutation: s1b s1k s2b s2u s1c s2c chk +step s1b: BEGIN; +step s1k: UPDATE vh SET id = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1c: COMMIT; +step s2u: <... completed> +v|w|v|w +-+-+-+- +(0 rows) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 2|0|0 +10|0|0 +(2 rows) + + +starting permutation: s1b s1k s2b s2u s1r s2c chk +step s1b: BEGIN; +step s1k: UPDATE vh SET id = 10 WHERE id = 1; +step s2b: BEGIN; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s1r: ROLLBACK; +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|0 + 2|0|0 +(2 rows) + + +starting permutation: s2b s2w5 s2u s2c chk +step s2b: BEGIN; +step s2w5: UPDATE vh SET w = 5 WHERE id = 1; +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +v|w|v|w +-+-+-+- +0|5|1|5 +(1 row) + +step s2c: COMMIT; +step chk: SELECT * FROM vh ORDER BY id; +id|v|w +--+-+- + 1|1|5 + 2|0|0 +(2 rows) + + +starting permutation: s1b s2b s3b s1u1 s3u2 s2u s3u1 s1u2 s1c s2h s2c s3c chkh +step s1b: BEGIN; +step s2b: BEGIN; +step s3b: BEGIN; +step s1u1: UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); +step s3u2: UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); +step s2u: UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; +step s3u1: UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); +step s1u2: UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); +step s1u2: <... completed> +ERROR: deadlock detected +step s2u: <... completed> +v|w|v|w +-+-+-+- +0|0|1|0 +(1 row) + +step s1c: COMMIT; +step s2h: INSERT INTO vh_hist VALUES (1); +step s2c: COMMIT; +step s3u1: <... completed> +step s3c: COMMIT; +step chkh: SELECT f.id, f.v, f.w, count(h.id) AS committed FROM vh f LEFT JOIN vh_hist h USING (id) GROUP BY f.id, f.v, f.w ORDER BY f.id; +id|v|w|committed +--+-+-+--------- + 1|2|0| 2 + 2|1|0| 1 +(2 rows) + diff --git a/src/test/isolation/expected/heap_vacuum_move.out b/src/test/isolation/expected/heap_vacuum_move.out new file mode 100644 index 0000000000000..c814ea0f403c2 --- /dev/null +++ b/src/test/isolation/expected/heap_vacuum_move.out @@ -0,0 +1,55 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_upd s2_vac s2_ins s1_idx s1_bmp s1_rollback s2_check +step s1_upd: BEGIN; UPDATE hvmove SET v = 7 WHERE id = 400; +step s2_vac: VACUUM hvmove; +step s2_ins: INSERT INTO hvmove SELECT g, 100, repeat('n', 200) FROM generate_series(9000, 9800) g; +step s1_idx: SELECT id, v FROM hvmove WHERE id = 400; + id|v +---+- +400|7 +(1 row) + +step s1_bmp: SET enable_indexscan = off; SET enable_bitmapscan = on; SELECT id, v FROM hvmove WHERE id = 400; + id|v +---+- +400|7 +(1 row) + +step s1_rollback: ROLLBACK; +step s2_check: + SET enable_indexscan = off; SET enable_bitmapscan = off; + SELECT count(*) AS n, count(DISTINCT id) AS ids, sum(v) AS sumv, + count(*) FILTER (WHERE id = 400) AS n400, + max(v) FILTER (WHERE id = 400) AS v400 + FROM hvmove; + + n|ids| sumv|n400|v400 +---+---+-----+----+---- +901|901|80100| 1| 0 +(1 row) + + +starting permutation: s1_upd s2_vac s2_ins s1_idx s1_commit s2_check +step s1_upd: BEGIN; UPDATE hvmove SET v = 7 WHERE id = 400; +step s2_vac: VACUUM hvmove; +step s2_ins: INSERT INTO hvmove SELECT g, 100, repeat('n', 200) FROM generate_series(9000, 9800) g; +step s1_idx: SELECT id, v FROM hvmove WHERE id = 400; + id|v +---+- +400|7 +(1 row) + +step s1_commit: COMMIT; +step s2_check: + SET enable_indexscan = off; SET enable_bitmapscan = off; + SELECT count(*) AS n, count(DISTINCT id) AS ids, sum(v) AS sumv, + count(*) FILTER (WHERE id = 400) AS n400, + max(v) FILTER (WHERE id = 400) AS v400 + FROM hvmove; + + n|ids| sumv|n400|v400 +---+---+-----+----+---- +901|901|80107| 1| 7 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index bf9a2037c705a..3b806b9fbcb6a 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -132,3 +132,64 @@ test: ddl-dependency-locking test: tablespace-dependency-locking test: pub-concurrent-drop test: drop-owned-grant +test: flux_inplace +test: flux_conflict +test: flux_specconflict_toast +test: flux_lostupdate +test: flux_scan_stable +test: flux_delete_conflict +test: flux_scan_history +test: flux_slot_tuple_context +test: flux_update_waiter_abort +test: heap_update_waiter_abort +test: heap_update_key_waiter +test: flux_update_key_waiter +test: flux_update_serialize +test: heap_update_serialize +test: flux_lock_recheck +test: heap_lock_recheck +test: flux_update_victim +test: heap_update_victim +test: flux_update_deadlock +test: heap_update_deadlock +test: flux_vacuum_move +test: flux_pvs_deep +test: heap_vacuum_move +test: flux_savepoint_lock_rollback +test: heap_savepoint_lock_rollback +test: flux_ssi_conflict_out +test: heap_ssi_conflict_out +test: flux_savepoint_lock_wake +test: heap_savepoint_lock_wake +test: flux_lock_delete_lockers +test: heap_lock_delete_lockers +test: flux_lock_update_lockers +test: heap_lock_update_lockers +test: flux_cluster_sort_snapshot +test: heap_cluster_sort_snapshot +test: flux_partition_move +test: heap_partition_move +test: flux_tid_fetch_snapshot +test: heap_tid_fetch_snapshot +test: flux_fk_crosscheck +test: heap_fk_crosscheck +test: flux_seqscan_stats +test: heap_seqscan_stats +test: flux_index_aba +test: flux_outofplace_update +test: heap_outofplace_update +test: flux_outofplace_grow +test: heap_outofplace_grow +test: flux_ssi_revive +test: flux_relstats +test: heap_ssi_revive +test: flux_lock_upgrade_queue +test: heap_lock_upgrade_queue +test: flux_recheck_after_wait +test: heap_recheck_after_wait + +test: heap_nonkey_move +test: flux_nonkey_move + +test: heap_ssi_copy +test: flux_ssi_copy diff --git a/src/test/isolation/specs/flux_cluster_sort_snapshot.spec b/src/test/isolation/specs/flux_cluster_sort_snapshot.spec new file mode 100644 index 0000000000000..963718e84a550 --- /dev/null +++ b/src/test/isolation/specs/flux_cluster_sort_snapshot.spec @@ -0,0 +1,62 @@ +# CLUSTER and VACUUM FULL are MVCC-safe (see "Caveats" in mvcc.sgml): a +# snapshot taken before the rewrite reads exactly the rows it read before, +# whichever way the rewrite reads the table: a sort (cs_e, an expression +# index), an index scan (cs_g: GiST never sorts), the planner's choice (cs_b) +# or a sequential scan (VACUUM FULL). s1 takes its snapshot on another +# table, so it holds no lock on cs and the rewrite runs at once. Before the +# rewrite s2 deletes half the rows, updates every row once or twice, or +# inserts one; s1 must then still read the 20 rows it saw, with their old +# values, by a sequential scan and through the rebuilt index. +# FLUX twin; heap_/flux_cluster_sort_snapshot have identical expected output. +setup +{ + CREATE TABLE cs (k int, v int, p point) USING flux; + INSERT INTO cs SELECT g, g, point(g, g) FROM generate_series(1, 20) g; + CREATE INDEX cs_e ON cs ((-k)); + CREATE INDEX cs_b ON cs (k); + CREATE INDEX cs_g ON cs USING gist (p); + CREATE TABLE cs_other (x int); +} + +teardown +{ + DROP TABLE cs, cs_other; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; } +step s1r { SELECT count(*), min(k), max(k), sum(v) FROM cs; } +step s1ri { + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; +} +step s1c { COMMIT; } + +session s2 +step s2d { DELETE FROM cs WHERE k > 10; } +step s2u { UPDATE cs SET v = v + 100; } +step s2i { INSERT INTO cs VALUES (21, 21, point(21, 21)); } +step s2cle { CLUSTER cs USING cs_e; } +step s2clb { CLUSTER cs USING cs_b; } +step s2clg { CLUSTER cs USING cs_g; } +step s2vf { VACUUM FULL cs; } +step s2vff { VACUUM (FULL, FREEZE) cs; } +step s2v { VACUUM cs; } +step s2flush { SELECT pg_stat_force_next_flush(); } +step s2dead { SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'cs'; } +step s2chk { SELECT count(*), min(k), max(k), sum(v) FROM cs; } + +# Deleted rows, each way of reading the table. +permutation s1b s2d s2cle s1r s1ri s1c s2chk +permutation s1b s2d s2clb s1r s1ri s1c s2chk +permutation s1b s2d s2clg s1r s1ri s1c s2chk +permutation s1b s2d s2vf s1r s1ri s1c s2chk +# Rows updated (in place, for FLUX) once or twice, then some deleted. +permutation s1b s2u s2cle s1r s1ri s1c s2chk +permutation s1b s2u s2u s2clg s1r s1ri s1c s2chk +permutation s1b s2u s2d s2vf s1r s1ri s1c s2chk +# A row s1 must not see, and freezing; VACUUM afterwards. +permutation s1b s2i s2u s2vff s1r s1ri s2flush s2v s2dead s1c s2v s2dead s2chk +# No rewrite: the control. +permutation s1b s2d s2u s1r s1c s2chk diff --git a/src/test/isolation/specs/flux_conflict.spec b/src/test/isolation/specs/flux_conflict.spec new file mode 100644 index 0000000000000..29b4edb0e9e1a --- /dev/null +++ b/src/test/isolation/specs/flux_conflict.spec @@ -0,0 +1,54 @@ +# FLUX concurrent write-conflict isolation tests. +# +# Validates that write-conflict detection (which transaction is concurrently +# modifying a tuple) is correct when derived from the on-page header: +# - two writers on the SAME row serialize (blocker waits for committer), +# - two writers on DIFFERENT rows of the SAME page do NOT falsely conflict, +# - concurrent delete vs update interact like heap. +# +# All rows are packed onto one page (fillfactor 100, tiny rows) so the +# "different rows, same page" case genuinely shares a page. + +setup +{ + CREATE TABLE flux_cc (id int PRIMARY KEY, v int) USING flux WITH (fillfactor = 100); + INSERT INTO flux_cc SELECT g, 0 FROM generate_series(1, 20) g; +} + +teardown +{ + DROP TABLE flux_cc; +} + +session s1 +setup { BEGIN; } +step s1_upd1 { UPDATE flux_cc SET v = 1 WHERE id = 1; } +step s1_del1 { DELETE FROM flux_cc WHERE id = 1; } +step s1_commit { COMMIT; } +step s1_abort { ROLLBACK; } + +session s2 +setup { BEGIN; } +step s2_upd1 { UPDATE flux_cc SET v = 2 WHERE id = 1; } +step s2_upd2 { UPDATE flux_cc SET v = 2 WHERE id = 2; } +step s2_del1 { DELETE FROM flux_cc WHERE id = 1; } +step s2_commit { COMMIT; } + +# Same row: s2's update must block until s1 commits, then apply on the new row. +permutation s1_upd1 s2_upd1 s1_commit s2_commit + +# Different rows, SAME page: s2 updating id=2 must NOT block on s1's id=1 update. +permutation s1_upd1 s2_upd2 s1_commit s2_commit + +# Concurrent delete (s1) vs update (s2) of the same row: s2 blocks, then finds +# the row deleted after s1 commits. +permutation s1_del1 s2_upd1 s1_commit s2_commit + +# Delete then abort: s2's update blocks, then proceeds after s1 rolls back. +permutation s1_del1 s2_upd1 s1_abort s2_commit + +# Update vs update on the SAME row: s2 blocks behind s1's in-progress update. +permutation s1_upd1 s2_upd1 s1_abort s2_commit + +# Update (s1) vs delete (s2) of the same row: s2's delete blocks behind s1's update. +permutation s1_upd1 s2_del1 s1_commit s2_commit diff --git a/src/test/isolation/specs/flux_delete_conflict.spec b/src/test/isolation/specs/flux_delete_conflict.spec new file mode 100644 index 0000000000000..3459bc2321f23 --- /dev/null +++ b/src/test/isolation/specs/flux_delete_conflict.spec @@ -0,0 +1,47 @@ +# FLUX concurrent UPDATE/DELETE and DELETE/DELETE behave like heap. +# +# Every permutation runs against a FLUX table; the expected output is heap's +# for the same spec with USING heap (only the table AM differs). Under READ +# COMMITTED the second writer waits, then re-checks the row (EvalPlanQual); +# under REPEATABLE READ it fails with a serialization error. + +setup +{ + CREATE TABLE d (id int PRIMARY KEY, v int) USING flux; + INSERT INTO d VALUES (1, 10), (2, 20); +} + +teardown +{ + DROP TABLE d; +} + +session s1 +step b1rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step b1rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step u1 { UPDATE d SET v = v + 1 WHERE id = 1; } +step d1 { DELETE FROM d WHERE id = 1; } +step c1 { COMMIT; } +step a1 { ROLLBACK; } + +session s2 +step b2rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step b2rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step snap2 { SELECT count(*) FROM d; } +step u2 { UPDATE d SET v = v + 100 WHERE id = 1 RETURNING v; } +step d2 { DELETE FROM d WHERE id = 1 RETURNING v; } +step c2 { COMMIT; } +step sel { SELECT id, v FROM d ORDER BY id; } + +# READ COMMITTED +permutation b1rc b2rc d1 d2 c1 c2 sel +permutation b1rc b2rc d1 d2 a1 c2 sel +permutation b1rc b2rc u1 d2 c1 c2 sel +permutation b1rc b2rc d1 u2 c1 c2 sel +permutation b1rc b2rc d1 u2 a1 c2 sel +# REPEATABLE READ (s2's snapshot taken before s1 writes) +permutation b1rr b2rr snap2 d1 d2 c1 c2 sel +permutation b1rr b2rr snap2 d1 d2 a1 c2 sel +permutation b1rr b2rr snap2 u1 d2 c1 c2 sel +permutation b1rr b2rr snap2 d1 u2 c1 c2 sel +permutation b1rr b2rr snap2 d1 c1 d2 c2 sel diff --git a/src/test/isolation/specs/flux_fk_crosscheck.spec b/src/test/isolation/specs/flux_fk_crosscheck.spec new file mode 100644 index 0000000000000..b7cdb3c14e7ae --- /dev/null +++ b/src/test/isolation/specs/flux_fk_crosscheck.spec @@ -0,0 +1,41 @@ +# A REPEATABLE READ RI action must fail with a serialization failure when it +# finds a referencing row that its transaction snapshot cannot see, as with +# heap, whose delete and update test the row against the RI crosscheck +# snapshot. heap_fk_crosscheck.spec runs the same steps on heap; the two +# expected outputs are identical. +# +# FLUX used to ignore the crosscheck snapshot, so ON DELETE CASCADE deleted, +# and ON DELETE SET NULL and ON UPDATE CASCADE updated, a child row inserted +# by a transaction that committed after the snapshot. +setup +{ + CREATE TABLE pk (a int PRIMARY KEY) USING flux; + CREATE TABLE fkc (a int REFERENCES pk ON DELETE CASCADE ON UPDATE CASCADE) USING flux; + CREATE TABLE fkn (a int REFERENCES pk ON DELETE SET NULL) USING flux; + INSERT INTO pk VALUES (2); +} +teardown +{ + DROP TABLE fkc, fkn, pk; +} + +session s1 +step s1brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s1ic { INSERT INTO fkc VALUES (2); } +step s1in { INSERT INTO fkn VALUES (2); } +step s1c { COMMIT; } +step s1sel { SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; } + +session s2 +step s2brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2brc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2d { DELETE FROM pk WHERE a = 2; } +step s2u { UPDATE pk SET a = 3 WHERE a = 2; } +step s2c { COMMIT; } + +permutation s1brr s1ic s2brr s2d s1c s2c s1sel +permutation s1brr s1in s2brr s2d s1c s2c s1sel +permutation s1brr s1ic s2brr s2u s1c s2c s1sel +# READ COMMITTED has no crosscheck: the action applies to the new child. +permutation s1brr s1ic s2brc s2d s1c s2c s1sel +permutation s1brr s1in s2brc s2d s1c s2c s1sel diff --git a/src/test/isolation/specs/flux_index_aba.spec b/src/test/isolation/specs/flux_index_aba.spec new file mode 100644 index 0000000000000..57b34070292f5 --- /dev/null +++ b/src/test/isolation/specs/flux_index_aba.spec @@ -0,0 +1,77 @@ +# FLUX: an old snapshot still reaches a row by its old key after a later +# key UPDATE of the row is rolled back. +# +# A FLUX UPDATE of an indexed column keeps the row at its TID: it inserts or +# revives (k_new, TID) and delete-marks (k_old, TID), which an older snapshot +# needs to find the row by k_old. s1's REPEATABLE READ snapshot predates +# s2's committed A -> C, so it reads A through the delete-marked (A, TID). +# s2 then moves the row back to A, reviving (A, TID), and rolls back. The +# rollback must leave (A, TID) delete-marked, not remove it: s1's index scan +# for A must still return the row, as its seqscan does. +# +# fx is FLUX, hp its heap twin, which gets the same statements; heap never +# reuses a TID, so it is the control. Every query reads both tables, by +# index scan and by seqscan, and must return the same rows from all four. + +setup +{ + CREATE TABLE fx (id int PRIMARY KEY, k int) USING flux; + CREATE TABLE hp (id int PRIMARY KEY, k int) USING heap; + INSERT INTO fx SELECT g, g FROM generate_series(1, 20) g; + INSERT INTO hp SELECT g, g FROM generate_series(1, 20) g; + CREATE INDEX fx_k ON fx (k); + CREATE INDEX hp_k ON hp (k); + + -- the rows each table returns for k = key, by index scan and by seqscan + CREATE FUNCTION aba_rows(key int) RETURNS TABLE (via text, id int, k int) + LANGUAGE plpgsql AS $$ + BEGIN + PERFORM set_config('enable_seqscan', 'off', true); + PERFORM set_config('enable_bitmapscan', 'off', true); + PERFORM set_config('enable_indexonlyscan', 'off', true); + RETURN QUERY SELECT 'fx-idx', fx.id, fx.k FROM fx WHERE fx.k = key; + RETURN QUERY SELECT 'hp-idx', hp.id, hp.k FROM hp WHERE hp.k = key; + PERFORM set_config('enable_seqscan', 'on', true); + PERFORM set_config('enable_indexscan', 'off', true); + RETURN QUERY SELECT 'fx-seq', fx.id, fx.k FROM fx WHERE fx.k = key; + RETURN QUERY SELECT 'hp-seq', hp.id, hp.k FROM hp WHERE hp.k = key; + PERFORM set_config('enable_indexscan', 'on', true); + PERFORM set_config('enable_bitmapscan', 'on', true); + PERFORM set_config('enable_indexonlyscan', 'on', true); + END $$; +} + +teardown +{ + DROP TABLE fx, hp; + DROP FUNCTION aba_rows; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT k FROM fx WHERE id = 12; } +step s1a { SELECT * FROM aba_rows(12); } +step s1c { SELECT * FROM aba_rows(3000012); } +step s1e { COMMIT; } + +session s2 +# committed A -> C +step s2c { UPDATE fx SET k = 3000012 WHERE id = 12; UPDATE hp SET k = 3000012 WHERE id = 12; } +# C -> A, rolled back +step s2a { BEGIN; UPDATE fx SET k = 12 WHERE id = 12; UPDATE hp SET k = 12 WHERE id = 12; ROLLBACK; } +# C -> A -> C -> A, rolled back +step s2x { BEGIN; + UPDATE fx SET k = 12 WHERE id = 12; UPDATE hp SET k = 12 WHERE id = 12; + UPDATE fx SET k = 3000012 WHERE id = 12; UPDATE hp SET k = 3000012 WHERE id = 12; + UPDATE fx SET k = 12 WHERE id = 12; UPDATE hp SET k = 12 WHERE id = 12; + ROLLBACK; } +# A -> B -> A, rolled back (no committed change: s1 and new snapshots agree) +step s2b { BEGIN; + UPDATE fx SET k = 4000012 WHERE id = 12; UPDATE hp SET k = 4000012 WHERE id = 12; + UPDATE fx SET k = 12 WHERE id = 12; UPDATE hp SET k = 12 WHERE id = 12; + ROLLBACK; } +# what a new snapshot sees +step s2r { SELECT * FROM aba_rows(12) UNION ALL SELECT * FROM aba_rows(3000012); } + +permutation s1b s2c s2a s1a s1c s1e s2r +permutation s1b s2c s2x s1a s1c s1e s2r +permutation s1b s2b s1a s1e s2r diff --git a/src/test/isolation/specs/flux_inplace.spec b/src/test/isolation/specs/flux_inplace.spec new file mode 100644 index 0000000000000..81b283325f9c8 --- /dev/null +++ b/src/test/isolation/specs/flux_inplace.spec @@ -0,0 +1,45 @@ +# FLUX: always-in-place indexed-column UPDATE via delete-marking. +# +# Validates the MVCC visibility of an in-place key UPDATE across snapshots: +# - a REPEATABLE READ reader whose snapshot predates the key UPDATE keeps +# seeing the OLD key + value (reachable through the delete-marked old +# index entry), and does NOT see the row under the NEW key; +# - a reader whose snapshot follows the UPDATE sees only the NEW key; +# - the TID is stable, so no row moves. +# Both the old and new versions are reached via the SAME index; the delete- +# marking key recheck ensures neither snapshot sees a duplicate +# or a phantom. + +setup +{ + CREATE TABLE flux_ip (id int PRIMARY KEY, k int, v text) USING flux; + INSERT INTO flux_ip VALUES (1, 100, 'a'); + CREATE INDEX flux_ip_k ON flux_ip (k); + SET enable_seqscan = off; +} + +teardown +{ + DROP TABLE flux_ip; +} + +session s_old +setup { BEGIN ISOLATION LEVEL REPEATABLE READ; + SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; + SELECT k, v FROM flux_ip WHERE id = 1; } +step o_old_key { SELECT k, v FROM flux_ip WHERE k = 100; } # old snapshot: sees 100,a +step o_new_key { SELECT count(*) AS n FROM flux_ip WHERE k = 200; } # old snapshot: 0 +step o_commit { COMMIT; } + +session s_writer +step w_update { UPDATE flux_ip SET k = 200, v = 'b' WHERE id = 1; } + +session s_new +setup { SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; } +step n_new_key { SELECT k, v FROM flux_ip WHERE k = 200; } # new snapshot: sees 200,b +step n_old_key { SELECT count(*) AS n FROM flux_ip WHERE k = 100; } # new snapshot: 0 + +# The old-snapshot reader established its snapshot in setup (before the +# UPDATE), so after the committed in-place key UPDATE it still sees the old +# key/value, while a fresh reader sees only the new key. +permutation w_update o_old_key o_new_key n_new_key n_old_key o_commit diff --git a/src/test/isolation/specs/flux_lock_delete_lockers.spec b/src/test/isolation/specs/flux_lock_delete_lockers.spec new file mode 100644 index 0000000000000..7c98ce4d1d420 --- /dev/null +++ b/src/test/isolation/specs/flux_lock_delete_lockers.spec @@ -0,0 +1,39 @@ +# A DELETE that conflicts with several row lockers must wait for all of them, +# not only the first: here two foreign-key checks hold FOR KEY SHARE on the +# parent row, and one of them rolls back. The DELETE must still wait for the +# other, so its foreign-key check then finds the committed child row. +# FLUX twin of heap_lock_delete_lockers; the expected output is identical. +setup +{ + CREATE TABLE parent (i int PRIMARY KEY) USING flux; + INSERT INTO parent VALUES (1); + CREATE TABLE child (i int REFERENCES parent (i)) USING flux; +} + +teardown +{ + DROP TABLE child, parent; +} + +session s1 +step s1b { BEGIN; } +step s1l { INSERT INTO child VALUES (1); } +step s1r { ROLLBACK; } +step s1c { COMMIT; } + +session s2 +step s2b { BEGIN; } +step s2l { INSERT INTO child VALUES (1); } +step s2c { COMMIT; } +step s2r { ROLLBACK; } + +session s3 +step s3d { DELETE FROM parent; } +step s3chk { SELECT (SELECT count(*) FROM parent) AS parents, (SELECT count(*) FROM child) AS children; } + +# The first locker rolls back; the DELETE must keep waiting for the second. +permutation s1b s1l s2b s2l s3d s1r s2c s3chk +# The second locker rolls back first. +permutation s1b s1l s2b s2l s3d s2r s1c s3chk +# Both roll back: the DELETE goes ahead. +permutation s1b s1l s2b s2l s3d s1r s2r s3chk diff --git a/src/test/isolation/specs/flux_lock_recheck.spec b/src/test/isolation/specs/flux_lock_recheck.spec new file mode 100644 index 0000000000000..3b62531cb6689 --- /dev/null +++ b/src/test/isolation/specs/flux_lock_recheck.spec @@ -0,0 +1,406 @@ +# FLUX: a row lock that waits on, or meets, a committed concurrent update must +# report it exactly as heap does. +# +# FLUX updates rows in place at a stable TID, so the latest version of a row +# is always at the TID the caller holds. table_tuple_lock() must still tell +# its callers that the version their snapshot saw was superseded: +# - READ COMMITTED, TUPLE_LOCK_FLAG_FIND_LAST_VERSION: TM_Ok with +# tmfd->traversed = true, so nodeLockRows, GetTupleForTrigger and +# ri_LockPKTuple re-check the latest version (EvalPlanQual, FK key recheck); +# - REPEATABLE READ: TM_Updated, so the caller raises "could not serialize +# access due to concurrent update"; +# - ON CONFLICT (flags 0) under READ COMMITTED: TM_Ok on the latest version, +# because heap's retry would find and lock that same row. +# A FOR KEY SHARE lock is compatible with a non-key UPDATE and, as in heap, +# neither waits nor reports it. +# +# The expected output is heap's: heap_lock_recheck.spec is this spec with +# USING heap, and both share the same expected .out. + +setup +{ + CREATE TABLE lr (id int PRIMARY KEY, v int, w int) USING flux; + INSERT INTO lr VALUES (1, 1, 0), (2, 1, 0); + CREATE TABLE lrp (id int PRIMARY KEY, note int) USING flux; + INSERT INTO lrp VALUES (1, 0); + CREATE TABLE lrc (id int, pid int REFERENCES lrp) USING flux; + CREATE TABLE lrt (id int PRIMARY KEY, v int, w int) USING flux; + INSERT INTO lrt VALUES (1, 1, 0); + CREATE FUNCTION lrt_bu() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN RAISE NOTICE 'lrt_bu: OLD.v=% NEW.v=%', OLD.v, NEW.v; RETURN NEW; END $$; + CREATE TRIGGER lrt_bu BEFORE UPDATE ON lrt FOR EACH ROW EXECUTE FUNCTION lrt_bu(); + -- A BEFORE UPDATE OR DELETE trigger that modifies NEW. + CREATE TABLE lrn (id int PRIMARY KEY, v int, w int) USING flux; + INSERT INTO lrn VALUES (1, 1, 0); + CREATE FUNCTION lrn_bu() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN + RAISE NOTICE 'lrn_bu %: OLD=% NEW=%', TG_OP, OLD, NEW; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + NEW.w := NEW.w + 100; + RETURN NEW; + END $$; + CREATE TRIGGER lrn_bu BEFORE UPDATE OR DELETE ON lrn FOR EACH ROW EXECUTE FUNCTION lrn_bu(); + -- Partitioned by v, for cross-partition UPDATE; lptt has a trigger. + CREATE TABLE lpt (id int, v int, w int) PARTITION BY LIST (v); + CREATE TABLE lpt1 PARTITION OF lpt FOR VALUES IN (1) USING flux; + CREATE TABLE lpt2 PARTITION OF lpt FOR VALUES IN (2) USING flux; + INSERT INTO lpt VALUES (1, 1, 0); + CREATE TABLE lptt (id int, v int, w int) PARTITION BY LIST (v); + CREATE TABLE lptt1 PARTITION OF lptt FOR VALUES IN (1) USING flux; + CREATE TABLE lptt2 PARTITION OF lptt FOR VALUES IN (2) USING flux; + INSERT INTO lptt VALUES (1, 1, 0); + CREATE FUNCTION lr_note() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN + RAISE NOTICE '% % %: OLD=% NEW=%', TG_TABLE_NAME, TG_WHEN, TG_OP, OLD, NEW; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; + END $$; + CREATE TRIGGER lr_note BEFORE UPDATE OR DELETE ON lptt FOR EACH ROW EXECUTE FUNCTION lr_note(); + -- FK with ON DELETE CASCADE: the RI action deletes a child row that a + -- concurrent transaction has updated. + CREATE TABLE lrq (id int PRIMARY KEY, note int) USING flux; + INSERT INTO lrq VALUES (1, 0); + CREATE TABLE lrqc (id int PRIMARY KEY, pid int REFERENCES lrq ON DELETE CASCADE, w int) USING flux; + INSERT INTO lrqc VALUES (1, 1, 0); + CREATE TABLE lrup (id int PRIMARY KEY) USING flux; + INSERT INTO lrup VALUES (1); + CREATE TABLE lruc (pid int REFERENCES lrup ON UPDATE CASCADE) USING flux; + CREATE TABLE lrrp (id int PRIMARY KEY) USING flux; + INSERT INTO lrrp VALUES (1); + CREATE TABLE lrrc (pid int REFERENCES lrrp ON UPDATE RESTRICT) USING flux; + CREATE TABLE lrmp (a int, b int, PRIMARY KEY (a, b)) USING flux; + INSERT INTO lrmp VALUES (1, 1); + CREATE TABLE lrmc (a int, b int, FOREIGN KEY (a, b) REFERENCES lrmp) USING flux; + CREATE TABLE lrvp (id int, note int) USING flux; + CREATE UNIQUE INDEX lrvp_unique ON lrvp (id); + INSERT INTO lrvp VALUES (1, 0); + CREATE TABLE lrvc (pid int REFERENCES lrvp (id)) USING flux; +} + +teardown +{ + DROP TABLE lruc, lrup, lrrc, lrrp, lrmc, lrmp, lrvc, lrvp; + DROP TABLE lr, lrc, lrp, lrt, lrn, lpt, lptt, lrqc, lrq; + DROP FUNCTION lrt_bu(), lrn_bu(), lr_note(); +} + +session s1 +step s1b { BEGIN; } +step s1sp { SAVEPOINT key_update; } +step s1rb { ROLLBACK TO key_update; } +step s1uk { UPDATE lrup SET id = 2 WHERE id = 1; } +step s1rk { UPDATE lrrp SET id = 2 WHERE id = 1; } +step s1mk { UPDATE lrmp SET b = 2 WHERE a = 1 AND b = 1; } +step s1vk { UPDATE lrvp SET id = 2 WHERE id = 1; } +step s1vn { UPDATE lrvp SET note = note + 1 WHERE id = 1; } +step s1u { UPDATE lr SET v = 2 WHERE id = 1; } +step s1k { UPDATE lr SET id = 10 WHERE id = 1; } +step s1d { DELETE FROM lr WHERE id = 1; } +step s1pk { UPDATE lrp SET id = 2 WHERE id = 1; } +step s1pfu { SELECT id FROM lrp WHERE id = 1 FOR UPDATE; } +step s1pn { UPDATE lrp SET note = 1 WHERE id = 1; } +step s1tu { UPDATE lrt SET v = 2 WHERE id = 1; } +step s1lfu { SELECT * FROM lr WHERE id = 1 FOR UPDATE; } +step s1tfu { SELECT * FROM lrt WHERE id = 1 FOR UPDATE; } +step s1tk { UPDATE lrt SET id = 10 WHERE id = 1; } +step s1tu2 { UPDATE lrt SET v = v + 1, w = w + 10 WHERE id = 1; } +step s1nu { UPDATE lrn SET v = 2 WHERE id = 1; } +step s1pu { UPDATE lpt SET w = 5 WHERE id = 1; } +step s1ptu { UPDATE lptt SET w = 5 WHERE id = 1; } +# session_replication_role = replica skips lrqc's own FK check trigger, which +# FLUX cannot run yet on UPDATE (it reads xmin from the old row's slot). +step s1qcu { SET LOCAL session_replication_role = replica; UPDATE lrqc SET w = 5 WHERE id = 1; } +step s1c { COMMIT; } +step s1a { ROLLBACK; } + +session s2 +step s2rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2a { ROLLBACK; } +step s2uci { INSERT INTO lruc VALUES (1); } +step s2rci { INSERT INTO lrrc VALUES (1); } +step s2mci { INSERT INTO lrmc VALUES (1, 1); } +step s2vci { INSERT INTO lrvc VALUES (1); } +step s2rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2snap { SELECT count(*) FROM lr; } +step s2fu { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; } +step s2fnk { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR NO KEY UPDATE; } +step s2fs { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR SHARE; } +step s2fks { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; } +step s2sl { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE SKIP LOCKED; } +step s2nw { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE NOWAIT; } +step s2upd { UPDATE lr SET w = w + 1 WHERE v = 1 RETURNING *; } +step s2ci { INSERT INTO lrc VALUES (1, 1); } +step s2tu { UPDATE lrt SET w = w + 1 WHERE v = 1 RETURNING *; } +step s2oc { INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; } +# The same locked row in several output rows: each is re-checked. +step s2jfu { SELECT lr.* FROM lr JOIN (VALUES (1), (1), (2)) s(id) USING (id) WHERE lr.v = 1 ORDER BY lr.id FOR UPDATE OF lr; } +# MERGE with a BEFORE UPDATE trigger: GetTupleForTrigger reports the +# concurrent update, MERGE re-checks the join and either drops the row or +# updates the latest version, and must not loop. +step s2mq { MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id AND lrt.v = 1 WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; } +step s2mu { MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; } +# Parent-row locks after several updates (FOR KEY SHARE must see a key change +# by any of them, not only the latest). A cursor fixes the READ COMMITTED +# snapshot before the updates. +step s2pks { SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; } +step s2pnk { SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; } +step s2dks { DECLARE c CURSOR FOR SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; } +step s2dka { DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; } +step s2dnk { DECLARE c CURSOR FOR SELECT * FROM lrp FOR NO KEY UPDATE; } +step s2fc { FETCH ALL FROM c; } +step s2pks101 { SELECT * FROM lrp WHERE id = 101 FOR KEY SHARE; } +step s2ci101 { INSERT INTO lrc VALUES (2, 101); } +# ON CONFLICT DO UPDATE with a BEFORE UPDATE trigger: the trigger locks the +# row ON CONFLICT already locked. +step s2toc { INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; } +step s2noc { INSERT INTO lrn VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrn.w + 1 RETURNING *; } +# MERGE without a trigger (lr) and with one that modifies NEW (lrn). +step s2mlu { MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id WHEN MATCHED THEN UPDATE SET w = lr.w + 1 RETURNING merge_action(), lr.*; } +step s2mlq { MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id AND lr.v = 1 WHEN MATCHED THEN UPDATE SET w = lr.w + 1 RETURNING merge_action(), lr.*; } +step s2mld { MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id WHEN MATCHED THEN DELETE RETURNING merge_action(), lr.*; } +step s2mnu { MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; } +step s2mnq { MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id AND lrn.v = 1 WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; } +step s2mnd { MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN DELETE RETURNING merge_action(), lrn.*; } +# Cross-partition UPDATE (ExecCrossPartitionUpdate), and MERGE moving the row. +step s2pm { UPDATE lpt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; } +step s2pmq { UPDATE lpt SET v = 2 WHERE id = 1 AND w = 0 RETURNING tableoid::regclass, *; } +step s2ptm { UPDATE lptt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; } +step s2ptmq { UPDATE lptt SET v = 2 WHERE id = 1 AND w = 0 RETURNING tableoid::regclass, *; } +step s2mpm { MERGE INTO lpt USING (VALUES (1)) s(id) ON lpt.id = s.id WHEN MATCHED THEN UPDATE SET v = 2 RETURNING merge_action(), lpt.*; } +step s2mptm { MERGE INTO lptt USING (VALUES (1)) s(id) ON lptt.id = s.id WHEN MATCHED THEN UPDATE SET v = 2 RETURNING merge_action(), lptt.*; } +# RI cascade action on a child row updated concurrently. +step s2qd { DELETE FROM lrq WHERE id = 1; } +step s2c { COMMIT; } + +session s3 +step s3orphans { SELECT count(*) AS orphans FROM lrc c WHERE NOT EXISTS (SELECT FROM lrp p WHERE p.id = c.pid); } +step s3u { SELECT * FROM lrup; SELECT * FROM lruc; SELECT count(*) AS orphans FROM lruc c WHERE NOT EXISTS (SELECT FROM lrup p WHERE p.id = c.pid); } +step s3restrict { SELECT * FROM lrrp; SELECT * FROM lrrc; SELECT count(*) AS orphans FROM lrrc c WHERE NOT EXISTS (SELECT FROM lrrp p WHERE p.id = c.pid); } +step s3multi { SELECT * FROM lrmp; SELECT * FROM lrmc; SELECT count(*) AS orphans FROM lrmc c WHERE NOT EXISTS (SELECT FROM lrmp p WHERE p.a = c.a AND p.b = c.b); } +step s3unique { SELECT * FROM lrvp; SELECT * FROM lrvc; SELECT count(*) AS orphans FROM lrvc c WHERE NOT EXISTS (SELECT FROM lrvp p WHERE p.id = c.pid); } +step s3r { SELECT * FROM lr ORDER BY id; } +step s3c { SELECT * FROM lrc ORDER BY id; } +step s3t { SELECT * FROM lrt ORDER BY id; } +step s3p { SELECT * FROM lrp ORDER BY id; } +step s3n { SELECT * FROM lrn ORDER BY id; } +step s3pt { SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; } +step s3q { SELECT * FROM lrq ORDER BY id; SELECT * FROM lrqc ORDER BY id; } + +# Further parent updates, each committed on its own (autocommit) or held open. +# lrp starts as (1, 0). +session s4 +step s4vn { UPDATE lrvp SET note = note + 1 WHERE id = 1; } +step s4pkk { UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; } +step s4pkb { UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; } +step s4pn101 { UPDATE lrp SET note = note + 10 WHERE id = 101; } +step s4pn1 { UPDATE lrp SET note = note + 10 WHERE id = 1; } + +session s5 +step s5b { BEGIN; } +step s5pkk { UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; } +step s5pn101 { UPDATE lrp SET note = note + 100 WHERE id = 101; } +step s5pn1 { UPDATE lrp SET note = note + 100 WHERE id = 1; } +step s5c { COMMIT; } +step s5a { ROLLBACK; } + +# READ COMMITTED, each lock strength that conflicts with the UPDATE waits for +# it, then re-checks v = 1 against the committed version: row 1 drops out. +permutation s1b s1u s2rc s2fu s1c s2c s3r +permutation s1b s1u s2rc s2fnk s1c s2c s3r +permutation s1b s1u s2rc s2fs s1c s2c s3r +# FOR KEY SHARE does not conflict with a non-key UPDATE: no wait, no recheck. +permutation s1b s1u s2rc s2fks s1c s2c s3r +# FOR KEY SHARE waits for a key UPDATE. +permutation s1b s1k s2rc s2fks s1c s2c s3r +# SKIP LOCKED skips the row being updated; NOWAIT fails at once. +permutation s1b s1u s2rc s2sl s1c s2c s3r +permutation s1b s1u s2rc s2nw s1c s2c s3r +# UPDATE ... WHERE v = 1 re-checks through EvalPlanQual: only row 2 updated. +permutation s1b s1u s2rc s2upd s1c s2c s3r +# DELETE racing FOR UPDATE: the deleted row is not returned. +permutation s1b s1d s2rc s2fu s1c s2c s3r +# The updater rolls back: the original row is locked and returned. +permutation s1b s1u s2rc s2fu s1a s2c s3r +permutation s1b s1u s2rc s2upd s1a s2c s3r +# REPEATABLE READ: an update committed after the snapshot is a serialization +# failure, whether the locker waited for it or met it already committed. +permutation s2rr s2snap s1b s1u s2fu s1c s2c s3r +permutation s2rr s2snap s1b s1u s1c s2fu s2c s3r +permutation s2rr s2snap s1b s1u s1c s2fnk s2c s3r +permutation s2rr s2snap s1b s1u s1c s2fs s2c s3r +permutation s2rr s2snap s1b s1u s1c s2sl s2c s3r +permutation s2rr s2snap s1b s1u s1c s2nw s2c s3r +permutation s2rr s2snap s1b s1u s1c s2upd s2c s3r +# REPEATABLE READ FOR KEY SHARE after a committed non-key UPDATE: no failure. +permutation s2rr s2snap s1b s1u s1c s2fks s2c s3r +# REPEATABLE READ FOR KEY SHARE after a committed key UPDATE: failure. +permutation s2rr s2snap s1b s1k s1c s2fks s2c s3r +# REPEATABLE READ, the updater rolls back while the locker waits: no failure. +permutation s2rr s2snap s1b s1u s2fu s1a s2c s3r +# REPEATABLE READ, a committed DELETE: "concurrent delete". +permutation s2rr s2snap s1b s1d s2fu s1c s2c s3r +# FK check (ri_LockPKTuple): the parent key changes while the child insert +# waits, so the insert fails. +permutation s1b s1pk s2rc s2ci s1c s2c s3c +# FK check: a non-key parent UPDATE commits while the child insert waits on +# FOR UPDATE; the key still matches, so the insert succeeds. +permutation s1b s1pfu s2rc s2ci s1pn s1c s2c s3c +# FK check: the parent key update rolls back; the insert succeeds. +permutation s1b s1pk s2rc s2ci s1a s2c s3c +# GetTupleForTrigger: the BEFORE UPDATE trigger row is locked after a +# concurrent update; EvalPlanQual drops it, so neither trigger nor UPDATE runs. +permutation s1b s1tu s2rc s2tu s1c s2c s3t +# ... and after a rollback the original row is updated. +permutation s1b s1tu s2rc s2tu s1a s2c s3t +# GetTupleForTrigger under REPEATABLE READ: serialization failure. +permutation s2rr s2snap s1b s1tu s2tu s1c s2c s3t +# A join repeats row 1: every repetition is re-checked and dropped. +permutation s1b s1u s2rc s2jfu s1c s2c s3r +# MERGE + BEFORE UPDATE trigger after a concurrent committed update. +permutation s1b s1tu s2rc s2mq s1c s2c s3t +permutation s1b s1tu s2rc s2mu s1c s2c s3t +permutation s2rr s2snap s1b s1tu s2mu s1c s2c s3t +# ON CONFLICT DO UPDATE (flags 0), READ COMMITTED: waits, then updates the +# committed version, without looping. +permutation s1b s1u s2rc s2oc s1c s2c s3r +# ON CONFLICT DO UPDATE, REPEATABLE READ: serialization failure. +permutation s2rr s2snap s1b s1u s2oc s1c s2c s3r +permutation s2rr s2snap s1b s1u s1c s2oc s2c s3r + +# Two updates committed after the locker's snapshot: a key change, then a +# non-key change. The key change conflicts with FOR KEY SHARE although the +# latest update left the key alone. REPEATABLE READ: serialization failure, +# for the lock and for the FK check. +permutation s2rr s2snap s4pkk s4pn101 s2pks s2c s3p +permutation s2rr s2snap s4pkk s4pn101 s2ci s2c s3c +permutation s2rr s2snap s4pkk s4pn101 s2pnk s2c s3p +# READ COMMITTED: the lock moves to the latest version and re-checks it: the +# row no longer has id = 1 and drops out, a query without that qual returns +# the latest version, and the FK insert fails because the key has gone. +permutation s2rc s2dks s4pkk s4pn101 s2fc s2ci s2c s3c +permutation s2rc s2dka s4pkk s4pn101 s2fc s2c s3p +permutation s2rc s2dnk s4pkk s4pn101 s2fc s2c s3p +permutation s2rc s4pkk s4pn101 s2ci s2c s3c +# The other order: non-key change, then key change (the latest). +permutation s2rr s2snap s4pn1 s4pkk s2pks s2c s3p +permutation s2rc s2dka s4pn1 s4pkk s2fc s2c s3p +# Two non-key changes: FOR KEY SHARE conflicts with neither. +permutation s2rr s2snap s4pn1 s4pn1 s2pks s2ci s2c s3c +permutation s2rc s2dka s4pn1 s4pn1 s2fc s2c s3p +# Three updates: key change, key changed back, non-key change. The key +# changes conflict although the key ends where it started. +permutation s2rr s2snap s4pkk s4pkb s4pn1 s2pks s2c s3p +permutation s2rr s2snap s4pkk s4pkb s4pn1 s2ci s2c s3c +permutation s2rc s2dka s4pkk s4pkb s4pn1 s2fc s2c s3p +permutation s2rc s2dks s4pkk s4pkb s4pn1 s2fc s2ci s2c s3c +# Three updates: key change, then two non-key changes. +permutation s2rr s2snap s4pkk s4pn101 s4pn101 s2pks s2c s3p +permutation s2rc s2dka s4pkk s4pn101 s4pn101 s2fc s2c s3p +# One committed, one in progress: committed key change, in-progress non-key +# change. REPEATABLE READ fails at once, without waiting for the in-progress +# update, for FOR KEY SHARE and FOR NO KEY UPDATE alike; READ COMMITTED waits +# for it, then locks the latest version. +permutation s2rr s2snap s4pkk s5b s5pn101 s2pks s5c s2c s3p +permutation s2rr s2snap s4pkk s5b s5pn101 s2ci s5c s2c s3c +permutation s2rr s2snap s4pkk s5b s5pn101 s2pnk s5c s2c s3p +permutation s2rc s2dka s4pkk s5b s5pn101 s2fc s5c s2c s3p +permutation s2rc s2dnk s4pkk s5b s5pn101 s2fc s5c s2c s3p +# ... committed non-key change, in-progress key change: FOR KEY SHARE waits, +# then fails (commit) or locks the snapshot's version (rollback). +permutation s2rr s2snap s4pn1 s5b s5pkk s2pks s5c s2c s3p +permutation s2rr s2snap s4pn1 s5b s5pkk s2pks s5a s2c s3p +permutation s2rc s2dka s4pn1 s5b s5pkk s2fc s5c s2c s3p +permutation s2rc s2dka s4pn1 s5b s5pkk s2fc s5a s2c s3p +# Both in progress in one transaction: key change, then non-key change. The +# FK check must wait for the key change, then fail. +permutation s5b s5pkk s5pn101 s2rc s2ci s5c s2c s3c +permutation s5b s5pkk s5pn101 s2rc s2ci s5a s2c s3c +permutation s5b s5pkk s5pn101 s2rr s2snap s2pks s5c s2c s3p +permutation s5b s5pkk s5pn101 s2rr s2snap s2pks s5a s2c s3p +# Committed non-key change, in-progress non-key change: FOR KEY SHARE +# conflicts with neither and does not wait. +permutation s2rr s2snap s4pn1 s5b s5pn1 s2pks s2ci s5c s2c s3c +permutation s2rc s2dka s4pn1 s5b s5pn1 s2fc s5c s2c s3p +# A key change the snapshot sees, then a non-key change it does not: the +# second update did not change the key, whatever the first one did. +permutation s4pkk s2rr s2snap s4pn101 s2pks101 s2ci101 s2c s3c +permutation s4pkk s2rr s2snap s5b s5pn101 s2pks101 s2ci101 s5c s2c s3c +# A key change that rolled back, then a committed non-key change: no conflict. +permutation s2rr s2snap s5b s5pkk s5a s4pn1 s2pks s2ci s2c s3c +permutation s2rc s2dka s5b s5pkk s5a s4pn1 s2fc s2c s3p +# FOR NO KEY UPDATE against the same histories: it conflicts with every +# update, key or not, committed or in progress. +permutation s2rr s2snap s4pn1 s4pn1 s2pnk s2c s3p +permutation s2rc s2dnk s4pn1 s4pn1 s2fc s2c s3p +permutation s2rr s2snap s4pn1 s5b s5pkk s2pnk s5c s2c s3p +permutation s2rr s2snap s4pn1 s5b s5pkk s2pnk s5a s2c s3p +permutation s2rr s2snap s5b s5pkk s5a s4pn1 s2pnk s2c s3p +permutation s2rr s2snap s4pkk s4pkb s4pn1 s2pnk s2c s3p + +# ON CONFLICT DO UPDATE with a BEFORE UPDATE trigger, READ COMMITTED, against +# a row updated and committed after the snapshot: ON CONFLICT locks the latest +# version and the trigger's lock of the same row reports nothing, as in heap +# (no EvalPlanQual: ON CONFLICT has none set up). Also with the row updated +# twice, with a trigger that modifies NEW, after a rollback, and under +# REPEATABLE READ. +permutation s1b s1tu s2rc s2toc s1c s2c s3t +permutation s1b s1tu s1tu2 s2rc s2toc s1c s2c s3t +permutation s1b s1tu s2rc s2toc s1a s2c s3t +permutation s2rr s2snap s1b s1tu s2toc s1c s2c s3t +permutation s1b s1nu s2rc s2noc s1c s2c s3n +permutation s1b s1nu s2rc s2noc s1a s2c s3n +# ON CONFLICT against a key update by a FOR UPDATE locker ON CONFLICT was +# waiting for: the row no longer conflicts, so the INSERT goes ahead. +permutation s1b s1lfu s2rc s2oc s1k s1c s2c s3r +permutation s1b s1tfu s2rc s2toc s1tk s1c s2c s3t +# ... and against a non-key update from that locker: the new version is +# updated (and its trigger fired once); a lock only: the row is updated. +permutation s1b s1lfu s2rc s2oc s1u s1c s2c s3r +permutation s1b s1tfu s2rc s2toc s1tu s1c s2c s3t +permutation s1b s1lfu s2rc s2oc s1c s2c s3r +permutation s2rr s2snap s1b s1lfu s2oc s1u s1c s2c s3r +# MERGE WHEN MATCHED UPDATE/DELETE after a concurrent committed update, +# without a trigger and with a trigger that modifies NEW. +permutation s1b s1u s2rc s2mlu s1c s2c s3r +permutation s1b s1u s2rc s2mlq s1c s2c s3r +permutation s1b s1u s2rc s2mld s1c s2c s3r +permutation s1b s1nu s2rc s2mnu s1c s2c s3n +permutation s1b s1nu s2rc s2mnq s1c s2c s3n +permutation s1b s1nu s2rc s2mnd s1c s2c s3n +permutation s2rr s2snap s1b s1nu s2mnu s1c s2c s3n +# Cross-partition UPDATE after a concurrent committed update, without and +# with BEFORE UPDATE/DELETE triggers, by UPDATE and by MERGE. +permutation s1b s1pu s2rc s2pm s1c s2c s3pt +permutation s1b s1pu s2rc s2pmq s1c s2c s3pt +permutation s1b s1ptu s2rc s2ptm s1c s2c s3pt +permutation s1b s1ptu s2rc s2ptmq s1c s2c s3pt +permutation s1b s1pu s2rc s2mpm s1c s2c s3pt +permutation s1b s1ptu s2rc s2mptm s1c s2c s3pt +permutation s2rr s2snap s1b s1ptu s2ptm s1c s2c s3pt +# RI ON DELETE CASCADE against a child row updated concurrently. +permutation s1b s1qcu s2rc s2qd s1c s2c s3q + +# Lock first, UPDATE second: key changes conflict with all four strengths, +# but a non-key change remains compatible with KEY SHARE (including CAS). +permutation s2rc s2fks s1b s1k s2c s1c s3r +permutation s2rc s2fks s1b s1u s1c s2c s3r +permutation s2rc s2fs s1b s1k s2c s1c s3r +permutation s2rc s2fnk s1b s1k s2c s1c s3r +permutation s2rc s2fu s1b s1k s2c s1c s3r +# FK insertion holds KEY SHARE until commit. The parent UPDATE must wait +# and then check the newly committed child; an aborted child permits it. +permutation s2rc s2ci s1b s1pk s2c s1c s3p s3c s3orphans +permutation s2rc s2ci s1b s1pk s2a s1c s3p s3c s3orphans +permutation s2rc s2uci s1b s1uk s2c s1c s3u +permutation s2rc s2rci s1b s1rk s2c s1c s3restrict +# Subtransaction rollback restores the parent and releases its key lock. +permutation s2rc s2fks s1b s1sp s1k s2c s1rb s1c s3r +permutation s1b s1sp s1pk s2rc s2ci s1rb s2c s1c s3p s3c s3orphans +# A single component change, and a standalone unique index used by an FK. +permutation s2rc s2mci s1b s1mk s2c s1c s3multi +permutation s2rc s2vci s1b s1vk s2c s1c s3unique +permutation s2rc s2vci s1b s1vn s1c s2c s3unique + +# A compatible non-key update clears the on-page lock flag, not the FK +# lock in sLog. The same-size unique-key update can now reach CAS. +permutation s2rc s2vci s4vn s1b s1vk s2c s1c s3unique diff --git a/src/test/isolation/specs/flux_lock_update_lockers.spec b/src/test/isolation/specs/flux_lock_update_lockers.spec new file mode 100644 index 0000000000000..ed15ea91019ca --- /dev/null +++ b/src/test/isolation/specs/flux_lock_update_lockers.spec @@ -0,0 +1,32 @@ +# An UPDATE that conflicts with several row lockers must wait for all of +# them: two sessions hold FOR SHARE on the row, the first rolls back, and the +# UPDATE must keep waiting for the second instead of overwriting the row it +# still has locked. FLUX twin; the expected output is identical. +setup +{ + CREATE TABLE lu (id int PRIMARY KEY, v int) USING flux; + INSERT INTO lu VALUES (1, 0); +} + +teardown +{ + DROP TABLE lu; +} + +session s1 +step s1b { BEGIN; } +step s1l { SELECT v FROM lu WHERE id = 1 FOR SHARE; } +step s1r { ROLLBACK; } + +session s2 +step s2b { BEGIN; } +step s2l { SELECT v FROM lu WHERE id = 1 FOR SHARE; } +step s2chk { SELECT v FROM lu WHERE id = 1; } +step s2c { COMMIT; } + +session s3 +step s3u { UPDATE lu SET v = v + 1 WHERE id = 1; } +step s3chk { SELECT v FROM lu WHERE id = 1; } + +# s3 must still be waiting after s1 rolls back; s2 still sees v = 0. +permutation s1b s1l s2b s2l s3u s1r s2chk s2c s3chk diff --git a/src/test/isolation/specs/flux_lock_upgrade_queue.spec b/src/test/isolation/specs/flux_lock_upgrade_queue.spec new file mode 100644 index 0000000000000..9a3407b191f31 --- /dev/null +++ b/src/test/isolation/specs/flux_lock_upgrade_queue.spec @@ -0,0 +1,51 @@ +# A transaction that already holds a row lock and waits to strengthen it +# must not queue on the heavyweight tuple lock, as with heap: a waiter +# ahead of it in that queue may be waiting for the lock it already holds +# (heap_lock_tuple's skip_tuple_lock, heap_delete's current_is_member). +# heap_lock_upgrade_queue.spec runs the same steps on heap; the two +# expected outputs are identical. +# +# FLUX used to queue the upgrader behind the waiter, which then waited for +# the upgrader's own lock: "deadlock detected" here, and in +# tuplelock-upgrade-no-deadlock, where the cycle closes through an idle +# session, a wait that never ends. +setup +{ + CREATE TABLE luq (id int PRIMARY KEY, v int) USING flux; + INSERT INTO luq VALUES (1, 0); +} +teardown +{ + DROP TABLE luq; +} + +session s1 +setup { BEGIN; } +step s1ks { SELECT id FROM luq FOR KEY SHARE; } +step s1sh { SELECT id FROM luq FOR SHARE; } +step s1sp { SAVEPOINT a; } +step s1rs { ROLLBACK TO a; } +step s1r { ROLLBACK; } + +session s2 +setup { BEGIN; } +step s2ks { SELECT id FROM luq FOR KEY SHARE; } +step s2nk { SELECT id FROM luq FOR NO KEY UPDATE; } +step s2fu { SELECT id FROM luq FOR UPDATE; } +step s2c { COMMIT; } + +session s3 +setup { BEGIN; } +step s3ks { SELECT id FROM luq FOR KEY SHARE; } +step s3sh { SELECT id FROM luq FOR SHARE; } +step s3fu { SELECT id FROM luq FOR UPDATE; } +step s3d { DELETE FROM luq RETURNING id; } +step s3c { COMMIT; } + +# s2 upgrades past a lock s1 took in a savepoint while s3 waits for s1: s2 +# wakes at ROLLBACK TO, then s3 waits for s2. +permutation s1ks s3fu s2ks s1sp s1sh s2nk s1rs s1r s2c s3c +# s3 upgrades its SHARE lock while s2 waits for s1. +permutation s1sh s2fu s3sh s3fu s1r s3c s2c +# s3 deletes the row it holds a KEY SHARE lock on while s2 waits for s1. +permutation s1ks s2fu s3ks s3d s1r s3c s2c diff --git a/src/test/isolation/specs/flux_lostupdate.spec b/src/test/isolation/specs/flux_lostupdate.spec new file mode 100644 index 0000000000000..d5c0e93c671f4 --- /dev/null +++ b/src/test/isolation/specs/flux_lostupdate.spec @@ -0,0 +1,40 @@ +# FLUX lost-update prevention under READ COMMITTED (EvalPlanQual). +# +# Two concurrent transactions each do UPDATE ... SET v = v + 1 on the SAME +# row. Under READ COMMITTED, PostgreSQL semantics require BOTH increments to +# land: the second updater must block on the first, and after the first commits +# it re-reads the latest committed version (EvalPlanQual) and re-applies its SET +# expression on top -- so v goes 0 -> 1 -> 2, never 0 -> 1 (a lost update). +# +# FLUX is a stable-TID, in-place AM with no persistent per-tuple xmax; before +# the serialization fix it returned TM_Ok against the stale snapshot value and +# silently lost the second increment. This spec pins the correct behaviour: +# s2's UPDATE waits for s1, and the final value is 2. + +setup +{ + CREATE TABLE flux_lu (id int PRIMARY KEY, v bigint) USING flux; + INSERT INTO flux_lu VALUES (1, 0); +} + +teardown +{ + DROP TABLE flux_lu; +} + +session s1 +setup { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1_upd { UPDATE flux_lu SET v = v + 1 WHERE id = 1; } +step s1_commit { COMMIT; } + +session s2 +setup { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2_upd { UPDATE flux_lu SET v = v + 1 WHERE id = 1; } +step s2_commit { COMMIT; } + +session s3 +step s3_read { SELECT v FROM flux_lu WHERE id = 1; } # must be 2, not 1 + +# s1 updates (0->1), s2's identical UPDATE blocks on s1's row lock, s1 commits, +# s2 wakes -> EvalPlanQual re-reads v=1 -> re-applies +1 -> v=2, then commits. +permutation s1_upd s2_upd s1_commit s2_commit s3_read diff --git a/src/test/isolation/specs/flux_nonkey_move.spec b/src/test/isolation/specs/flux_nonkey_move.spec new file mode 100644 index 0000000000000..76cdc424bca54 --- /dev/null +++ b/src/test/isolation/specs/flux_nonkey_move.spec @@ -0,0 +1,88 @@ +# Non-key updates that move a row must remain compatible with KEY SHARE. +# Heap is the control. A subsequent move still waits for existing lockers +# because FLUX does not transfer their sLog locks to a newly inserted TID. +setup +{ + CREATE TABLE fi (id int, v int, PRIMARY KEY (id) INCLUDE (v)) USING flux; + CREATE TABLE fg (id int PRIMARY KEY, v int, pad text) USING flux WITH (fillfactor=100); + ALTER TABLE fg ALTER COLUMN pad SET STORAGE PLAIN; + INSERT INTO fi VALUES (1, 1); + INSERT INTO fg SELECT g, 1, repeat('x', 640) FROM generate_series(1, 40) g; + CREATE TABLE ci (id int REFERENCES fi) USING flux; + CREATE TABLE cg (id int REFERENCES fg) USING flux; +} +teardown { DROP TABLE ci, cg, fi, fg; } + +session s1 +step rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step snap { SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; } +step ki { SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; } +step kg { SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; } +step ii { INSERT INTO ci VALUES (1); } +step ig { INSERT INTO cg VALUES (1); } +step si { SELECT id, v FROM fi WHERE id=1 FOR SHARE; } +step sg { SELECT id, v, length(pad) FROM fg WHERE id=1 FOR SHARE; } +step di { DELETE FROM fi WHERE id=1; } +step dg { DELETE FROM fg WHERE id=1; } +step wi { UPDATE fi SET v=v+1 WHERE id=1; } +step wg { UPDATE fg SET v=v+1 WHERE id=1; } +step c1 { COMMIT; } + +session s2 +step b2 { BEGIN; } +step ui { UPDATE fi SET v=2 WHERE id=1; } +step ug { UPDATE fg SET pad=repeat('y',1920) WHERE id=1; } +step c2 { COMMIT; } +step r2 { ROLLBACK; } +step d2i { DELETE FROM fi WHERE id=1; } +step n2i { UPDATE fi SET v=3 WHERE id=1; } +step n2g { UPDATE fg SET pad=repeat('z',1940) WHERE id=1; } +step k2i { UPDATE fi SET id=2 WHERE id=1; } +step d2g { DELETE FROM fg WHERE id=1; } +step k2g { UPDATE fg SET id=100 WHERE id=1; } + +session s3 +step b3 { BEGIN; } +step u3 { UPDATE fi SET v=3 WHERE id=1; } +step k3 { UPDATE fi SET id=2 WHERE id=1; } +step z3 { UPDATE fi SET id=1 WHERE id=2; } +step c3 { COMMIT; } +permutation rr snap b2 ui ki c2 c1 +permutation rr snap b2 ui ii c2 c1 +permutation rr snap b2 ui si c2 c1 +permutation rr snap b2 ui di c2 c1 +permutation rr snap b2 ui wi c2 c1 +permutation rr snap b2 ug kg c2 c1 +permutation rr snap b2 ug ig c2 c1 +permutation rr snap b2 ug sg c2 c1 +permutation rr snap b2 ug dg c2 c1 +permutation rr snap b2 ug wg c2 c1 +permutation rc snap b2 ui ki c2 c1 +permutation rc snap b2 ui ii c2 c1 +permutation rc snap b2 ui si c2 c1 +permutation rc snap b2 ui di c2 c1 +permutation rc snap b2 ui wi c2 c1 +permutation rc snap b2 ug kg c2 c1 +permutation rc snap b2 ug ig c2 c1 +permutation rc snap b2 ug sg c2 c1 +permutation rc snap b2 ug dg c2 c1 +permutation rc snap b2 ug wg c2 c1 +permutation rr snap b2 ui ki c2 d2i c1 +permutation rr snap b2 ui ki c2 k2i c1 +permutation rr snap b2 ug kg c2 d2g c1 +permutation rr snap b2 ug kg c2 k2g c1 +permutation rr snap b2 ui ki r2 c1 +permutation rr snap b2 ui c2 ki c1 +permutation rr snap b2 ug kg r2 c1 +permutation rr snap b2 ug c2 kg c1 + +permutation rr snap b2 ui ki c2 n2i c1 +permutation rr snap b2 ug kg c2 n2g c1 + +permutation rr snap b2 ui c2 b3 k3 c3 ki c1 +permutation rc snap b2 ui c2 b3 k3 c3 ki c1 +permutation rr snap b2 ui c2 b3 k3 ki c3 c1 +permutation rr snap b2 ui c2 b3 u3 ki c3 c1 +permutation rr snap b2 ui c2 b3 k3 z3 u3 c3 ki c1 +permutation rr snap b2 ui c2 b3 k3 z3 u3 ki c3 c1 diff --git a/src/test/isolation/specs/flux_outofplace_grow.spec b/src/test/isolation/specs/flux_outofplace_grow.spec new file mode 100644 index 0000000000000..f3d8a1945a5d2 --- /dev/null +++ b/src/test/isolation/specs/flux_outofplace_grow.spec @@ -0,0 +1,37 @@ +# A row that grows on a full page cannot stay at its TID in FLUX, so the +# UPDATE moves it to a new TID even though no indexed column changed. A +# READ COMMITTED writer that waited for it must follow the row to its new +# version, as with heap: no update is lost. +# heap_outofplace_grow.spec runs the same steps on heap; the two expected +# outputs are identical. +# +# FLUX used to report the moved row as deleted, so the waiting UPDATE +# silently did nothing. +setup +{ + CREATE TABLE xg (id int, n int, p1 bigint, p2 bigint, p3 bigint, p4 bigint, + p5 bigint, p6 bigint, p7 bigint, p8 bigint) + USING flux WITH (fillfactor = 100); + INSERT INTO xg (id, n) SELECT g, 0 FROM generate_series(1, 400) g; +} +teardown +{ + DROP TABLE xg; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1g { UPDATE xg SET p1 = 1, p2 = 2, p3 = 3, p4 = 4, p5 = 5, p6 = 6, + p7 = 7, p8 = 8 WHERE id = 1; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } +step s1sel { SELECT id, n, p8, (SELECT count(*) FROM xg) AS nrows + FROM xg WHERE id = 1; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2u { UPDATE xg SET n = n + 1 WHERE id = 1; } +step s2c { COMMIT; } + +permutation s1b s2b s1g s2u s1c s2c s1sel +permutation s1b s2b s1g s2u s1r s2c s1sel diff --git a/src/test/isolation/specs/flux_outofplace_update.spec b/src/test/isolation/specs/flux_outofplace_update.spec new file mode 100644 index 0000000000000..859bde9a4e2e1 --- /dev/null +++ b/src/test/isolation/specs/flux_outofplace_update.spec @@ -0,0 +1,71 @@ +# An UPDATE that FLUX cannot do in place moves the row to a new TID: it +# deletes the old version and inserts the new one. A key change on a table +# with an exclusion constraint takes that path. A writer or locker that +# waited for such an update must follow the row to its new version, as with +# heap, whose old version's t_ctid links to the new one: under READ COMMITTED +# it rechecks and acts on the new version, under REPEATABLE READ it fails with +# a serialization failure for a concurrent update. A foreign-key check +# follows the parent row the same way. +# heap_outofplace_update.spec runs the same steps on heap; the two expected +# outputs are identical. +# +# FLUX used to report the moved row as deleted: a waiting UPDATE silently did +# nothing (a lost update), a DELETE left the row behind, FOR UPDATE returned +# no row, an FK insert failed with a foreign key violation, and REPEATABLE +# READ reported a concurrent delete. +setup +{ + CREATE TABLE xt (id int, r int4range, v int, + EXCLUDE USING gist (r WITH &&)) USING flux; + INSERT INTO xt VALUES (1, '[1,10)', 0); + CREATE TABLE xp (id int4range NOT NULL, valid_at daterange NOT NULL, + PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)) USING flux; + CREATE TABLE xc (id int4range NOT NULL, valid_at daterange NOT NULL, + parent_id int4range, + FOREIGN KEY (parent_id, PERIOD valid_at) REFERENCES xp) + USING flux; + INSERT INTO xp VALUES ('[1,2)', '[2020-01-01,2030-01-01)'); +} +teardown +{ + DROP TABLE xc, xp, xt; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1u { UPDATE xt SET v = v + 1 WHERE id = 1; } +step s1uk { UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; } +step s1up { UPDATE xp SET valid_at = '[2020-01-01,2026-01-01)' WHERE id = '[1,2)'; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } +step s1sel { SELECT id, v, r, count(*) OVER () AS nrows FROM xt; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2sel { SELECT id, v, r FROM xt; } +step s2u { UPDATE xt SET v = v + 1 WHERE id = 1; } +step s2uk { UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; } +step s2d { DELETE FROM xt WHERE id = 1; } +step s2fu { SELECT id, v, r FROM xt WHERE id = 1 FOR UPDATE; } +step s2ins { INSERT INTO xc VALUES ('[1,2)', '[2020-01-01,2025-01-01)', '[1,2)'); } +step s2c { COMMIT; } + +# (a) READ COMMITTED: the waiter acts on the new version; no update is lost. +permutation s1b s2b s1u s2u s1c s2c s1sel +permutation s1b s2b s1uk s2uk s1c s2c s1sel +permutation s1b s2b s1uk s2u s1c s2c s1sel +permutation s1b s2b s1u s2uk s1c s2c s1sel +permutation s1b s2b s1uk s2d s1c s2c s1sel +# The move rolls back: the waiter updates the old version. +permutation s1b s2b s1uk s2u s1r s2c s1sel +permutation s1b s2b s1uk s2uk s1r s2c s1sel +# (b) REPEATABLE READ: a serialization failure for a concurrent update. +permutation s1b s2brr s1uk s2u s1c s2c s1sel +permutation s1b s2brr s2sel s1uk s1c s2d s2c s1sel +# (c) The FK check follows the parent row to its new version. +permutation s1b s2b s1up s2ins s1c s2c +permutation s1b s2brr s1up s2ins s1c s2c +# (d) FOR UPDATE locks the new version, or fails under REPEATABLE READ. +permutation s1b s2b s1uk s2fu s1c s2c s1sel +permutation s1b s2brr s2sel s1uk s1c s2fu s2c diff --git a/src/test/isolation/specs/flux_partition_move.spec b/src/test/isolation/specs/flux_partition_move.spec new file mode 100644 index 0000000000000..a6b23c2ced39f --- /dev/null +++ b/src/test/isolation/specs/flux_partition_move.spec @@ -0,0 +1,69 @@ +# A row that a concurrent UPDATE moved to another partition must be reported +# as moved, as with heap, whose deleter stamps the moved-partitions marker in +# t_ctid: under READ COMMITTED every write or lock that follows the row fails +# with "tuple to be locked was already moved to another partition", and under +# REPEATABLE READ with a serialization failure for a concurrent update. A +# rolled-back move leaves the row in place, lockable and updatable. +# heap_partition_move.spec runs the same steps on heap; the two expected +# outputs are identical. +# +# FLUX used to report the moved row as deleted: the write silently did +# nothing, MERGE returned no rows, and an FK insert failed with a foreign +# key violation. +setup +{ + CREATE TABLE pm (a int, b text) PARTITION BY LIST (a); + CREATE TABLE pm1 PARTITION OF pm FOR VALUES IN (1) USING flux; + CREATE TABLE pm2 PARTITION OF pm FOR VALUES IN (2) USING flux; + INSERT INTO pm VALUES (1, 'ABC'); + CREATE TABLE pr (a int, b text) PARTITION BY RANGE (a); + CREATE TABLE pr1 PARTITION OF pr FOR VALUES FROM (1) TO (10) USING flux; + CREATE TABLE pr2 PARTITION OF pr FOR VALUES FROM (10) TO (20) USING flux; + INSERT INTO pr VALUES (7, 'ABC'); + CREATE UNIQUE INDEX pr1_a ON pr1 (a); + CREATE TABLE prc (a int REFERENCES pr1 (a)) USING flux; +} +teardown +{ + DROP TABLE prc, pr, pm; +} + +session s1 +setup { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1u { UPDATE pm SET a = 2 WHERE a = 1; } +step s1sp { SAVEPOINT sp; } +step s1rs { ROLLBACK TO sp; } +step s1upr { UPDATE pr SET a = 11 WHERE a = 7; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2sel { SELECT * FROM pm ORDER BY a; } +step s2u { UPDATE pm SET b = 'EFG' WHERE a = 1; } +step s2d { DELETE FROM pm WHERE a = 1; } +step s2l { SELECT * FROM pm WHERE a = 1 FOR UPDATE; } +step s2ks { SELECT * FROM pm WHERE a = 1 FOR KEY SHARE; } +step s2m { MERGE INTO pm t USING (SELECT 1 AS a) s ON t.a = s.a + WHEN MATCHED THEN UPDATE SET b = 'merged'; } +step s2i { INSERT INTO prc VALUES (7); } +step s2c { COMMIT; } + +# The move commits: each write or lock of the old row fails. +permutation s2b s1u s2d s1c s2sel s2c +permutation s2b s1u s2u s1c s2sel s2c +permutation s2b s1u s2l s1c s2c +permutation s2b s1u s2ks s1c s2c +permutation s2b s1u s2m s1c s2sel s2c +permutation s2b s1upr s2i s1c s2c +# Under REPEATABLE READ the row was concurrently updated. +permutation s2brr s2sel s1u s1c s2d s2c +permutation s2brr s2sel s1u s1c s2l s2c +# The move rolls back: the old row is still there, lockable and updatable. +permutation s2b s1u s2u s1r s2sel s2c +permutation s2b s1u s2d s1r s2sel s2c +permutation s2b s1u s2l s1r s2c +permutation s2b s1upr s2i s1r s2c +permutation s1sp s1u s1rs s2b s2u s2sel s2c s1c +permutation s2b s1u s1r s2l s2u s2sel s2c diff --git a/src/test/isolation/specs/flux_pvs_deep.spec b/src/test/isolation/specs/flux_pvs_deep.spec new file mode 100644 index 0000000000000..16fccef606d01 --- /dev/null +++ b/src/test/isolation/specs/flux_pvs_deep.spec @@ -0,0 +1,45 @@ +# FLUX: an old snapshot reads its version of a row through a version chain +# longer than any fixed bound. +# +# A REPEATABLE READ snapshot is taken, then another session commits 10010 +# in-place UPDATEs of one row, one transaction each. The snapshot must still +# see the row as it was, by walking the whole UNDO version chain back, as +# heap returns its old tuple version. + +setup +{ + CREATE TABLE pvs_h (id int, v int) USING heap; + CREATE TABLE pvs_f (id int, v int) USING flux; + INSERT INTO pvs_h VALUES (1, 0), (2, 0); + INSERT INTO pvs_f VALUES (1, 0), (2, 0); + CREATE PROCEDURE pvs_churn(n int) LANGUAGE plpgsql AS $$ + BEGIN + FOR i IN 1..n LOOP + UPDATE pvs_h SET v = i WHERE id = 1; + UPDATE pvs_f SET v = i WHERE id = 1; + COMMIT; + END LOOP; + END $$; +} + +teardown +{ + DROP TABLE pvs_h, pvs_f; + DROP PROCEDURE pvs_churn(int); +} + +session old +setup { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step snap { SELECT 'heap' AS am, * FROM pvs_h UNION ALL + SELECT 'flux', * FROM pvs_f ORDER BY 1, 2; } +step read { SELECT 'heap' AS am, * FROM pvs_h UNION ALL + SELECT 'flux', * FROM pvs_f ORDER BY 1, 2; } +step done { COMMIT; } + +session churn +setup { SET synchronous_commit = off; } +step upd { CALL pvs_churn(10010); } +step now { SELECT 'heap' AS am, * FROM pvs_h UNION ALL + SELECT 'flux', * FROM pvs_f ORDER BY 1, 2; } + +permutation snap upd read done now diff --git a/src/test/isolation/specs/flux_recheck_after_wait.spec b/src/test/isolation/specs/flux_recheck_after_wait.spec new file mode 100644 index 0000000000000..71ce6b0ee8c8c --- /dev/null +++ b/src/test/isolation/specs/flux_recheck_after_wait.spec @@ -0,0 +1,57 @@ +# A DELETE or UPDATE that waited for another transaction must classify the +# row again when it wakes, as heap does (heap_delete and heap_update start +# over): a row another writer has delete-marked in the meantime is deleted +# only once that writer commits, so it waits for it, and a row the waited-for +# transaction updated and committed goes to EvalPlanQual. +# heap_recheck_after_wait.spec runs the same steps on heap; the two expected +# outputs are identical. +# +# FLUX reported a delete-marked row as deleted at once, though its deleter +# was still in progress: the waiter returned no row, and when the deleter +# rolled back the row survived a DELETE, or lost an UPDATE. And a DELETE +# that waited for a row lock deleted the row without rechecking it, after +# the locker had changed it so that it no longer matched. +setup +{ + CREATE TABLE rw (id int PRIMARY KEY, v int) USING flux; + INSERT INTO rw VALUES (1, 0); +} +teardown +{ + DROP TABLE rw; +} + +session s1 +setup { BEGIN; } +step s1sh { SELECT id FROM rw FOR SHARE; } +step s1u { UPDATE rw SET v = 5; } +step s1r { ROLLBACK; } +step s1c { COMMIT; } + +session s2 +setup { BEGIN; } +step s2d { DELETE FROM rw RETURNING id, v; } +step s2r { ROLLBACK; } +step s2c { COMMIT; } + +session s3 +setup { BEGIN; } +step s3d { DELETE FROM rw WHERE v = 0 RETURNING id, v; } +step s3u { UPDATE rw SET v = v + 10 RETURNING id, v; } +step s3c { COMMIT; } + +session s4 +step s4sel { SELECT id, v FROM rw; } + +# s2 waits for s1's row lock or update, s3 waits behind s2; s1 rolls back +# and s2 deletes. s3 must wait for s2, which rolls back: s3 deletes or +# updates the row. +permutation s1sh s2d s3d s1r s2r s3c s4sel +permutation s1sh s2d s3u s1r s2r s3c s4sel +permutation s1u s2d s3d s1r s2r s3c s4sel +permutation s1u s2d s3u s1r s2r s3c s4sel +# s2 commits its delete: s3 finds no row. +permutation s1sh s2d s3d s1r s2c s3c s4sel +# s3 waits for s1's row lock; s1 updates the row so that it no longer +# matches s3's WHERE, and commits: s3 deletes nothing. +permutation s1sh s3d s1u s1c s3c s4sel diff --git a/src/test/isolation/specs/flux_relstats.spec b/src/test/isolation/specs/flux_relstats.spec new file mode 100644 index 0000000000000..f5d1d635a6a60 --- /dev/null +++ b/src/test/isolation/specs/flux_relstats.spec @@ -0,0 +1,123 @@ +# The relation statistics VACUUM, ANALYZE and the UPDATE counters give the +# planner and autovacuum, for heap and FLUX side by side. +# +# - VACUUM or ANALYZE while another transaction deletes and inserts rows: as +# for heap, rows being inserted are not counted yet and rows being deleted +# are still live, because the writer's commit reports both to pgstat. So +# after the commit n_live_tup and n_dead_tup are the real counts. +# - UPDATE counters: n_tup_upd and n_mod_since_analyze count an UPDATE once, +# whether FLUX does it in place or at a new TID, and it is not also counted +# as an INSERT and a DELETE. n_dead_tup counts the versions VACUUM will +# have to remove: heap leaves the old version of each row; a FLUX in-place +# UPDATE of a non-indexed column leaves none (its before-image is in UNDO, +# which VACUUM does not reclaim); a FLUX in-place key UPDATE leaves a +# delete-marked index entry and an out-of-place one a deleted version, +# both for VACUUM. A FLUX non-key UPDATE is counted as HOT: it touches no +# index. +# - ANALYZE counts no dead row for a FLUX in-place UPDATE either (permutation +# 1 and 2 cover what it counts for in-progress DML). + +setup +{ + CREATE FUNCTION st_count(tbl regclass) RETURNS bigint LANGUAGE plpgsql AS $$ + DECLARE n bigint; + BEGIN EXECUTE format('SELECT count(*) FROM %s', tbl) INTO n; RETURN n; END $$; + CREATE TABLE st_heap (a int PRIMARY KEY, b int, c bigint) USING heap WITH (autovacuum_enabled = off); + CREATE TABLE st_flux (a int PRIMARY KEY, b int, c bigint) USING flux WITH (autovacuum_enabled = off); + CREATE TABLE sh_heap (a int, c bigint) USING heap WITH (autovacuum_enabled = off); + CREATE TABLE sh_flux (a int, c bigint) USING flux WITH (autovacuum_enabled = off); + CREATE INDEX ON sh_heap USING hash (a); + CREATE INDEX ON sh_flux USING hash (a); + INSERT INTO st_heap SELECT g, g, g FROM generate_series(1, 10000) g; + INSERT INTO st_flux SELECT g, g, g FROM generate_series(1, 10000) g; + INSERT INTO sh_heap SELECT g, g FROM generate_series(1, 10000) g; + INSERT INTO sh_flux SELECT g, g FROM generate_series(1, 10000) g; + SELECT pg_stat_force_next_flush(); +} +setup { VACUUM ANALYZE st_heap; } +setup { VACUUM ANALYZE st_flux; } +setup { VACUUM ANALYZE sh_heap; } +setup { VACUUM ANALYZE sh_flux; } + +teardown +{ + DROP TABLE st_heap, st_flux, sh_heap, sh_flux; + DROP FUNCTION st_count; +} + +session s1 +step s1_dml +{ + BEGIN; + DELETE FROM st_heap WHERE a <= 1000; + DELETE FROM st_flux WHERE a <= 1000; + INSERT INTO st_heap SELECT g, g, g FROM generate_series(20001, 22000) g; + INSERT INTO st_flux SELECT g, g, g FROM generate_series(20001, 22000) g; +} +step s1_commit { COMMIT; SELECT pg_stat_force_next_flush(); } + +session s2 +step s2_vacuum_heap { VACUUM st_heap; } +step s2_vacuum_flux { VACUUM st_flux; } +step s2_analyze { ANALYZE st_heap, st_flux; } +# 10000 committed rows were live when VACUUM or ANALYZE ran (heap's VACUUM +# may extrapolate from the pages it scanned, hence the tolerance). +step s2_reltuples +{ + SELECT relname, abs(reltuples - 10000) <= 500 AS committed_rows_only + FROM pg_class WHERE relname IN ('st_heap', 'st_flux') ORDER BY relname; +} +step s2_pgstat +{ + SELECT pg_stat_clear_snapshot(); + SELECT relname, abs(n_live_tup - st_count(relid)) <= 500 AS live_ok, + n_dead_tup = 1000 AS dead_ok + FROM pg_stat_user_tables WHERE relname IN ('st_heap', 'st_flux') + ORDER BY relname; +} +step s2_update_nonkey +{ + UPDATE st_heap SET c = c + 1; + UPDATE st_flux SET c = c + 1; + SELECT pg_stat_force_next_flush(); +} +step s2_update_key +{ + UPDATE st_heap SET a = a + 100000; + UPDATE st_flux SET a = a + 100000; + SELECT pg_stat_force_next_flush(); +} +step s2_update_hashkey +{ + UPDATE sh_heap SET a = a + 100000; + UPDATE sh_flux SET a = a + 100000; + SELECT pg_stat_force_next_flush(); +} +step s2_update_counts +{ + SELECT pg_stat_clear_snapshot(); + SELECT relname, n_tup_upd = 10000 AS upd_ok, + n_tup_ins = 10000 AND n_tup_del = 0 AS not_ins_del, + n_mod_since_analyze = 10000 AS mod_ok, + n_tup_hot_upd = n_tup_upd AS all_hot, + CASE n_dead_tup WHEN 0 THEN 'none' WHEN 10000 THEN 'all' + ELSE 'some' END AS dead + FROM pg_stat_user_tables + WHERE relname IN ('st_heap', 'st_flux', 'sh_heap', 'sh_flux') + AND n_tup_upd + n_tup_del > 0 + ORDER BY relname; +} +step s2_dead +{ + SELECT pg_stat_clear_snapshot(); + SELECT relname, CASE n_dead_tup WHEN 0 THEN 'none' WHEN 10000 THEN 'all' + ELSE 'some' END AS dead + FROM pg_stat_user_tables WHERE relname IN ('st_heap', 'st_flux') + ORDER BY relname; +} + +permutation s1_dml s2_vacuum_heap s2_vacuum_flux s2_reltuples s1_commit s2_pgstat +permutation s1_dml s2_analyze s2_reltuples s1_commit s2_pgstat +permutation s2_update_nonkey s2_update_counts s2_analyze s2_dead +permutation s2_update_key s2_update_counts +permutation s2_update_hashkey s2_update_counts diff --git a/src/test/isolation/specs/flux_savepoint_lock_rollback.spec b/src/test/isolation/specs/flux_savepoint_lock_rollback.spec new file mode 100644 index 0000000000000..f5aeb79978ec6 --- /dev/null +++ b/src/test/isolation/specs/flux_savepoint_lock_rollback.spec @@ -0,0 +1,121 @@ +# A row lock or a row change made in a savepoint that is rolled back must +# leave nothing behind once the transaction commits. heap_savepoint_lock_ +# rollback.spec runs the same steps on heap; the two expected outputs are +# identical. +# +# FLUX used to mark the row DELETED when s1 committed, because s1 still had +# an ABORTED sLog op for it (the rolled-back lock), and the image on the page +# carried an UNCOMMITTED hint -- s2's, committed or still running. The row +# stayed visible to a plain SELECT, but UPDATE, DELETE and row locks no +# longer found it. +setup +{ + CREATE TABLE lr (id int PRIMARY KEY, v int) USING flux; + INSERT INTO lr VALUES (1, 0), (2, 0); +} +setup +{ + VACUUM lr; +} +teardown +{ + DROP TABLE lr; +} + +session s1 +step s1b { BEGIN; } +step s1sp { SAVEPOINT a; } +step s1sp2 { SAVEPOINT b; } +step s1fu { SELECT * FROM lr WHERE id = 1 FOR UPDATE; } +step s1fnk { SELECT * FROM lr WHERE id = 1 FOR NO KEY UPDATE; } +step s1fs { SELECT * FROM lr WHERE id = 1 FOR SHARE; } +step s1fks { SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; } +step s1u { UPDATE lr SET v = v + 10 WHERE id = 1; } +step s1i { INSERT INTO lr VALUES (3, 0); } +step s1fu3 { SELECT * FROM lr WHERE id = 3 FOR UPDATE; } +step s1u3 { UPDATE lr SET v = v + 10 WHERE id = 3; } +step s1d { DELETE FROM lr WHERE id = 1; } +step s1rs { ROLLBACK TO a; } +step s1rs2 { ROLLBACK TO b; } +step s1rel { RELEASE a; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } + +session s2 +step s2b { BEGIN; } +step s2u { UPDATE lr SET v = v + 1 WHERE id = 1; } +step s2d { DELETE FROM lr WHERE id = 1; } +step s2c { COMMIT; } +step s2r { ROLLBACK; } + +# Every way of reaching the rows must find the same rows. +session s3 +step s3chk +{ + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; +} + +# Each lock strength, in a savepoint that is rolled back, then a committed +# UPDATE of the row by s2. +permutation s1b s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1sp s1fnk s1rs s2u s1c s3chk +permutation s1b s1sp s1fs s1rs s2u s1c s3chk +permutation s1b s1sp s1fks s1rs s2u s1c s3chk +# Nested savepoints: both rolled back; only the inner one, the outer +# released. +permutation s1b s1sp s1sp2 s1fu s1rs2 s1rs s2u s1c s3chk +permutation s1b s1sp s1sp2 s1fu s1rs2 s1rel s2u s1c s3chk +# RELEASE instead of ROLLBACK TO: the lock is s1's until it commits. +permutation s1b s1sp s1fu s1rel s2u s1c s3chk +# s2 deletes the row instead. +permutation s1b s1sp s1fu s1rs s2d s1c s3chk +# s2 aborts its UPDATE before s1 commits. +permutation s1b s1sp s1fu s1rs s2b s2u s2r s1c s3chk +# s2's UPDATE is still running when s1 commits. +permutation s1b s1sp s1fu s1rs s2b s2u s1c s2c s3chk +# s1 rolls back instead, with the lock in a savepoint or at the top level. +permutation s1b s1sp s1fu s1rs s2u s1r s3chk +permutation s1b s1fu s1r s2u s3chk +# A row change, not a lock, in the rolled-back savepoint, then s2's +# committed UPDATE. +permutation s1b s1sp s1u s1rs s2u s1c s3chk +permutation s1b s1sp s1d s1rs s2u s1c s3chk +# ... and s2's UPDATE still running when s1 commits. +permutation s1b s1sp s1u s1rs s2b s2u s1c s2c s3chk +permutation s1b s1sp s1d s1rs s2b s2u s1c s2c s3chk +# A top-level lock survives the same or a weaker lock in a rolled-back +# savepoint: s2 waits for s1. +permutation s1b s1fu s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1fu s1sp s1fks s1rs s2u s1c s3chk +permutation s1b s1fnk s1sp s1fu s1rs s2u s1c s3chk +# ... and a weaker lock at the same level does not give the stronger one up. +permutation s1b s1fu s1fks s2u s1c s3chk +permutation s1b s1fu s1sp s1fks s1rel s2u s1c s3chk +# ... nor does a row change in a rolled-back savepoint. +permutation s1b s1fu s1sp s1u s1rs s2u s1c s3chk +permutation s1b s1fks s1sp s1u s1rs s2d s1c s3chk +# s1's own top-level UPDATE of the row, then the rolled-back lock or +# UPDATE. s1 still holds its UPDATE's row lock, so s2 waits for s1. +permutation s1b s1u s1sp s1fu s1rs s1c s3chk +permutation s1b s1u s1sp s1u s1rs s1c s3chk +permutation s1b s1u s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1u s1sp s1fks s1rs s2u s1r s3chk +# s1's own top-level INSERT, locked in a rolled-back savepoint: kept. An +# INSERT in a rolled-back savepoint: discarded. +permutation s1b s1i s1sp s1fu3 s1rs s1c s3chk +permutation s1b s1sp s1i s1rs s1c s3chk +# An INSERT kept (top level, or in a released savepoint), then updated in a +# rolled-back savepoint: kept, with its first value. +permutation s1b s1i s1sp s1u3 s1rs s1c s3chk +permutation s1b s1sp s1i s1rel s1sp2 s1u3 s1rs2 s1c s3chk + +# A rolled-back upgrade releases only the savepoint's stronger lock. +permutation s1b s1fks s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1fks s1sp s1sp2 s1fu s1rs2 s1rs s2u s1c s3chk +permutation s1b s1fks s1sp s1sp2 s1fu s1rel s2u s1c s3chk diff --git a/src/test/isolation/specs/flux_savepoint_lock_wake.spec b/src/test/isolation/specs/flux_savepoint_lock_wake.spec new file mode 100644 index 0000000000000..cc376bcd28c4a --- /dev/null +++ b/src/test/isolation/specs/flux_savepoint_lock_wake.spec @@ -0,0 +1,50 @@ +# A waiter blocked by a row lock taken in a savepoint must wake when the +# savepoint is rolled back, as with heap, whose locker is the +# subtransaction's xid. heap_savepoint_lock_wake.spec runs the same steps +# on heap; the two expected outputs are identical. +# +# FLUX used to wait on the locker's top-level xid, so the waiter stayed +# blocked after ROLLBACK TO had released the stronger lock, until the +# locker's transaction ended. +setup +{ + CREATE TABLE lw (id int PRIMARY KEY, v int) USING flux; + INSERT INTO lw VALUES (1, 0); +} +teardown +{ + DROP TABLE lw; +} + +session s1 +setup { BEGIN; } +step s1ks { SELECT * FROM lw FOR KEY SHARE; } +step s1sp { SAVEPOINT a; } +step s1sp2 { SAVEPOINT b; } +step s1nk { SELECT * FROM lw FOR NO KEY UPDATE; } +step s1fu { SELECT * FROM lw FOR UPDATE; } +step s1rs { ROLLBACK TO a; } +step s1rs2 { ROLLBACK TO b; } +step s1rel2 { RELEASE b; } +step s1c { COMMIT; } + +session s2 +setup { BEGIN; } +step s2nk { SELECT * FROM lw FOR NO KEY UPDATE; } +step s2fs { SELECT * FROM lw FOR SHARE; } +step s2u { UPDATE lw SET v = v + 1 RETURNING *; } +step s2d { DELETE FROM lw RETURNING *; } +step s2c { COMMIT; } + +# The waiter wakes at ROLLBACK TO: a lock upgrade, and a lock with nothing +# at the top level. +permutation s1ks s1sp s1nk s2nk s1rs s1c s2c +permutation s1ks s1sp s1nk s2u s1rs s1c s2c +permutation s1ks s1sp s1fu s2fs s1rs s1c s2c +permutation s1sp s1fu s2u s1rs s1c s2c +# Nested savepoints: the inner lock released into the outer savepoint, +# which is then rolled back; and only the inner savepoint rolled back. +permutation s1ks s1sp s1sp2 s1nk s1rel2 s2nk s1rs s1c s2c +permutation s1ks s1sp s1sp2 s1nk s2nk s1rs2 s1c s2c +# The top-level KEY SHARE lock still blocks a DELETE until commit. +permutation s1ks s1sp s1nk s2d s1rs s1c s2c diff --git a/src/test/isolation/specs/flux_scan_history.spec b/src/test/isolation/specs/flux_scan_history.spec new file mode 100644 index 0000000000000..5055f5ae9416a --- /dev/null +++ b/src/test/isolation/specs/flux_scan_history.spec @@ -0,0 +1,45 @@ +# FLUX: TID range and TABLESAMPLE scans serve the snapshot's version of a row +# updated in place after the scan began. +# +# The scan returns row 1, then blocks on an advisory lock while another +# session UPDATEs (or DELETEs) both rows and commits. Row 2, read after +# that, must still be the version the scan's snapshot sees, reconstructed +# from UNDO, as heap returns it. (Based on the review harness +# collection_{flux,heap}_{tid,sample}.) + +setup +{ + CREATE TABLE t (id int, v text) USING flux; + CREATE INDEX ti ON t (id); + INSERT INTO t VALUES (1, 'aaaa'), (2, 'cccc'); +} + +teardown +{ + DROP TABLE t; +} + +session w +step lock { SELECT pg_advisory_lock(881); } +step upd { UPDATE t SET v = CASE id WHEN 1 THEN 'bbbb' ELSE 'dddd' END; } +step del { DELETE FROM t WHERE id = 2; } +step unlock { SELECT pg_advisory_unlock(881); } + +session tid +setup { SET enable_seqscan = off; SET enable_indexscan = off; + SET enable_bitmapscan = off; } +step tplan { EXPLAIN (COSTS OFF) SELECT v FROM t WHERE ctid >= '(0,1)'; } +step tread { SELECT id, v, CASE WHEN id = 1 THEN pg_advisory_lock(881) END IS NULL AS waited + FROM t WHERE ctid >= '(0,1)'; } +step tdone { SELECT pg_advisory_unlock_all(); } + +session smp +step splan { EXPLAIN (COSTS OFF) SELECT v FROM t TABLESAMPLE SYSTEM (100); } +step sread { SELECT id, v, CASE WHEN id = 1 THEN pg_advisory_lock(881) END IS NULL AS waited + FROM t TABLESAMPLE SYSTEM (100); } +step sdone { SELECT pg_advisory_unlock_all(); } + +permutation tplan lock tread upd unlock tdone +permutation tplan lock tread del unlock tdone +permutation splan lock sread upd unlock sdone +permutation splan lock sread del unlock sdone diff --git a/src/test/isolation/specs/flux_scan_stable.spec b/src/test/isolation/specs/flux_scan_stable.spec new file mode 100644 index 0000000000000..4c29388ac5298 --- /dev/null +++ b/src/test/isolation/specs/flux_scan_stable.spec @@ -0,0 +1,76 @@ +# FLUX: a scan's row must not change under its reader. +# +# FLUX updates rows in place. A scan slot that kept pointing into the shared +# page saw whatever was written there later, so a row a query had already +# fetched could turn into a newer version its snapshot must not see. The +# reader fetches the row, then blocks on an advisory lock while a same-size +# UPDATE rewrites the row and commits; it must still return the value its +# snapshot saw, as heap does. Whether v is deformed before or after the +# lock wait does not matter: the row was fetched before the UPDATE, and +# either way the answer is the snapshot's 'aaaa'. One session per scan path +# (plans shown), each against FLUX (ft) and heap (ht). + +setup +{ + CREATE TABLE ft (id int, v text) USING flux; + CREATE INDEX ft_id ON ft (id); + INSERT INTO ft VALUES (1, 'aaaa'); + CREATE TABLE ht (id int, v text) USING heap; + CREATE INDEX ht_id ON ht (id); + INSERT INTO ht VALUES (1, 'aaaa'); +} + +teardown +{ + DROP TABLE ft; + DROP TABLE ht; +} + +session w +step w_lock { SELECT pg_advisory_lock(1); } +step w_upd { UPDATE ft SET v = 'bbbb' WHERE id = 1; + UPDATE ht SET v = 'bbbb' WHERE id = 1; } +step w_unlock { SELECT pg_advisory_unlock(1); } + +session seq +setup { SET enable_indexscan = off; SET enable_bitmapscan = off; + SET enable_tidscan = off; } +step seq_plan { EXPLAIN (COSTS OFF) SELECT v FROM ft; } +step seq_f { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ft; } +step seq_h { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ht; } +step seq_unlock { SELECT pg_advisory_unlock(1); } + +session bmp +setup { SET enable_seqscan = off; SET enable_indexscan = off; + SET enable_tidscan = off; } +step bmp_plan { EXPLAIN (COSTS OFF) SELECT v FROM ft WHERE id = 1; } +step bmp_f { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ft WHERE id = 1; } +step bmp_h { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ht WHERE id = 1; } +step bmp_unlock { SELECT pg_advisory_unlock(1); } + +session tid +setup { SET enable_seqscan = off; SET enable_indexscan = off; + SET enable_bitmapscan = off; } +step tid_plan { EXPLAIN (COSTS OFF) SELECT v FROM ft WHERE ctid >= '(0,1)'; } +step tid_f { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ft + WHERE ctid >= '(0,1)'; } +step tid_h { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ht + WHERE ctid >= '(0,1)'; } +step tid_unlock { SELECT pg_advisory_unlock(1); } + +session idx +setup { SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_tidscan = off; } +step idx_plan { EXPLAIN (COSTS OFF) SELECT v FROM ft WHERE id = 1; } +step idx_f { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ft WHERE id = 1; } +step idx_h { SELECT v, pg_advisory_lock(1) IS NOT NULL AS waited FROM ht WHERE id = 1; } +step idx_unlock { SELECT pg_advisory_unlock(1); } + +permutation seq_plan w_lock seq_f w_upd w_unlock seq_unlock +permutation w_lock seq_h w_upd w_unlock seq_unlock +permutation bmp_plan w_lock bmp_f w_upd w_unlock bmp_unlock +permutation w_lock bmp_h w_upd w_unlock bmp_unlock +permutation tid_plan w_lock tid_f w_upd w_unlock tid_unlock +permutation w_lock tid_h w_upd w_unlock tid_unlock +permutation idx_plan w_lock idx_f w_upd w_unlock idx_unlock +permutation w_lock idx_h w_upd w_unlock idx_unlock diff --git a/src/test/isolation/specs/flux_seqscan_stats.spec b/src/test/isolation/specs/flux_seqscan_stats.spec new file mode 100644 index 0000000000000..dc3d3107c1ee1 --- /dev/null +++ b/src/test/isolation/specs/flux_seqscan_stats.spec @@ -0,0 +1,27 @@ +# A sequential scan counts itself and the rows it returns in the table's +# statistics, as with heap: seq_scan and seq_tup_read. +# heap_seqscan_stats.spec runs the same steps on heap; the two expected +# outputs are identical. +# +# FLUX used to leave both counters at zero. +setup +{ + CREATE TABLE ss (k int, v int) USING flux; + INSERT INTO ss SELECT g, g FROM generate_series(1, 5) g; +} +teardown +{ + DROP TABLE ss; +} + +session s1 +setup { SET enable_indexscan = off; SET enable_bitmapscan = off; } +step s1scan { SELECT count(*) FROM ss; } +step s1where { SELECT k FROM ss WHERE v > 3 ORDER BY k; } +step s1ff { SELECT pg_stat_force_next_flush(); } +step s1stats { + SELECT pg_stat_get_numscans('ss'::regclass) AS seq_scan, + pg_stat_get_tuples_returned('ss'::regclass) AS seq_tup_read; +} + +permutation s1stats s1scan s1ff s1stats s1where s1ff s1stats diff --git a/src/test/isolation/specs/flux_slot_tuple_context.spec b/src/test/isolation/specs/flux_slot_tuple_context.spec new file mode 100644 index 0000000000000..a8a3f616cb2f7 --- /dev/null +++ b/src/test/isolation/specs/flux_slot_tuple_context.spec @@ -0,0 +1,34 @@ +# FLUX: a scan that reconstructs an older version of a row from UNDO, run +# in a per-tuple memory context that is reset after every row. +# +# ALTER TABLE ... VALIDATE CONSTRAINT (validateCheckConstraint), like the +# table rewrite of ATRewriteTable, scans in the executor's per-tuple context +# and resets it for every row. A row that another session updated in place +# is served as its committed version, rebuilt from UNDO. The slot must own +# that image in its own context: it used to adopt the reconstruction's +# allocation, made in the per-tuple context, so the context reset freed it +# under the slot and the next clear of the slot freed it again ("pfree +# called with invalid pointer"). + +setup +{ + CREATE TABLE fv (id int PRIMARY KEY, v int, pad text) USING flux; + INSERT INTO fv SELECT g, g, 'p' || g FROM generate_series(1, 30) g; + ALTER TABLE fv ADD CONSTRAINT fv_v CHECK (v > 0) NOT VALID; +} + +teardown +{ + DROP TABLE fv; +} + +session u +step upd { BEGIN; UPDATE fv SET v = v + 100, pad = pad || 'x' WHERE id IN (3, 17); } +step abort { ROLLBACK; } + +session a +step validate { ALTER TABLE fv VALIDATE CONSTRAINT fv_v; } +step check { SELECT convalidated FROM pg_constraint WHERE conname = 'fv_v'; + SELECT count(*), sum(v) FROM fv; } + +permutation upd validate check abort diff --git a/src/test/isolation/specs/flux_specconflict_toast.spec b/src/test/isolation/specs/flux_specconflict_toast.spec new file mode 100644 index 0000000000000..adbe828ee3e0f --- /dev/null +++ b/src/test/isolation/specs/flux_specconflict_toast.spec @@ -0,0 +1,75 @@ +# FLUX: a speculative insertion that is killed (INSERT ... ON CONFLICT losing +# the race to a concurrent inserter) must delete the TOAST values it stored, +# as heap_abort_speculative does. Otherwise the loser's values stay in the +# TOAST relation, referenced by nothing, once its transaction commits. +# +# Same control flow as insert-conflict-specconflict's TOAST permutation, on a +# FLUX table whose data column is always stored out of line. + +setup +{ + CREATE FUNCTION flux_sct_lock(text) RETURNS text IMMUTABLE LANGUAGE plpgsql AS $$ + BEGIN + IF pg_try_advisory_xact_lock(current_setting('spec.session')::int, 1) THEN + PERFORM pg_advisory_xact_lock(current_setting('spec.session')::int, 2); + ELSE + PERFORM pg_advisory_xact_lock(current_setting('spec.session')::int, 3); + END IF; + RETURN $1; + END;$$; + CREATE FUNCTION flux_sct_val() RETURNS text LANGUAGE sql AS $$ + SELECT string_agg(encode(sha256(g::text::bytea), 'hex'), '') + FROM generate_series(1, 133) g $$; + CREATE TABLE flux_sct (key text, data text) USING flux; + ALTER TABLE flux_sct ALTER COLUMN data SET STORAGE EXTERNAL; + CREATE UNIQUE INDEX flux_sct_key ON flux_sct ((flux_sct_lock(key))); + CREATE FUNCTION flux_sct_chunks() RETURNS bigint LANGUAGE plpgsql AS $$ + DECLARE + n bigint; + BEGIN + EXECUTE format('SELECT count(DISTINCT chunk_id) FROM %s', + (SELECT reltoastrelid::regclass FROM pg_class + WHERE oid = 'flux_sct'::regclass)) + INTO n; + RETURN n; + END;$$; +} + +teardown +{ + DROP TABLE flux_sct; + DROP FUNCTION flux_sct_lock(text), flux_sct_val(), flux_sct_chunks(); +} + +session controller +step c_locks { SELECT pg_advisory_lock(sess, lock), sess, lock FROM generate_series(1, 2) a(sess), generate_series(1, 3) b(lock); } +step c_unlock_1_1 { SELECT pg_advisory_unlock(1, 1); } +step c_unlock_2_1 { SELECT pg_advisory_unlock(2, 1); } +step c_unlock_1_2 { SELECT pg_advisory_unlock(1, 2); } +step c_unlock_2_2 { SELECT pg_advisory_unlock(2, 2); } +step c_unlock_1_3 { SELECT pg_advisory_unlock(1, 3); } +step c_unlock_2_3 { SELECT pg_advisory_unlock(2, 3); } +# One row; one TOAST value, the winner's. +step c_show { SELECT count(*) AS nrows, flux_sct_chunks() AS nvalues, + bool_and(md5(data) = md5(flux_sct_val())) AS ok + FROM flux_sct; } + +session s1 +setup { SET spec.session = 1; } +step s1_insert { INSERT INTO flux_sct VALUES ('k', flux_sct_val()) ON CONFLICT DO NOTHING; } + +session s2 +setup { SET spec.session = 2; } +step s2_insert { INSERT INTO flux_sct VALUES ('k', flux_sct_val()) ON CONFLICT DO NOTHING; } + +permutation + c_locks + s1_insert s2_insert + # both pass the conflict probe and insert speculatively + c_unlock_1_1 c_unlock_2_1 + c_unlock_1_3 c_unlock_2_3 + # s1 finishes first and commits + c_unlock_1_2 + # s2 finds the conflict, kills its tuple, does nothing, and commits + c_unlock_2_2 + c_show diff --git a/src/test/isolation/specs/flux_ssi_conflict_out.spec b/src/test/isolation/specs/flux_ssi_conflict_out.spec new file mode 100644 index 0000000000000..d6a1d8ba49a2c --- /dev/null +++ b/src/test/isolation/specs/flux_ssi_conflict_out.spec @@ -0,0 +1,99 @@ +# SSI rw-conflict-out detection for FLUX. heap_ssi_conflict_out.spec runs +# the same steps on heap; the two expected outputs are identical. +# +# A serializable reader must report a conflict out to every writer of a +# version it examines that its snapshot cannot see, whether that writer is +# still running or committed after the snapshot was taken. FLUX keeps only +# the newest version on the page and reaches older ones through the UNDO +# version chain, so the writers the walk steps over must be reported too. +# +# Permutations 1-2 are update-conflict-out.spec. Permutations 3-4 are a write +# skew whose read goes through an index and is served a version rebuilt from +# UNDO, past one committed in-place UPDATE (3) and past a committed and a +# running one (4). Permutations 5-6 are receipt-report.spec's schedules +# where a read-only transaction sees a state no serial order produces. + +setup +{ + CREATE TABLE txn0 (id int4 PRIMARY KEY, val text) USING flux; + CREATE TABLE txn1 (id int4 PRIMARY KEY, val text) USING flux; + CREATE TABLE ws (id int PRIMARY KEY, v int) USING flux; + INSERT INTO ws VALUES (1, 0), (2, 0), (3, 0); + CREATE TABLE ctl (k text NOT NULL PRIMARY KEY, deposit_date date NOT NULL) USING flux; + INSERT INTO ctl VALUES ('receipt', DATE '2008-12-22'); + CREATE TABLE receipt (receipt_no int NOT NULL PRIMARY KEY, deposit_date date NOT NULL, amount numeric(13,2)) USING flux; + INSERT INTO receipt VALUES (1, DATE '2008-12-22', 1.00), (2, DATE '2008-12-22', 2.00); +} + +teardown +{ + DROP TABLE txn0, txn1, ws, ctl, receipt; +} + +session foo +step foo_begin { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step foo_select { SELECT * FROM txn0 WHERE id = 42; } +step foo_insert { INSERT INTO txn1 SELECT 7, 'foo_insert'; } +step foo_commit { COMMIT; } + +session bar +step bar_begin { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step bar_select { SELECT * FROM txn1 WHERE id = 7; } +step bar_insert { INSERT INTO txn0 SELECT 42, 'bar_insert'; } +step bar_commit { COMMIT; } + +session trouble +step trouble_begin { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step trouble_update { UPDATE txn1 SET val = 'add physical version for "bar_select"' WHERE id = 7; } +step trouble_delete { DELETE FROM txn1 WHERE id = 7; } +step trouble_abort { ABORT; } + +session w1 +step w1b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step w1snap { SELECT v FROM ws WHERE id = 3; } +step w1r1 { SELECT v FROM ws WHERE id = 1; } +step w1w2 { UPDATE ws SET v = v + 1 WHERE id = 2; } +step w1c { COMMIT; } + +session w2 +step w2b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step w2r2 { SELECT v FROM ws WHERE id = 2; } +step w2w1 { UPDATE ws SET v = v + 10 WHERE id = 1; } +step w2c { COMMIT; } + +session w3 +step w3b { BEGIN; } +step w3w1 { UPDATE ws SET v = v + 100 WHERE id = 1; } +step w3a { ABORT; } + +session s1 +step s1b { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step rxwy1 { INSERT INTO receipt VALUES (3, (SELECT deposit_date FROM ctl WHERE k = 'receipt'), 4.00); } +step c1 { COMMIT; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step wx2 { UPDATE ctl SET deposit_date = DATE '2008-12-23' WHERE k = 'receipt'; } +step c2 { COMMIT; } + +session s3 +step s3b { BEGIN ISOLATION LEVEL SERIALIZABLE, READ ONLY; } +step rx3 { SELECT * FROM ctl WHERE k = 'receipt'; } +step ry3 { SELECT * FROM receipt WHERE deposit_date = DATE '2008-12-22'; } +step c3 { COMMIT; } + +permutation foo_begin bar_begin trouble_begin + foo_select bar_insert foo_insert foo_commit + trouble_update bar_select bar_commit trouble_abort + +permutation foo_begin bar_begin trouble_begin + foo_select bar_insert foo_insert foo_commit + trouble_delete bar_select bar_commit trouble_abort + +permutation w1b w1snap w2b w2r2 w2w1 w2c w1r1 w1w2 w1c + +permutation w1b w1snap w2b w2r2 w2w1 w2c w3b w3w1 w1r1 w1w2 w1c w3a + +permutation s1b s2b s3b rxwy1 wx2 c2 rx3 c1 ry3 c3 + +permutation s1b s2b s3b wx2 rxwy1 c2 rx3 c1 ry3 c3 diff --git a/src/test/isolation/specs/flux_ssi_copy.spec b/src/test/isolation/specs/flux_ssi_copy.spec new file mode 100644 index 0000000000000..211cd4d5e8781 --- /dev/null +++ b/src/test/isolation/specs/flux_ssi_copy.spec @@ -0,0 +1,18 @@ +# Bulk INSERT must check relation predicate locks, not promoted page locks. +setup +{ + CREATE TABLE a (id int) USING flux; + CREATE TABLE b (id int) USING flux; +} +teardown { DROP TABLE a, b; } +session s1 +step b1 { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step r1 { SELECT count(*) FROM a; } +step w1 { COPY b FROM PROGRAM 'printf "1\n"'; } +step c1 { COMMIT; } +session s2 +step b2 { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step r2 { SELECT count(*) FROM b; } +step w2 { COPY a FROM PROGRAM 'printf "2\n"'; } +step c2 { COMMIT; } +permutation b1 b2 r1 r2 w1 c1 w2 c2 diff --git a/src/test/isolation/specs/flux_ssi_revive.spec b/src/test/isolation/specs/flux_ssi_revive.spec new file mode 100644 index 0000000000000..cccfe0583797c --- /dev/null +++ b/src/test/isolation/specs/flux_ssi_revive.spec @@ -0,0 +1,54 @@ +# SSI rw-conflict-in on an index entry revived by an in-place key UPDATE. +# heap_ssi_revive.spec runs the same steps on heap; the two expected +# outputs are identical. +# +# s3 (read committed) moves row 1 to k = 1 and away again, so FLUX keeps a +# delete-marked (1, TID) entry in t_k. When s2 moves the row back to k = 1, +# FLUX revives that entry instead of inserting a new one. An insert reports +# a rw-conflict to every serializable reader holding a predicate lock on the +# leaf page it lands on, which is what protects a key-range read from a +# phantom; the revive must report it the same way. +# +# Permutations 1-2: s1 reads k = 1 before the tombstone exists, so it holds +# only the leaf page lock; s1 and s2 then form a write skew through u, and +# one of them must fail. Permutation 3: the tombstone exists when s1 reads +# k = 1, so s1 also locks row 1 through it. Permutation 4: s1 reads another +# key on the same leaf; heap reports the (false positive) conflict through +# the page lock, and FLUX does the same. + +setup +{ + CREATE TABLE t (id int PRIMARY KEY, k int, v int) USING flux; + CREATE INDEX t_k ON t (k); + INSERT INTO t VALUES (1, 3, 0), (2, 5, 0), (3, 7, 0); + CREATE TABLE u (id int PRIMARY KEY, v int) USING flux; + INSERT INTO u VALUES (1, 0); +} + +teardown +{ + DROP TABLE t, u; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step s1r { SELECT count(*) FROM t WHERE k = 1; } +step s1r5 { SELECT count(*) FROM t WHERE k = 5; } +step s1w { UPDATE u SET v = v + 1 WHERE id = 1; } +step s1c { COMMIT; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step s2r { SELECT v FROM u WHERE id = 1; } +step s2w { UPDATE t SET k = 1 WHERE id = 1; } +step s2c { COMMIT; } + +session s3 +step s3a { UPDATE t SET k = 1 WHERE id = 1; } +step s3b { UPDATE t SET k = 2 WHERE id = 1; } +step s3chk { SELECT id, k FROM t ORDER BY id; SELECT * FROM u; } + +permutation s1b s1r s3a s3b s2b s2r s2w s1w s1c s2c s3chk +permutation s1b s1r s3a s3b s2b s2r s2w s2c s1w s1c s3chk +permutation s3a s3b s1b s1r s2b s2r s2w s1w s1c s2c s3chk +permutation s3a s3b s1b s1r5 s2b s2r s2w s1w s1c s2c s3chk diff --git a/src/test/isolation/specs/flux_tid_fetch_snapshot.spec b/src/test/isolation/specs/flux_tid_fetch_snapshot.spec new file mode 100644 index 0000000000000..21ebae1c97056 --- /dev/null +++ b/src/test/isolation/specs/flux_tid_fetch_snapshot.spec @@ -0,0 +1,48 @@ +# Fetching a row by TID must return the version the snapshot sees, as heap +# does, whose TID still names the version the scan found. An EvalPlanQual +# recheck of a join refetches the unlocked side's row by TID: it must see +# that row as the query saw it, not as a concurrent update left it. A TID +# scan under REPEATABLE READ must find a row updated since its snapshot, +# with its old values. heap_tid_fetch_snapshot.spec runs the same steps on +# heap; the two expected outputs are identical. +# +# FLUX used to return the row's newest version to the recheck, and nothing +# to the TID scan. +setup +{ + CREATE TABLE acc (id text PRIMARY KEY, bal int) USING flux; + INSERT INTO acc VALUES ('checking', 600), ('savings', 600); + CREATE TABLE oth (id text, v int) USING flux; + INSERT INTO oth VALUES ('checking', 1), ('savings', 1); +} +teardown +{ + DROP TABLE acc, oth; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s1sel { SELECT * FROM acc ORDER BY id; } +step s1lock { SELECT * FROM acc a1, acc a2 WHERE a1.id = a2.id ORDER BY a1.id FOR UPDATE OF a1; } +step s1tid { SELECT * FROM acc WHERE ctid = '(0,1)'; } +step s1join { SELECT * FROM acc a JOIN oth o USING (id) ORDER BY id FOR UPDATE OF a; } +step s1c { COMMIT; } + +session s2 +step s2u { UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; } +step s2d { DELETE FROM acc WHERE id = 'checking'; } +step s2uo { UPDATE oth SET v = v + 10 WHERE id = 'checking'; } +step s2do { DELETE FROM oth WHERE id = 'checking'; } +step s2b { BEGIN; } +step s2c { COMMIT; } + +# The recheck sees a2 as the query did: 600, beside a1's new 1050. +permutation s2b s2u s1b s1lock s2c s1c +# The unlocked side is another table, updated or deleted concurrently. +permutation s2b s2u s2uo s1b s1join s2c s1c +permutation s2b s2u s2do s1b s1join s2c s1c +# A TID scan sees the version its snapshot sees, updated or deleted since. +permutation s1brr s1sel s2u s1tid s1c +permutation s1brr s1sel s2d s1tid s1c +permutation s1brr s1sel s2u s2u s1tid s1c diff --git a/src/test/isolation/specs/flux_update_deadlock.spec b/src/test/isolation/specs/flux_update_deadlock.spec new file mode 100644 index 0000000000000..7b57080f1d819 --- /dev/null +++ b/src/test/isolation/specs/flux_update_deadlock.spec @@ -0,0 +1,51 @@ +# FLUX: concurrent two-row UPDATE transactions that deadlock. The deadlock +# victim's increments must be rolled back and every survivor's must land +# exactly once, as in heap. hist records the increments of the transactions +# that commit, in the same transaction. + +setup +{ + CREATE TABLE fdl (id int PRIMARY KEY, v int) USING flux; + INSERT INTO fdl VALUES (1, 0), (2, 0); + CREATE TABLE fdl_hist (id int, who text); +} + +teardown +{ + DROP TABLE fdl, fdl_hist; +} + +session s1 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s1u1 { UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); } +step s1u2 { UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); } +step s1c { COMMIT; } + +session s2 +setup { SET deadlock_timeout = '500ms'; BEGIN; } +step s2u1 { UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); } +step s2u2 { UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); } +step s2c { COMMIT; } + +session s3 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s3u1 { UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's3'); } +step s3u2 { UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's3'); } +step s3c { COMMIT; } + +session s4 +step chk { SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; } + +# s2 runs with a deadlock_timeout far below the others' but far above the +# few milliseconds the tester needs to issue the step that closes the cycle, +# so s2 is always the one that detects it and is the victim. Commits are +# ordered so that no session is asked to commit while it still waits. +# +# Two-way deadlock, s2 is the victim; s1 survives. +permutation s1u1 s2u2 s1u2 s2u1 s1c s2c s3c chk +# A third transaction waits on row 1 behind s1 before the cycle forms. +permutation s1u1 s2u2 s3u1 s1u2 s2u1 s1c s2c s3c chk +# The victim holds the row a waiter is queued on. +permutation s2u1 s1u2 s3u1 s2u2 s1u1 s3c s1c s2c chk +# Victim's row 2 has a queued waiter too. +permutation s1u1 s2u2 s3u2 s1u2 s2u1 s3c s1c s2c chk diff --git a/src/test/isolation/specs/flux_update_key_waiter.spec b/src/test/isolation/specs/flux_update_key_waiter.spec new file mode 100644 index 0000000000000..6b9ad8eab44a6 --- /dev/null +++ b/src/test/isolation/specs/flux_update_key_waiter.spec @@ -0,0 +1,60 @@ +# An UPDATE that waits for a concurrent writer of the same row must decide +# whether it changes an indexed column, and which index entries to change, +# from the row the writer leaves behind, not from the writer's uncommitted +# image it found before the wait. heap_update_key_waiter.spec runs the same +# steps on heap; the two expected outputs are identical. +# +# FLUX updates in place, so while the writer runs the page holds its +# uncommitted image. An UPDATE that set k to the value the writer had set +# compared its new row with that image, found no indexed column changed, and +# after the writer aborted overwrote the restored row in place without index +# maintenance: the row then had the new k but the index had only its old one. +# Each permutation reads the row through the index under every key it may +# have, and once by a sequential scan; the two must agree. +setup +{ + CREATE TABLE kw (id int, k int, v int) USING flux; + CREATE INDEX kw_k ON kw (k); + INSERT INTO kw SELECT g, g, 0 FROM generate_series(1, 5) g; +} +teardown +{ + DROP TABLE kw; +} +session s1 +step s1b { BEGIN; } +step s1k { UPDATE kw SET k = 100 WHERE id = 3; } +step s1v { UPDATE kw SET v = 1 WHERE id = 3; } +step s1d { DELETE FROM kw WHERE id = 3; } +step s1r { ROLLBACK; } +step s1c { COMMIT; } +session s2 +step s2b { BEGIN; } +step s2k { UPDATE kw SET k = 100 WHERE id = 3; } +step s2k2 { UPDATE kw SET k = 200 WHERE id = 3; } +step s2v { UPDATE kw SET v = 2 WHERE id = 3; } +step s2c { COMMIT; } +session s3 +step chk { + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; +} + +# The waiter's new key equals the aborted writer's key. +permutation s1b s1k s2b s2k s1r s2c chk +permutation s1b s1k s2b s2k s1c s2c chk +# The waiter changes the key to another value. +permutation s1b s1k s2b s2k2 s1r s2c chk +permutation s1b s1k s2b s2k2 s1c s2c chk +# The writer changed the key, the waiter changes only a non-key column. +permutation s1b s1k s2b s2v s1r s2c chk +permutation s1b s1k s2b s2v s1c s2c chk +# The writer changed a non-key column, the waiter changes the key. +permutation s1b s1v s2b s2k s1r s2c chk +permutation s1b s1v s2b s2k s1c s2c chk +# The writer deleted the row and rolls back. +permutation s1b s1d s2b s2k s1r s2c chk +permutation s1b s1d s2b s2k s1c s2c chk diff --git a/src/test/isolation/specs/flux_update_serialize.spec b/src/test/isolation/specs/flux_update_serialize.spec new file mode 100644 index 0000000000000..9d62110afde9e --- /dev/null +++ b/src/test/isolation/specs/flux_update_serialize.spec @@ -0,0 +1,66 @@ +# FLUX: concurrent UPDATE serialization must match heap, and a plain UPDATE +# must not hold a heavyweight tuple lock until commit. +# +# FLUX updates rows in place at a stable TID. The in-progress updater is the +# on-page t_xmin of the new image (FLUX_TUPLE_UPDATED | FLUX_TUPLE_UNCOMMITTED), +# which is what a concurrent updater must wait on, exactly as heap waits on +# xmax. The expected output is heap's (the same spec with USING heap). + +setup +{ + CREATE TABLE fus (id int PRIMARY KEY, v int) USING flux; + INSERT INTO fus VALUES (1, 0); + CREATE TABLE fus_big (id int PRIMARY KEY, v int) USING flux; + INSERT INTO fus_big SELECT g, 0 FROM generate_series(1, 20000) g; +} + +teardown +{ + DROP TABLE fus, fus_big; +} + +session s1 +step s1rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1u { UPDATE fus SET v = v + 1 WHERE id = 1; } +step s1ks { SELECT id FROM fus WHERE id = 1 FOR KEY SHARE; } +step s1fu { SELECT id FROM fus WHERE id = 1 FOR UPDATE; } +step s1oc { INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; } +step s1c { COMMIT; } +step s1a { ROLLBACK; } +# A whole-table UPDATE must not take one transaction-duration lock per row: +# 20000 rows exhaust the default lock table ("out of shared memory"). +step s1big { UPDATE fus_big SET v = v + 1; } +step s1bigsum { SELECT sum(v) FROM fus_big; } +# No tuple lock may survive the UPDATE statement (heap holds none). +step s1locks { SELECT count(*) AS tuple_locks FROM pg_locks WHERE locktype = 'tuple' AND pid = pg_backend_pid(); } + +session s2 +step s2rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2snap { SELECT v FROM fus WHERE id = 1; } +step s2u { UPDATE fus SET v = v + 1 WHERE id = 1; } +step s2oc { INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; } +step s2c { COMMIT; } + +session s3 +step s3r { SELECT v FROM fus WHERE id = 1; } + +# READ COMMITTED: s2 waits for s1, then re-evaluates on s1's value -> 2. +permutation s1rc s2rc s1u s2u s1c s2c s3r +# READ COMMITTED, s1 rolls back: s2 proceeds on the original value -> 1. +permutation s1rc s2rc s1u s2u s1a s2c s3r +# REPEATABLE READ, s1 commits while s2 waits: serialization failure -> 1. +permutation s1rc s2rr s2snap s1u s2u s1c s2c s3r +# REPEATABLE READ, s1 rolls back while s2 waits: s2 succeeds -> 1. +permutation s1rc s2rr s2snap s1u s2u s1a s2c s3r +# REPEATABLE READ, s1 committed after s2's snapshot: immediate failure -> 1. +permutation s1rc s2rr s2snap s1u s1c s2u s2c s3r +# s1 holds only a KEY SHARE lock, so s2's no-key UPDATE proceeds; s1's own +# later UPDATE must then wait for s2 and re-evaluate (no lost update) -> 2. +permutation s1rc s2rc s1ks s2u s1u s2c s1c s3r +# FOR UPDATE blocks a concurrent UPDATE until commit -> 2. +permutation s1rc s2rc s1fu s2u s1u s1c s2c s3r +# Concurrent ON CONFLICT DO UPDATE on the same key -> 2. +permutation s1rc s2rc s1oc s2oc s1c s2c s3r +# Bulk UPDATE at default settings, then no tuple locks left in the xact. +permutation s1rc s1big s1locks s1c s1bigsum diff --git a/src/test/isolation/specs/flux_update_victim.spec b/src/test/isolation/specs/flux_update_victim.spec new file mode 100644 index 0000000000000..9d1422df2e2be --- /dev/null +++ b/src/test/isolation/specs/flux_update_victim.spec @@ -0,0 +1,47 @@ +# FLUX: an UPDATE that waited for a row and then became a deadlock victim +# must not leave its increments behind. Every value must equal the number +# of committed increments recorded in fdv_hist (same transaction), as in +# heap. + +setup +{ + CREATE TABLE fdv (id int PRIMARY KEY, v int) USING flux; + INSERT INTO fdv VALUES (1, 0), (2, 0); + CREATE TABLE fdv_hist (id int); +} + +teardown +{ + DROP TABLE fdv, fdv_hist; +} + +session s1 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s1u1 { UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); } +step s1u2 { UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); } +step s1c { COMMIT; } + +session s2 +setup { SET deadlock_timeout = '10ms'; BEGIN; } +step s2u1 { UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); } +step s2u2 { UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); } +step s2c { COMMIT; } + +session s3 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s3u1 { UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); } +step s3u2 { UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); } +step s3c { COMMIT; } + +session s4 +step chk { SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; } + +# s2 waits for s1 on row 1, after updating row 2; then s1 waits on row 2 +# -> s2 is the victim (short deadlock_timeout) while its row-1 wait is +# granted the row only after s1's commit. +permutation s2u2 s1u1 s2u1 s1u2 s1c s2c chk +# Same, with s3 queued on row 2 behind s2. +permutation s2u2 s1u1 s3u2 s2u1 s1u2 s1c s2c s3c chk +# s2's first update waited on s1 and succeeded after s1 committed; its +# second update then deadlocks with s3. +permutation s1u1 s3u2 s2u1 s1c s2u2 s3u1 s2c s3c chk diff --git a/src/test/isolation/specs/flux_update_waiter_abort.spec b/src/test/isolation/specs/flux_update_waiter_abort.spec new file mode 100644 index 0000000000000..0d793fcad0787 --- /dev/null +++ b/src/test/isolation/specs/flux_update_waiter_abort.spec @@ -0,0 +1,81 @@ +# A row change that waits for a concurrent writer of the same row must work +# on the row the writer leaves behind: its committed new version if it +# commits, the version it replaced if it aborts. heap_update_waiter_abort.spec +# runs the same steps on heap; the two expected outputs are identical. +# +# FLUX updates in place, so while the writer runs the page holds its +# uncommitted image. The executor fetches the row it is about to update +# (SnapshotAny) before the update waits, and builds the new row from what it +# got. That fetch returned the writer's image, so after the writer aborted +# the update applied the aborted values, and RETURNING OLD showed them. +setup +{ + CREATE TABLE vh (id int PRIMARY KEY, v int, w int) USING flux; + INSERT INTO vh VALUES (1, 0, 0), (2, 0, 0); + CREATE TABLE vh_hist (id int); +} +teardown +{ + DROP TABLE vh, vh_hist; +} +session s1 +setup { SET deadlock_timeout = '10ms'; } +step s1b { BEGIN; } +step s1w { UPDATE vh SET w = 10 WHERE id = 1; } +step s1w2 { UPDATE vh SET w = 20 WHERE id = 1; } +step s1k { UPDATE vh SET id = 10 WHERE id = 1; } +step s1sp { SAVEPOINT a; } +step s1rs { ROLLBACK TO a; } +step s1u1 { UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); } +step s1u2 { UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); } +step s1r { ROLLBACK; } +step s1c { COMMIT; } +session s2 +setup { SET deadlock_timeout = '10s'; } +step s2b { BEGIN; } +step s2rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2u { UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; } +step s2w5 { UPDATE vh SET w = 5 WHERE id = 1; } +step s2d { DELETE FROM vh WHERE id = 1 RETURNING *; } +step s2l { SELECT * FROM vh WHERE id = 1 FOR UPDATE; } +step s2h { INSERT INTO vh_hist VALUES (1); } +step s2c { COMMIT; } +session s3 +setup { SET deadlock_timeout = '10s'; } +step s3b { BEGIN; } +step s3u { UPDATE vh SET v = v + 10 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; } +step s3u2 { UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); } +step s3u1 { UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); } +step s3c { COMMIT; } +session s4 +step chk { SELECT * FROM vh ORDER BY id; } +step chkh { SELECT f.id, f.v, f.w, count(h.id) AS committed FROM vh f LEFT JOIN vh_hist h USING (id) GROUP BY f.id, f.v, f.w ORDER BY f.id; } + +# READ COMMITTED UPDATE waiter: the writer commits (re-fetch: OLD w = 10), +# or aborts (OLD w = 0). +permutation s1b s1w s2b s2u s1c s2c chk +permutation s1b s1w s2b s2u s1r s2c chk +# REPEATABLE READ: commit is a serialization failure, abort succeeds. +permutation s1b s1w s2rr s2u s1c s2c chk +permutation s1b s1w s2rr s2u s1r s2c chk +# The writer's change is in a savepoint rolled back while the update waits; +# then with an earlier change outside the savepoint, which commits. +permutation s1b s1sp s1w s2b s2u s1rs s1c s2c chk +permutation s1b s1w s1sp s1w2 s2b s2u s1rs s1c s2c chk +# DELETE ... RETURNING waiters, and a SELECT ... FOR UPDATE waiter whose +# writer aborts. (FOR UPDATE behind a writer that commits is +# flux_lock_waiter_commit.) +permutation s1b s1w s2b s2d s1c s2c chk +permutation s1b s1w s2b s2d s1r s2c chk +permutation s1b s1w s2b s2l s1r s2c chk +# Two waiters queued behind the writer. +permutation s1b s1w s2b s3b s2u s3u s1c s2c s3c chk +permutation s1b s1w s2b s3b s2u s3u s1r s2c s3c chk +# The writer changes the column the waiter's WHERE clause tests. +permutation s1b s1k s2b s2u s1c s2c chk +permutation s1b s1k s2b s2u s1r s2c chk +# The on-page version is our own earlier update. +permutation s2b s2w5 s2u s2c chk +# s1->s3 (row 2), s3->s2 (row 1, queued), s2->s1 (row 1): s1 is the deadlock +# victim; s2 must see OLD v = 0, not the victim's 1. +permutation s1b s2b s3b s1u1 s3u2 s2u(s1u2) s3u1 s1u2(*) s1c s2h s2c s3c chkh diff --git a/src/test/isolation/specs/flux_vacuum_move.spec b/src/test/isolation/specs/flux_vacuum_move.spec new file mode 100644 index 0000000000000..29b5488b4b98e --- /dev/null +++ b/src/test/isolation/specs/flux_vacuum_move.spec @@ -0,0 +1,45 @@ +# Ordinary VACUUM must not move a row out from under its writer. +# +# s1 updates the last row in place and keeps the transaction open. s2 +# VACUUMs the table (lower pages are empty after setup's DELETE) and inserts +# enough rows to reuse any slot VACUUM might have freed. s1 must still see +# exactly its own row, and after it ends every row must be intact. Heap gives +# the same output (heap_vacuum_move). + +setup +{ + CREATE TABLE fvmove (id int, v int, pad text) USING flux WITH (autovacuum_enabled = off); + CREATE INDEX fvmove_id ON fvmove (id); + INSERT INTO fvmove SELECT g, 0, repeat('x', 200) FROM generate_series(1, 400) g; + DELETE FROM fvmove WHERE id <= 300; +} +setup { VACUUM fvmove; } +setup { VACUUM fvmove; } + +teardown +{ + DROP TABLE fvmove; +} + +session s1 +setup { SET enable_seqscan = off; SET enable_bitmapscan = off; } +step s1_upd { BEGIN; UPDATE fvmove SET v = 7 WHERE id = 400; } +step s1_idx { SELECT id, v FROM fvmove WHERE id = 400; } +step s1_bmp { SET enable_indexscan = off; SET enable_bitmapscan = on; SELECT id, v FROM fvmove WHERE id = 400; } +step s1_commit { COMMIT; } +step s1_rollback { ROLLBACK; } + +session s2 +step s2_vac { VACUUM fvmove; } +step s2_ins { INSERT INTO fvmove SELECT g, 100, repeat('n', 200) FROM generate_series(9000, 9800) g; } +step s2_check +{ + SET enable_indexscan = off; SET enable_bitmapscan = off; + SELECT count(*) AS n, count(DISTINCT id) AS ids, sum(v) AS sumv, + count(*) FILTER (WHERE id = 400) AS n400, + max(v) FILTER (WHERE id = 400) AS v400 + FROM fvmove; +} + +permutation s1_upd s2_vac s2_ins s1_idx s1_bmp s1_rollback s2_check +permutation s1_upd s2_vac s2_ins s1_idx s1_commit s2_check diff --git a/src/test/isolation/specs/heap_cluster_sort_snapshot.spec b/src/test/isolation/specs/heap_cluster_sort_snapshot.spec new file mode 100644 index 0000000000000..825feb25936d0 --- /dev/null +++ b/src/test/isolation/specs/heap_cluster_sort_snapshot.spec @@ -0,0 +1,62 @@ +# CLUSTER and VACUUM FULL are MVCC-safe (see "Caveats" in mvcc.sgml): a +# snapshot taken before the rewrite reads exactly the rows it read before, +# whichever way the rewrite reads the table: a sort (cs_e, an expression +# index), an index scan (cs_g: GiST never sorts), the planner's choice (cs_b) +# or a sequential scan (VACUUM FULL). s1 takes its snapshot on another +# table, so it holds no lock on cs and the rewrite runs at once. Before the +# rewrite s2 deletes half the rows, updates every row once or twice, or +# inserts one; s1 must then still read the 20 rows it saw, with their old +# values, by a sequential scan and through the rebuilt index. +# HEAP twin; heap_/flux_cluster_sort_snapshot have identical expected output. +setup +{ + CREATE TABLE cs (k int, v int, p point) USING heap; + INSERT INTO cs SELECT g, g, point(g, g) FROM generate_series(1, 20) g; + CREATE INDEX cs_e ON cs ((-k)); + CREATE INDEX cs_b ON cs (k); + CREATE INDEX cs_g ON cs USING gist (p); + CREATE TABLE cs_other (x int); +} + +teardown +{ + DROP TABLE cs, cs_other; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM cs_other; } +step s1r { SELECT count(*), min(k), max(k), sum(v) FROM cs; } +step s1ri { + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT count(*), min(k), max(k), sum(v) FROM cs WHERE k > 0; +} +step s1c { COMMIT; } + +session s2 +step s2d { DELETE FROM cs WHERE k > 10; } +step s2u { UPDATE cs SET v = v + 100; } +step s2i { INSERT INTO cs VALUES (21, 21, point(21, 21)); } +step s2cle { CLUSTER cs USING cs_e; } +step s2clb { CLUSTER cs USING cs_b; } +step s2clg { CLUSTER cs USING cs_g; } +step s2vf { VACUUM FULL cs; } +step s2vff { VACUUM (FULL, FREEZE) cs; } +step s2v { VACUUM cs; } +step s2flush { SELECT pg_stat_force_next_flush(); } +step s2dead { SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'cs'; } +step s2chk { SELECT count(*), min(k), max(k), sum(v) FROM cs; } + +# Deleted rows, each way of reading the table. +permutation s1b s2d s2cle s1r s1ri s1c s2chk +permutation s1b s2d s2clb s1r s1ri s1c s2chk +permutation s1b s2d s2clg s1r s1ri s1c s2chk +permutation s1b s2d s2vf s1r s1ri s1c s2chk +# Rows updated (in place, for FLUX) once or twice, then some deleted. +permutation s1b s2u s2cle s1r s1ri s1c s2chk +permutation s1b s2u s2u s2clg s1r s1ri s1c s2chk +permutation s1b s2u s2d s2vf s1r s1ri s1c s2chk +# A row s1 must not see, and freezing; VACUUM afterwards. +permutation s1b s2i s2u s2vff s1r s1ri s2flush s2v s2dead s1c s2v s2dead s2chk +# No rewrite: the control. +permutation s1b s2d s2u s1r s1c s2chk diff --git a/src/test/isolation/specs/heap_fk_crosscheck.spec b/src/test/isolation/specs/heap_fk_crosscheck.spec new file mode 100644 index 0000000000000..663828f5bd624 --- /dev/null +++ b/src/test/isolation/specs/heap_fk_crosscheck.spec @@ -0,0 +1,34 @@ +# heap control for flux_fk_crosscheck.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE pk (a int PRIMARY KEY) USING heap; + CREATE TABLE fkc (a int REFERENCES pk ON DELETE CASCADE ON UPDATE CASCADE) USING heap; + CREATE TABLE fkn (a int REFERENCES pk ON DELETE SET NULL) USING heap; + INSERT INTO pk VALUES (2); +} +teardown +{ + DROP TABLE fkc, fkn, pk; +} + +session s1 +step s1brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s1ic { INSERT INTO fkc VALUES (2); } +step s1in { INSERT INTO fkn VALUES (2); } +step s1c { COMMIT; } +step s1sel { SELECT 'pk' t, a FROM pk UNION ALL SELECT 'fkc', a FROM fkc UNION ALL SELECT 'fkn', a FROM fkn ORDER BY 1, 2; } + +session s2 +step s2brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2brc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2d { DELETE FROM pk WHERE a = 2; } +step s2u { UPDATE pk SET a = 3 WHERE a = 2; } +step s2c { COMMIT; } + +permutation s1brr s1ic s2brr s2d s1c s2c s1sel +permutation s1brr s1in s2brr s2d s1c s2c s1sel +permutation s1brr s1ic s2brr s2u s1c s2c s1sel +# READ COMMITTED has no crosscheck: the action applies to the new child. +permutation s1brr s1ic s2brc s2d s1c s2c s1sel +permutation s1brr s1in s2brc s2d s1c s2c s1sel diff --git a/src/test/isolation/specs/heap_lock_delete_lockers.spec b/src/test/isolation/specs/heap_lock_delete_lockers.spec new file mode 100644 index 0000000000000..823b302fd4a09 --- /dev/null +++ b/src/test/isolation/specs/heap_lock_delete_lockers.spec @@ -0,0 +1,39 @@ +# A DELETE that conflicts with several row lockers must wait for all of them, +# not only the first: here two foreign-key checks hold FOR KEY SHARE on the +# parent row, and one of them rolls back. The DELETE must still wait for the +# other, so its foreign-key check then finds the committed child row. +# HEAP twin of flux_lock_delete_lockers; the expected output is identical. +setup +{ + CREATE TABLE parent (i int PRIMARY KEY) USING heap; + INSERT INTO parent VALUES (1); + CREATE TABLE child (i int REFERENCES parent (i)) USING heap; +} + +teardown +{ + DROP TABLE child, parent; +} + +session s1 +step s1b { BEGIN; } +step s1l { INSERT INTO child VALUES (1); } +step s1r { ROLLBACK; } +step s1c { COMMIT; } + +session s2 +step s2b { BEGIN; } +step s2l { INSERT INTO child VALUES (1); } +step s2c { COMMIT; } +step s2r { ROLLBACK; } + +session s3 +step s3d { DELETE FROM parent; } +step s3chk { SELECT (SELECT count(*) FROM parent) AS parents, (SELECT count(*) FROM child) AS children; } + +# The first locker rolls back; the DELETE must keep waiting for the second. +permutation s1b s1l s2b s2l s3d s1r s2c s3chk +# The second locker rolls back first. +permutation s1b s1l s2b s2l s3d s2r s1c s3chk +# Both roll back: the DELETE goes ahead. +permutation s1b s1l s2b s2l s3d s1r s2r s3chk diff --git a/src/test/isolation/specs/heap_lock_recheck.spec b/src/test/isolation/specs/heap_lock_recheck.spec new file mode 100644 index 0000000000000..7a03302158925 --- /dev/null +++ b/src/test/isolation/specs/heap_lock_recheck.spec @@ -0,0 +1,407 @@ +# heap control for flux_lock_recheck.spec: same steps, same expected output. +# FLUX: a row lock that waits on, or meets, a committed concurrent update must +# report it exactly as heap does. +# +# FLUX updates rows in place at a stable TID, so the latest version of a row +# is always at the TID the caller holds. table_tuple_lock() must still tell +# its callers that the version their snapshot saw was superseded: +# - READ COMMITTED, TUPLE_LOCK_FLAG_FIND_LAST_VERSION: TM_Ok with +# tmfd->traversed = true, so nodeLockRows, GetTupleForTrigger and +# ri_LockPKTuple re-check the latest version (EvalPlanQual, FK key recheck); +# - REPEATABLE READ: TM_Updated, so the caller raises "could not serialize +# access due to concurrent update"; +# - ON CONFLICT (flags 0) under READ COMMITTED: TM_Ok on the latest version, +# because heap's retry would find and lock that same row. +# A FOR KEY SHARE lock is compatible with a non-key UPDATE and, as in heap, +# neither waits nor reports it. +# +# The expected output is heap's: heap_lock_recheck.spec is this spec with +# USING heap, and both share the same expected .out. + +setup +{ + CREATE TABLE lr (id int PRIMARY KEY, v int, w int) USING heap; + INSERT INTO lr VALUES (1, 1, 0), (2, 1, 0); + CREATE TABLE lrp (id int PRIMARY KEY, note int) USING heap; + INSERT INTO lrp VALUES (1, 0); + CREATE TABLE lrc (id int, pid int REFERENCES lrp) USING heap; + CREATE TABLE lrt (id int PRIMARY KEY, v int, w int) USING heap; + INSERT INTO lrt VALUES (1, 1, 0); + CREATE FUNCTION lrt_bu() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN RAISE NOTICE 'lrt_bu: OLD.v=% NEW.v=%', OLD.v, NEW.v; RETURN NEW; END $$; + CREATE TRIGGER lrt_bu BEFORE UPDATE ON lrt FOR EACH ROW EXECUTE FUNCTION lrt_bu(); + -- A BEFORE UPDATE OR DELETE trigger that modifies NEW. + CREATE TABLE lrn (id int PRIMARY KEY, v int, w int) USING heap; + INSERT INTO lrn VALUES (1, 1, 0); + CREATE FUNCTION lrn_bu() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN + RAISE NOTICE 'lrn_bu %: OLD=% NEW=%', TG_OP, OLD, NEW; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + NEW.w := NEW.w + 100; + RETURN NEW; + END $$; + CREATE TRIGGER lrn_bu BEFORE UPDATE OR DELETE ON lrn FOR EACH ROW EXECUTE FUNCTION lrn_bu(); + -- Partitioned by v, for cross-partition UPDATE; lptt has a trigger. + CREATE TABLE lpt (id int, v int, w int) PARTITION BY LIST (v); + CREATE TABLE lpt1 PARTITION OF lpt FOR VALUES IN (1) USING heap; + CREATE TABLE lpt2 PARTITION OF lpt FOR VALUES IN (2) USING heap; + INSERT INTO lpt VALUES (1, 1, 0); + CREATE TABLE lptt (id int, v int, w int) PARTITION BY LIST (v); + CREATE TABLE lptt1 PARTITION OF lptt FOR VALUES IN (1) USING heap; + CREATE TABLE lptt2 PARTITION OF lptt FOR VALUES IN (2) USING heap; + INSERT INTO lptt VALUES (1, 1, 0); + CREATE FUNCTION lr_note() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN + RAISE NOTICE '% % %: OLD=% NEW=%', TG_TABLE_NAME, TG_WHEN, TG_OP, OLD, NEW; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; + END $$; + CREATE TRIGGER lr_note BEFORE UPDATE OR DELETE ON lptt FOR EACH ROW EXECUTE FUNCTION lr_note(); + -- FK with ON DELETE CASCADE: the RI action deletes a child row that a + -- concurrent transaction has updated. + CREATE TABLE lrq (id int PRIMARY KEY, note int) USING heap; + INSERT INTO lrq VALUES (1, 0); + CREATE TABLE lrqc (id int PRIMARY KEY, pid int REFERENCES lrq ON DELETE CASCADE, w int) USING heap; + INSERT INTO lrqc VALUES (1, 1, 0); + CREATE TABLE lrup (id int PRIMARY KEY) USING heap; + INSERT INTO lrup VALUES (1); + CREATE TABLE lruc (pid int REFERENCES lrup ON UPDATE CASCADE) USING heap; + CREATE TABLE lrrp (id int PRIMARY KEY) USING heap; + INSERT INTO lrrp VALUES (1); + CREATE TABLE lrrc (pid int REFERENCES lrrp ON UPDATE RESTRICT) USING heap; + CREATE TABLE lrmp (a int, b int, PRIMARY KEY (a, b)) USING heap; + INSERT INTO lrmp VALUES (1, 1); + CREATE TABLE lrmc (a int, b int, FOREIGN KEY (a, b) REFERENCES lrmp) USING heap; + CREATE TABLE lrvp (id int, note int) USING heap; + CREATE UNIQUE INDEX lrvp_unique ON lrvp (id); + INSERT INTO lrvp VALUES (1, 0); + CREATE TABLE lrvc (pid int REFERENCES lrvp (id)) USING heap; +} + +teardown +{ + DROP TABLE lruc, lrup, lrrc, lrrp, lrmc, lrmp, lrvc, lrvp; + DROP TABLE lr, lrc, lrp, lrt, lrn, lpt, lptt, lrqc, lrq; + DROP FUNCTION lrt_bu(), lrn_bu(), lr_note(); +} + +session s1 +step s1b { BEGIN; } +step s1sp { SAVEPOINT key_update; } +step s1rb { ROLLBACK TO key_update; } +step s1uk { UPDATE lrup SET id = 2 WHERE id = 1; } +step s1rk { UPDATE lrrp SET id = 2 WHERE id = 1; } +step s1mk { UPDATE lrmp SET b = 2 WHERE a = 1 AND b = 1; } +step s1vk { UPDATE lrvp SET id = 2 WHERE id = 1; } +step s1vn { UPDATE lrvp SET note = note + 1 WHERE id = 1; } +step s1u { UPDATE lr SET v = 2 WHERE id = 1; } +step s1k { UPDATE lr SET id = 10 WHERE id = 1; } +step s1d { DELETE FROM lr WHERE id = 1; } +step s1pk { UPDATE lrp SET id = 2 WHERE id = 1; } +step s1pfu { SELECT id FROM lrp WHERE id = 1 FOR UPDATE; } +step s1pn { UPDATE lrp SET note = 1 WHERE id = 1; } +step s1tu { UPDATE lrt SET v = 2 WHERE id = 1; } +step s1lfu { SELECT * FROM lr WHERE id = 1 FOR UPDATE; } +step s1tfu { SELECT * FROM lrt WHERE id = 1 FOR UPDATE; } +step s1tk { UPDATE lrt SET id = 10 WHERE id = 1; } +step s1tu2 { UPDATE lrt SET v = v + 1, w = w + 10 WHERE id = 1; } +step s1nu { UPDATE lrn SET v = 2 WHERE id = 1; } +step s1pu { UPDATE lpt SET w = 5 WHERE id = 1; } +step s1ptu { UPDATE lptt SET w = 5 WHERE id = 1; } +# session_replication_role = replica skips lrqc's own FK check trigger, which +# FLUX cannot run yet on UPDATE (it reads xmin from the old row's slot). +step s1qcu { SET LOCAL session_replication_role = replica; UPDATE lrqc SET w = 5 WHERE id = 1; } +step s1c { COMMIT; } +step s1a { ROLLBACK; } + +session s2 +step s2rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2a { ROLLBACK; } +step s2uci { INSERT INTO lruc VALUES (1); } +step s2rci { INSERT INTO lrrc VALUES (1); } +step s2mci { INSERT INTO lrmc VALUES (1, 1); } +step s2vci { INSERT INTO lrvc VALUES (1); } +step s2rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2snap { SELECT count(*) FROM lr; } +step s2fu { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE; } +step s2fnk { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR NO KEY UPDATE; } +step s2fs { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR SHARE; } +step s2fks { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR KEY SHARE; } +step s2sl { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE SKIP LOCKED; } +step s2nw { SELECT * FROM lr WHERE v = 1 ORDER BY id FOR UPDATE NOWAIT; } +step s2upd { UPDATE lr SET w = w + 1 WHERE v = 1 RETURNING *; } +step s2ci { INSERT INTO lrc VALUES (1, 1); } +step s2tu { UPDATE lrt SET w = w + 1 WHERE v = 1 RETURNING *; } +step s2oc { INSERT INTO lr VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lr.w + 1 RETURNING *; } +# The same locked row in several output rows: each is re-checked. +step s2jfu { SELECT lr.* FROM lr JOIN (VALUES (1), (1), (2)) s(id) USING (id) WHERE lr.v = 1 ORDER BY lr.id FOR UPDATE OF lr; } +# MERGE with a BEFORE UPDATE trigger: GetTupleForTrigger reports the +# concurrent update, MERGE re-checks the join and either drops the row or +# updates the latest version, and must not loop. +step s2mq { MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id AND lrt.v = 1 WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; } +step s2mu { MERGE INTO lrt USING (VALUES (1)) s(id) ON lrt.id = s.id WHEN MATCHED THEN UPDATE SET w = lrt.w + 1; } +# Parent-row locks after several updates (FOR KEY SHARE must see a key change +# by any of them, not only the latest). A cursor fixes the READ COMMITTED +# snapshot before the updates. +step s2pks { SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; } +step s2pnk { SELECT * FROM lrp WHERE id = 1 FOR NO KEY UPDATE; } +step s2dks { DECLARE c CURSOR FOR SELECT * FROM lrp WHERE id = 1 FOR KEY SHARE; } +step s2dka { DECLARE c CURSOR FOR SELECT * FROM lrp FOR KEY SHARE; } +step s2dnk { DECLARE c CURSOR FOR SELECT * FROM lrp FOR NO KEY UPDATE; } +step s2fc { FETCH ALL FROM c; } +step s2pks101 { SELECT * FROM lrp WHERE id = 101 FOR KEY SHARE; } +step s2ci101 { INSERT INTO lrc VALUES (2, 101); } +# ON CONFLICT DO UPDATE with a BEFORE UPDATE trigger: the trigger locks the +# row ON CONFLICT already locked. +step s2toc { INSERT INTO lrt VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrt.w + 1 RETURNING *; } +step s2noc { INSERT INTO lrn VALUES (1, 1, 0) ON CONFLICT (id) DO UPDATE SET w = lrn.w + 1 RETURNING *; } +# MERGE without a trigger (lr) and with one that modifies NEW (lrn). +step s2mlu { MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id WHEN MATCHED THEN UPDATE SET w = lr.w + 1 RETURNING merge_action(), lr.*; } +step s2mlq { MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id AND lr.v = 1 WHEN MATCHED THEN UPDATE SET w = lr.w + 1 RETURNING merge_action(), lr.*; } +step s2mld { MERGE INTO lr USING (VALUES (1)) s(id) ON lr.id = s.id WHEN MATCHED THEN DELETE RETURNING merge_action(), lr.*; } +step s2mnu { MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; } +step s2mnq { MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id AND lrn.v = 1 WHEN MATCHED THEN UPDATE SET w = lrn.w + 1 RETURNING merge_action(), lrn.*; } +step s2mnd { MERGE INTO lrn USING (VALUES (1)) s(id) ON lrn.id = s.id WHEN MATCHED THEN DELETE RETURNING merge_action(), lrn.*; } +# Cross-partition UPDATE (ExecCrossPartitionUpdate), and MERGE moving the row. +step s2pm { UPDATE lpt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; } +step s2pmq { UPDATE lpt SET v = 2 WHERE id = 1 AND w = 0 RETURNING tableoid::regclass, *; } +step s2ptm { UPDATE lptt SET v = 2 WHERE id = 1 RETURNING tableoid::regclass, *; } +step s2ptmq { UPDATE lptt SET v = 2 WHERE id = 1 AND w = 0 RETURNING tableoid::regclass, *; } +step s2mpm { MERGE INTO lpt USING (VALUES (1)) s(id) ON lpt.id = s.id WHEN MATCHED THEN UPDATE SET v = 2 RETURNING merge_action(), lpt.*; } +step s2mptm { MERGE INTO lptt USING (VALUES (1)) s(id) ON lptt.id = s.id WHEN MATCHED THEN UPDATE SET v = 2 RETURNING merge_action(), lptt.*; } +# RI cascade action on a child row updated concurrently. +step s2qd { DELETE FROM lrq WHERE id = 1; } +step s2c { COMMIT; } + +session s3 +step s3orphans { SELECT count(*) AS orphans FROM lrc c WHERE NOT EXISTS (SELECT FROM lrp p WHERE p.id = c.pid); } +step s3u { SELECT * FROM lrup; SELECT * FROM lruc; SELECT count(*) AS orphans FROM lruc c WHERE NOT EXISTS (SELECT FROM lrup p WHERE p.id = c.pid); } +step s3restrict { SELECT * FROM lrrp; SELECT * FROM lrrc; SELECT count(*) AS orphans FROM lrrc c WHERE NOT EXISTS (SELECT FROM lrrp p WHERE p.id = c.pid); } +step s3multi { SELECT * FROM lrmp; SELECT * FROM lrmc; SELECT count(*) AS orphans FROM lrmc c WHERE NOT EXISTS (SELECT FROM lrmp p WHERE p.a = c.a AND p.b = c.b); } +step s3unique { SELECT * FROM lrvp; SELECT * FROM lrvc; SELECT count(*) AS orphans FROM lrvc c WHERE NOT EXISTS (SELECT FROM lrvp p WHERE p.id = c.pid); } +step s3r { SELECT * FROM lr ORDER BY id; } +step s3c { SELECT * FROM lrc ORDER BY id; } +step s3t { SELECT * FROM lrt ORDER BY id; } +step s3p { SELECT * FROM lrp ORDER BY id; } +step s3n { SELECT * FROM lrn ORDER BY id; } +step s3pt { SELECT tableoid::regclass, * FROM lpt UNION ALL SELECT tableoid::regclass, * FROM lptt ORDER BY 1; } +step s3q { SELECT * FROM lrq ORDER BY id; SELECT * FROM lrqc ORDER BY id; } + +# Further parent updates, each committed on its own (autocommit) or held open. +# lrp starts as (1, 0). +session s4 +step s4vn { UPDATE lrvp SET note = note + 1 WHERE id = 1; } +step s4pkk { UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; } +step s4pkb { UPDATE lrp SET id = id - 100, note = note + 1 WHERE id = 101; } +step s4pn101 { UPDATE lrp SET note = note + 10 WHERE id = 101; } +step s4pn1 { UPDATE lrp SET note = note + 10 WHERE id = 1; } + +session s5 +step s5b { BEGIN; } +step s5pkk { UPDATE lrp SET id = id + 100, note = note + 1 WHERE id = 1; } +step s5pn101 { UPDATE lrp SET note = note + 100 WHERE id = 101; } +step s5pn1 { UPDATE lrp SET note = note + 100 WHERE id = 1; } +step s5c { COMMIT; } +step s5a { ROLLBACK; } + +# READ COMMITTED, each lock strength that conflicts with the UPDATE waits for +# it, then re-checks v = 1 against the committed version: row 1 drops out. +permutation s1b s1u s2rc s2fu s1c s2c s3r +permutation s1b s1u s2rc s2fnk s1c s2c s3r +permutation s1b s1u s2rc s2fs s1c s2c s3r +# FOR KEY SHARE does not conflict with a non-key UPDATE: no wait, no recheck. +permutation s1b s1u s2rc s2fks s1c s2c s3r +# FOR KEY SHARE waits for a key UPDATE. +permutation s1b s1k s2rc s2fks s1c s2c s3r +# SKIP LOCKED skips the row being updated; NOWAIT fails at once. +permutation s1b s1u s2rc s2sl s1c s2c s3r +permutation s1b s1u s2rc s2nw s1c s2c s3r +# UPDATE ... WHERE v = 1 re-checks through EvalPlanQual: only row 2 updated. +permutation s1b s1u s2rc s2upd s1c s2c s3r +# DELETE racing FOR UPDATE: the deleted row is not returned. +permutation s1b s1d s2rc s2fu s1c s2c s3r +# The updater rolls back: the original row is locked and returned. +permutation s1b s1u s2rc s2fu s1a s2c s3r +permutation s1b s1u s2rc s2upd s1a s2c s3r +# REPEATABLE READ: an update committed after the snapshot is a serialization +# failure, whether the locker waited for it or met it already committed. +permutation s2rr s2snap s1b s1u s2fu s1c s2c s3r +permutation s2rr s2snap s1b s1u s1c s2fu s2c s3r +permutation s2rr s2snap s1b s1u s1c s2fnk s2c s3r +permutation s2rr s2snap s1b s1u s1c s2fs s2c s3r +permutation s2rr s2snap s1b s1u s1c s2sl s2c s3r +permutation s2rr s2snap s1b s1u s1c s2nw s2c s3r +permutation s2rr s2snap s1b s1u s1c s2upd s2c s3r +# REPEATABLE READ FOR KEY SHARE after a committed non-key UPDATE: no failure. +permutation s2rr s2snap s1b s1u s1c s2fks s2c s3r +# REPEATABLE READ FOR KEY SHARE after a committed key UPDATE: failure. +permutation s2rr s2snap s1b s1k s1c s2fks s2c s3r +# REPEATABLE READ, the updater rolls back while the locker waits: no failure. +permutation s2rr s2snap s1b s1u s2fu s1a s2c s3r +# REPEATABLE READ, a committed DELETE: "concurrent delete". +permutation s2rr s2snap s1b s1d s2fu s1c s2c s3r +# FK check (ri_LockPKTuple): the parent key changes while the child insert +# waits, so the insert fails. +permutation s1b s1pk s2rc s2ci s1c s2c s3c +# FK check: a non-key parent UPDATE commits while the child insert waits on +# FOR UPDATE; the key still matches, so the insert succeeds. +permutation s1b s1pfu s2rc s2ci s1pn s1c s2c s3c +# FK check: the parent key update rolls back; the insert succeeds. +permutation s1b s1pk s2rc s2ci s1a s2c s3c +# GetTupleForTrigger: the BEFORE UPDATE trigger row is locked after a +# concurrent update; EvalPlanQual drops it, so neither trigger nor UPDATE runs. +permutation s1b s1tu s2rc s2tu s1c s2c s3t +# ... and after a rollback the original row is updated. +permutation s1b s1tu s2rc s2tu s1a s2c s3t +# GetTupleForTrigger under REPEATABLE READ: serialization failure. +permutation s2rr s2snap s1b s1tu s2tu s1c s2c s3t +# A join repeats row 1: every repetition is re-checked and dropped. +permutation s1b s1u s2rc s2jfu s1c s2c s3r +# MERGE + BEFORE UPDATE trigger after a concurrent committed update. +permutation s1b s1tu s2rc s2mq s1c s2c s3t +permutation s1b s1tu s2rc s2mu s1c s2c s3t +permutation s2rr s2snap s1b s1tu s2mu s1c s2c s3t +# ON CONFLICT DO UPDATE (flags 0), READ COMMITTED: waits, then updates the +# committed version, without looping. +permutation s1b s1u s2rc s2oc s1c s2c s3r +# ON CONFLICT DO UPDATE, REPEATABLE READ: serialization failure. +permutation s2rr s2snap s1b s1u s2oc s1c s2c s3r +permutation s2rr s2snap s1b s1u s1c s2oc s2c s3r + +# Two updates committed after the locker's snapshot: a key change, then a +# non-key change. The key change conflicts with FOR KEY SHARE although the +# latest update left the key alone. REPEATABLE READ: serialization failure, +# for the lock and for the FK check. +permutation s2rr s2snap s4pkk s4pn101 s2pks s2c s3p +permutation s2rr s2snap s4pkk s4pn101 s2ci s2c s3c +permutation s2rr s2snap s4pkk s4pn101 s2pnk s2c s3p +# READ COMMITTED: the lock moves to the latest version and re-checks it: the +# row no longer has id = 1 and drops out, a query without that qual returns +# the latest version, and the FK insert fails because the key has gone. +permutation s2rc s2dks s4pkk s4pn101 s2fc s2ci s2c s3c +permutation s2rc s2dka s4pkk s4pn101 s2fc s2c s3p +permutation s2rc s2dnk s4pkk s4pn101 s2fc s2c s3p +permutation s2rc s4pkk s4pn101 s2ci s2c s3c +# The other order: non-key change, then key change (the latest). +permutation s2rr s2snap s4pn1 s4pkk s2pks s2c s3p +permutation s2rc s2dka s4pn1 s4pkk s2fc s2c s3p +# Two non-key changes: FOR KEY SHARE conflicts with neither. +permutation s2rr s2snap s4pn1 s4pn1 s2pks s2ci s2c s3c +permutation s2rc s2dka s4pn1 s4pn1 s2fc s2c s3p +# Three updates: key change, key changed back, non-key change. The key +# changes conflict although the key ends where it started. +permutation s2rr s2snap s4pkk s4pkb s4pn1 s2pks s2c s3p +permutation s2rr s2snap s4pkk s4pkb s4pn1 s2ci s2c s3c +permutation s2rc s2dka s4pkk s4pkb s4pn1 s2fc s2c s3p +permutation s2rc s2dks s4pkk s4pkb s4pn1 s2fc s2ci s2c s3c +# Three updates: key change, then two non-key changes. +permutation s2rr s2snap s4pkk s4pn101 s4pn101 s2pks s2c s3p +permutation s2rc s2dka s4pkk s4pn101 s4pn101 s2fc s2c s3p +# One committed, one in progress: committed key change, in-progress non-key +# change. REPEATABLE READ fails at once, without waiting for the in-progress +# update, for FOR KEY SHARE and FOR NO KEY UPDATE alike; READ COMMITTED waits +# for it, then locks the latest version. +permutation s2rr s2snap s4pkk s5b s5pn101 s2pks s5c s2c s3p +permutation s2rr s2snap s4pkk s5b s5pn101 s2ci s5c s2c s3c +permutation s2rr s2snap s4pkk s5b s5pn101 s2pnk s5c s2c s3p +permutation s2rc s2dka s4pkk s5b s5pn101 s2fc s5c s2c s3p +permutation s2rc s2dnk s4pkk s5b s5pn101 s2fc s5c s2c s3p +# ... committed non-key change, in-progress key change: FOR KEY SHARE waits, +# then fails (commit) or locks the snapshot's version (rollback). +permutation s2rr s2snap s4pn1 s5b s5pkk s2pks s5c s2c s3p +permutation s2rr s2snap s4pn1 s5b s5pkk s2pks s5a s2c s3p +permutation s2rc s2dka s4pn1 s5b s5pkk s2fc s5c s2c s3p +permutation s2rc s2dka s4pn1 s5b s5pkk s2fc s5a s2c s3p +# Both in progress in one transaction: key change, then non-key change. The +# FK check must wait for the key change, then fail. +permutation s5b s5pkk s5pn101 s2rc s2ci s5c s2c s3c +permutation s5b s5pkk s5pn101 s2rc s2ci s5a s2c s3c +permutation s5b s5pkk s5pn101 s2rr s2snap s2pks s5c s2c s3p +permutation s5b s5pkk s5pn101 s2rr s2snap s2pks s5a s2c s3p +# Committed non-key change, in-progress non-key change: FOR KEY SHARE +# conflicts with neither and does not wait. +permutation s2rr s2snap s4pn1 s5b s5pn1 s2pks s2ci s5c s2c s3c +permutation s2rc s2dka s4pn1 s5b s5pn1 s2fc s5c s2c s3p +# A key change the snapshot sees, then a non-key change it does not: the +# second update did not change the key, whatever the first one did. +permutation s4pkk s2rr s2snap s4pn101 s2pks101 s2ci101 s2c s3c +permutation s4pkk s2rr s2snap s5b s5pn101 s2pks101 s2ci101 s5c s2c s3c +# A key change that rolled back, then a committed non-key change: no conflict. +permutation s2rr s2snap s5b s5pkk s5a s4pn1 s2pks s2ci s2c s3c +permutation s2rc s2dka s5b s5pkk s5a s4pn1 s2fc s2c s3p +# FOR NO KEY UPDATE against the same histories: it conflicts with every +# update, key or not, committed or in progress. +permutation s2rr s2snap s4pn1 s4pn1 s2pnk s2c s3p +permutation s2rc s2dnk s4pn1 s4pn1 s2fc s2c s3p +permutation s2rr s2snap s4pn1 s5b s5pkk s2pnk s5c s2c s3p +permutation s2rr s2snap s4pn1 s5b s5pkk s2pnk s5a s2c s3p +permutation s2rr s2snap s5b s5pkk s5a s4pn1 s2pnk s2c s3p +permutation s2rr s2snap s4pkk s4pkb s4pn1 s2pnk s2c s3p + +# ON CONFLICT DO UPDATE with a BEFORE UPDATE trigger, READ COMMITTED, against +# a row updated and committed after the snapshot: ON CONFLICT locks the latest +# version and the trigger's lock of the same row reports nothing, as in heap +# (no EvalPlanQual: ON CONFLICT has none set up). Also with the row updated +# twice, with a trigger that modifies NEW, after a rollback, and under +# REPEATABLE READ. +permutation s1b s1tu s2rc s2toc s1c s2c s3t +permutation s1b s1tu s1tu2 s2rc s2toc s1c s2c s3t +permutation s1b s1tu s2rc s2toc s1a s2c s3t +permutation s2rr s2snap s1b s1tu s2toc s1c s2c s3t +permutation s1b s1nu s2rc s2noc s1c s2c s3n +permutation s1b s1nu s2rc s2noc s1a s2c s3n +# ON CONFLICT against a key update by a FOR UPDATE locker ON CONFLICT was +# waiting for: the row no longer conflicts, so the INSERT goes ahead. +permutation s1b s1lfu s2rc s2oc s1k s1c s2c s3r +permutation s1b s1tfu s2rc s2toc s1tk s1c s2c s3t +# ... and against a non-key update from that locker: the new version is +# updated (and its trigger fired once); a lock only: the row is updated. +permutation s1b s1lfu s2rc s2oc s1u s1c s2c s3r +permutation s1b s1tfu s2rc s2toc s1tu s1c s2c s3t +permutation s1b s1lfu s2rc s2oc s1c s2c s3r +permutation s2rr s2snap s1b s1lfu s2oc s1u s1c s2c s3r +# MERGE WHEN MATCHED UPDATE/DELETE after a concurrent committed update, +# without a trigger and with a trigger that modifies NEW. +permutation s1b s1u s2rc s2mlu s1c s2c s3r +permutation s1b s1u s2rc s2mlq s1c s2c s3r +permutation s1b s1u s2rc s2mld s1c s2c s3r +permutation s1b s1nu s2rc s2mnu s1c s2c s3n +permutation s1b s1nu s2rc s2mnq s1c s2c s3n +permutation s1b s1nu s2rc s2mnd s1c s2c s3n +permutation s2rr s2snap s1b s1nu s2mnu s1c s2c s3n +# Cross-partition UPDATE after a concurrent committed update, without and +# with BEFORE UPDATE/DELETE triggers, by UPDATE and by MERGE. +permutation s1b s1pu s2rc s2pm s1c s2c s3pt +permutation s1b s1pu s2rc s2pmq s1c s2c s3pt +permutation s1b s1ptu s2rc s2ptm s1c s2c s3pt +permutation s1b s1ptu s2rc s2ptmq s1c s2c s3pt +permutation s1b s1pu s2rc s2mpm s1c s2c s3pt +permutation s1b s1ptu s2rc s2mptm s1c s2c s3pt +permutation s2rr s2snap s1b s1ptu s2ptm s1c s2c s3pt +# RI ON DELETE CASCADE against a child row updated concurrently. +permutation s1b s1qcu s2rc s2qd s1c s2c s3q + +# Lock first, UPDATE second: key changes conflict with all four strengths, +# but a non-key change remains compatible with KEY SHARE (including CAS). +permutation s2rc s2fks s1b s1k s2c s1c s3r +permutation s2rc s2fks s1b s1u s1c s2c s3r +permutation s2rc s2fs s1b s1k s2c s1c s3r +permutation s2rc s2fnk s1b s1k s2c s1c s3r +permutation s2rc s2fu s1b s1k s2c s1c s3r +# FK insertion holds KEY SHARE until commit. The parent UPDATE must wait +# and then check the newly committed child; an aborted child permits it. +permutation s2rc s2ci s1b s1pk s2c s1c s3p s3c s3orphans +permutation s2rc s2ci s1b s1pk s2a s1c s3p s3c s3orphans +permutation s2rc s2uci s1b s1uk s2c s1c s3u +permutation s2rc s2rci s1b s1rk s2c s1c s3restrict +# Subtransaction rollback restores the parent and releases its key lock. +permutation s2rc s2fks s1b s1sp s1k s2c s1rb s1c s3r +permutation s1b s1sp s1pk s2rc s2ci s1rb s2c s1c s3p s3c s3orphans +# A single component change, and a standalone unique index used by an FK. +permutation s2rc s2mci s1b s1mk s2c s1c s3multi +permutation s2rc s2vci s1b s1vk s2c s1c s3unique +permutation s2rc s2vci s1b s1vn s1c s2c s3unique + +# A compatible non-key update clears the on-page lock flag, not the FK +# lock in sLog. The same-size unique-key update can now reach CAS. +permutation s2rc s2vci s4vn s1b s1vk s2c s1c s3unique diff --git a/src/test/isolation/specs/heap_lock_update_lockers.spec b/src/test/isolation/specs/heap_lock_update_lockers.spec new file mode 100644 index 0000000000000..ab6a47d80a6da --- /dev/null +++ b/src/test/isolation/specs/heap_lock_update_lockers.spec @@ -0,0 +1,32 @@ +# An UPDATE that conflicts with several row lockers must wait for all of +# them: two sessions hold FOR SHARE on the row, the first rolls back, and the +# UPDATE must keep waiting for the second instead of overwriting the row it +# still has locked. HEAP twin; the expected output is identical. +setup +{ + CREATE TABLE lu (id int PRIMARY KEY, v int) USING heap; + INSERT INTO lu VALUES (1, 0); +} + +teardown +{ + DROP TABLE lu; +} + +session s1 +step s1b { BEGIN; } +step s1l { SELECT v FROM lu WHERE id = 1 FOR SHARE; } +step s1r { ROLLBACK; } + +session s2 +step s2b { BEGIN; } +step s2l { SELECT v FROM lu WHERE id = 1 FOR SHARE; } +step s2chk { SELECT v FROM lu WHERE id = 1; } +step s2c { COMMIT; } + +session s3 +step s3u { UPDATE lu SET v = v + 1 WHERE id = 1; } +step s3chk { SELECT v FROM lu WHERE id = 1; } + +# s3 must still be waiting after s1 rolls back; s2 still sees v = 0. +permutation s1b s1l s2b s2l s3u s1r s2chk s2c s3chk diff --git a/src/test/isolation/specs/heap_lock_upgrade_queue.spec b/src/test/isolation/specs/heap_lock_upgrade_queue.spec new file mode 100644 index 0000000000000..7ec4de6119a40 --- /dev/null +++ b/src/test/isolation/specs/heap_lock_upgrade_queue.spec @@ -0,0 +1,42 @@ +# heap control for flux_lock_upgrade_queue.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE luq (id int PRIMARY KEY, v int) USING heap; + INSERT INTO luq VALUES (1, 0); +} +teardown +{ + DROP TABLE luq; +} + +session s1 +setup { BEGIN; } +step s1ks { SELECT id FROM luq FOR KEY SHARE; } +step s1sh { SELECT id FROM luq FOR SHARE; } +step s1sp { SAVEPOINT a; } +step s1rs { ROLLBACK TO a; } +step s1r { ROLLBACK; } + +session s2 +setup { BEGIN; } +step s2ks { SELECT id FROM luq FOR KEY SHARE; } +step s2nk { SELECT id FROM luq FOR NO KEY UPDATE; } +step s2fu { SELECT id FROM luq FOR UPDATE; } +step s2c { COMMIT; } + +session s3 +setup { BEGIN; } +step s3ks { SELECT id FROM luq FOR KEY SHARE; } +step s3sh { SELECT id FROM luq FOR SHARE; } +step s3fu { SELECT id FROM luq FOR UPDATE; } +step s3d { DELETE FROM luq RETURNING id; } +step s3c { COMMIT; } + +# s2 upgrades past a lock s1 took in a savepoint while s3 waits for s1: s2 +# wakes at ROLLBACK TO, then s3 waits for s2. +permutation s1ks s3fu s2ks s1sp s1sh s2nk s1rs s1r s2c s3c +# s3 upgrades its SHARE lock while s2 waits for s1. +permutation s1sh s2fu s3sh s3fu s1r s3c s2c +# s3 deletes the row it holds a KEY SHARE lock on while s2 waits for s1. +permutation s1ks s2fu s3ks s3d s1r s3c s2c diff --git a/src/test/isolation/specs/heap_nonkey_move.spec b/src/test/isolation/specs/heap_nonkey_move.spec new file mode 100644 index 0000000000000..5fa04ffa53558 --- /dev/null +++ b/src/test/isolation/specs/heap_nonkey_move.spec @@ -0,0 +1,87 @@ +# Non-key updates that move a row must remain compatible with KEY SHARE. +# Heap and FLUX twins have identical expected output. +setup +{ + CREATE TABLE fi (id int, v int, PRIMARY KEY (id) INCLUDE (v)) USING heap; + CREATE TABLE fg (id int PRIMARY KEY, v int, pad text) USING heap WITH (fillfactor=100); + ALTER TABLE fg ALTER COLUMN pad SET STORAGE PLAIN; + INSERT INTO fi VALUES (1, 1); + INSERT INTO fg SELECT g, 1, repeat('x', 640) FROM generate_series(1, 40) g; + CREATE TABLE ci (id int REFERENCES fi) USING heap; + CREATE TABLE cg (id int REFERENCES fg) USING heap; +} +teardown { DROP TABLE ci, cg, fi, fg; } + +session s1 +step rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step snap { SELECT v FROM fi WHERE id=1; SELECT v FROM fg WHERE id=1; } +step ki { SELECT id, v FROM fi WHERE id=1 FOR KEY SHARE; } +step kg { SELECT id, v, length(pad) FROM fg WHERE id=1 FOR KEY SHARE; } +step ii { INSERT INTO ci VALUES (1); } +step ig { INSERT INTO cg VALUES (1); } +step si { SELECT id, v FROM fi WHERE id=1 FOR SHARE; } +step sg { SELECT id, v, length(pad) FROM fg WHERE id=1 FOR SHARE; } +step di { DELETE FROM fi WHERE id=1; } +step dg { DELETE FROM fg WHERE id=1; } +step wi { UPDATE fi SET v=v+1 WHERE id=1; } +step wg { UPDATE fg SET v=v+1 WHERE id=1; } +step c1 { COMMIT; } + +session s2 +step b2 { BEGIN; } +step ui { UPDATE fi SET v=2 WHERE id=1; } +step ug { UPDATE fg SET pad=repeat('y',1920) WHERE id=1; } +step c2 { COMMIT; } +step r2 { ROLLBACK; } +step d2i { DELETE FROM fi WHERE id=1; } +step n2i { UPDATE fi SET v=3 WHERE id=1; } +step n2g { UPDATE fg SET pad=repeat('z',1940) WHERE id=1; } +step k2i { UPDATE fi SET id=2 WHERE id=1; } +step d2g { DELETE FROM fg WHERE id=1; } +step k2g { UPDATE fg SET id=100 WHERE id=1; } + +session s3 +step b3 { BEGIN; } +step u3 { UPDATE fi SET v=3 WHERE id=1; } +step k3 { UPDATE fi SET id=2 WHERE id=1; } +step z3 { UPDATE fi SET id=1 WHERE id=2; } +step c3 { COMMIT; } +permutation rr snap b2 ui ki c2 c1 +permutation rr snap b2 ui ii c2 c1 +permutation rr snap b2 ui si c2 c1 +permutation rr snap b2 ui di c2 c1 +permutation rr snap b2 ui wi c2 c1 +permutation rr snap b2 ug kg c2 c1 +permutation rr snap b2 ug ig c2 c1 +permutation rr snap b2 ug sg c2 c1 +permutation rr snap b2 ug dg c2 c1 +permutation rr snap b2 ug wg c2 c1 +permutation rc snap b2 ui ki c2 c1 +permutation rc snap b2 ui ii c2 c1 +permutation rc snap b2 ui si c2 c1 +permutation rc snap b2 ui di c2 c1 +permutation rc snap b2 ui wi c2 c1 +permutation rc snap b2 ug kg c2 c1 +permutation rc snap b2 ug ig c2 c1 +permutation rc snap b2 ug sg c2 c1 +permutation rc snap b2 ug dg c2 c1 +permutation rc snap b2 ug wg c2 c1 +permutation rr snap b2 ui ki c2 d2i c1 +permutation rr snap b2 ui ki c2 k2i c1 +permutation rr snap b2 ug kg c2 d2g c1 +permutation rr snap b2 ug kg c2 k2g c1 +permutation rr snap b2 ui ki r2 c1 +permutation rr snap b2 ui c2 ki c1 +permutation rr snap b2 ug kg r2 c1 +permutation rr snap b2 ug c2 kg c1 + +permutation rr snap b2 ui ki c2 n2i c1 +permutation rr snap b2 ug kg c2 n2g c1 + +permutation rr snap b2 ui c2 b3 k3 c3 ki c1 +permutation rc snap b2 ui c2 b3 k3 c3 ki c1 +permutation rr snap b2 ui c2 b3 k3 ki c3 c1 +permutation rr snap b2 ui c2 b3 u3 ki c3 c1 +permutation rr snap b2 ui c2 b3 k3 z3 u3 c3 ki c1 +permutation rr snap b2 ui c2 b3 k3 z3 u3 ki c3 c1 diff --git a/src/test/isolation/specs/heap_outofplace_grow.spec b/src/test/isolation/specs/heap_outofplace_grow.spec new file mode 100644 index 0000000000000..ae627d687fc70 --- /dev/null +++ b/src/test/isolation/specs/heap_outofplace_grow.spec @@ -0,0 +1,30 @@ +# heap control for flux_outofplace_grow.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE xg (id int, n int, p1 bigint, p2 bigint, p3 bigint, p4 bigint, + p5 bigint, p6 bigint, p7 bigint, p8 bigint) + USING heap WITH (fillfactor = 100); + INSERT INTO xg (id, n) SELECT g, 0 FROM generate_series(1, 400) g; +} +teardown +{ + DROP TABLE xg; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1g { UPDATE xg SET p1 = 1, p2 = 2, p3 = 3, p4 = 4, p5 = 5, p6 = 6, + p7 = 7, p8 = 8 WHERE id = 1; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } +step s1sel { SELECT id, n, p8, (SELECT count(*) FROM xg) AS nrows + FROM xg WHERE id = 1; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2u { UPDATE xg SET n = n + 1 WHERE id = 1; } +step s2c { COMMIT; } + +permutation s1b s2b s1g s2u s1c s2c s1sel +permutation s1b s2b s1g s2u s1r s2c s1sel diff --git a/src/test/isolation/specs/heap_outofplace_update.spec b/src/test/isolation/specs/heap_outofplace_update.spec new file mode 100644 index 0000000000000..95b169c6e0736 --- /dev/null +++ b/src/test/isolation/specs/heap_outofplace_update.spec @@ -0,0 +1,58 @@ +# heap control for flux_outofplace_update.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE xt (id int, r int4range, v int, + EXCLUDE USING gist (r WITH &&)) USING heap; + INSERT INTO xt VALUES (1, '[1,10)', 0); + CREATE TABLE xp (id int4range NOT NULL, valid_at daterange NOT NULL, + PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)) USING heap; + CREATE TABLE xc (id int4range NOT NULL, valid_at daterange NOT NULL, + parent_id int4range, + FOREIGN KEY (parent_id, PERIOD valid_at) REFERENCES xp) + USING heap; + INSERT INTO xp VALUES ('[1,2)', '[2020-01-01,2030-01-01)'); +} +teardown +{ + DROP TABLE xc, xp, xt; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1u { UPDATE xt SET v = v + 1 WHERE id = 1; } +step s1uk { UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; } +step s1up { UPDATE xp SET valid_at = '[2020-01-01,2026-01-01)' WHERE id = '[1,2)'; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } +step s1sel { SELECT id, v, r, count(*) OVER () AS nrows FROM xt; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2sel { SELECT id, v, r FROM xt; } +step s2u { UPDATE xt SET v = v + 1 WHERE id = 1; } +step s2uk { UPDATE xt SET v = v + 1, r = int4range(lower(r), upper(r) + 1) WHERE id = 1; } +step s2d { DELETE FROM xt WHERE id = 1; } +step s2fu { SELECT id, v, r FROM xt WHERE id = 1 FOR UPDATE; } +step s2ins { INSERT INTO xc VALUES ('[1,2)', '[2020-01-01,2025-01-01)', '[1,2)'); } +step s2c { COMMIT; } + +# (a) READ COMMITTED: the waiter acts on the new version; no update is lost. +permutation s1b s2b s1u s2u s1c s2c s1sel +permutation s1b s2b s1uk s2uk s1c s2c s1sel +permutation s1b s2b s1uk s2u s1c s2c s1sel +permutation s1b s2b s1u s2uk s1c s2c s1sel +permutation s1b s2b s1uk s2d s1c s2c s1sel +# The move rolls back: the waiter updates the old version. +permutation s1b s2b s1uk s2u s1r s2c s1sel +permutation s1b s2b s1uk s2uk s1r s2c s1sel +# (b) REPEATABLE READ: a serialization failure for a concurrent update. +permutation s1b s2brr s1uk s2u s1c s2c s1sel +permutation s1b s2brr s2sel s1uk s1c s2d s2c s1sel +# (c) The FK check follows the parent row to its new version. +permutation s1b s2b s1up s2ins s1c s2c +permutation s1b s2brr s1up s2ins s1c s2c +# (d) FOR UPDATE locks the new version, or fails under REPEATABLE READ. +permutation s1b s2b s1uk s2fu s1c s2c s1sel +permutation s1b s2brr s2sel s1uk s1c s2fu s2c diff --git a/src/test/isolation/specs/heap_partition_move.spec b/src/test/isolation/specs/heap_partition_move.spec new file mode 100644 index 0000000000000..15a1a0539be45 --- /dev/null +++ b/src/test/isolation/specs/heap_partition_move.spec @@ -0,0 +1,59 @@ +# heap control for flux_partition_move.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE pm (a int, b text) PARTITION BY LIST (a); + CREATE TABLE pm1 PARTITION OF pm FOR VALUES IN (1) USING heap; + CREATE TABLE pm2 PARTITION OF pm FOR VALUES IN (2) USING heap; + INSERT INTO pm VALUES (1, 'ABC'); + CREATE TABLE pr (a int, b text) PARTITION BY RANGE (a); + CREATE TABLE pr1 PARTITION OF pr FOR VALUES FROM (1) TO (10) USING heap; + CREATE TABLE pr2 PARTITION OF pr FOR VALUES FROM (10) TO (20) USING heap; + INSERT INTO pr VALUES (7, 'ABC'); + CREATE UNIQUE INDEX pr1_a ON pr1 (a); + CREATE TABLE prc (a int REFERENCES pr1 (a)) USING heap; +} +teardown +{ + DROP TABLE prc, pr, pm; +} + +session s1 +setup { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1u { UPDATE pm SET a = 2 WHERE a = 1; } +step s1sp { SAVEPOINT sp; } +step s1rs { ROLLBACK TO sp; } +step s1upr { UPDATE pr SET a = 11 WHERE a = 7; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2sel { SELECT * FROM pm ORDER BY a; } +step s2u { UPDATE pm SET b = 'EFG' WHERE a = 1; } +step s2d { DELETE FROM pm WHERE a = 1; } +step s2l { SELECT * FROM pm WHERE a = 1 FOR UPDATE; } +step s2ks { SELECT * FROM pm WHERE a = 1 FOR KEY SHARE; } +step s2m { MERGE INTO pm t USING (SELECT 1 AS a) s ON t.a = s.a + WHEN MATCHED THEN UPDATE SET b = 'merged'; } +step s2i { INSERT INTO prc VALUES (7); } +step s2c { COMMIT; } + +# The move commits: each write or lock of the old row fails. +permutation s2b s1u s2d s1c s2sel s2c +permutation s2b s1u s2u s1c s2sel s2c +permutation s2b s1u s2l s1c s2c +permutation s2b s1u s2ks s1c s2c +permutation s2b s1u s2m s1c s2sel s2c +permutation s2b s1upr s2i s1c s2c +# Under REPEATABLE READ the row was concurrently updated. +permutation s2brr s2sel s1u s1c s2d s2c +permutation s2brr s2sel s1u s1c s2l s2c +# The move rolls back: the old row is still there, lockable and updatable. +permutation s2b s1u s2u s1r s2sel s2c +permutation s2b s1u s2d s1r s2sel s2c +permutation s2b s1u s2l s1r s2c +permutation s2b s1upr s2i s1r s2c +permutation s1sp s1u s1rs s2b s2u s2sel s2c s1c +permutation s2b s1u s1r s2l s2u s2sel s2c diff --git a/src/test/isolation/specs/heap_recheck_after_wait.spec b/src/test/isolation/specs/heap_recheck_after_wait.spec new file mode 100644 index 0000000000000..392b03a1f0b75 --- /dev/null +++ b/src/test/isolation/specs/heap_recheck_after_wait.spec @@ -0,0 +1,46 @@ +# heap control for flux_recheck_after_wait.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE rw (id int PRIMARY KEY, v int) USING heap; + INSERT INTO rw VALUES (1, 0); +} +teardown +{ + DROP TABLE rw; +} + +session s1 +setup { BEGIN; } +step s1sh { SELECT id FROM rw FOR SHARE; } +step s1u { UPDATE rw SET v = 5; } +step s1r { ROLLBACK; } +step s1c { COMMIT; } + +session s2 +setup { BEGIN; } +step s2d { DELETE FROM rw RETURNING id, v; } +step s2r { ROLLBACK; } +step s2c { COMMIT; } + +session s3 +setup { BEGIN; } +step s3d { DELETE FROM rw WHERE v = 0 RETURNING id, v; } +step s3u { UPDATE rw SET v = v + 10 RETURNING id, v; } +step s3c { COMMIT; } + +session s4 +step s4sel { SELECT id, v FROM rw; } + +# s2 waits for s1's row lock or update, s3 waits behind s2; s1 rolls back +# and s2 deletes. s3 must wait for s2, which rolls back: s3 deletes or +# updates the row. +permutation s1sh s2d s3d s1r s2r s3c s4sel +permutation s1sh s2d s3u s1r s2r s3c s4sel +permutation s1u s2d s3d s1r s2r s3c s4sel +permutation s1u s2d s3u s1r s2r s3c s4sel +# s2 commits its delete: s3 finds no row. +permutation s1sh s2d s3d s1r s2c s3c s4sel +# s3 waits for s1's row lock; s1 updates the row so that it no longer +# matches s3's WHERE, and commits: s3 deletes nothing. +permutation s1sh s3d s1u s1c s3c s4sel diff --git a/src/test/isolation/specs/heap_savepoint_lock_rollback.spec b/src/test/isolation/specs/heap_savepoint_lock_rollback.spec new file mode 100644 index 0000000000000..235b577ba6a05 --- /dev/null +++ b/src/test/isolation/specs/heap_savepoint_lock_rollback.spec @@ -0,0 +1,113 @@ +# heap control for flux_savepoint_lock_rollback.spec, which runs the same +# steps on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE lr (id int PRIMARY KEY, v int) USING heap; + INSERT INTO lr VALUES (1, 0), (2, 0); +} +setup +{ + VACUUM lr; +} +teardown +{ + DROP TABLE lr; +} + +session s1 +step s1b { BEGIN; } +step s1sp { SAVEPOINT a; } +step s1sp2 { SAVEPOINT b; } +step s1fu { SELECT * FROM lr WHERE id = 1 FOR UPDATE; } +step s1fnk { SELECT * FROM lr WHERE id = 1 FOR NO KEY UPDATE; } +step s1fs { SELECT * FROM lr WHERE id = 1 FOR SHARE; } +step s1fks { SELECT * FROM lr WHERE id = 1 FOR KEY SHARE; } +step s1u { UPDATE lr SET v = v + 10 WHERE id = 1; } +step s1i { INSERT INTO lr VALUES (3, 0); } +step s1fu3 { SELECT * FROM lr WHERE id = 3 FOR UPDATE; } +step s1u3 { UPDATE lr SET v = v + 10 WHERE id = 3; } +step s1d { DELETE FROM lr WHERE id = 1; } +step s1rs { ROLLBACK TO a; } +step s1rs2 { ROLLBACK TO b; } +step s1rel { RELEASE a; } +step s1c { COMMIT; } +step s1r { ROLLBACK; } + +session s2 +step s2b { BEGIN; } +step s2u { UPDATE lr SET v = v + 1 WHERE id = 1; } +step s2d { DELETE FROM lr WHERE id = 1; } +step s2c { COMMIT; } +step s2r { ROLLBACK; } + +# Every way of reaching the rows must find the same rows. +session s3 +step s3chk +{ + SELECT 'select' AS via, * FROM lr ORDER BY id; + SELECT 'key share' AS via, * FROM lr ORDER BY id FOR KEY SHARE; + SELECT 'for update' AS via, * FROM lr ORDER BY id FOR UPDATE; + WITH u AS (UPDATE lr SET v = v + 100 RETURNING *) + SELECT 'update' AS via, * FROM u ORDER BY id; + DELETE FROM lr WHERE id = 1 RETURNING 'delete' AS via, *; + SELECT 'after delete' AS via, * FROM lr ORDER BY id; +} + +# Each lock strength, in a savepoint that is rolled back, then a committed +# UPDATE of the row by s2. +permutation s1b s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1sp s1fnk s1rs s2u s1c s3chk +permutation s1b s1sp s1fs s1rs s2u s1c s3chk +permutation s1b s1sp s1fks s1rs s2u s1c s3chk +# Nested savepoints: both rolled back; only the inner one, the outer +# released. +permutation s1b s1sp s1sp2 s1fu s1rs2 s1rs s2u s1c s3chk +permutation s1b s1sp s1sp2 s1fu s1rs2 s1rel s2u s1c s3chk +# RELEASE instead of ROLLBACK TO: the lock is s1's until it commits. +permutation s1b s1sp s1fu s1rel s2u s1c s3chk +# s2 deletes the row instead. +permutation s1b s1sp s1fu s1rs s2d s1c s3chk +# s2 aborts its UPDATE before s1 commits. +permutation s1b s1sp s1fu s1rs s2b s2u s2r s1c s3chk +# s2's UPDATE is still running when s1 commits. +permutation s1b s1sp s1fu s1rs s2b s2u s1c s2c s3chk +# s1 rolls back instead, with the lock in a savepoint or at the top level. +permutation s1b s1sp s1fu s1rs s2u s1r s3chk +permutation s1b s1fu s1r s2u s3chk +# A row change, not a lock, in the rolled-back savepoint, then s2's +# committed UPDATE. +permutation s1b s1sp s1u s1rs s2u s1c s3chk +permutation s1b s1sp s1d s1rs s2u s1c s3chk +# ... and s2's UPDATE still running when s1 commits. +permutation s1b s1sp s1u s1rs s2b s2u s1c s2c s3chk +permutation s1b s1sp s1d s1rs s2b s2u s1c s2c s3chk +# A top-level lock survives the same or a weaker lock in a rolled-back +# savepoint: s2 waits for s1. +permutation s1b s1fu s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1fu s1sp s1fks s1rs s2u s1c s3chk +permutation s1b s1fnk s1sp s1fu s1rs s2u s1c s3chk +# ... and a weaker lock at the same level does not give the stronger one up. +permutation s1b s1fu s1fks s2u s1c s3chk +permutation s1b s1fu s1sp s1fks s1rel s2u s1c s3chk +# ... nor does a row change in a rolled-back savepoint. +permutation s1b s1fu s1sp s1u s1rs s2u s1c s3chk +permutation s1b s1fks s1sp s1u s1rs s2d s1c s3chk +# s1's own top-level UPDATE of the row, then the rolled-back lock or +# UPDATE. s1 still holds its UPDATE's row lock, so s2 waits for s1. +permutation s1b s1u s1sp s1fu s1rs s1c s3chk +permutation s1b s1u s1sp s1u s1rs s1c s3chk +permutation s1b s1u s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1u s1sp s1fks s1rs s2u s1r s3chk +# s1's own top-level INSERT, locked in a rolled-back savepoint: kept. An +# INSERT in a rolled-back savepoint: discarded. +permutation s1b s1i s1sp s1fu3 s1rs s1c s3chk +permutation s1b s1sp s1i s1rs s1c s3chk +# An INSERT kept (top level, or in a released savepoint), then updated in a +# rolled-back savepoint: kept, with its first value. +permutation s1b s1i s1sp s1u3 s1rs s1c s3chk +permutation s1b s1sp s1i s1rel s1sp2 s1u3 s1rs2 s1c s3chk + +# A rolled-back upgrade releases only the savepoint's stronger lock. +permutation s1b s1fks s1sp s1fu s1rs s2u s1c s3chk +permutation s1b s1fks s1sp s1sp2 s1fu s1rs2 s1rs s2u s1c s3chk +permutation s1b s1fks s1sp s1sp2 s1fu s1rel s2u s1c s3chk diff --git a/src/test/isolation/specs/heap_savepoint_lock_wake.spec b/src/test/isolation/specs/heap_savepoint_lock_wake.spec new file mode 100644 index 0000000000000..f1a756f8fdcfe --- /dev/null +++ b/src/test/isolation/specs/heap_savepoint_lock_wake.spec @@ -0,0 +1,44 @@ +# heap control for flux_savepoint_lock_wake.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE lw (id int PRIMARY KEY, v int) USING heap; + INSERT INTO lw VALUES (1, 0); +} +teardown +{ + DROP TABLE lw; +} + +session s1 +setup { BEGIN; } +step s1ks { SELECT * FROM lw FOR KEY SHARE; } +step s1sp { SAVEPOINT a; } +step s1sp2 { SAVEPOINT b; } +step s1nk { SELECT * FROM lw FOR NO KEY UPDATE; } +step s1fu { SELECT * FROM lw FOR UPDATE; } +step s1rs { ROLLBACK TO a; } +step s1rs2 { ROLLBACK TO b; } +step s1rel2 { RELEASE b; } +step s1c { COMMIT; } + +session s2 +setup { BEGIN; } +step s2nk { SELECT * FROM lw FOR NO KEY UPDATE; } +step s2fs { SELECT * FROM lw FOR SHARE; } +step s2u { UPDATE lw SET v = v + 1 RETURNING *; } +step s2d { DELETE FROM lw RETURNING *; } +step s2c { COMMIT; } + +# The waiter wakes at ROLLBACK TO: a lock upgrade, and a lock with nothing +# at the top level. +permutation s1ks s1sp s1nk s2nk s1rs s1c s2c +permutation s1ks s1sp s1nk s2u s1rs s1c s2c +permutation s1ks s1sp s1fu s2fs s1rs s1c s2c +permutation s1sp s1fu s2u s1rs s1c s2c +# Nested savepoints: the inner lock released into the outer savepoint, +# which is then rolled back; and only the inner savepoint rolled back. +permutation s1ks s1sp s1sp2 s1nk s1rel2 s2nk s1rs s1c s2c +permutation s1ks s1sp s1sp2 s1nk s2nk s1rs2 s1c s2c +# The top-level KEY SHARE lock still blocks a DELETE until commit. +permutation s1ks s1sp s1nk s2d s1rs s1c s2c diff --git a/src/test/isolation/specs/heap_seqscan_stats.spec b/src/test/isolation/specs/heap_seqscan_stats.spec new file mode 100644 index 0000000000000..6d5389f09c3a5 --- /dev/null +++ b/src/test/isolation/specs/heap_seqscan_stats.spec @@ -0,0 +1,23 @@ +# heap control for flux_seqscan_stats.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE ss (k int, v int) USING heap; + INSERT INTO ss SELECT g, g FROM generate_series(1, 5) g; +} +teardown +{ + DROP TABLE ss; +} + +session s1 +setup { SET enable_indexscan = off; SET enable_bitmapscan = off; } +step s1scan { SELECT count(*) FROM ss; } +step s1where { SELECT k FROM ss WHERE v > 3 ORDER BY k; } +step s1ff { SELECT pg_stat_force_next_flush(); } +step s1stats { + SELECT pg_stat_get_numscans('ss'::regclass) AS seq_scan, + pg_stat_get_tuples_returned('ss'::regclass) AS seq_tup_read; +} + +permutation s1stats s1scan s1ff s1stats s1where s1ff s1stats diff --git a/src/test/isolation/specs/heap_ssi_conflict_out.spec b/src/test/isolation/specs/heap_ssi_conflict_out.spec new file mode 100644 index 0000000000000..d0e8e9f12d8b9 --- /dev/null +++ b/src/test/isolation/specs/heap_ssi_conflict_out.spec @@ -0,0 +1,99 @@ +# heap control for flux_ssi_conflict_out.spec, which runs the same steps on +# FLUX; the two expected outputs are identical. +# +# A serializable reader must report a conflict out to every writer of a +# version it examines that its snapshot cannot see, whether that writer is +# still running or committed after the snapshot was taken. FLUX keeps only +# the newest version on the page and reaches older ones through the UNDO +# version chain, so the writers the walk steps over must be reported too. +# +# Permutations 1-2 are update-conflict-out.spec. Permutations 3-4 are a write +# skew whose read goes through an index and is served a version rebuilt from +# UNDO, past one committed in-place UPDATE (3) and past a committed and a +# running one (4). Permutations 5-6 are receipt-report.spec's schedules +# where a read-only transaction sees a state no serial order produces. + +setup +{ + CREATE TABLE txn0 (id int4 PRIMARY KEY, val text) USING heap; + CREATE TABLE txn1 (id int4 PRIMARY KEY, val text) USING heap; + CREATE TABLE ws (id int PRIMARY KEY, v int) USING heap; + INSERT INTO ws VALUES (1, 0), (2, 0), (3, 0); + CREATE TABLE ctl (k text NOT NULL PRIMARY KEY, deposit_date date NOT NULL) USING heap; + INSERT INTO ctl VALUES ('receipt', DATE '2008-12-22'); + CREATE TABLE receipt (receipt_no int NOT NULL PRIMARY KEY, deposit_date date NOT NULL, amount numeric(13,2)) USING heap; + INSERT INTO receipt VALUES (1, DATE '2008-12-22', 1.00), (2, DATE '2008-12-22', 2.00); +} + +teardown +{ + DROP TABLE txn0, txn1, ws, ctl, receipt; +} + +session foo +step foo_begin { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step foo_select { SELECT * FROM txn0 WHERE id = 42; } +step foo_insert { INSERT INTO txn1 SELECT 7, 'foo_insert'; } +step foo_commit { COMMIT; } + +session bar +step bar_begin { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step bar_select { SELECT * FROM txn1 WHERE id = 7; } +step bar_insert { INSERT INTO txn0 SELECT 42, 'bar_insert'; } +step bar_commit { COMMIT; } + +session trouble +step trouble_begin { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step trouble_update { UPDATE txn1 SET val = 'add physical version for "bar_select"' WHERE id = 7; } +step trouble_delete { DELETE FROM txn1 WHERE id = 7; } +step trouble_abort { ABORT; } + +session w1 +step w1b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step w1snap { SELECT v FROM ws WHERE id = 3; } +step w1r1 { SELECT v FROM ws WHERE id = 1; } +step w1w2 { UPDATE ws SET v = v + 1 WHERE id = 2; } +step w1c { COMMIT; } + +session w2 +step w2b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step w2r2 { SELECT v FROM ws WHERE id = 2; } +step w2w1 { UPDATE ws SET v = v + 10 WHERE id = 1; } +step w2c { COMMIT; } + +session w3 +step w3b { BEGIN; } +step w3w1 { UPDATE ws SET v = v + 100 WHERE id = 1; } +step w3a { ABORT; } + +session s1 +step s1b { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step rxwy1 { INSERT INTO receipt VALUES (3, (SELECT deposit_date FROM ctl WHERE k = 'receipt'), 4.00); } +step c1 { COMMIT; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step wx2 { UPDATE ctl SET deposit_date = DATE '2008-12-23' WHERE k = 'receipt'; } +step c2 { COMMIT; } + +session s3 +step s3b { BEGIN ISOLATION LEVEL SERIALIZABLE, READ ONLY; } +step rx3 { SELECT * FROM ctl WHERE k = 'receipt'; } +step ry3 { SELECT * FROM receipt WHERE deposit_date = DATE '2008-12-22'; } +step c3 { COMMIT; } + +permutation foo_begin bar_begin trouble_begin + foo_select bar_insert foo_insert foo_commit + trouble_update bar_select bar_commit trouble_abort + +permutation foo_begin bar_begin trouble_begin + foo_select bar_insert foo_insert foo_commit + trouble_delete bar_select bar_commit trouble_abort + +permutation w1b w1snap w2b w2r2 w2w1 w2c w1r1 w1w2 w1c + +permutation w1b w1snap w2b w2r2 w2w1 w2c w3b w3w1 w1r1 w1w2 w1c w3a + +permutation s1b s2b s3b rxwy1 wx2 c2 rx3 c1 ry3 c3 + +permutation s1b s2b s3b wx2 rxwy1 c2 rx3 c1 ry3 c3 diff --git a/src/test/isolation/specs/heap_ssi_copy.spec b/src/test/isolation/specs/heap_ssi_copy.spec new file mode 100644 index 0000000000000..f7ae127740805 --- /dev/null +++ b/src/test/isolation/specs/heap_ssi_copy.spec @@ -0,0 +1,18 @@ +# Bulk INSERT must check relation predicate locks, not promoted page locks. +setup +{ + CREATE TABLE a (id int) USING heap; + CREATE TABLE b (id int) USING heap; +} +teardown { DROP TABLE a, b; } +session s1 +step b1 { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step r1 { SELECT count(*) FROM a; } +step w1 { COPY b FROM PROGRAM 'printf "1\n"'; } +step c1 { COMMIT; } +session s2 +step b2 { BEGIN ISOLATION LEVEL SERIALIZABLE; } +step r2 { SELECT count(*) FROM b; } +step w2 { COPY a FROM PROGRAM 'printf "2\n"'; } +step c2 { COMMIT; } +permutation b1 b2 r1 r2 w1 c1 w2 c2 diff --git a/src/test/isolation/specs/heap_ssi_revive.spec b/src/test/isolation/specs/heap_ssi_revive.spec new file mode 100644 index 0000000000000..68a7b9240b4fd --- /dev/null +++ b/src/test/isolation/specs/heap_ssi_revive.spec @@ -0,0 +1,54 @@ +# Heap control for flux_ssi_revive.spec. +# flux_ssi_revive.spec runs the same steps on FLUX; the two expected +# outputs are identical. +# +# s3 (read committed) moves row 1 to k = 1 and away again, so FLUX keeps a +# delete-marked (1, TID) entry in t_k. When s2 moves the row back to k = 1, +# FLUX revives that entry instead of inserting a new one. An insert reports +# a rw-conflict to every serializable reader holding a predicate lock on the +# leaf page it lands on, which is what protects a key-range read from a +# phantom; the revive must report it the same way. +# +# Permutations 1-2: s1 reads k = 1 before the tombstone exists, so it holds +# only the leaf page lock; s1 and s2 then form a write skew through u, and +# one of them must fail. Permutation 3: the tombstone exists when s1 reads +# k = 1, so s1 also locks row 1 through it. Permutation 4: s1 reads another +# key on the same leaf; heap reports the (false positive) conflict through +# the page lock, and FLUX does the same. + +setup +{ + CREATE TABLE t (id int PRIMARY KEY, k int, v int) USING heap; + CREATE INDEX t_k ON t (k); + INSERT INTO t VALUES (1, 3, 0), (2, 5, 0), (3, 7, 0); + CREATE TABLE u (id int PRIMARY KEY, v int) USING heap; + INSERT INTO u VALUES (1, 0); +} + +teardown +{ + DROP TABLE t, u; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step s1r { SELECT count(*) FROM t WHERE k = 1; } +step s1r5 { SELECT count(*) FROM t WHERE k = 5; } +step s1w { UPDATE u SET v = v + 1 WHERE id = 1; } +step s1c { COMMIT; } + +session s2 +step s2b { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL enable_seqscan = off; SET LOCAL enable_bitmapscan = off; } +step s2r { SELECT v FROM u WHERE id = 1; } +step s2w { UPDATE t SET k = 1 WHERE id = 1; } +step s2c { COMMIT; } + +session s3 +step s3a { UPDATE t SET k = 1 WHERE id = 1; } +step s3b { UPDATE t SET k = 2 WHERE id = 1; } +step s3chk { SELECT id, k FROM t ORDER BY id; SELECT * FROM u; } + +permutation s1b s1r s3a s3b s2b s2r s2w s1w s1c s2c s3chk +permutation s1b s1r s3a s3b s2b s2r s2w s2c s1w s1c s3chk +permutation s3a s3b s1b s1r s2b s2r s2w s1w s1c s2c s3chk +permutation s3a s3b s1b s1r5 s2b s2r s2w s1w s1c s2c s3chk diff --git a/src/test/isolation/specs/heap_tid_fetch_snapshot.spec b/src/test/isolation/specs/heap_tid_fetch_snapshot.spec new file mode 100644 index 0000000000000..54b9c0917f055 --- /dev/null +++ b/src/test/isolation/specs/heap_tid_fetch_snapshot.spec @@ -0,0 +1,40 @@ +# heap control for flux_tid_fetch_snapshot.spec, which runs the same steps +# on FLUX; the two expected outputs are identical. +setup +{ + CREATE TABLE acc (id text PRIMARY KEY, bal int) USING heap; + INSERT INTO acc VALUES ('checking', 600), ('savings', 600); + CREATE TABLE oth (id text, v int) USING heap; + INSERT INTO oth VALUES ('checking', 1), ('savings', 1); +} +teardown +{ + DROP TABLE acc, oth; +} + +session s1 +step s1b { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1brr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s1sel { SELECT * FROM acc ORDER BY id; } +step s1lock { SELECT * FROM acc a1, acc a2 WHERE a1.id = a2.id ORDER BY a1.id FOR UPDATE OF a1; } +step s1tid { SELECT * FROM acc WHERE ctid = '(0,1)'; } +step s1join { SELECT * FROM acc a JOIN oth o USING (id) ORDER BY id FOR UPDATE OF a; } +step s1c { COMMIT; } + +session s2 +step s2u { UPDATE acc SET bal = bal + 450 WHERE id = 'checking'; } +step s2d { DELETE FROM acc WHERE id = 'checking'; } +step s2uo { UPDATE oth SET v = v + 10 WHERE id = 'checking'; } +step s2do { DELETE FROM oth WHERE id = 'checking'; } +step s2b { BEGIN; } +step s2c { COMMIT; } + +# The recheck sees a2 as the query did: 600, beside a1's new 1050. +permutation s2b s2u s1b s1lock s2c s1c +# The unlocked side is another table, updated or deleted concurrently. +permutation s2b s2u s2uo s1b s1join s2c s1c +permutation s2b s2u s2do s1b s1join s2c s1c +# A TID scan sees the version its snapshot sees, updated or deleted since. +permutation s1brr s1sel s2u s1tid s1c +permutation s1brr s1sel s2d s1tid s1c +permutation s1brr s1sel s2u s2u s1tid s1c diff --git a/src/test/isolation/specs/heap_update_deadlock.spec b/src/test/isolation/specs/heap_update_deadlock.spec new file mode 100644 index 0000000000000..daa77589c510e --- /dev/null +++ b/src/test/isolation/specs/heap_update_deadlock.spec @@ -0,0 +1,52 @@ +# heap control for flux_update_deadlock.spec: same steps, same expected output. +# FLUX: concurrent two-row UPDATE transactions that deadlock. The deadlock +# victim's increments must be rolled back and every survivor's must land +# exactly once, as in heap. hist records the increments of the transactions +# that commit, in the same transaction. + +setup +{ + CREATE TABLE fdl (id int PRIMARY KEY, v int) USING heap; + INSERT INTO fdl VALUES (1, 0), (2, 0); + CREATE TABLE fdl_hist (id int, who text); +} + +teardown +{ + DROP TABLE fdl, fdl_hist; +} + +session s1 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s1u1 { UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's1'); } +step s1u2 { UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's1'); } +step s1c { COMMIT; } + +session s2 +setup { SET deadlock_timeout = '500ms'; BEGIN; } +step s2u1 { UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's2'); } +step s2u2 { UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's2'); } +step s2c { COMMIT; } + +session s3 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s3u1 { UPDATE fdl SET v = v + 1 WHERE id = 1; INSERT INTO fdl_hist VALUES (1, 's3'); } +step s3u2 { UPDATE fdl SET v = v + 1 WHERE id = 2; INSERT INTO fdl_hist VALUES (2, 's3'); } +step s3c { COMMIT; } + +session s4 +step chk { SELECT id, v, (SELECT count(*) FROM fdl_hist h WHERE h.id = f.id) AS committed FROM fdl f ORDER BY id; } + +# s2 runs with a deadlock_timeout far below the others' but far above the +# few milliseconds the tester needs to issue the step that closes the cycle, +# so s2 is always the one that detects it and is the victim. Commits are +# ordered so that no session is asked to commit while it still waits. +# +# Two-way deadlock, s2 is the victim; s1 survives. +permutation s1u1 s2u2 s1u2 s2u1 s1c s2c s3c chk +# A third transaction waits on row 1 behind s1 before the cycle forms. +permutation s1u1 s2u2 s3u1 s1u2 s2u1 s1c s2c s3c chk +# The victim holds the row a waiter is queued on. +permutation s2u1 s1u2 s3u1 s2u2 s1u1 s3c s1c s2c chk +# Victim's row 2 has a queued waiter too. +permutation s1u1 s2u2 s3u2 s1u2 s2u1 s3c s1c s2c chk diff --git a/src/test/isolation/specs/heap_update_key_waiter.spec b/src/test/isolation/specs/heap_update_key_waiter.spec new file mode 100644 index 0000000000000..adaca147f0e0e --- /dev/null +++ b/src/test/isolation/specs/heap_update_key_waiter.spec @@ -0,0 +1,50 @@ +# heap control for flux_update_key_waiter.spec: same steps, same expected +# output. An UPDATE that waits for a concurrent writer of the same row +# decides its index changes from the row the writer leaves behind. +setup +{ + CREATE TABLE kw (id int, k int, v int) USING heap; + CREATE INDEX kw_k ON kw (k); + INSERT INTO kw SELECT g, g, 0 FROM generate_series(1, 5) g; +} +teardown +{ + DROP TABLE kw; +} +session s1 +step s1b { BEGIN; } +step s1k { UPDATE kw SET k = 100 WHERE id = 3; } +step s1v { UPDATE kw SET v = 1 WHERE id = 3; } +step s1d { DELETE FROM kw WHERE id = 3; } +step s1r { ROLLBACK; } +step s1c { COMMIT; } +session s2 +step s2b { BEGIN; } +step s2k { UPDATE kw SET k = 100 WHERE id = 3; } +step s2k2 { UPDATE kw SET k = 200 WHERE id = 3; } +step s2v { UPDATE kw SET v = 2 WHERE id = 3; } +step s2c { COMMIT; } +session s3 +step chk { + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT id, k, v FROM kw WHERE id = 3; +} + +# The waiter's new key equals the aborted writer's key. +permutation s1b s1k s2b s2k s1r s2c chk +permutation s1b s1k s2b s2k s1c s2c chk +# The waiter changes the key to another value. +permutation s1b s1k s2b s2k2 s1r s2c chk +permutation s1b s1k s2b s2k2 s1c s2c chk +# The writer changed the key, the waiter changes only a non-key column. +permutation s1b s1k s2b s2v s1r s2c chk +permutation s1b s1k s2b s2v s1c s2c chk +# The writer changed a non-key column, the waiter changes the key. +permutation s1b s1v s2b s2k s1r s2c chk +permutation s1b s1v s2b s2k s1c s2c chk +# The writer deleted the row and rolls back. +permutation s1b s1d s2b s2k s1r s2c chk +permutation s1b s1d s2b s2k s1c s2c chk diff --git a/src/test/isolation/specs/heap_update_serialize.spec b/src/test/isolation/specs/heap_update_serialize.spec new file mode 100644 index 0000000000000..355ab725edf5e --- /dev/null +++ b/src/test/isolation/specs/heap_update_serialize.spec @@ -0,0 +1,67 @@ +# heap control for flux_update_serialize.spec: same steps, same expected output. +# FLUX: concurrent UPDATE serialization must match heap, and a plain UPDATE +# must not hold a heavyweight tuple lock until commit. +# +# FLUX updates rows in place at a stable TID. The in-progress updater is the +# on-page t_xmin of the new image (FLUX_TUPLE_UPDATED | FLUX_TUPLE_UNCOMMITTED), +# which is what a concurrent updater must wait on, exactly as heap waits on +# xmax. The expected output is heap's (the same spec with USING heap). + +setup +{ + CREATE TABLE fus (id int PRIMARY KEY, v int) USING heap; + INSERT INTO fus VALUES (1, 0); + CREATE TABLE fus_big (id int PRIMARY KEY, v int) USING heap; + INSERT INTO fus_big SELECT g, 0 FROM generate_series(1, 20000) g; +} + +teardown +{ + DROP TABLE fus, fus_big; +} + +session s1 +step s1rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s1u { UPDATE fus SET v = v + 1 WHERE id = 1; } +step s1ks { SELECT id FROM fus WHERE id = 1 FOR KEY SHARE; } +step s1fu { SELECT id FROM fus WHERE id = 1 FOR UPDATE; } +step s1oc { INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; } +step s1c { COMMIT; } +step s1a { ROLLBACK; } +# A whole-table UPDATE must not take one transaction-duration lock per row: +# 20000 rows exhaust the default lock table ("out of shared memory"). +step s1big { UPDATE fus_big SET v = v + 1; } +step s1bigsum { SELECT sum(v) FROM fus_big; } +# No tuple lock may survive the UPDATE statement (heap holds none). +step s1locks { SELECT count(*) AS tuple_locks FROM pg_locks WHERE locktype = 'tuple' AND pid = pg_backend_pid(); } + +session s2 +step s2rc { BEGIN ISOLATION LEVEL READ COMMITTED; } +step s2rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2snap { SELECT v FROM fus WHERE id = 1; } +step s2u { UPDATE fus SET v = v + 1 WHERE id = 1; } +step s2oc { INSERT INTO fus VALUES (1, 0) ON CONFLICT (id) DO UPDATE SET v = fus.v + 1; } +step s2c { COMMIT; } + +session s3 +step s3r { SELECT v FROM fus WHERE id = 1; } + +# READ COMMITTED: s2 waits for s1, then re-evaluates on s1's value -> 2. +permutation s1rc s2rc s1u s2u s1c s2c s3r +# READ COMMITTED, s1 rolls back: s2 proceeds on the original value -> 1. +permutation s1rc s2rc s1u s2u s1a s2c s3r +# REPEATABLE READ, s1 commits while s2 waits: serialization failure -> 1. +permutation s1rc s2rr s2snap s1u s2u s1c s2c s3r +# REPEATABLE READ, s1 rolls back while s2 waits: s2 succeeds -> 1. +permutation s1rc s2rr s2snap s1u s2u s1a s2c s3r +# REPEATABLE READ, s1 committed after s2's snapshot: immediate failure -> 1. +permutation s1rc s2rr s2snap s1u s1c s2u s2c s3r +# s1 holds only a KEY SHARE lock, so s2's no-key UPDATE proceeds; s1's own +# later UPDATE must then wait for s2 and re-evaluate (no lost update) -> 2. +permutation s1rc s2rc s1ks s2u s1u s2c s1c s3r +# FOR UPDATE blocks a concurrent UPDATE until commit -> 2. +permutation s1rc s2rc s1fu s2u s1u s1c s2c s3r +# Concurrent ON CONFLICT DO UPDATE on the same key -> 2. +permutation s1rc s2rc s1oc s2oc s1c s2c s3r +# Bulk UPDATE at default settings, then no tuple locks left in the xact. +permutation s1rc s1big s1locks s1c s1bigsum diff --git a/src/test/isolation/specs/heap_update_victim.spec b/src/test/isolation/specs/heap_update_victim.spec new file mode 100644 index 0000000000000..92ce8259d21d2 --- /dev/null +++ b/src/test/isolation/specs/heap_update_victim.spec @@ -0,0 +1,48 @@ +# heap control for flux_update_victim.spec: same steps, same expected output. +# FLUX: an UPDATE that waited for a row and then became a deadlock victim +# must not leave its increments behind. Every value must equal the number +# of committed increments recorded in fdv_hist (same transaction), as in +# heap. + +setup +{ + CREATE TABLE fdv (id int PRIMARY KEY, v int) USING heap; + INSERT INTO fdv VALUES (1, 0), (2, 0); + CREATE TABLE fdv_hist (id int); +} + +teardown +{ + DROP TABLE fdv, fdv_hist; +} + +session s1 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s1u1 { UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); } +step s1u2 { UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); } +step s1c { COMMIT; } + +session s2 +setup { SET deadlock_timeout = '10ms'; BEGIN; } +step s2u1 { UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); } +step s2u2 { UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); } +step s2c { COMMIT; } + +session s3 +setup { SET deadlock_timeout = '10s'; BEGIN; } +step s3u1 { UPDATE fdv SET v = v + 1 WHERE id = 1; INSERT INTO fdv_hist VALUES (1); } +step s3u2 { UPDATE fdv SET v = v + 1 WHERE id = 2; INSERT INTO fdv_hist VALUES (2); } +step s3c { COMMIT; } + +session s4 +step chk { SELECT f.id, f.v, count(h.id) AS committed FROM fdv f LEFT JOIN fdv_hist h USING (id) GROUP BY f.id, f.v ORDER BY f.id; } + +# s2 waits for s1 on row 1, after updating row 2; then s1 waits on row 2 +# -> s2 is the victim (short deadlock_timeout) while its row-1 wait is +# granted the row only after s1's commit. +permutation s2u2 s1u1 s2u1 s1u2 s1c s2c chk +# Same, with s3 queued on row 2 behind s2. +permutation s2u2 s1u1 s3u2 s2u1 s1u2 s1c s2c s3c chk +# s2's first update waited on s1 and succeeded after s1 committed; its +# second update then deadlocks with s3. +permutation s1u1 s3u2 s2u1 s1c s2u2 s3u1 s2c s3c chk diff --git a/src/test/isolation/specs/heap_update_waiter_abort.spec b/src/test/isolation/specs/heap_update_waiter_abort.spec new file mode 100644 index 0000000000000..e6851473d56c1 --- /dev/null +++ b/src/test/isolation/specs/heap_update_waiter_abort.spec @@ -0,0 +1,75 @@ +# heap control for flux_update_waiter_abort.spec: same steps, same expected +# output. A row change that waits for a concurrent writer of the same row +# works on the committed new version if the writer commits, and on the version +# the writer replaced if it aborts. +setup +{ + CREATE TABLE vh (id int PRIMARY KEY, v int, w int) USING heap; + INSERT INTO vh VALUES (1, 0, 0), (2, 0, 0); + CREATE TABLE vh_hist (id int); +} +teardown +{ + DROP TABLE vh, vh_hist; +} +session s1 +setup { SET deadlock_timeout = '10ms'; } +step s1b { BEGIN; } +step s1w { UPDATE vh SET w = 10 WHERE id = 1; } +step s1w2 { UPDATE vh SET w = 20 WHERE id = 1; } +step s1k { UPDATE vh SET id = 10 WHERE id = 1; } +step s1sp { SAVEPOINT a; } +step s1rs { ROLLBACK TO a; } +step s1u1 { UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); } +step s1u2 { UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); } +step s1r { ROLLBACK; } +step s1c { COMMIT; } +session s2 +setup { SET deadlock_timeout = '10s'; } +step s2b { BEGIN; } +step s2rr { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step s2u { UPDATE vh SET v = v + 1 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; } +step s2w5 { UPDATE vh SET w = 5 WHERE id = 1; } +step s2d { DELETE FROM vh WHERE id = 1 RETURNING *; } +step s2l { SELECT * FROM vh WHERE id = 1 FOR UPDATE; } +step s2h { INSERT INTO vh_hist VALUES (1); } +step s2c { COMMIT; } +session s3 +setup { SET deadlock_timeout = '10s'; } +step s3b { BEGIN; } +step s3u { UPDATE vh SET v = v + 10 WHERE id = 1 RETURNING old.v, old.w, new.v, new.w; } +step s3u2 { UPDATE vh SET v = v + 1 WHERE id = 2; INSERT INTO vh_hist VALUES (2); } +step s3u1 { UPDATE vh SET v = v + 1 WHERE id = 1; INSERT INTO vh_hist VALUES (1); } +step s3c { COMMIT; } +session s4 +step chk { SELECT * FROM vh ORDER BY id; } +step chkh { SELECT f.id, f.v, f.w, count(h.id) AS committed FROM vh f LEFT JOIN vh_hist h USING (id) GROUP BY f.id, f.v, f.w ORDER BY f.id; } + +# READ COMMITTED UPDATE waiter: the writer commits (re-fetch: OLD w = 10), +# or aborts (OLD w = 0). +permutation s1b s1w s2b s2u s1c s2c chk +permutation s1b s1w s2b s2u s1r s2c chk +# REPEATABLE READ: commit is a serialization failure, abort succeeds. +permutation s1b s1w s2rr s2u s1c s2c chk +permutation s1b s1w s2rr s2u s1r s2c chk +# The writer's change is in a savepoint rolled back while the update waits; +# then with an earlier change outside the savepoint, which commits. +permutation s1b s1sp s1w s2b s2u s1rs s1c s2c chk +permutation s1b s1w s1sp s1w2 s2b s2u s1rs s1c s2c chk +# DELETE ... RETURNING waiters, and a SELECT ... FOR UPDATE waiter whose +# writer aborts. (FOR UPDATE behind a writer that commits is +# flux_lock_waiter_commit.) +permutation s1b s1w s2b s2d s1c s2c chk +permutation s1b s1w s2b s2d s1r s2c chk +permutation s1b s1w s2b s2l s1r s2c chk +# Two waiters queued behind the writer. +permutation s1b s1w s2b s3b s2u s3u s1c s2c s3c chk +permutation s1b s1w s2b s3b s2u s3u s1r s2c s3c chk +# The writer changes the column the waiter's WHERE clause tests. +permutation s1b s1k s2b s2u s1c s2c chk +permutation s1b s1k s2b s2u s1r s2c chk +# The on-page version is our own earlier update. +permutation s2b s2w5 s2u s2c chk +# s1->s3 (row 2), s3->s2 (row 1, queued), s2->s1 (row 1): s1 is the deadlock +# victim; s2 must see OLD v = 0, not the victim's 1. +permutation s1b s2b s3b s1u1 s3u2 s2u(s1u2) s3u1 s1u2(*) s1c s2h s2c s3c chkh diff --git a/src/test/isolation/specs/heap_vacuum_move.spec b/src/test/isolation/specs/heap_vacuum_move.spec new file mode 100644 index 0000000000000..33cc6f3ab4bfb --- /dev/null +++ b/src/test/isolation/specs/heap_vacuum_move.spec @@ -0,0 +1,45 @@ +# heap control for flux_vacuum_move: same steps, same +# output. Ordinary VACUUM must not move a row out from under its writer. +# +# s1 updates the last row in place and keeps the transaction open. s2 +# VACUUMs the table (lower pages are empty after setup's DELETE) and inserts +# enough rows to reuse any slot VACUUM might have freed. s1 must still see +# exactly its own row, and after it ends every row must be intact. + +setup +{ + CREATE TABLE hvmove (id int, v int, pad text) USING heap WITH (autovacuum_enabled = off); + CREATE INDEX hvmove_id ON hvmove (id); + INSERT INTO hvmove SELECT g, 0, repeat('x', 200) FROM generate_series(1, 400) g; + DELETE FROM hvmove WHERE id <= 300; +} +setup { VACUUM hvmove; } +setup { VACUUM hvmove; } + +teardown +{ + DROP TABLE hvmove; +} + +session s1 +setup { SET enable_seqscan = off; SET enable_bitmapscan = off; } +step s1_upd { BEGIN; UPDATE hvmove SET v = 7 WHERE id = 400; } +step s1_idx { SELECT id, v FROM hvmove WHERE id = 400; } +step s1_bmp { SET enable_indexscan = off; SET enable_bitmapscan = on; SELECT id, v FROM hvmove WHERE id = 400; } +step s1_commit { COMMIT; } +step s1_rollback { ROLLBACK; } + +session s2 +step s2_vac { VACUUM hvmove; } +step s2_ins { INSERT INTO hvmove SELECT g, 100, repeat('n', 200) FROM generate_series(9000, 9800) g; } +step s2_check +{ + SET enable_indexscan = off; SET enable_bitmapscan = off; + SELECT count(*) AS n, count(DISTINCT id) AS ids, sum(v) AS sumv, + count(*) FILTER (WHERE id = 400) AS n400, + max(v) FILTER (WHERE id = 400) AS v400 + FROM hvmove; +} + +permutation s1_upd s2_vac s2_ins s1_idx s1_bmp s1_rollback s2_check +permutation s1_upd s2_vac s2_ins s1_idx s1_commit s2_check diff --git a/src/test/modules/brin/Makefile b/src/test/modules/brin/Makefile index e74af891ce7f6..4f81a53b40936 100644 --- a/src/test/modules/brin/Makefile +++ b/src/test/modules/brin/Makefile @@ -2,7 +2,9 @@ EXTRA_INSTALL = contrib/pageinspect contrib/pg_walinspect -ISOLATION = summarization-and-inprogress-insertion +REGRESS = flux_summarize +ISOLATION = summarization-and-inprogress-insertion \ + flux-summarization-and-inprogress-insertion TAP_TESTS = 1 ifdef USE_PGXS diff --git a/src/test/modules/brin/expected/flux-summarization-and-inprogress-insertion.out b/src/test/modules/brin/expected/flux-summarization-and-inprogress-insertion.out new file mode 100644 index 0000000000000..201786c82c001 --- /dev/null +++ b/src/test/modules/brin/expected/flux-summarization-and-inprogress-insertion.out @@ -0,0 +1,51 @@ +Parsed test spec with 2 sessions + +starting permutation: s2check s1b s2b s1i s2summ s1c s2c s2check +step s2check: SELECT * FROM brin_page_items(get_raw_page('brinidx', 2), 'brinidx'::regclass); +itemoffset|blknum|attnum|allnulls|hasnulls|placeholder|empty|value +----------+------+------+--------+--------+-----------+-----+-------- + 1| 0| 1|f |t |f |f |{1 .. 1} +(1 row) + +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s2b: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1; +?column? +-------- + 1 +(1 row) + +step s1i: INSERT INTO brin_iso VALUES (1000); +step s2summ: SELECT brin_summarize_new_values('brinidx'::regclass); +brin_summarize_new_values +------------------------- + 1 +(1 row) + +step s1c: COMMIT; +step s2c: COMMIT; +step s2check: SELECT * FROM brin_page_items(get_raw_page('brinidx', 2), 'brinidx'::regclass); +itemoffset|blknum|attnum|allnulls|hasnulls|placeholder|empty|value +----------+------+------+--------+--------+-----------+-----+----------- + 1| 0| 1|f |t |f |f |{1 .. 1} + 2| 1| 1|f |f |f |f |{1 .. 1000} +(2 rows) + + +starting permutation: s2check s1b s1i s2vacuum s1c s2check +step s2check: SELECT * FROM brin_page_items(get_raw_page('brinidx', 2), 'brinidx'::regclass); +itemoffset|blknum|attnum|allnulls|hasnulls|placeholder|empty|value +----------+------+------+--------+--------+-----------+-----+-------- + 1| 0| 1|f |t |f |f |{1 .. 1} +(1 row) + +step s1b: BEGIN ISOLATION LEVEL REPEATABLE READ; +step s1i: INSERT INTO brin_iso VALUES (1000); +step s2vacuum: VACUUM brin_iso; +step s1c: COMMIT; +step s2check: SELECT * FROM brin_page_items(get_raw_page('brinidx', 2), 'brinidx'::regclass); +itemoffset|blknum|attnum|allnulls|hasnulls|placeholder|empty|value +----------+------+------+--------+--------+-----------+-----+----------- + 1| 0| 1|f |t |f |f |{1 .. 1} + 2| 1| 1|f |f |f |f |{1 .. 1000} +(2 rows) + diff --git a/src/test/modules/brin/expected/flux_summarize.out b/src/test/modules/brin/expected/flux_summarize.out new file mode 100644 index 0000000000000..acb074bcec6d3 --- /dev/null +++ b/src/test/modules/brin/expected/flux_summarize.out @@ -0,0 +1,177 @@ +-- +-- BRIN summarization of one block range on a FLUX table, with a heap twin. +-- +-- VACUUM, brin_summarize_new_values and brin_summarize_range summarize a +-- range through table_index_build_range_scan with that range's blocks; the +-- table AM must scan only those. Each summary item must then equal the +-- minimum and maximum of the rows in its range, there must be one item per +-- range, and a BRIN bitmap scan must find every row. +-- +CREATE EXTENSION pageinspect; +-- Items of a BRIN index on column v that are not the min/max of the rows in +-- their range ("wrong"), and items beyond one per range ("dup"). +CREATE FUNCTION brin_check(tbl regclass, idx regclass, ppr int, + OUT wrong bigint, OUT dup bigint) +LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format($q$ + WITH items AS ( + SELECT i.blknum, i.value + FROM generate_series(1, (pg_relation_size(%2$L::regclass) / + current_setting('block_size')::int)::int - 1) p, + LATERAL get_raw_page(%2$L, p) g(pg), + LATERAL brin_page_items(g.pg, %2$L::regclass) i + WHERE brin_page_type(g.pg) = 'regular' AND NOT i.placeholder), + ranges AS ( + SELECT (ctid::text::point)[0]::bigint / %3$s * %3$s AS blknum, + '{' || min(v) || ' .. ' || max(v) || '}' AS value + FROM %1$s GROUP BY 1) + SELECT (SELECT count(*) FROM (SELECT * FROM items + EXCEPT ALL SELECT * FROM ranges) x), + (SELECT count(*) - count(DISTINCT blknum) FROM items)$q$, + tbl, idx, ppr) + INTO wrong, dup; +END $$; +-- The text key of row g. +CREATE FUNCTION brin_key(g int) RETURNS text IMMUTABLE LANGUAGE sql + RETURN lpad(g::text, 8, '0') || md5(g::text) || md5(g::text || 'a'); +-- Text keys, ranges summarized by VACUUM: a varlena summary does not fit in +-- place, so a summary of the wrong blocks lands elsewhere and the revmap +-- points at it. +CREATE TABLE brin_t_heap (k int, v text) USING heap; +CREATE TABLE brin_t_flux (k int, v text) USING flux; +CREATE INDEX brin_t_heap_idx ON brin_t_heap USING brin (v) WITH (pages_per_range = 4); +CREATE INDEX brin_t_flux_idx ON brin_t_flux USING brin (v) WITH (pages_per_range = 4); +INSERT INTO brin_t_heap SELECT g, brin_key(g) FROM generate_series(1, 20000) g; +INSERT INTO brin_t_flux SELECT g, brin_key(g) FROM generate_series(1, 20000) g; +VACUUM brin_t_heap, brin_t_flux; +SELECT * FROM brin_check('brin_t_heap', 'brin_t_heap_idx', 4); + wrong | dup +-------+----- + 0 | 0 +(1 row) + +SELECT * FROM brin_check('brin_t_flux', 'brin_t_flux_idx', 4); + wrong | dup +-------+----- + 0 | 0 +(1 row) + +SET enable_seqscan = off; +SET enable_indexscan = off; +EXPLAIN (COSTS OFF) SELECT k FROM brin_t_flux WHERE v = brin_key(300); + QUERY PLAN +------------------------------------------------------------------------------------------------------------ + Bitmap Heap Scan on brin_t_flux + Recheck Cond: (v = '0000030094f6d7e04a4d452035300f18b984988cff0b2007a9ecc1bba0a8ef8528e80653'::text) + -> Bitmap Index Scan on brin_t_flux_idx + Index Cond: (v = '0000030094f6d7e04a4d452035300f18b984988cff0b2007a9ecc1bba0a8ef8528e80653'::text) +(4 rows) + +SELECT count(*) AS missed FROM generate_series(100, 20000, 100) x + WHERE NOT EXISTS (SELECT FROM brin_t_heap t WHERE t.v = brin_key(x)); + missed +-------- + 0 +(1 row) + +SELECT count(*) AS missed FROM generate_series(100, 20000, 100) x + WHERE NOT EXISTS (SELECT FROM brin_t_flux t WHERE t.v = brin_key(x)); + missed +-------- + 0 +(1 row) + +RESET enable_seqscan; +RESET enable_indexscan; +-- Integer keys, one range desummarized and summarized again. +CREATE TABLE brin_i_heap (v int) USING heap; +CREATE TABLE brin_i_flux (v int) USING flux; +INSERT INTO brin_i_heap SELECT generate_series(1, 20000); +INSERT INTO brin_i_flux SELECT generate_series(1, 20000); +CREATE INDEX brin_i_heap_idx ON brin_i_heap USING brin (v) WITH (pages_per_range = 1); +CREATE INDEX brin_i_flux_idx ON brin_i_flux USING brin (v) WITH (pages_per_range = 1); +SELECT brin_desummarize_range('brin_i_heap_idx', 10); + brin_desummarize_range +------------------------ + +(1 row) + +SELECT brin_desummarize_range('brin_i_flux_idx', 10); + brin_desummarize_range +------------------------ + +(1 row) + +SELECT brin_summarize_range('brin_i_heap_idx', 10); + brin_summarize_range +---------------------- + 1 +(1 row) + +SELECT brin_summarize_range('brin_i_flux_idx', 10); + brin_summarize_range +---------------------- + 1 +(1 row) + +SELECT * FROM brin_check('brin_i_heap', 'brin_i_heap_idx', 1); + wrong | dup +-------+----- + 0 | 0 +(1 row) + +SELECT * FROM brin_check('brin_i_flux', 'brin_i_flux_idx', 1); + wrong | dup +-------+----- + 0 | 0 +(1 row) + +-- New ranges, summarized by brin_summarize_new_values. +INSERT INTO brin_i_heap SELECT generate_series(20001, 30000); +INSERT INTO brin_i_flux SELECT generate_series(20001, 30000); +SELECT brin_summarize_new_values('brin_i_heap_idx') > 0 AS summarized; + summarized +------------ + t +(1 row) + +SELECT brin_summarize_new_values('brin_i_flux_idx') > 0 AS summarized; + summarized +------------ + t +(1 row) + +SELECT * FROM brin_check('brin_i_heap', 'brin_i_heap_idx', 1); + wrong | dup +-------+----- + 0 | 0 +(1 row) + +SELECT * FROM brin_check('brin_i_flux', 'brin_i_flux_idx', 1); + wrong | dup +-------+----- + 0 | 0 +(1 row) + +SET enable_seqscan = off; +SET enable_indexscan = off; +SELECT count(*) AS missed FROM generate_series(1, 30000, 97) x + WHERE NOT EXISTS (SELECT FROM brin_i_heap t WHERE t.v = x); + missed +-------- + 0 +(1 row) + +SELECT count(*) AS missed FROM generate_series(1, 30000, 97) x + WHERE NOT EXISTS (SELECT FROM brin_i_flux t WHERE t.v = x); + missed +-------- + 0 +(1 row) + +RESET enable_seqscan; +RESET enable_indexscan; +DROP TABLE brin_t_heap, brin_t_flux, brin_i_heap, brin_i_flux; +DROP FUNCTION brin_check, brin_key; +DROP EXTENSION pageinspect; diff --git a/src/test/modules/brin/meson.build b/src/test/modules/brin/meson.build index 39a8b2fc925c0..f923d302c468a 100644 --- a/src/test/modules/brin/meson.build +++ b/src/test/modules/brin/meson.build @@ -4,9 +4,15 @@ tests += { 'name': 'brin', 'sd': meson.current_source_dir(), 'bd': meson.current_build_dir(), + 'regress': { + 'sql': [ + 'flux_summarize', + ], + }, 'isolation': { 'specs': [ 'summarization-and-inprogress-insertion', + 'flux-summarization-and-inprogress-insertion', ], }, 'tap': { diff --git a/src/test/modules/brin/specs/flux-summarization-and-inprogress-insertion.spec b/src/test/modules/brin/specs/flux-summarization-and-inprogress-insertion.spec new file mode 100644 index 0000000000000..136c97803a012 --- /dev/null +++ b/src/test/modules/brin/specs/flux-summarization-and-inprogress-insertion.spec @@ -0,0 +1,46 @@ +# summarization-and-inprogress-insertion on a FLUX table: the same output as +# heap's. Summarizing range 1 must scan only block 1 (the NULL of block 0 +# stays out of its summary), and finding an in-progress insert there is not +# worth a warning in the "any visible" scan that summarization uses. + +setup +{ + CREATE TABLE brin_iso ( + value int + ) USING flux WITH (fillfactor=10); + CREATE INDEX brinidx ON brin_iso USING brin (value) WITH (pages_per_range=1); + -- this fills the first page + INSERT INTO brin_iso VALUES (NULL); + DO $$ + DECLARE curtid tid; + BEGIN + LOOP + INSERT INTO brin_iso VALUES (1) RETURNING ctid INTO curtid; + EXIT WHEN curtid > tid '(1, 0)'; + END LOOP; + END; + $$; + CREATE EXTENSION IF NOT EXISTS pageinspect; +} + +teardown +{ + DROP TABLE brin_iso; +} + +session "s1" +step "s1b" { BEGIN ISOLATION LEVEL REPEATABLE READ; } +step "s1i" { INSERT INTO brin_iso VALUES (1000); } +step "s1c" { COMMIT; } + +session "s2" +step "s2b" { BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1; } +step "s2summ" { SELECT brin_summarize_new_values('brinidx'::regclass); } +step "s2c" { COMMIT; } + +step "s2vacuum" { VACUUM brin_iso; } + +step "s2check" { SELECT * FROM brin_page_items(get_raw_page('brinidx', 2), 'brinidx'::regclass); } + +permutation "s2check" "s1b" "s2b" "s1i" "s2summ" "s1c" "s2c" "s2check" +permutation "s2check" "s1b" "s1i" "s2vacuum" "s1c" "s2check" diff --git a/src/test/modules/brin/sql/flux_summarize.sql b/src/test/modules/brin/sql/flux_summarize.sql new file mode 100644 index 0000000000000..f6dd110a26fe9 --- /dev/null +++ b/src/test/modules/brin/sql/flux_summarize.sql @@ -0,0 +1,97 @@ +-- +-- BRIN summarization of one block range on a FLUX table, with a heap twin. +-- +-- VACUUM, brin_summarize_new_values and brin_summarize_range summarize a +-- range through table_index_build_range_scan with that range's blocks; the +-- table AM must scan only those. Each summary item must then equal the +-- minimum and maximum of the rows in its range, there must be one item per +-- range, and a BRIN bitmap scan must find every row. +-- +CREATE EXTENSION pageinspect; + +-- Items of a BRIN index on column v that are not the min/max of the rows in +-- their range ("wrong"), and items beyond one per range ("dup"). +CREATE FUNCTION brin_check(tbl regclass, idx regclass, ppr int, + OUT wrong bigint, OUT dup bigint) +LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format($q$ + WITH items AS ( + SELECT i.blknum, i.value + FROM generate_series(1, (pg_relation_size(%2$L::regclass) / + current_setting('block_size')::int)::int - 1) p, + LATERAL get_raw_page(%2$L, p) g(pg), + LATERAL brin_page_items(g.pg, %2$L::regclass) i + WHERE brin_page_type(g.pg) = 'regular' AND NOT i.placeholder), + ranges AS ( + SELECT (ctid::text::point)[0]::bigint / %3$s * %3$s AS blknum, + '{' || min(v) || ' .. ' || max(v) || '}' AS value + FROM %1$s GROUP BY 1) + SELECT (SELECT count(*) FROM (SELECT * FROM items + EXCEPT ALL SELECT * FROM ranges) x), + (SELECT count(*) - count(DISTINCT blknum) FROM items)$q$, + tbl, idx, ppr) + INTO wrong, dup; +END $$; + +-- The text key of row g. +CREATE FUNCTION brin_key(g int) RETURNS text IMMUTABLE LANGUAGE sql + RETURN lpad(g::text, 8, '0') || md5(g::text) || md5(g::text || 'a'); + +-- Text keys, ranges summarized by VACUUM: a varlena summary does not fit in +-- place, so a summary of the wrong blocks lands elsewhere and the revmap +-- points at it. +CREATE TABLE brin_t_heap (k int, v text) USING heap; +CREATE TABLE brin_t_flux (k int, v text) USING flux; +CREATE INDEX brin_t_heap_idx ON brin_t_heap USING brin (v) WITH (pages_per_range = 4); +CREATE INDEX brin_t_flux_idx ON brin_t_flux USING brin (v) WITH (pages_per_range = 4); +INSERT INTO brin_t_heap SELECT g, brin_key(g) FROM generate_series(1, 20000) g; +INSERT INTO brin_t_flux SELECT g, brin_key(g) FROM generate_series(1, 20000) g; +VACUUM brin_t_heap, brin_t_flux; +SELECT * FROM brin_check('brin_t_heap', 'brin_t_heap_idx', 4); +SELECT * FROM brin_check('brin_t_flux', 'brin_t_flux_idx', 4); + +SET enable_seqscan = off; +SET enable_indexscan = off; +EXPLAIN (COSTS OFF) SELECT k FROM brin_t_flux WHERE v = brin_key(300); +SELECT count(*) AS missed FROM generate_series(100, 20000, 100) x + WHERE NOT EXISTS (SELECT FROM brin_t_heap t WHERE t.v = brin_key(x)); +SELECT count(*) AS missed FROM generate_series(100, 20000, 100) x + WHERE NOT EXISTS (SELECT FROM brin_t_flux t WHERE t.v = brin_key(x)); +RESET enable_seqscan; +RESET enable_indexscan; + +-- Integer keys, one range desummarized and summarized again. +CREATE TABLE brin_i_heap (v int) USING heap; +CREATE TABLE brin_i_flux (v int) USING flux; +INSERT INTO brin_i_heap SELECT generate_series(1, 20000); +INSERT INTO brin_i_flux SELECT generate_series(1, 20000); +CREATE INDEX brin_i_heap_idx ON brin_i_heap USING brin (v) WITH (pages_per_range = 1); +CREATE INDEX brin_i_flux_idx ON brin_i_flux USING brin (v) WITH (pages_per_range = 1); +SELECT brin_desummarize_range('brin_i_heap_idx', 10); +SELECT brin_desummarize_range('brin_i_flux_idx', 10); +SELECT brin_summarize_range('brin_i_heap_idx', 10); +SELECT brin_summarize_range('brin_i_flux_idx', 10); +SELECT * FROM brin_check('brin_i_heap', 'brin_i_heap_idx', 1); +SELECT * FROM brin_check('brin_i_flux', 'brin_i_flux_idx', 1); + +-- New ranges, summarized by brin_summarize_new_values. +INSERT INTO brin_i_heap SELECT generate_series(20001, 30000); +INSERT INTO brin_i_flux SELECT generate_series(20001, 30000); +SELECT brin_summarize_new_values('brin_i_heap_idx') > 0 AS summarized; +SELECT brin_summarize_new_values('brin_i_flux_idx') > 0 AS summarized; +SELECT * FROM brin_check('brin_i_heap', 'brin_i_heap_idx', 1); +SELECT * FROM brin_check('brin_i_flux', 'brin_i_flux_idx', 1); + +SET enable_seqscan = off; +SET enable_indexscan = off; +SELECT count(*) AS missed FROM generate_series(1, 30000, 97) x + WHERE NOT EXISTS (SELECT FROM brin_i_heap t WHERE t.v = x); +SELECT count(*) AS missed FROM generate_series(1, 30000, 97) x + WHERE NOT EXISTS (SELECT FROM brin_i_flux t WHERE t.v = x); +RESET enable_seqscan; +RESET enable_indexscan; + +DROP TABLE brin_t_heap, brin_t_flux, brin_i_heap, brin_i_flux; +DROP FUNCTION brin_check, brin_key; +DROP EXTENSION pageinspect; diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile index 2463d6e0fb9a5..92db2e585d3ff 100644 --- a/src/test/modules/injection_points/Makefile +++ b/src/test/modules/injection_points/Makefile @@ -9,7 +9,7 @@ EXTENSION = injection_points DATA = injection_points--1.0.sql PGFILEDESC = "injection_points - facility for injection points" -EXTRA_INSTALL = contrib/test_decoding +EXTRA_INSTALL = contrib/amcheck contrib/test_decoding REGRESS = injection_points hashagg reindex_conc vacuum REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress @@ -30,12 +30,19 @@ ISOLATION = basic \ repack_temporal \ repack_temporal_multirange \ repack_toast \ + repack_other_table \ ri_fastpath_reindex \ ri_fastpath_snapshot \ syscache-update-pruned \ $(WAIT_CLEANUP_TST) \ heap_lock_update \ - on_conflict_probe_window + on_conflict_probe_window \ + flux_cic \ + flux_update_cas_race \ + flux_update_redecide_queue \ + flux_repack \ + flux_repack_decode \ + vacuum_progress # some isolation tests require wal_level=replica ISOLATION_OPTS = --temp-config $(top_srcdir)/src/test/modules/injection_points/extra.conf diff --git a/src/test/modules/injection_points/expected/flux_cic.out b/src/test/modules/injection_points/expected/flux_cic.out new file mode 100644 index 0000000000000..e7b2628ab7f77 --- /dev/null +++ b/src/test/modules/injection_points/expected/flux_cic.out @@ -0,0 +1,1017 @@ +Parsed test spec with 6 sessions + +starting permutation: pick_fx b1l c_cic w1b w1u b1c w1c chk100 chk1 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_cic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_cic w1b w1u b1c w1c chk100 chk1 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_cic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_cic w1b w1u b2l b1c w1c w2a b2c chk1 chk100 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w1c: COMMIT; +step w2a: UPDATE t SET k = 1 WHERE id = 1; +step b2c: COMMIT; +step c_cic: <... completed> +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_cic w1b w1u b2l b1c w1c w2a b2c chk1 chk100 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w1c: COMMIT; +step w2a: UPDATE t SET k = 1 WHERE id = 1; +step b2c: COMMIT; +step c_cic: <... completed> +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_cic w1b w1x w1i b1c w1c chk100 chk1 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1x: DELETE FROM t WHERE id = 1; +step w1i: INSERT INTO t VALUES (23, 100); +step b1c: COMMIT; +step w1c: COMMIT; +step c_cic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_cic w1b w1x w1i b1c w1c chk100 chk1 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1x: DELETE FROM t WHERE id = 1; +step w1i: INSERT INTO t VALUES (23, 100); +step b1c: COMMIT; +step w1c: COMMIT; +step c_cic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_cic w1b w1u b1c w1r chk100 chk1 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1r: ROLLBACK; +step c_cic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_cic w1b w1u b1c w1r chk100 chk1 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1r: ROLLBACK; +step c_cic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_cic b2l b1c w2b w2u b2c w2r chk300 chk3 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w2b: BEGIN; +step w2u: UPDATE t SET k = 300 WHERE id = 3; +step b2c: COMMIT; +step c_cic: <... completed> +step w2r: ROLLBACK; +step chk300: SELECT * FROM chk('k = 300'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + +step chk3: SELECT * FROM chk('k = 3'); +via |ids +-------+--- +idx |3 +seq |3 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_cic b2l b1c w2b w2u b2c w2r chk300 chk3 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w2b: BEGIN; +step w2u: UPDATE t SET k = 300 WHERE id = 3; +step b2c: COMMIT; +step c_cic: <... completed> +step w2r: ROLLBACK; +step chk300: SELECT * FROM chk('k = 300'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + +step chk3: SELECT * FROM chk('k = 3'); +via |ids +-------+--- +idx |3 +seq |3 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c w2r chk100 chk500 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1i: INSERT INTO t VALUES (23, 100); +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w1c: COMMIT; +step w2b: BEGIN; +step w2v: UPDATE t SET k = 500 WHERE id = 23; +step b2c: COMMIT; +step c_cic: <... completed> +step w2r: ROLLBACK; +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + +step chk500: SELECT * FROM chk('k = 500'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c w2r chk100 chk500 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1i: INSERT INTO t VALUES (23, 100); +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w1c: COMMIT; +step w2b: BEGIN; +step w2v: UPDATE t SET k = 500 WHERE id = 23; +step b2c: COMMIT; +step c_cic: <... completed> +step w2r: ROLLBACK; +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + +step chk500: SELECT * FROM chk('k = 500'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c rrb w2c chk100 rrc chk500 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1i: INSERT INTO t VALUES (23, 100); +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w1c: COMMIT; +step w2b: BEGIN; +step w2v: UPDATE t SET k = 500 WHERE id = 23; +step b2c: COMMIT; +step c_cic: <... completed> +step rrb: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; +snap +---- + 1 +(1 row) + +step w2c: COMMIT; +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + +step rrc: COMMIT; +step chk500: SELECT * FROM chk('k = 500'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c rrb w2c chk100 rrc chk500 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_cic: CREATE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1i: INSERT INTO t VALUES (23, 100); +step b2l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step b1c: COMMIT; +step w1c: COMMIT; +step w2b: BEGIN; +step w2v: UPDATE t SET k = 500 WHERE id = 23; +step b2c: COMMIT; +step c_cic: <... completed> +step rrb: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; +snap +---- + 1 +(1 row) + +step w2c: COMMIT; +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + +step rrc: COMMIT; +step chk500: SELECT * FROM chk('k = 500'); +via |ids +-------+--- +idx |23 +seq |23 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_ucic w1b w1d b1c w1c c_state chk2 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ucic: CREATE UNIQUE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1d: UPDATE t SET k = 2 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ucic: <... completed> +ERROR: duplicate key value violates unique constraint "t_k" +step c_state: + SELECT c.relname, i.indisready, i.indisvalid FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 't_k'; + +relname|indisready|indisvalid +-------+----------+---------- +t_k |t |f +(1 row) + +step chk2: SELECT * FROM chk('k = 2'); +via |ids +-------+--- +idx |1,2 +seq |1,2 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_ucic w1b w1d b1c w1c c_state chk2 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ucic: CREATE UNIQUE INDEX CONCURRENTLY t_k ON t (k); +step w1b: BEGIN; +step w1d: UPDATE t SET k = 2 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ucic: <... completed> +ERROR: duplicate key value violates unique constraint "t_k" +step c_state: + SELECT c.relname, i.indisready, i.indisvalid FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 't_k'; + +relname|indisready|indisvalid +-------+----------+---------- +t_k |t |f +(1 row) + +step chk2: SELECT * FROM chk('k = 2'); +via |ids +-------+--- +idx |1,2 +seq |1,2 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_ecic w1b w1u b1c w1c chke +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ecic: CREATE INDEX CONCURRENTLY t_k ON t ((k * 2)); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ecic: <... completed> +step chke: SELECT * FROM chk('k * 2 = 200'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_ecic w1b w1u b1c w1c chke +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ecic: CREATE INDEX CONCURRENTLY t_k ON t ((k * 2)); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ecic: <... completed> +step chke: SELECT * FROM chk('k * 2 = 200'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_pcic w1b w1u b1c w1c chkp +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_pcic: CREATE INDEX CONCURRENTLY t_k ON t (k) WHERE k < 1000; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_pcic: <... completed> +step chkp: SELECT * FROM chk('k = 100 AND k < 1000'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_pcic w1b w1u b1c w1c chkp +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_pcic: CREATE INDEX CONCURRENTLY t_k ON t (k) WHERE k < 1000; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_pcic: <... completed> +step chkp: SELECT * FROM chk('k = 100 AND k < 1000'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx b1l c_icic w1b w1v b1c w1c chk1 +step pick_fx: ALTER TABLE fx RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_icic: CREATE INDEX CONCURRENTLY t_k ON t (k) INCLUDE (v); +step w1b: BEGIN; +step w1v: UPDATE t SET v = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_icic: <... completed> +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp b1l c_icic w1b w1v b1c w1c chk1 +step pick_hp: ALTER TABLE hp RENAME TO t; +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_icic: CREATE INDEX CONCURRENTLY t_k ON t (k) INCLUDE (v); +step w1b: BEGIN; +step w1v: UPDATE t SET v = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_icic: <... completed> +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx c_uidx b1l c_ric w1b w1u b1c w1c chk100 w2i100 w2i1 chk1 +step pick_fx: ALTER TABLE fx RENAME TO t; +step c_uidx: CREATE UNIQUE INDEX t_k ON t (k); +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ric: REINDEX INDEX CONCURRENTLY t_k; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ric: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step w2i100: INSERT INTO t VALUES (22, 100); +ERROR: duplicate key value violates unique constraint "t_k" +step w2i1: INSERT INTO t VALUES (21, 1); +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |21 +seq |21 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp c_uidx b1l c_ric w1b w1u b1c w1c chk100 w2i100 w2i1 chk1 +step pick_hp: ALTER TABLE hp RENAME TO t; +step c_uidx: CREATE UNIQUE INDEX t_k ON t (k); +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ric: REINDEX INDEX CONCURRENTLY t_k; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ric: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step w2i100: INSERT INTO t VALUES (22, 100); +ERROR: duplicate key value violates unique constraint "t_k" +step w2i1: INSERT INTO t VALUES (21, 1); +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx |21 +seq |21 +amcheck|ok +(3 rows) + + +starting permutation: pick_fx c_idx w1b w1u c_dic w1c chk100 chk1 +step pick_fx: ALTER TABLE fx RENAME TO t; +step c_idx: CREATE INDEX t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step c_dic: DROP INDEX CONCURRENTLY t_k; +step w1c: COMMIT; +step c_dic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_hp c_idx w1b w1u c_dic w1c chk100 chk1 +step pick_hp: ALTER TABLE hp RENAME TO t; +step c_idx: CREATE INDEX t_k ON t (k); +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step c_dic: DROP INDEX CONCURRENTLY t_k; +step w1c: COMMIT; +step c_dic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_fx c_idx b1l c_dic w1b w1u b1c w1c chk100 chk1 +step pick_fx: ALTER TABLE fx RENAME TO t; +step c_idx: CREATE INDEX t_k ON t (k); +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_dic: DROP INDEX CONCURRENTLY t_k; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_dic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_hp c_idx b1l c_dic w1b w1u b1c w1c chk100 chk1 +step pick_hp: ALTER TABLE hp RENAME TO t; +step c_idx: CREATE INDEX t_k ON t (k); +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_dic: DROP INDEX CONCURRENTLY t_k; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_dic: <... completed> +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk1: SELECT * FROM chk('k = 1'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_fx c_dup c_ucic c_undup c_state c_tid w1b w1n w1u2 w1c chk200 c_tid b1l c_ric w1b w1u b1c w1c c_state chk100 chk200 +step pick_fx: ALTER TABLE fx RENAME TO t; +step c_dup: UPDATE t SET k = 2 WHERE id = 1; +step c_ucic: CREATE UNIQUE INDEX CONCURRENTLY t_k ON t (k); +ERROR: could not create unique index "t_k" +step c_undup: UPDATE t SET k = 1 WHERE id = 1; +step c_state: + SELECT c.relname, i.indisready, i.indisvalid FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 't_k'; + +relname|indisready|indisvalid +-------+----------+---------- +t_k |f |f +(1 row) + +step c_tid: SELECT tid_at(1); +tid_at +------ + +(1 row) + +step w1b: BEGIN; +step w1n: UPDATE t SET v = 1000 WHERE id = 1; SELECT tid_kept(1) AS in_place; +in_place +-------- +t +(1 row) + +step w1u2: UPDATE t SET k = 200 WHERE id = 1; SELECT tid_kept(1) AS in_place; +in_place +-------- +f +(1 row) + +step w1c: COMMIT; +step chk200: SELECT * FROM chk('k = 200'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step c_tid: SELECT tid_at(1); +tid_at +------ + +(1 row) + +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ric: REINDEX INDEX CONCURRENTLY t_k; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ric: <... completed> +step c_state: + SELECT c.relname, i.indisready, i.indisvalid FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 't_k'; + +relname|indisready|indisvalid +-------+----------+---------- +t_k |t |t +(1 row) + +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk200: SELECT * FROM chk('k = 200'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_hp c_dup c_ucic c_undup c_state c_tid w1b w1n w1u2 w1c chk200 c_tid b1l c_ric w1b w1u b1c w1c c_state chk100 chk200 +step pick_hp: ALTER TABLE hp RENAME TO t; +step c_dup: UPDATE t SET k = 2 WHERE id = 1; +step c_ucic: CREATE UNIQUE INDEX CONCURRENTLY t_k ON t (k); +ERROR: could not create unique index "t_k" +step c_undup: UPDATE t SET k = 1 WHERE id = 1; +step c_state: + SELECT c.relname, i.indisready, i.indisvalid FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 't_k'; + +relname|indisready|indisvalid +-------+----------+---------- +t_k |f |f +(1 row) + +step c_tid: SELECT tid_at(1); +tid_at +------ + +(1 row) + +step w1b: BEGIN; +step w1n: UPDATE t SET v = 1000 WHERE id = 1; SELECT tid_kept(1) AS in_place; +in_place +-------- +f +(1 row) + +step w1u2: UPDATE t SET k = 200 WHERE id = 1; SELECT tid_kept(1) AS in_place; +in_place +-------- +f +(1 row) + +step w1c: COMMIT; +step chk200: SELECT * FROM chk('k = 200'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step c_tid: SELECT tid_at(1); +tid_at +------ + +(1 row) + +step b1l: BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; +step c_ric: REINDEX INDEX CONCURRENTLY t_k; +step w1b: BEGIN; +step w1u: UPDATE t SET k = 100 WHERE id = 1; +step b1c: COMMIT; +step w1c: COMMIT; +step c_ric: <... completed> +step c_state: + SELECT c.relname, i.indisready, i.indisvalid FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 't_k'; + +relname|indisready|indisvalid +-------+----------+---------- +t_k |t |t +(1 row) + +step chk100: SELECT * FROM chk('k = 100'); +via |ids +-------+--- +idx |1 +seq |1 +amcheck|ok +(3 rows) + +step chk200: SELECT * FROM chk('k = 200'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + + +starting permutation: pick_fx c_idx c_attach c_ric w2b w2u r_wake w2r c_done chk300 chk3 +step pick_fx: ALTER TABLE fx RENAME TO t; +step c_idx: CREATE INDEX t_k ON t (k); +step c_attach: + SELECT injection_points_set_local(); + SELECT injection_points_attach('reindex-relation-concurrently-before-swap', 'wait'); + +injection_points_set_local +-------------------------- + +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +step c_ric: REINDEX INDEX CONCURRENTLY t_k; +step w2b: BEGIN; +step w2u: UPDATE t SET k = 300 WHERE id = 3; +step r_wake: + SELECT injection_points_detach('reindex-relation-concurrently-before-swap'); + SELECT injection_points_wakeup('reindex-relation-concurrently-before-swap'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_wakeup +----------------------- + +(1 row) + +step w2r: ROLLBACK; +step c_ric: <... completed> +step c_done: +step chk300: SELECT * FROM chk('k = 300'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + +step chk3: SELECT * FROM chk('k = 3'); +via |ids +-------+--- +idx |3 +seq |3 +amcheck|ok +(3 rows) + + +starting permutation: pick_hp c_idx c_attach c_ric w2b w2u r_wake w2r c_done chk300 chk3 +step pick_hp: ALTER TABLE hp RENAME TO t; +step c_idx: CREATE INDEX t_k ON t (k); +step c_attach: + SELECT injection_points_set_local(); + SELECT injection_points_attach('reindex-relation-concurrently-before-swap', 'wait'); + +injection_points_set_local +-------------------------- + +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +step c_ric: REINDEX INDEX CONCURRENTLY t_k; +step w2b: BEGIN; +step w2u: UPDATE t SET k = 300 WHERE id = 3; +step r_wake: + SELECT injection_points_detach('reindex-relation-concurrently-before-swap'); + SELECT injection_points_wakeup('reindex-relation-concurrently-before-swap'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_wakeup +----------------------- + +(1 row) + +step w2r: ROLLBACK; +step c_ric: <... completed> +step c_done: +step chk300: SELECT * FROM chk('k = 300'); +via |ids +-------+--- +idx | +seq | +amcheck|ok +(3 rows) + +step chk3: SELECT * FROM chk('k = 3'); +via |ids +-------+--- +idx |3 +seq |3 +amcheck|ok +(3 rows) + diff --git a/src/test/modules/injection_points/expected/flux_commit_window.out b/src/test/modules/injection_points/expected/flux_commit_window.out new file mode 100644 index 0000000000000..ed775c6eb779e --- /dev/null +++ b/src/test/modules/injection_points/expected/flux_commit_window.out @@ -0,0 +1,29 @@ +Parsed test spec with 3 sessions + +starting permutation: s1u s2u s3w s2r +injection_points_attach +----------------------- + +(1 row) + +step s1u: UPDATE fcw SET v = v + 1 WHERE id = 1; +step s2u: UPDATE fcw SET v = v + 1 WHERE id = 1; +step s3w: SELECT injection_points_wakeup('commit-before-clog-update'); +injection_points_wakeup +----------------------- + +(1 row) + +step s1u: <... completed> +step s2u: <... completed> +step s2r: SELECT v FROM fcw WHERE id = 1; +v +- +2 +(1 row) + +injection_points_detach +----------------------- + +(1 row) + diff --git a/src/test/modules/injection_points/expected/flux_repack.out b/src/test/modules/injection_points/expected/flux_repack.out new file mode 100644 index 0000000000000..622b6874105dc --- /dev/null +++ b/src/test/modules/injection_points/expected/flux_repack.out @@ -0,0 +1,3103 @@ +Parsed test spec with 8 sessions + +starting permutation: pick_fx rp hold w_ins w_nonkey w_k w_id w_grow w_del truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|1a0c7d64b869b4a39f83527199b76e78| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold w_ins w_nonkey w_k w_id w_grow w_del truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|1a0c7d64b869b4a39f83527199b76e78| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold w_other w_k truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_other: + INSERT INTO ofx VALUES (1001, 1, 'other', NULL); + UPDATE ofx SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ofx SET id = 1022 WHERE id = 22; + DELETE FROM ofx WHERE id = 23; + INSERT INTO ohp VALUES (1001, 1, 'other', NULL); + UPDATE ohp SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ohp SET id = 1022 WHERE id = 22; + DELETE FROM ohp WHERE id = 23; + +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9339|12000|16000|de51f4abb310dd0e8ba162224553468b| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold w_other w_k truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_other: + INSERT INTO ofx VALUES (1001, 1, 'other', NULL); + UPDATE ofx SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ofx SET id = 1022 WHERE id = 22; + DELETE FROM ofx WHERE id = 23; + INSERT INTO ohp VALUES (1001, 1, 'other', NULL); + UPDATE ohp SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ohp SET id = 1022 WHERE id = 22; + DELETE FROM ohp WHERE id = 23; + +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9339|12000|16000|de51f4abb310dd0e8ba162224553468b| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold wb w_other w_ins wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_other: + INSERT INTO ofx VALUES (1001, 1, 'other', NULL); + UPDATE ofx SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ofx SET id = 1022 WHERE id = 22; + DELETE FROM ofx WHERE id = 23; + INSERT INTO ohp VALUES (1001, 1, 'other', NULL); + UPDATE ohp SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ohp SET id = 1022 WHERE id = 22; + DELETE FROM ohp WHERE id = 23; + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 8261| 8261|12003|16000|067aa91d50e89e3915ee51dc80f7f74e| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold wb w_other w_ins wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_other: + INSERT INTO ofx VALUES (1001, 1, 'other', NULL); + UPDATE ofx SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ofx SET id = 1022 WHERE id = 22; + DELETE FROM ofx WHERE id = 23; + INSERT INTO ohp VALUES (1001, 1, 'other', NULL); + UPDATE ohp SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ohp SET id = 1022 WHERE id = 22; + DELETE FROM ohp WHERE id = 23; + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 8261| 8261|12003|16000|067aa91d50e89e3915ee51dc80f7f74e| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx by_idx rpi hold w_ins w_k w_id w_grow w_del truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_idx: SET enable_sort = off; +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|836f2948712aaffe935e24de2196eeeb| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp by_idx rpi hold w_ins w_k w_id w_grow w_del truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_idx: SET enable_sort = off; +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|836f2948712aaffe935e24de2196eeeb| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx by_sort rpi hold w_ins w_k w_id w_grow w_del truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_sort: SET enable_indexscan = off; +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|836f2948712aaffe935e24de2196eeeb| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp by_sort rpi hold w_ins w_k w_id w_grow w_del truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_sort: SET enable_indexscan = off; +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|836f2948712aaffe935e24de2196eeeb| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold wb w_ins w_nonkey w_k w_id w_grow w_del wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|1a0c7d64b869b4a39f83527199b76e78| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold wb w_ins w_nonkey w_k w_id w_grow w_del wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|1a0c7d64b869b4a39f83527199b76e78| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_life: + INSERT INTO t VALUES (1040, 1040, 'life', NULL); + UPDATE t SET k = 1041, v = 'life2' WHERE id = 1040; + UPDATE t SET id = 1042 WHERE id = 1040; + DELETE FROM t WHERE id = 1042; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_b2a: UPDATE t SET id = 31, k = 3100 WHERE id = 1031; +step w_ins2: INSERT INTO t VALUES (1050, 1050, 'x', NULL); +step w_upd2: UPDATE t SET k = 1051, id = 1052 WHERE id = 1050; +step w_del2: DELETE FROM t WHERE id = 1052; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260|13299|12000|16000|c2f0e4988647417c710b3e9e49ed870b| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_life: + INSERT INTO t VALUES (1040, 1040, 'life', NULL); + UPDATE t SET k = 1041, v = 'life2' WHERE id = 1040; + UPDATE t SET id = 1042 WHERE id = 1040; + DELETE FROM t WHERE id = 1042; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_b2a: UPDATE t SET id = 31, k = 3100 WHERE id = 1031; +step w_ins2: INSERT INTO t VALUES (1050, 1050, 'x', NULL); +step w_upd2: UPDATE t SET k = 1051, id = 1052 WHERE id = 1050; +step w_del2: DELETE FROM t WHERE id = 1052; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260|13299|12000|16000|c2f0e4988647417c710b3e9e49ed870b| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold wb w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_life: + INSERT INTO t VALUES (1040, 1040, 'life', NULL); + UPDATE t SET k = 1041, v = 'life2' WHERE id = 1040; + UPDATE t SET id = 1042 WHERE id = 1040; + DELETE FROM t WHERE id = 1042; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_b2a: UPDATE t SET id = 31, k = 3100 WHERE id = 1031; +step w_ins2: INSERT INTO t VALUES (1050, 1050, 'x', NULL); +step w_upd2: UPDATE t SET k = 1051, id = 1052 WHERE id = 1050; +step w_del2: DELETE FROM t WHERE id = 1052; +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260|13299|12000|16000|c2f0e4988647417c710b3e9e49ed870b| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold wb w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_life: + INSERT INTO t VALUES (1040, 1040, 'life', NULL); + UPDATE t SET k = 1041, v = 'life2' WHERE id = 1040; + UPDATE t SET id = 1042 WHERE id = 1040; + DELETE FROM t WHERE id = 1042; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_b2a: UPDATE t SET id = 31, k = 3100 WHERE id = 1031; +step w_ins2: INSERT INTO t VALUES (1050, 1050, 'x', NULL); +step w_upd2: UPDATE t SET k = 1051, id = 1052 WHERE id = 1050; +step w_del2: DELETE FROM t WHERE id = 1052; +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260|13299|12000|16000|c2f0e4988647417c710b3e9e49ed870b| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold wb w_ins w_nonkey w_k w_id w_grow w_del w_aba wr w_after truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step wr: ROLLBACK; +step w_after: UPDATE t SET v = 'later', k = 2200 WHERE id = 22; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9438|11905|16000|eae5d64d70d5d934192e3b974eed0daf| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold wb w_ins w_nonkey w_k w_id w_grow w_del w_aba wr w_after truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step wr: ROLLBACK; +step w_after: UPDATE t SET v = 'later', k = 2200 WHERE id = 22; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9438|11905|16000|eae5d64d70d5d934192e3b974eed0daf| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold wb w_sp wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_sp: + UPDATE t SET k = 4000 WHERE id = 40; + SAVEPOINT a; + UPDATE t SET k = 4001, id = 1040 WHERE id = 40; + DELETE FROM t WHERE id = 41; + INSERT INTO t VALUES (1043, 1043, 'sp', NULL); + UPDATE t SET v = repeat('s', 2000) WHERE id = 11; + ROLLBACK TO SAVEPOINT a; + UPDATE t SET v = 'after' WHERE id = 41; + SAVEPOINT b; + INSERT INTO t VALUES (1044, 1044, 'kept', NULL); + RELEASE SAVEPOINT b; + +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 8304|12264|11909|16000|907eafa90f7ce59a7242e06214603fa5| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold wb w_sp wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_sp: + UPDATE t SET k = 4000 WHERE id = 40; + SAVEPOINT a; + UPDATE t SET k = 4001, id = 1040 WHERE id = 40; + DELETE FROM t WHERE id = 41; + INSERT INTO t VALUES (1043, 1043, 'sp', NULL); + UPDATE t SET v = repeat('s', 2000) WHERE id = 11; + ROLLBACK TO SAVEPOINT a; + UPDATE t SET v = 'after' WHERE id = 41; + SAVEPOINT b; + INSERT INTO t VALUES (1044, 1044, 'kept', NULL); + RELEASE SAVEPOINT b; + +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 8304|12264|11909|16000|907eafa90f7ce59a7242e06214603fa5| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold w_upsert truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_upsert: + INSERT INTO t VALUES (50, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = excluded.k + 5000, v = 'upd'; + INSERT INTO t VALUES (1060, 1060, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = 0; + INSERT INTO t VALUES (51, 0, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (1061, 1061, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (52, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET id = 1062; + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 122| 10391|14331|11905|16000|50f34a2add247dd21bf86459d1dda042| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold w_upsert truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_upsert: + INSERT INTO t VALUES (50, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = excluded.k + 5000, v = 'upd'; + INSERT INTO t VALUES (1060, 1060, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = 0; + INSERT INTO t VALUES (51, 0, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (1061, 1061, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (52, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET id = 1062; + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 122| 10391|14331|11905|16000|50f34a2add247dd21bf86459d1dda042| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold w_toast truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_toast: + INSERT INTO t VALUES (1070, 1070, 'toast', repeat(md5('new'), 100)); + UPDATE t SET w = repeat(md5('upd'), 100) WHERE id = 1; + UPDATE t SET k = 2002 WHERE id = 2; + UPDATE t SET id = 1003 WHERE id = 3; + DELETE FROM t WHERE id = 4; + UPDATE t SET w = NULL WHERE id = 5; + UPDATE t SET w = repeat(md5('grown'), 100) WHERE id = 60; + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9326|10326|11905|16000|bb5b2535bbf84bdb49b76d69d72da293| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold w_toast truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_toast: + INSERT INTO t VALUES (1070, 1070, 'toast', repeat(md5('new'), 100)); + UPDATE t SET w = repeat(md5('upd'), 100) WHERE id = 1; + UPDATE t SET k = 2002 WHERE id = 2; + UPDATE t SET id = 1003 WHERE id = 3; + DELETE FROM t WHERE id = 4; + UPDATE t SET w = NULL WHERE id = 5; + UPDATE t SET w = repeat(md5('grown'), 100) WHERE id = 60; + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9326|10326|11905|16000|bb5b2535bbf84bdb49b76d69d72da293| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold wb w_k w_id w_grow w_prep pc1 truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_prep: PREPARE TRANSACTION 'rp1'; +step pc1: COMMIT PREPARED 'rp1'; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 8260| 9339|13900|16000|40a27fe85c8cc1ad079bb79ed9f1cafa| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold wb w_k w_id w_grow w_prep pc1 truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wb: BEGIN; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_prep: PREPARE TRANSACTION 'rp1'; +step pc1: COMMIT PREPARED 'rp1'; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 8260| 9339|13900|16000|40a27fe85c8cc1ad079bb79ed9f1cafa| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx pp rp pc0 hold w_ins truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step pp: + BEGIN; + UPDATE t SET k = 8000, v = repeat('p', 100) WHERE id = 80; + UPDATE t SET id = 1081 WHERE id = 81; + PREPARE TRANSACTION 'rp0'; + +step rp: REPACK (CONCURRENTLY) t; +step pc0: COMMIT PREPARED 'rp0'; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 9261|16181|12003|16000|6c96eae3861ba706cfa636f24f6b62f5| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp pp rp pc0 hold w_ins truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step pp: + BEGIN; + UPDATE t SET k = 8000, v = repeat('p', 100) WHERE id = 80; + UPDATE t SET id = 1081 WHERE id = 81; + PREPARE TRANSACTION 'rp0'; + +step rp: REPACK (CONCURRENTLY) t; +step pc0: COMMIT PREPARED 'rp0'; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 9261|16181|12003|16000|6c96eae3861ba706cfa636f24f6b62f5| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rrb rp hold w_k truth wake chk rrs rrc +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rrb: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; +snap +---- + 1 +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9339|12000|16000|de51f4abb310dd0e8ba162224553468b| 0|same |same |ok +(1 row) + +step rrs: + SELECT count(*) AS old_snapshot_rows, sum(k) AS old_snapshot_sum_k, + (SELECT k FROM t WHERE id = 21) AS k21 FROM t; + +old_snapshot_rows|old_snapshot_sum_k|k21 +-----------------+------------------+--- + 0| | +(1 row) + +step rrc: COMMIT; +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rrb rp hold w_k truth wake chk rrs rrc +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rrb: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; +snap +---- + 1 +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9339|12000|16000|de51f4abb310dd0e8ba162224553468b| 0|same |same |ok +(1 row) + +step rrs: + SELECT count(*) AS old_snapshot_rows, sum(k) AS old_snapshot_sum_k, + (SELECT k FROM t WHERE id = 21) AS k21 FROM t; + +old_snapshot_rows|old_snapshot_sum_k|k21 +-----------------+------------------+--- + 0| | +(1 row) + +step rrc: COMMIT; +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx rp hold w_k rrb truth wake chk rrs rrc +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step rrb: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; +snap +---- + 1 +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9339|12000|16000|de51f4abb310dd0e8ba162224553468b| 0|same |same |ok +(1 row) + +step rrs: + SELECT count(*) AS old_snapshot_rows, sum(k) AS old_snapshot_sum_k, + (SELECT k FROM t WHERE id = 21) AS k21 FROM t; + +old_snapshot_rows|old_snapshot_sum_k|k21 +-----------------+------------------+--- + 0| | +(1 row) + +step rrc: COMMIT; +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp hold w_k rrb truth wake chk rrs rrc +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step rp: REPACK (CONCURRENTLY) t; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step rrb: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; +snap +---- + 1 +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9339|12000|16000|de51f4abb310dd0e8ba162224553468b| 0|same |same |ok +(1 row) + +step rrs: + SELECT count(*) AS old_snapshot_rows, sum(k) AS old_snapshot_sum_k, + (SELECT k FROM t WHERE id = 21) AS k21 FROM t; + +old_snapshot_rows|old_snapshot_sum_k|k21 +-----------------+------------------+--- + 0| | +(1 row) + +step rrc: COMMIT; +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step x3r: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|1a0c7d64b869b4a39f83527199b76e78| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wc truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step x3r: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wc: COMMIT; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|10317|13803|16000|1a0c7d64b869b4a39f83527199b76e78| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wr w_after truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step x3r: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wr: ROLLBACK; +step w_after: UPDATE t SET v = 'later', k = 2200 WHERE id = 22; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9438|11905|16000|eae5d64d70d5d934192e3b974eed0daf| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wr w_after truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step x3r: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step wr: ROLLBACK; +step w_after: UPDATE t SET v = 'later', k = 2200 WHERE id = 22; +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9438|11905|16000|eae5d64d70d5d934192e3b974eed0daf| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx x2b rp x3b x2r wx3 gb x3r wg w_ins w_nonkey w_k w_id w_grow w_del w_aba gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|13287|13803|16000|2f6cc286819633d865aa7a52d6509356| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp x2b rp x3b x2r wx3 gb x3r wg w_ins w_nonkey w_k w_id w_grow w_del w_aba gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 9238|13287|13803|16000|2f6cc286819633d865aa7a52d6509356| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx x2b rp x3b x2r wx3 wb w_ins w_k w_id w_grow gb x3r wg wc w_toast w_upsert gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step wc: COMMIT; +step w_toast: + INSERT INTO t VALUES (1070, 1070, 'toast', repeat(md5('new'), 100)); + UPDATE t SET w = repeat(md5('upd'), 100) WHERE id = 1; + UPDATE t SET k = 2002 WHERE id = 2; + UPDATE t SET id = 1003 WHERE id = 3; + DELETE FROM t WHERE id = 4; + UPDATE t SET w = NULL WHERE id = 5; + UPDATE t SET w = repeat(md5('grown'), 100) WHERE id = 60; + +step w_upsert: + INSERT INTO t VALUES (50, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = excluded.k + 5000, v = 'upd'; + INSERT INTO t VALUES (1060, 1060, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = 0; + INSERT INTO t VALUES (51, 0, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (1061, 1061, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (52, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET id = 1062; + +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 123| 14458|20477|13713|16000|43655c13bb425e94e41df2864e20f237| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp x2b rp x3b x2r wx3 wb w_ins w_k w_id w_grow gb x3r wg wc w_toast w_upsert gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step wc: COMMIT; +step w_toast: + INSERT INTO t VALUES (1070, 1070, 'toast', repeat(md5('new'), 100)); + UPDATE t SET w = repeat(md5('upd'), 100) WHERE id = 1; + UPDATE t SET k = 2002 WHERE id = 2; + UPDATE t SET id = 1003 WHERE id = 3; + DELETE FROM t WHERE id = 4; + UPDATE t SET w = NULL WHERE id = 5; + UPDATE t SET w = repeat(md5('grown'), 100) WHERE id = 60; + +step w_upsert: + INSERT INTO t VALUES (50, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = excluded.k + 5000, v = 'upd'; + INSERT INTO t VALUES (1060, 1060, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = 0; + INSERT INTO t VALUES (51, 0, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (1061, 1061, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (52, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET id = 1062; + +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 123| 14458|20477|13713|16000|43655c13bb425e94e41df2864e20f237| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx x2b rp x3b x2r wx3 gb x3r wg wb w_ins w_nonkey w_k w_id w_grow w_del wr w_after gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step wr: ROLLBACK; +step w_after: UPDATE t SET v = 'later', k = 2200 WHERE id = 22; +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9438|11905|16000|eae5d64d70d5d934192e3b974eed0daf| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp x2b rp x3b x2r wx3 gb x3r wg wb w_ins w_nonkey w_k w_id w_grow w_del wr w_after gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step wb: BEGIN; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step w_nonkey: UPDATE t SET v = repeat('n', 100) WHERE id = 20; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_grow: + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; + +moved +----- +t +(1 row) + +step w_del: DELETE FROM t WHERE id = 23; +step wr: ROLLBACK; +step w_after: UPDATE t SET v = 'later', k = 2200 WHERE id = 22; +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 7260| 9438|11905|16000|eae5d64d70d5d934192e3b974eed0daf| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx by_idx x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_idx: SET enable_sort = off; +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 10238|13287|11903|16000|e9f7c76534c899cabbc76ee4d0243270| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp by_idx x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_idx: SET enable_sort = off; +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 10238|13287|11903|16000|e9f7c76534c899cabbc76ee4d0243270| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx by_sort x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_sort: SET enable_indexscan = off; +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 10238|13287|11903|16000|e9f7c76534c899cabbc76ee4d0243270| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp by_sort x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step by_sort: SET enable_indexscan = off; +step x2b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rpi: REPACK (CONCURRENTLY) t USING INDEX t_pkey; +step x3b: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step x2r: ROLLBACK; +step wx3: SELECT wait_for('x3'); +wait_for +-------- + +(1 row) + +step gb: + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; + +step x3r: ROLLBACK; +step wg: SELECT wait_for('g'); +wait_for +-------- + +(1 row) + +step w_id: UPDATE t SET id = 1022 WHERE id = 22; +step w_aba: + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; + +step w_a2b: UPDATE t SET id = 1031 WHERE id = 31; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step w_del: DELETE FROM t WHERE id = 23; +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step gr: ROLLBACK; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rpi: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 120| 10238|13287|11903|16000|e9f7c76534c899cabbc76ee4d0243270| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_fx wxid rp w_same w_k wc hold w_ins truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step wxid: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step w_same: UPDATE t SET v = repeat('c', 100) WHERE id = 70; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step wc: COMMIT; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 8261|10340|12003|16000|a0ab81a914c0f48975de77d063e1a0d1| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp wxid rp w_same w_k wc hold w_ins truth wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; + +step wxid: BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; +has_xid +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step w_same: UPDATE t SET v = repeat('c', 100) WHERE id = 70; +step w_k: UPDATE t SET k = 2100 WHERE id = 21; +step wc: COMMIT; +step hold: SELECT wait_hold(); +wait_hold +--------- + +(1 row) + +step w_ins: INSERT INTO t VALUES (1001, 1001, 'new', NULL); +step truth: INSERT INTO truth SELECT * FROM t; +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: SELECT * FROM chk(); +rewritten|nrows|sum_id|sum_k|len_v|len_w|digest |vs_truth|pk_scan|k_scan|amcheck +---------+-----+------+-----+-----+-----+--------------------------------+--------+-------+------+------- +t | 121| 8261|10340|12003|16000|a0ab81a914c0f48975de77d063e1a0d1| 0|same |same |ok +(1 row) + +injection_points_detach +----------------------- + +(1 row) + diff --git a/src/test/modules/injection_points/expected/flux_repack_decode.out b/src/test/modules/injection_points/expected/flux_repack_decode.out new file mode 100644 index 0000000000000..8c3f69692939a --- /dev/null +++ b/src/test/modules/injection_points/expected/flux_repack_decode.out @@ -0,0 +1,129 @@ +Parsed test spec with 2 sessions + +starting permutation: pick_fx skip rp s2_changes s2_wake decode +?column? +-------- +init +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: ALTER TABLE fx RENAME TO t; +step skip: + SELECT count(*) > 0 AS skipped FROM pg_logical_slot_get_changes('s', NULL, NULL); + +skipped +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step s2_changes: + UPDATE t SET t = 'short' WHERE i = 1; + UPDATE t SET k = 20 WHERE i = 2; + UPDATE t SET t = repeat(md5('new'), 100) WHERE i = 3; + INSERT INTO t VALUES (4, 4, 'four'); + DELETE FROM t WHERE i = 4; + +step s2_wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step decode: + SELECT regexp_replace(data, '''[0-9a-f]+''', '''''', 'g') AS data + FROM pg_logical_slot_get_changes('s', NULL, NULL, 'include-rewrites', '1', + 'skip-empty-xacts', '1', 'include-xids', '0'); + +data +-------------------------------------------------------------------------------- +BEGIN +table public.t: UPDATE: i[integer]:1 k[integer]:1 t[text]:'short' +table public.t: UPDATE: i[integer]:2 k[integer]:20 t[text]:unchanged-toast-datum +table public.t: UPDATE: i[integer]:3 k[integer]:3 t[text]:'' +table public.t: INSERT: i[integer]:4 k[integer]:4 t[text]:'four' +table public.t: DELETE: i[integer]:4 +COMMIT +(7 rows) + +injection_points_detach +----------------------- + +(1 row) + +pg_drop_replication_slot +------------------------ + +(1 row) + + +starting permutation: pick_hp skip rp s2_changes s2_wake decode +?column? +-------- +init +(1 row) + +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: ALTER TABLE hp RENAME TO t; +step skip: + SELECT count(*) > 0 AS skipped FROM pg_logical_slot_get_changes('s', NULL, NULL); + +skipped +------- +t +(1 row) + +step rp: REPACK (CONCURRENTLY) t; +step s2_changes: + UPDATE t SET t = 'short' WHERE i = 1; + UPDATE t SET k = 20 WHERE i = 2; + UPDATE t SET t = repeat(md5('new'), 100) WHERE i = 3; + INSERT INTO t VALUES (4, 4, 'four'); + DELETE FROM t WHERE i = 4; + +step s2_wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step decode: + SELECT regexp_replace(data, '''[0-9a-f]+''', '''''', 'g') AS data + FROM pg_logical_slot_get_changes('s', NULL, NULL, 'include-rewrites', '1', + 'skip-empty-xacts', '1', 'include-xids', '0'); + +data +-------------------------------------------------------------------------------- +BEGIN +table public.t: UPDATE: i[integer]:1 k[integer]:1 t[text]:'short' +table public.t: UPDATE: i[integer]:2 k[integer]:20 t[text]:unchanged-toast-datum +table public.t: UPDATE: i[integer]:3 k[integer]:3 t[text]:'' +table public.t: INSERT: i[integer]:4 k[integer]:4 t[text]:'four' +table public.t: DELETE: i[integer]:4 +COMMIT +(7 rows) + +injection_points_detach +----------------------- + +(1 row) + +pg_drop_replication_slot +------------------------ + +(1 row) + diff --git a/src/test/modules/injection_points/expected/flux_update_cas_race.out b/src/test/modules/injection_points/expected/flux_update_cas_race.out new file mode 100644 index 0000000000000..2f6f185ce42eb --- /dev/null +++ b/src/test/modules/injection_points/expected/flux_update_cas_race.out @@ -0,0 +1,314 @@ +Parsed test spec with 3 sessions + +starting permutation: pick_fx s1b s1k s2k s1r wake s2done chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: ALTER TABLE cr_fx RENAME TO kw; ALTER INDEX cr_fx_k RENAME TO kw_k; +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2k: UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; +step s1r: ROLLBACK; +step wake: + DO $$ + BEGIN + PERFORM injection_points_detach('flux-update-after-decision'); + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-after-decision') THEN + PERFORM injection_points_wakeup('flux-update-after-decision'); + END IF; + END $$; + +step s2k: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step s2done: +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT 'idx' AS via, id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT 'seq' AS via, id, k, v FROM kw WHERE id = 3; + SELECT bt_index_parent_check('kw_k', true, true); + +via|id| k|v +---+--+---+- +idx| 3|100|2 +(1 row) + +via|id| k|v +---+--+---+- +seq| 3|100|2 +(1 row) + +bt_index_parent_check +--------------------- + +(1 row) + + +starting permutation: pick_hp s1b s1k s2k s1r wake s2done chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: ALTER TABLE cr_hp RENAME TO kw; ALTER INDEX cr_hp_k RENAME TO kw_k; +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2k: UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; +step s1r: ROLLBACK; +step s2k: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step wake: + DO $$ + BEGIN + PERFORM injection_points_detach('flux-update-after-decision'); + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-after-decision') THEN + PERFORM injection_points_wakeup('flux-update-after-decision'); + END IF; + END $$; + +step s2done: +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT 'idx' AS via, id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT 'seq' AS via, id, k, v FROM kw WHERE id = 3; + SELECT bt_index_parent_check('kw_k', true, true); + +via|id| k|v +---+--+---+- +idx| 3|100|2 +(1 row) + +via|id| k|v +---+--+---+- +seq| 3|100|2 +(1 row) + +bt_index_parent_check +--------------------- + +(1 row) + + +starting permutation: pick_fx s1b s1k s2k s1c wake s2done chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: ALTER TABLE cr_fx RENAME TO kw; ALTER INDEX cr_fx_k RENAME TO kw_k; +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2k: UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; +step s1c: COMMIT; +step wake: + DO $$ + BEGIN + PERFORM injection_points_detach('flux-update-after-decision'); + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-after-decision') THEN + PERFORM injection_points_wakeup('flux-update-after-decision'); + END IF; + END $$; + +step s2k: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step s2done: +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT 'idx' AS via, id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT 'seq' AS via, id, k, v FROM kw WHERE id = 3; + SELECT bt_index_parent_check('kw_k', true, true); + +via|id| k|v +---+--+---+- +idx| 3|100|2 +(1 row) + +via|id| k|v +---+--+---+- +seq| 3|100|2 +(1 row) + +bt_index_parent_check +--------------------- + +(1 row) + + +starting permutation: pick_hp s1b s1k s2k s1c wake s2done chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: ALTER TABLE cr_hp RENAME TO kw; ALTER INDEX cr_hp_k RENAME TO kw_k; +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2k: UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; +step s1c: COMMIT; +step s2k: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step wake: + DO $$ + BEGIN + PERFORM injection_points_detach('flux-update-after-decision'); + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-after-decision') THEN + PERFORM injection_points_wakeup('flux-update-after-decision'); + END IF; + END $$; + +step s2done: +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT 'idx' AS via, id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT 'seq' AS via, id, k, v FROM kw WHERE id = 3; + SELECT bt_index_parent_check('kw_k', true, true); + +via|id| k|v +---+--+---+- +idx| 3|100|2 +(1 row) + +via|id| k|v +---+--+---+- +seq| 3|100|2 +(1 row) + +bt_index_parent_check +--------------------- + +(1 row) + + +starting permutation: pick_fx s2k2 s1b s1k wake s1r s2done back chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: ALTER TABLE cr_fx RENAME TO kw; ALTER INDEX cr_fx_k RENAME TO kw_k; +step s2k2: UPDATE kw SET k = 200, v = 2 WHERE id = 3 RETURNING id, k, v; +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step wake: + DO $$ + BEGIN + PERFORM injection_points_detach('flux-update-after-decision'); + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-after-decision') THEN + PERFORM injection_points_wakeup('flux-update-after-decision'); + END IF; + END $$; + +step s1r: ROLLBACK; +step s2k2: <... completed> +id| k|v +--+---+- + 3|200|2 +(1 row) + +step s2done: +step back: UPDATE kw SET k = 3 WHERE id = 3; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT 'idx' AS via, id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT 'seq' AS via, id, k, v FROM kw WHERE id = 3; + SELECT bt_index_parent_check('kw_k', true, true); + +via|id|k|v +---+--+-+- +idx| 3|3|2 +(1 row) + +via|id|k|v +---+--+-+- +seq| 3|3|2 +(1 row) + +bt_index_parent_check +--------------------- + +(1 row) + + +starting permutation: pick_hp s2k2 s1b s1k wake s1r s2done back chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: ALTER TABLE cr_hp RENAME TO kw; ALTER INDEX cr_hp_k RENAME TO kw_k; +step s2k2: UPDATE kw SET k = 200, v = 2 WHERE id = 3 RETURNING id, k, v; +id| k|v +--+---+- + 3|200|2 +(1 row) + +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step wake: + DO $$ + BEGIN + PERFORM injection_points_detach('flux-update-after-decision'); + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-after-decision') THEN + PERFORM injection_points_wakeup('flux-update-after-decision'); + END IF; + END $$; + +step s1r: ROLLBACK; +step s2done: +step back: UPDATE kw SET k = 3 WHERE id = 3; +step chk: + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT 'idx' AS via, id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT 'seq' AS via, id, k, v FROM kw WHERE id = 3; + SELECT bt_index_parent_check('kw_k', true, true); + +via|id|k|v +---+--+-+- +idx| 3|3|2 +(1 row) + +via|id|k|v +---+--+-+- +seq| 3|3|2 +(1 row) + +bt_index_parent_check +--------------------- + +(1 row) + diff --git a/src/test/modules/injection_points/expected/flux_update_redecide_queue.out b/src/test/modules/injection_points/expected/flux_update_redecide_queue.out new file mode 100644 index 0000000000000..13305311c20d8 --- /dev/null +++ b/src/test/modules/injection_points/expected/flux_update_redecide_queue.out @@ -0,0 +1,141 @@ +Parsed test spec with 4 sessions + +starting permutation: pick_fx s1b s1k s2k s3d s1r sync wake s3sel s2det +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: ALTER TABLE ur_fx RENAME TO kw; +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2k: UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; +step s3d: DELETE FROM kw WHERE id = 3 RETURNING id, k, v; +step s1r: ROLLBACK; +step sync: + DO $$ + DECLARE + n int := 0; + BEGIN + LOOP + PERFORM pg_stat_clear_snapshot(); + EXIT WHEN EXISTS ( + SELECT FROM pg_stat_activity a2, pg_stat_activity a3 + WHERE a2.application_name LIKE '%/s2' + AND a3.application_name LIKE '%/s3' + AND (a2.wait_event = 'flux-update-redecide' OR a2.state = 'idle') + AND (a3.state = 'idle' OR a2.pid = ANY (pg_blocking_pids(a3.pid)))); + n := n + 1; + IF n > 30000 THEN + RAISE EXCEPTION 's2 and s3 did not settle'; + END IF; + PERFORM pg_sleep(0.01); + END LOOP; + END $$; + +step wake: + DO $$ + BEGIN + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-redecide') THEN + PERFORM injection_points_wakeup('flux-update-redecide'); + END IF; + END $$; + +step s2k: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step s3d: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step s3sel: SELECT id, k, v FROM kw ORDER BY id; +id|k|v +--+-+- + 1|1|0 + 2|2|0 + 4|4|0 + 5|5|0 +(4 rows) + +step s2det: SELECT injection_points_detach('flux-update-redecide'); +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp s1b s1k s2k s3d s1r sync wake s3sel s2det +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: ALTER TABLE ur_hp RENAME TO kw; +step s1b: BEGIN; +step s1k: UPDATE kw SET k = 100 WHERE id = 3; +step s2k: UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; +step s3d: DELETE FROM kw WHERE id = 3 RETURNING id, k, v; +step s1r: ROLLBACK; +step s2k: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step s3d: <... completed> +id| k|v +--+---+- + 3|100|2 +(1 row) + +step sync: + DO $$ + DECLARE + n int := 0; + BEGIN + LOOP + PERFORM pg_stat_clear_snapshot(); + EXIT WHEN EXISTS ( + SELECT FROM pg_stat_activity a2, pg_stat_activity a3 + WHERE a2.application_name LIKE '%/s2' + AND a3.application_name LIKE '%/s3' + AND (a2.wait_event = 'flux-update-redecide' OR a2.state = 'idle') + AND (a3.state = 'idle' OR a2.pid = ANY (pg_blocking_pids(a3.pid)))); + n := n + 1; + IF n > 30000 THEN + RAISE EXCEPTION 's2 and s3 did not settle'; + END IF; + PERFORM pg_sleep(0.01); + END LOOP; + END $$; + +step wake: + DO $$ + BEGIN + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-redecide') THEN + PERFORM injection_points_wakeup('flux-update-redecide'); + END IF; + END $$; + +step s3sel: SELECT id, k, v FROM kw ORDER BY id; +id|k|v +--+-+- + 1|1|0 + 2|2|0 + 4|4|0 + 5|5|0 +(4 rows) + +step s2det: SELECT injection_points_detach('flux-update-redecide'); +injection_points_detach +----------------------- + +(1 row) + diff --git a/src/test/modules/injection_points/expected/repack_other_table.out b/src/test/modules/injection_points/expected/repack_other_table.out new file mode 100644 index 0000000000000..b0897fdc6fdfb --- /dev/null +++ b/src/test/modules/injection_points/expected/repack_other_table.out @@ -0,0 +1,131 @@ +Parsed test spec with 2 sessions + +starting permutation: pick_fx rp w_dml wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_fx: ALTER TABLE ofx RENAME TO o; +step rp: REPACK (CONCURRENTLY) r; +step w_dml: + INSERT INTO o VALUES (1001, 1001, 'other'); + UPDATE o SET k = 2222, v = 'other' WHERE id = 2; + UPDATE o SET id = 1003 WHERE id = 3; + DELETE FROM o WHERE id = 4; + INSERT INTO o SELECT g, g, 'other' FROM generate_series(1010, 1019) g; + INSERT INTO o VALUES (5, 0, 'other') ON CONFLICT (id) DO UPDATE SET k = 5555; + INSERT INTO o VALUES (1020, 0, 'other') ON CONFLICT (id) DO NOTHING; + UPDATE o SET v = repeat('x', 2000) WHERE id = 6; + SELECT (ctid::text::point)[0] > 0 AS moved FROM o WHERE id = 6; + DO $$ BEGIN + EXECUTE format('COPY o FROM %L', + current_setting('data_directory') || '/repack_other_table.dat'); + END $$; + +moved +----- +t +(1 row) + +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: + SELECT count(*), sum(id), sum(k), string_agg(DISTINCT v, ',') AS v FROM r; + SELECT bt_index_check(indexrelid, true) FROM pg_index + WHERE indrelid = 'r'::regclass; + SELECT count(*), sum(id), sum(k) FROM o; + +count|sum|sum|v +-----+---+---+- + 20|210|210|r +(1 row) + +bt_index_check +-------------- + + +(2 rows) + +count| sum| sum +-----+-----+------ + 141|30767|754168 +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: pick_hp rp w_dml wake chk +injection_points_attach +----------------------- + +(1 row) + +step pick_hp: ALTER TABLE ohp RENAME TO o; +step rp: REPACK (CONCURRENTLY) r; +step w_dml: + INSERT INTO o VALUES (1001, 1001, 'other'); + UPDATE o SET k = 2222, v = 'other' WHERE id = 2; + UPDATE o SET id = 1003 WHERE id = 3; + DELETE FROM o WHERE id = 4; + INSERT INTO o SELECT g, g, 'other' FROM generate_series(1010, 1019) g; + INSERT INTO o VALUES (5, 0, 'other') ON CONFLICT (id) DO UPDATE SET k = 5555; + INSERT INTO o VALUES (1020, 0, 'other') ON CONFLICT (id) DO NOTHING; + UPDATE o SET v = repeat('x', 2000) WHERE id = 6; + SELECT (ctid::text::point)[0] > 0 AS moved FROM o WHERE id = 6; + DO $$ BEGIN + EXECUTE format('COPY o FROM %L', + current_setting('data_directory') || '/repack_other_table.dat'); + END $$; + +moved +----- +t +(1 row) + +step wake: + SELECT injection_points_wakeup('repack-concurrently-before-lock'); + +injection_points_wakeup +----------------------- + +(1 row) + +step rp: <... completed> +step chk: + SELECT count(*), sum(id), sum(k), string_agg(DISTINCT v, ',') AS v FROM r; + SELECT bt_index_check(indexrelid, true) FROM pg_index + WHERE indrelid = 'r'::regclass; + SELECT count(*), sum(id), sum(k) FROM o; + +count|sum|sum|v +-----+---+---+- + 20|210|210|r +(1 row) + +bt_index_check +-------------- + + +(2 rows) + +count| sum| sum +-----+-----+------ + 141|30767|754168 +(1 row) + +injection_points_detach +----------------------- + +(1 row) + diff --git a/src/test/modules/injection_points/expected/vacuum_progress.out b/src/test/modules/injection_points/expected/vacuum_progress.out new file mode 100644 index 0000000000000..b4fe626d0501a --- /dev/null +++ b/src/test/modules/injection_points/expected/vacuum_progress.out @@ -0,0 +1,73 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_vac_heap s2_round0 s2_wake s1_done s1_rows_heap +step s1_vac_heap: VACUUM (PARALLEL 0) vp_heap; +step s2_round0: SELECT * FROM vp_progress(0); +rel |phase |total_is_size|scanned|vacuumed|index_vacuum_count|max_dead_tuple_bytes|have_dead_bytes|num_dead_item_ids|indexes_total|indexes_processed|at_index|delayed|mode |started_by +-------+-----------------+-------------+-------+--------+------------------+--------------------+---------------+-----------------+-------------+-----------------+--------+-------+------+---------- +vp_heap|vacuuming indexes|t |all |none | 0| 1048576|t | 1000| 1| 0|t |t |normal|manual +(1 row) + +step s2_wake: SELECT injection_points_wakeup('vacuum-bulkdel-one-index'); +injection_points_wakeup +----------------------- + +(1 row) + +step s1_vac_heap: <... completed> +step s1_done: + SELECT count(*) AS progress_rows FROM pg_stat_progress_vacuum + WHERE pid = pg_backend_pid(); + +progress_rows +------------- + 0 +(1 row) + +step s1_rows_heap: SELECT count(*), sum(id) FROM vp_heap; +count| sum +-----+------- + 2000|3000000 +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: s1_vac_flux s2_round0 s2_wake s1_done s1_rows_flux +step s1_vac_flux: VACUUM (PARALLEL 0) vp_flux; +step s2_round0: SELECT * FROM vp_progress(0); +rel |phase |total_is_size|scanned|vacuumed|index_vacuum_count|max_dead_tuple_bytes|have_dead_bytes|num_dead_item_ids|indexes_total|indexes_processed|at_index|delayed|mode |started_by +-------+-----------------+-------------+-------+--------+------------------+--------------------+---------------+-----------------+-------------+-----------------+--------+-------+------+---------- +vp_flux|vacuuming indexes|t |all |none | 0| 1048576|t | 1000| 1| 0|t |t |normal|manual +(1 row) + +step s2_wake: SELECT injection_points_wakeup('vacuum-bulkdel-one-index'); +injection_points_wakeup +----------------------- + +(1 row) + +step s1_vac_flux: <... completed> +step s1_done: + SELECT count(*) AS progress_rows FROM pg_stat_progress_vacuum + WHERE pid = pg_backend_pid(); + +progress_rows +------------- + 0 +(1 row) + +step s1_rows_flux: SELECT count(*), sum(id) FROM vp_flux; +count| sum +-----+------- + 2000|3000000 +(1 row) + +injection_points_detach +----------------------- + +(1 row) + diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build index b4a0079484b75..568514d619405 100644 --- a/src/test/modules/injection_points/meson.build +++ b/src/test/modules/injection_points/meson.build @@ -43,6 +43,14 @@ injection_points_isolation = [ 'syscache-update-pruned', 'heap_lock_update', 'on_conflict_probe_window', + 'repack_other_table', + 'vacuum_progress', + 'flux_commit_window', + 'flux_cic', + 'flux_update_cas_race', + 'flux_update_redecide_queue', + 'flux_repack', + 'flux_repack_decode', ] # wait_cleanup terminates another backend, whose FATAL message can be lost diff --git a/src/test/modules/injection_points/specs/flux_cic.spec b/src/test/modules/injection_points/specs/flux_cic.spec new file mode 100644 index 0000000000000..4c604c269350a --- /dev/null +++ b/src/test/modules/injection_points/specs/flux_cic.spec @@ -0,0 +1,239 @@ +# FLUX: CREATE INDEX CONCURRENTLY and REINDEX CONCURRENTLY under concurrent +# key UPDATEs. +# +# FLUX updates a row in place, at the same TID, even when an indexed column +# changes: it inserts the new (key, TID) entry and delete-marks the old one +# in every ready index. A concurrent build cannot rely on that for the index +# it is building. Its phase 2 indexes the rows a snapshot sees, and its +# validation (phase 3) adds only the TIDs the index does not have yet. A key +# UPDATE that sees the new index not yet ready skips it, so if the build's +# snapshot missed that UPDATE, the index keeps the old key for a TID the +# validation then skips. Heap avoids this because such an UPDATE is never HOT: +# the new version gets a new TID, which the validation finds missing. +# +# Each scenario runs twice, on a FLUX table and on its heap twin (pick_fx or +# pick_hp renames one of them to t); heap is the control and both must give +# the same answers. chk() compares an index scan with a seqscan for a qual +# and runs amcheck (heapallindexed, checkunique) on every valid index of t, +# reporting its error if it finds one. +# +# The windows between the phases are held open by lock holders: blocker b1 +# holds RowExclusiveLock before the build starts, so the build waits for it +# (phase 2 wait) after its catalog entry is visible but before it takes its +# snapshot; b2 takes the lock while the build waits, so the build later waits +# for it with the index ready (phase 3 wait) before validating. For +# REINDEX CONCURRENTLY the swap is held at an injection point. + +setup +{ + CREATE EXTENSION amcheck; + CREATE EXTENSION injection_points; + CREATE TABLE fx (id int PRIMARY KEY, k int, v int) USING flux + WITH (autovacuum_enabled = off); + CREATE TABLE hp (id int PRIMARY KEY, k int, v int) USING heap + WITH (autovacuum_enabled = off); + INSERT INTO fx SELECT g, g, g FROM generate_series(1, 20) g; + INSERT INTO hp SELECT g, g, g FROM generate_series(1, 20) g; + + -- whether row id is still where tid_at() last found it: an in-place + -- UPDATE keeps the TID, a moved row has a new one + CREATE TABLE tid_seen (id int PRIMARY KEY, tid tid); + CREATE FUNCTION tid_at(rid int) RETURNS void LANGUAGE plpgsql AS $$ + BEGIN + INSERT INTO tid_seen SELECT id, ctid FROM t WHERE id = rid + ON CONFLICT (id) DO UPDATE SET tid = excluded.tid; + END $$; + CREATE FUNCTION tid_kept(rid int) RETURNS bool LANGUAGE plpgsql AS $$ + BEGIN + RETURN (SELECT s.tid = t.ctid FROM tid_seen s JOIN t USING (id) + WHERE id = rid); + END $$; + + -- ids that match qual through an index scan and through a seqscan, then + -- amcheck every valid index of t + CREATE FUNCTION chk(qual text) RETURNS TABLE (via text, ids text) + LANGUAGE plpgsql AS $$ + DECLARE + q text := format('SELECT string_agg(id::text, '','' ORDER BY id) FROM t WHERE %s', qual); + r text; + BEGIN + PERFORM set_config('enable_seqscan', 'off', true); + PERFORM set_config('enable_bitmapscan', 'off', true); + EXECUTE q INTO r; + via := 'idx'; ids := r; RETURN NEXT; + PERFORM set_config('enable_seqscan', 'on', true); + PERFORM set_config('enable_indexscan', 'off', true); + EXECUTE q INTO r; + via := 'seq'; ids := r; RETURN NEXT; + PERFORM set_config('enable_indexscan', 'on', true); + PERFORM set_config('enable_bitmapscan', 'on', true); + via := 'amcheck'; ids := 'ok'; + BEGIN + PERFORM bt_index_check(i.indexrelid, true, true) + FROM pg_index i WHERE i.indrelid = 't'::regclass AND i.indisvalid; + EXCEPTION WHEN OTHERS THEN + ids := SQLERRM; + END; + RETURN NEXT; + END $$; +} + +teardown +{ + DROP TABLE IF EXISTS fx, hp, t, tid_seen; + DROP FUNCTION chk, tid_at, tid_kept; + DROP EXTENSION amcheck; + DROP EXTENSION injection_points; +} + +session b1 +step b1l { BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; } +step b1c { COMMIT; } + +session b2 +step b2l { BEGIN; LOCK TABLE t IN ROW EXCLUSIVE MODE; } +step b2c { COMMIT; } + +session c +step c_attach { + SELECT injection_points_set_local(); + SELECT injection_points_attach('reindex-relation-concurrently-before-swap', 'wait'); +} +step pick_fx { ALTER TABLE fx RENAME TO t; } +step pick_hp { ALTER TABLE hp RENAME TO t; } +step c_idx { CREATE INDEX t_k ON t (k); } +step c_uidx { CREATE UNIQUE INDEX t_k ON t (k); } +step c_cic { CREATE INDEX CONCURRENTLY t_k ON t (k); } +step c_ucic { CREATE UNIQUE INDEX CONCURRENTLY t_k ON t (k); } +step c_ecic { CREATE INDEX CONCURRENTLY t_k ON t ((k * 2)); } +step c_icic { CREATE INDEX CONCURRENTLY t_k ON t (k) INCLUDE (v); } +step c_pcic { CREATE INDEX CONCURRENTLY t_k ON t (k) WHERE k < 1000; } +step c_ric { REINDEX INDEX CONCURRENTLY t_k; } +step c_dic { DROP INDEX CONCURRENTLY t_k; } +step c_dup { UPDATE t SET k = 2 WHERE id = 1; } +step c_undup { UPDATE t SET k = 1 WHERE id = 1; } +step c_tid { SELECT tid_at(1); } +# Cannot start before the session's previous step (c_ric) has finished. +step c_done { } +step c_state { + SELECT c.relname, i.indisready, i.indisvalid FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 't_k'; +} + +session w1 +step w1b { BEGIN; } +step w1u { UPDATE t SET k = 100 WHERE id = 1; } +step w1d { UPDATE t SET k = 2 WHERE id = 1; } +step w1v { UPDATE t SET v = 100 WHERE id = 1; } +step w1x { DELETE FROM t WHERE id = 1; } +step w1i { INSERT INTO t VALUES (23, 100); } +step w1n { UPDATE t SET v = 1000 WHERE id = 1; SELECT tid_kept(1) AS in_place; } +step w1u2 { UPDATE t SET k = 200 WHERE id = 1; SELECT tid_kept(1) AS in_place; } +step w1c { COMMIT; } +step w1r { ROLLBACK; } + +session w2 +step w2b { BEGIN; } +step w2u { UPDATE t SET k = 300 WHERE id = 3; } +step w2a { UPDATE t SET k = 1 WHERE id = 1; } +step w2v { UPDATE t SET k = 500 WHERE id = 23; } +step w2r { ROLLBACK; } +step w2c { COMMIT; } +step w2i1 { INSERT INTO t VALUES (21, 1); } +step w2i100 { INSERT INTO t VALUES (22, 100); } + +session r +step r_wake { + SELECT injection_points_detach('reindex-relation-concurrently-before-swap'); + SELECT injection_points_wakeup('reindex-relation-concurrently-before-swap'); +} +step rrb { BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; } +step rrc { COMMIT; } +step chk200 { SELECT * FROM chk('k = 200'); } +step chk1 { SELECT * FROM chk('k = 1'); } +step chk2 { SELECT * FROM chk('k = 2'); } +step chk100 { SELECT * FROM chk('k = 100'); } +step chk300 { SELECT * FROM chk('k = 300'); } +step chk3 { SELECT * FROM chk('k = 3'); } +step chke { SELECT * FROM chk('k * 2 = 200'); } +step chk500 { SELECT * FROM chk('k = 500'); } +step chkp { SELECT * FROM chk('k = 100 AND k < 1000'); } + +# Key UPDATE in phase 2, after the index is visible, invisible to the build +# snapshot, committed before validation. +permutation pick_fx b1l c_cic w1b w1u b1c w1c chk100 chk1 +permutation pick_hp b1l c_cic w1b w1u b1c w1c chk100 chk1 + +# A -> B in phase 2, B -> A in phase 3 (index ready, not yet validated). +permutation pick_fx b1l c_cic w1b w1u b2l b1c w1c w2a b2c chk1 chk100 +permutation pick_hp b1l c_cic w1b w1u b2l b1c w1c w2a b2c chk1 chk100 + +# DELETE and INSERT in phase 2. +permutation pick_fx b1l c_cic w1b w1x w1i b1c w1c chk100 chk1 +permutation pick_hp b1l c_cic w1b w1x w1i b1c w1c chk100 chk1 + +# Key UPDATE in phase 2 that rolls back after the build. +permutation pick_fx b1l c_cic w1b w1u b1c w1r chk100 chk1 +permutation pick_hp b1l c_cic w1b w1u b1c w1r chk100 chk1 + +# Key UPDATE in phase 3, still running through validation, rolled back +# after the index is valid: the rollback must reverse it in the new index. +permutation pick_fx b1l c_cic b2l b1c w2b w2u b2c w2r chk300 chk3 +permutation pick_hp b1l c_cic b2l b1c w2b w2u b2c w2r chk300 chk3 + +# INSERT in phase 2, then in phase 3 (index ready) a key UPDATE of that row +# that runs through validation and rolls back after it. The validation must +# index the version its snapshot sees, and the rollback must leave it there. +permutation pick_fx b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c w2r chk100 chk500 +permutation pick_hp b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c w2r chk100 chk500 + +# The same UPDATE committed after a REPEATABLE READ snapshot that predates it +# was taken: the snapshot still needs the row under its old key. +permutation pick_fx b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c rrb w2c chk100 rrc chk500 +permutation pick_hp b1l c_cic w1b w1i b2l b1c w1c w2b w2v b2c rrb w2c chk100 rrc chk500 + +# UNIQUE: an UPDATE in phase 2 makes a duplicate; the build must fail. +permutation pick_fx b1l c_ucic w1b w1d b1c w1c c_state chk2 +permutation pick_hp b1l c_ucic w1b w1d b1c w1c c_state chk2 + +# Expression and partial indexes. +permutation pick_fx b1l c_ecic w1b w1u b1c w1c chke +permutation pick_hp b1l c_ecic w1b w1u b1c w1c chke +permutation pick_fx b1l c_pcic w1b w1u b1c w1c chkp +permutation pick_hp b1l c_pcic w1b w1u b1c w1c chkp + +# An UPDATE of an INCLUDE column only. +permutation pick_fx b1l c_icic w1b w1v b1c w1c chk1 +permutation pick_hp b1l c_icic w1b w1v b1c w1c chk1 + +# REINDEX CONCURRENTLY of a unique index with a key UPDATE in phase 2: the +# rebuilt index must still reject a duplicate of the new key and accept the +# old one. +permutation pick_fx c_uidx b1l c_ric w1b w1u b1c w1c chk100 w2i100 w2i1 chk1 +permutation pick_hp c_uidx b1l c_ric w1b w1u b1c w1c chk100 w2i100 w2i1 chk1 + +# DROP INDEX CONCURRENTLY: a key UPDATE made while the index is still +# valid commits after the drop has cleared indisvalid; another one is made +# with the index invalid, before it is dead. +permutation pick_fx c_idx w1b w1u c_dic w1c chk100 chk1 +permutation pick_hp c_idx w1b w1u c_dic w1c chk100 chk1 +permutation pick_fx c_idx b1l c_dic w1b w1u b1c w1c chk100 chk1 +permutation pick_hp c_idx b1l c_dic w1b w1u b1c w1c chk100 chk1 + +# A UNIQUE build that fails leaves an invalid index. On FLUX a key UPDATE +# then moves the row and an UPDATE of a column no index uses stays in place +# (in_place; heap gives every updated row a new TID, so its twin says f +# both times). REINDEX INDEX CONCURRENTLY of the invalid index, with a key +# UPDATE in its phase 2, makes it valid and complete. +permutation pick_fx c_dup c_ucic c_undup c_state c_tid w1b w1n w1u2 w1c chk200 c_tid b1l c_ric w1b w1u b1c w1c c_state chk100 chk200 +permutation pick_hp c_dup c_ucic c_undup c_state c_tid w1b w1n w1u2 w1c chk200 c_tid b1l c_ric w1b w1u b1c w1c c_state chk100 chk200 + +# REINDEX CONCURRENTLY held before the swap; a key UPDATE made with both the +# old and the new index ready rolls back after the swap wait is released. +# This does not force the rollback to occur after the swap itself. +# After r_wake, c_ric may still be in the injection point's wait loop and +# so look blocked when w2r has finished; c_done keeps chk300 from starting +# before c_ric is done. +permutation pick_fx c_idx c_attach c_ric w2b w2u r_wake w2r c_done chk300 chk3 +permutation pick_hp c_idx c_attach c_ric w2b w2u r_wake w2r c_done chk300 chk3 diff --git a/src/test/modules/injection_points/specs/flux_commit_window.spec b/src/test/modules/injection_points/specs/flux_commit_window.spec new file mode 100644 index 0000000000000..11fd92864a0ab --- /dev/null +++ b/src/test/modules/injection_points/specs/flux_commit_window.spec @@ -0,0 +1,49 @@ +# FLUX: an UPDATE that meets a row whose last writer is between PRE_COMMIT +# and its CLOG update must wait for it and then re-evaluate on its value. +# +# At PRE_COMMIT FLUX finalizes the writer's sLog ops and clears the on-page +# UNCOMMITTED hint, but the transaction is not committed in CLOG until +# RecordTransactionCommit. The commit-before-clog-update injection point +# holds s1 exactly in that window. The row's writer is still on the page +# (t_xmin of the in-place image), so s2 must wait on it rather than overwrite +# the value it cannot see yet; after s1 completes, s2 re-evaluates via +# EvalPlanQual and the row ends at 2 -- heap's result. Before the writer was +# read from the header, nothing on the page or in the sLog named s1 in this +# window except the transaction-duration tuple lock FLUX used to hold. + +setup +{ + CREATE EXTENSION injection_points; + CREATE TABLE fcw (id int PRIMARY KEY, v int) USING flux; + INSERT INTO fcw VALUES (1, 0); +} + +teardown +{ + DROP TABLE fcw; + DROP EXTENSION injection_points; +} + +session s1 +setup +{ + SET synchronous_commit = on; + SELECT injection_points_set_local(); + SELECT injection_points_attach('commit-before-clog-update', 'wait'); +} +step s1u { UPDATE fcw SET v = v + 1 WHERE id = 1; } +teardown +{ + SELECT injection_points_detach('commit-before-clog-update'); +} + +session s2 +step s2u { UPDATE fcw SET v = v + 1 WHERE id = 1; } +step s2r { SELECT v FROM fcw WHERE id = 1; } + +session s3 +step s3w { SELECT injection_points_wakeup('commit-before-clog-update'); } + +# s1 (autocommit) stops after writing its commit record, before CLOG. s2's +# UPDATE must block until s3 lets s1 finish, then apply on top of s1's value. +permutation s1u s2u(s1u) s3w s2r diff --git a/src/test/modules/injection_points/specs/flux_repack.spec b/src/test/modules/injection_points/specs/flux_repack.spec new file mode 100644 index 0000000000000..ccc74e6bbdbe6 --- /dev/null +++ b/src/test/modules/injection_points/specs/flux_repack.spec @@ -0,0 +1,397 @@ +# FLUX: REPACK (CONCURRENTLY) with concurrent data changes. +# +# REPACK (CONCURRENTLY) copies the rows its initial snapshot sees into a new +# table, then replays onto it, by replica identity key, the changes logical +# decoding finds committed after that snapshot. FLUX updates rows in place +# (also when a key changes), writes the old version to UNDO, and reverts an +# aborted transaction in place from UNDO; the copy must still read exactly +# what the snapshot sees, and the replay must neither miss a committed change +# nor apply an aborted one. +# +# Each scenario runs twice, on a FLUX table and on its heap twin (pick_fx or +# pick_hp renames one of them, and its indexes, to t); heap is the control +# and both must give the same answers. The changes are made while REPACK +# waits at repack-concurrently-before-lock (after the copy and the index +# builds, before the catch-up), unless a scenario says otherwise. "truth" +# saves what a reader sees just before REPACK resumes; chk() then reports +# whether the table was rewritten, a digest of its contents, how many rows +# differ from truth, whether an index scan of each index returns what a +# seqscan does, and amcheck (heapallindexed, checkunique) of every index. +# +# Row 10 sits on a full first page and v is stored PLAIN, so growing v moves +# the row (FLUX: out-of-place UPDATE, logged as XLOG_FLUX_SUPERSEDE); w is +# stored EXTERNAL, so rows 1-5 have their w in the TOAST table. + +setup +{ + CREATE EXTENSION amcheck; + CREATE EXTENSION injection_points; + CREATE TABLE fx (id int PRIMARY KEY, k int, v text, w text) USING flux + WITH (fillfactor = 100, autovacuum_enabled = off); + CREATE TABLE hp (id int PRIMARY KEY, k int, v text, w text) USING heap + WITH (fillfactor = 100, autovacuum_enabled = off); + ALTER TABLE fx ALTER COLUMN v SET STORAGE PLAIN, + ALTER COLUMN w SET STORAGE EXTERNAL; + ALTER TABLE hp ALTER COLUMN v SET STORAGE PLAIN, + ALTER COLUMN w SET STORAGE EXTERNAL; + CREATE INDEX fx_k ON fx (k); + CREATE INDEX hp_k ON hp (k); + INSERT INTO fx SELECT g, g, repeat('a', 100), + CASE WHEN g <= 5 THEN repeat(md5(g::text), 100) END + FROM generate_series(1, 120) g; + INSERT INTO hp SELECT * FROM fx; + + CREATE TABLE truth (id int, k int, v text, w text) USING heap; + -- other tables, changed during REPACK: their changes are not t's + CREATE TABLE ofx (id int PRIMARY KEY, k int, v text, w text) USING flux; + CREATE TABLE ohp (id int PRIMARY KEY, k int, v text, w text) USING heap; + INSERT INTO ofx SELECT * FROM fx WHERE id <= 30; + INSERT INTO ohp SELECT * FROM fx WHERE id <= 30; + CREATE TABLE rf (node oid) USING heap; + + -- wait until REPACK holds at the injection point (copy and index builds + -- done, catch-up not started) + CREATE FUNCTION wait_hold() RETURNS void LANGUAGE plpgsql AS $$ + BEGIN + FOR i IN 1 .. 30000 LOOP + PERFORM pg_stat_clear_snapshot(); + IF EXISTS (SELECT 1 FROM pg_stat_activity + WHERE wait_event_type = 'InjectionPoint' + AND wait_event = 'repack-concurrently-before-lock') THEN + RETURN; + END IF; + PERFORM pg_sleep(0.01); + END LOOP; + RAISE EXCEPTION 'REPACK did not reach the injection point'; + END $$; + + -- wait until some process waits for the xid of isolation session sess + CREATE FUNCTION wait_for(sess text) RETURNS void LANGUAGE plpgsql AS $$ + BEGIN + FOR i IN 1 .. 30000 LOOP + IF EXISTS (SELECT 1 FROM pg_locks w JOIN pg_locks h + ON h.locktype = 'transactionid' + AND h.transactionid = w.transactionid AND h.granted + JOIN pg_stat_activity a ON a.pid = h.pid + WHERE w.locktype = 'transactionid' AND NOT w.granted + AND a.application_name LIKE '%/' || sess) THEN + RETURN; + END IF; + PERFORM pg_sleep(0.01); + END LOOP; + RAISE EXCEPTION 'nobody waited for session %', sess; + END $$; + + CREATE FUNCTION chk() RETURNS TABLE (rewritten bool, nrows bigint, + sum_id bigint, sum_k bigint, len_v bigint, len_w bigint, + digest text, vs_truth bigint, pk_scan text, k_scan text, amcheck text) + LANGUAGE plpgsql AS $$ + DECLARE + seq_pk text; + seq_k text; + idx_pk text; + idx_k text; + BEGIN + rewritten := (SELECT c.relfilenode <> (SELECT node FROM rf) + FROM pg_class c WHERE c.relname = 't'); + SELECT count(*), sum(id), sum(k), sum(length(v)), sum(length(w)), + md5(string_agg(format('%s/%s/%s/%s', id, k, md5(v), md5(w)), + ',' ORDER BY id)) + INTO nrows, sum_id, sum_k, len_v, len_w, digest FROM t; + vs_truth := (SELECT count(*) FROM + ((SELECT * FROM t EXCEPT ALL SELECT * FROM truth) + UNION ALL + (SELECT * FROM truth EXCEPT ALL SELECT * FROM t)) d); + PERFORM set_config('enable_indexscan', 'off', true); + PERFORM set_config('enable_bitmapscan', 'off', true); + SELECT string_agg(format('%s:%s', id, k), ',' ORDER BY id) INTO seq_pk + FROM t WHERE id > -1000000; + SELECT string_agg(format('%s:%s', id, k), ',' ORDER BY id) INTO seq_k + FROM t WHERE k > -1000000; + PERFORM set_config('enable_indexscan', 'on', true); + PERFORM set_config('enable_seqscan', 'off', true); + PERFORM set_config('enable_indexonlyscan', 'off', true); + SELECT string_agg(format('%s:%s', id, k), ',' ORDER BY id) INTO idx_pk + FROM t WHERE id > -1000000; + SELECT string_agg(format('%s:%s', id, k), ',' ORDER BY id) INTO idx_k + FROM t WHERE k > -1000000; + pk_scan := CASE WHEN idx_pk IS NOT DISTINCT FROM seq_pk THEN 'same' + ELSE 'differs' END; + k_scan := CASE WHEN idx_k IS NOT DISTINCT FROM seq_k THEN 'same' + ELSE 'differs' END; + amcheck := 'ok'; + BEGIN + PERFORM bt_index_check(i.indexrelid, true, true) + FROM pg_index i WHERE i.indrelid = 't'::regclass; + EXCEPTION WHEN OTHERS THEN + amcheck := SQLERRM; + END; + RETURN NEXT; + END $$; +} + +teardown +{ + DROP TABLE IF EXISTS fx, hp, t, truth, rf, ofx, ohp; + DROP FUNCTION wait_hold, wait_for, chk; + DROP EXTENSION amcheck; + DROP EXTENSION injection_points; +} + +# The REPACK session. The injection point is attached for every process +# (injection_points_set_local() registers an exit callback each time a +# permutation starts, and this spec has more permutations than there are +# slots); only REPACK reaches it. +session s +setup +{ + RESET enable_sort; + RESET enable_indexscan; + SELECT injection_points_attach('repack-concurrently-before-lock', 'wait'); +} +step pick_fx { + ALTER TABLE fx RENAME TO t; + ALTER INDEX fx_pkey RENAME TO t_pkey; + ALTER INDEX fx_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; +} +step pick_hp { + ALTER TABLE hp RENAME TO t; + ALTER INDEX hp_pkey RENAME TO t_pkey; + ALTER INDEX hp_k RENAME TO t_k; + INSERT INTO rf SELECT relfilenode FROM pg_class WHERE relname = 't'; +} +step by_idx { SET enable_sort = off; } +step by_sort { SET enable_indexscan = off; } +step rp { REPACK (CONCURRENTLY) t; } +step rpi { REPACK (CONCURRENTLY) t USING INDEX t_pkey; } +step chk { SELECT * FROM chk(); } +teardown +{ + SELECT injection_points_detach('repack-concurrently-before-lock'); +} + +# Writers. +session w +step wb { BEGIN; } +step wc { COMMIT; } +step wr { ROLLBACK; } +step wxid { BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; } +step w_ins { INSERT INTO t VALUES (1001, 1001, 'new', NULL); } +step w_nonkey { UPDATE t SET v = repeat('n', 100) WHERE id = 20; } +step w_k { UPDATE t SET k = 2100 WHERE id = 21; } +step w_id { UPDATE t SET id = 1022 WHERE id = 22; } +step w_grow { + UPDATE t SET v = repeat('g', 2000) WHERE id = 10; + SELECT (ctid::text::point)[0] > 0 AS moved FROM t WHERE id = 10; +} +step w_del { DELETE FROM t WHERE id = 23; } +step w_aba { + UPDATE t SET id = 1030 WHERE id = 30; + UPDATE t SET k = 3000 WHERE id = 1030; + UPDATE t SET id = 30 WHERE id = 1030; +} +step w_life { + INSERT INTO t VALUES (1040, 1040, 'life', NULL); + UPDATE t SET k = 1041, v = 'life2' WHERE id = 1040; + UPDATE t SET id = 1042 WHERE id = 1040; + DELETE FROM t WHERE id = 1042; +} +step w_a2b { UPDATE t SET id = 1031 WHERE id = 31; } +step w_b2a { UPDATE t SET id = 31, k = 3100 WHERE id = 1031; } +step w_ins2 { INSERT INTO t VALUES (1050, 1050, 'x', NULL); } +step w_upd2 { UPDATE t SET k = 1051, id = 1052 WHERE id = 1050; } +step w_del2 { DELETE FROM t WHERE id = 1052; } +step w_sp { + UPDATE t SET k = 4000 WHERE id = 40; + SAVEPOINT a; + UPDATE t SET k = 4001, id = 1040 WHERE id = 40; + DELETE FROM t WHERE id = 41; + INSERT INTO t VALUES (1043, 1043, 'sp', NULL); + UPDATE t SET v = repeat('s', 2000) WHERE id = 11; + ROLLBACK TO SAVEPOINT a; + UPDATE t SET v = 'after' WHERE id = 41; + SAVEPOINT b; + INSERT INTO t VALUES (1044, 1044, 'kept', NULL); + RELEASE SAVEPOINT b; +} +step w_upsert { + INSERT INTO t VALUES (50, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = excluded.k + 5000, v = 'upd'; + INSERT INTO t VALUES (1060, 1060, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET k = 0; + INSERT INTO t VALUES (51, 0, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (1061, 1061, 'n', NULL) ON CONFLICT DO NOTHING; + INSERT INTO t VALUES (52, 0, 'u', NULL) + ON CONFLICT (id) DO UPDATE SET id = 1062; +} +step w_toast { + INSERT INTO t VALUES (1070, 1070, 'toast', repeat(md5('new'), 100)); + UPDATE t SET w = repeat(md5('upd'), 100) WHERE id = 1; + UPDATE t SET k = 2002 WHERE id = 2; + UPDATE t SET id = 1003 WHERE id = 3; + DELETE FROM t WHERE id = 4; + UPDATE t SET w = NULL WHERE id = 5; + UPDATE t SET w = repeat(md5('grown'), 100) WHERE id = 60; +} +step w_prep { PREPARE TRANSACTION 'rp1'; } +step w_same { UPDATE t SET v = repeat('c', 100) WHERE id = 70; } +step w_after { UPDATE t SET v = 'later', k = 2200 WHERE id = 22; } +step w_other { + INSERT INTO ofx VALUES (1001, 1, 'other', NULL); + UPDATE ofx SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ofx SET id = 1022 WHERE id = 22; + DELETE FROM ofx WHERE id = 23; + INSERT INTO ohp VALUES (1001, 1, 'other', NULL); + UPDATE ohp SET k = 2100, v = repeat('o', 2000) WHERE id = 21; + UPDATE ohp SET id = 1022 WHERE id = 22; + DELETE FROM ohp WHERE id = 23; +} + +# Transactions that keep an xid open, so that the decoding's snapshot builder +# has to wait for them (see repack_commit_race.spec). +session x2 +step x2b { BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; } +step x2r { ROLLBACK; } +session x3 +step x3b { BEGIN; SELECT pg_current_xact_id() IS NOT NULL AS has_xid; } +step x3r { ROLLBACK; } + +# Holds REPACK between its snapshot and its copy: REPACK names its new table +# pg_temp_, so while g has created a table of that name and not +# committed, REPACK waits for g in make_new_heap. g gets its xid only after +# the snapshot builder stopped waiting for new transactions (x3), so REPACK's +# snapshot does not wait for g. +session g +step gb { + BEGIN; + DO $$ BEGIN + EXECUTE format('CREATE TABLE %I (x int)', 'pg_temp_' || 't'::regclass::oid); + END $$; +} +step gr { ROLLBACK; } + +# A transaction prepared before REPACK starts. +session p +step pp { + BEGIN; + UPDATE t SET k = 8000, v = repeat('p', 100) WHERE id = 80; + UPDATE t SET id = 1081 WHERE id = 81; + PREPARE TRANSACTION 'rp0'; +} +step pc0 { COMMIT PREPARED 'rp0'; } +step pc1 { COMMIT PREPARED 'rp1'; } + +# An old snapshot. +session r +step rrb { BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1 AS snap; } +step rrs { + SELECT count(*) AS old_snapshot_rows, sum(k) AS old_snapshot_sum_k, + (SELECT k FROM t WHERE id = 21) AS k21 FROM t; +} +step rrc { COMMIT; } + +# Observer: waits for the hold, saves the expected contents, resumes REPACK. +session o +step hold { SELECT wait_hold(); } +step wx3 { SELECT wait_for('x3'); } +step wg { SELECT wait_for('g'); } +step truth { INSERT INTO truth SELECT * FROM t; } +step wake { + SELECT injection_points_wakeup('repack-concurrently-before-lock'); +} + +# F1: INSERT, non-key UPDATE, secondary-key UPDATE, identity-key UPDATE, an +# UPDATE that moves the row, DELETE; each its own transaction. +permutation pick_fx rp hold w_ins w_nonkey w_k w_id w_grow w_del truth wake chk +permutation pick_hp rp hold w_ins w_nonkey w_k w_id w_grow w_del truth wake chk +# Other tables, FLUX and heap, changed by the same keys during REPACK: none +# of their changes may reach t. +permutation pick_fx rp hold w_other w_k truth wake chk +permutation pick_hp rp hold w_other w_k truth wake chk +permutation pick_fx rp hold wb w_other w_ins wc truth wake chk +permutation pick_hp rp hold wb w_other w_ins wc truth wake chk +# The same, copying in index order and through a sort. +permutation pick_fx by_idx rpi hold w_ins w_k w_id w_grow w_del truth wake chk +permutation pick_hp by_idx rpi hold w_ins w_k w_id w_grow w_del truth wake chk +permutation pick_fx by_sort rpi hold w_ins w_k w_id w_grow w_del truth wake chk +permutation pick_hp by_sort rpi hold w_ins w_k w_id w_grow w_del truth wake chk +# The same changes in one transaction. +permutation pick_fx rp hold wb w_ins w_nonkey w_k w_id w_grow w_del wc truth wake chk +permutation pick_hp rp hold wb w_ins w_nonkey w_k w_id w_grow w_del wc truth wake chk + +# F2: one row changed many times: A->B->A of the identity key in one +# transaction and across transactions; INSERT, UPDATEs and DELETE of a new +# row in one transaction and across transactions. +permutation pick_fx rp hold w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 truth wake chk +permutation pick_hp rp hold w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 truth wake chk +permutation pick_fx rp hold wb w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 wc truth wake chk +permutation pick_hp rp hold wb w_aba w_life w_a2b w_b2a w_ins2 w_upd2 w_del2 wc truth wake chk + +# F3: a transaction that changes everything and rolls back during REPACK +# (its revert runs at once), then committed changes of the same rows. +permutation pick_fx rp hold wb w_ins w_nonkey w_k w_id w_grow w_del w_aba wr w_after truth wake chk +permutation pick_hp rp hold wb w_ins w_nonkey w_k w_id w_grow w_del w_aba wr w_after truth wake chk + +# F4: ROLLBACK TO SAVEPOINT inside a committed transaction. +permutation pick_fx rp hold wb w_sp wc truth wake chk +permutation pick_hp rp hold wb w_sp wc truth wake chk + +# F5: INSERT ... ON CONFLICT DO UPDATE / DO NOTHING. +permutation pick_fx rp hold w_upsert truth wake chk +permutation pick_hp rp hold w_upsert truth wake chk + +# F6: TOAST: wide values inserted, replaced, kept across an UPDATE, deleted. +permutation pick_fx rp hold w_toast truth wake chk +permutation pick_hp rp hold w_toast truth wake chk + +# F7: a transaction prepared and committed during REPACK, and one prepared +# before REPACK starts, whose COMMIT PREPARED REPACK's snapshot waits for. +permutation pick_fx rp hold wb w_k w_id w_grow w_prep pc1 truth wake chk +permutation pick_hp rp hold wb w_k w_id w_grow w_prep pc1 truth wake chk +permutation pick_fx pp rp(*) pc0 hold w_ins truth wake chk +permutation pick_hp pp rp(*) pc0 hold w_ins truth wake chk + +# F8: snapshots taken before REPACK starts, and during it, used after it +# committed. REPACK (CONCURRENTLY) is not MVCC-safe: like heap, FLUX must +# show such a snapshot an empty table. +permutation pick_fx rrb rp hold w_k truth wake chk rrs rrc +permutation pick_hp rrb rp hold w_k truth wake chk rrs rrc +permutation pick_fx rp hold w_k rrb truth wake chk rrs rrc +permutation pick_hp rp hold w_k rrb truth wake chk rrs rrc + +# Changes in progress while REPACK copies: the snapshot builder waits for x2, +# then x3; w gets its xid once the builder waits for x3, so w is still running +# when the snapshot is taken, and its changes are decoded. The copy must read the +# versions before them (FLUX: from UNDO), and commit or rollback during the +# hold decides whether they are replayed. +permutation pick_fx x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wc truth wake chk +permutation pick_hp x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wc truth wake chk +permutation pick_fx x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wr w_after truth wake chk +permutation pick_hp x2b rp x3b x2r wx3 wb w_nonkey w_k w_id w_grow w_del w_ins x3r hold wr w_after truth wake chk + +# Changes committed after REPACK's snapshot, before its copy: the copy must +# read the versions the snapshot sees (FLUX: from UNDO) and the replay apply +# the changes once. Also a transaction that rolls back in that window, and +# one running at the snapshot that commits in it. +permutation pick_fx x2b rp x3b x2r wx3 gb x3r wg w_ins w_nonkey w_k w_id w_grow w_del w_aba gr hold truth wake chk +permutation pick_hp x2b rp x3b x2r wx3 gb x3r wg w_ins w_nonkey w_k w_id w_grow w_del w_aba gr hold truth wake chk +permutation pick_fx x2b rp x3b x2r wx3 wb w_ins w_k w_id w_grow gb x3r wg wc w_toast w_upsert gr hold truth wake chk +permutation pick_hp x2b rp x3b x2r wx3 wb w_ins w_k w_id w_grow gb x3r wg wc w_toast w_upsert gr hold truth wake chk +permutation pick_fx x2b rp x3b x2r wx3 gb x3r wg wb w_ins w_nonkey w_k w_id w_grow w_del wr w_after gr hold truth wake chk +permutation pick_hp x2b rp x3b x2r wx3 gb x3r wg wb w_ins w_nonkey w_k w_id w_grow w_del wr w_after gr hold truth wake chk + +# The same window, copying through the identity index and through a sort: +# key UPDATEs after the snapshot leave delete-marked entries under the old +# keys (FLUX), and each row must still be copied once. +permutation pick_fx by_idx x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk +permutation pick_hp by_idx x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk +permutation pick_fx by_sort x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk +permutation pick_hp by_sort x2b rpi x3b x2r wx3 gb x3r wg w_id w_aba w_a2b w_k w_del w_ins gr hold truth wake chk + +# F11 (wal_level = replica, so REPACK raises the effective level): w has an +# xid before REPACK enables logical decoding, so w keeps logging without +# logical images (FLUX may take its same-size fast path) until it ends. +# REPACK's snapshot waits for w, so w's changes must be in the copy. +permutation pick_fx wxid rp w_same w_k wc hold w_ins truth wake chk +permutation pick_hp wxid rp w_same w_k wc hold w_ins truth wake chk diff --git a/src/test/modules/injection_points/specs/flux_repack_decode.spec b/src/test/modules/injection_points/specs/flux_repack_decode.spec new file mode 100644 index 0000000000000..2d2b4dbe8bbf4 --- /dev/null +++ b/src/test/modules/injection_points/specs/flux_repack_decode.spec @@ -0,0 +1,67 @@ +# REPACK (CONCURRENTLY) of a FLUX table: the changes REPACK replays onto its +# transient table must not reach an output plugin, not even one that asks for +# the changes of table rewrites (see repack_decode.spec, for heap). Each +# permutation runs on a FLUX table and on its heap twin; heap is the control. + +setup +{ + SELECT 'init' FROM pg_create_logical_replication_slot('s', 'test_decoding'); +} +setup +{ + CREATE EXTENSION injection_points; + CREATE TABLE fx (i int PRIMARY KEY, k int, t text) USING flux; + CREATE TABLE hp (i int PRIMARY KEY, k int, t text) USING heap; + ALTER TABLE fx ALTER COLUMN t SET STORAGE EXTERNAL; + ALTER TABLE hp ALTER COLUMN t SET STORAGE EXTERNAL; + INSERT INTO fx SELECT g, g, repeat(md5(g::text), 100) + FROM generate_series(1, 3) g; + INSERT INTO hp SELECT * FROM fx; +} + +teardown +{ + DROP TABLE IF EXISTS fx, hp, t; + DROP EXTENSION injection_points; + SELECT pg_drop_replication_slot('s'); +} + +session s1 +setup +{ + SELECT injection_points_set_local(); + SELECT injection_points_attach('repack-concurrently-before-lock', 'wait'); +} +step pick_fx { ALTER TABLE fx RENAME TO t; } +step pick_hp { ALTER TABLE hp RENAME TO t; } +step rp { REPACK (CONCURRENTLY) t; } +# Skip what the setup and the rename did. +step skip { + SELECT count(*) > 0 AS skipped FROM pg_logical_slot_get_changes('s', NULL, NULL); +} +step decode { + SELECT regexp_replace(data, '''[0-9a-f]+''', '''''', 'g') AS data + FROM pg_logical_slot_get_changes('s', NULL, NULL, 'include-rewrites', '1', + 'skip-empty-xacts', '1', 'include-xids', '0'); +} +teardown +{ + SELECT injection_points_detach('repack-concurrently-before-lock'); +} + +session s2 +# Replayed by REPACK: an in-place UPDATE of a non-key column to a short +# value, of a key, to a new TOASTed value, an INSERT and a DELETE. +step s2_changes { + UPDATE t SET t = 'short' WHERE i = 1; + UPDATE t SET k = 20 WHERE i = 2; + UPDATE t SET t = repeat(md5('new'), 100) WHERE i = 3; + INSERT INTO t VALUES (4, 4, 'four'); + DELETE FROM t WHERE i = 4; +} +step s2_wake { + SELECT injection_points_wakeup('repack-concurrently-before-lock'); +} + +permutation pick_fx skip rp s2_changes s2_wake decode +permutation pick_hp skip rp s2_changes s2_wake decode diff --git a/src/test/modules/injection_points/specs/flux_update_cas_race.spec b/src/test/modules/injection_points/specs/flux_update_cas_race.spec new file mode 100644 index 0000000000000..72d717cdbd074 --- /dev/null +++ b/src/test/modules/injection_points/specs/flux_update_cas_race.spec @@ -0,0 +1,91 @@ +# An UPDATE decides whether it changes an indexed column, and which index +# entries change, from the image on the page, read without waiting. The row +# can change between that decision and the moment the UPDATE has it locked: +# a running writer can roll back, or a new one can write. Two checks keep +# the decision tied to the version it was made from (flux_same_version): +# +# - the same-size in-place path (CAS), which overwrites the row without +# index work when no indexed column changes, must find that version on the +# page. s1 changes k from 3 to 100; s2 sets k to 100 too, so it finds no +# key change; s1 rolls back before s2 locks the page. Overwriting the +# restored row (k = 3) without index work would leave it with k = 100 and +# no entry for it. +# +# - the copy of the old row that a key change forms its old index entries +# from must be that version too. s2 sets k to 200 and decides from the +# committed row (k = 3); s1 then changes k to 100, so s2's copy is s1's +# image; s1 rolls back. Delete-marking the entry of k = 100 instead of +# k = 3 would leave (3, TID) live, and changing k back to 3 would add a +# second (3, TID) entry. +# +# s2 stops at flux-update-after-decision; wake detaches the point (a second +# decision does not stop) and lets s2 go on; s2done waits for s2. The row +# is then read through the index under every key it may have and by a +# seqscan, and amcheck checks the index. Each permutation runs on FLUX +# (pick_fx) and heap (pick_hp), which must return the same rows; heap's s2 +# never stops at the injection point. + +setup +{ + CREATE EXTENSION injection_points; + CREATE EXTENSION amcheck; + CREATE TABLE cr_fx (id int, k int, v int) USING flux; + CREATE TABLE cr_hp (id int, k int, v int) USING heap; + CREATE INDEX cr_fx_k ON cr_fx (k); + CREATE INDEX cr_hp_k ON cr_hp (k); + INSERT INTO cr_fx SELECT g, g, 0 FROM generate_series(1, 5) g; + INSERT INTO cr_hp SELECT g, g, 0 FROM generate_series(1, 5) g; +} +teardown +{ + DROP TABLE IF EXISTS kw, cr_fx, cr_hp; + DROP EXTENSION amcheck; + DROP EXTENSION injection_points; +} + +session s1 +step pick_fx { ALTER TABLE cr_fx RENAME TO kw; ALTER INDEX cr_fx_k RENAME TO kw_k; } +step pick_hp { ALTER TABLE cr_hp RENAME TO kw; ALTER INDEX cr_hp_k RENAME TO kw_k; } +step s1b { BEGIN; } +step s1k { UPDATE kw SET k = 100 WHERE id = 3; } +step s1r { ROLLBACK; } +step s1c { COMMIT; } +step back { UPDATE kw SET k = 3 WHERE id = 3; } + +session s2 +setup { + SELECT injection_points_set_local(); + SELECT injection_points_attach('flux-update-after-decision', 'wait'); +} +step s2k { UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; } +step s2k2 { UPDATE kw SET k = 200, v = 2 WHERE id = 3 RETURNING id, k, v; } +step s2done { } + +session s3 +step wake { + DO $$ + BEGIN + PERFORM injection_points_detach('flux-update-after-decision'); + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-after-decision') THEN + PERFORM injection_points_wakeup('flux-update-after-decision'); + END IF; + END $$; +} +step chk { + SET enable_seqscan = off; SET enable_bitmapscan = off; + SET enable_indexonlyscan = off; + SELECT 'idx' AS via, id, k, v FROM kw WHERE k IN (3, 100, 200) ORDER BY k; + RESET enable_seqscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; + SELECT 'seq' AS via, id, k, v FROM kw WHERE id = 3; + SELECT bt_index_parent_check('kw_k', true, true); +} + +# CAS: s2 decides from s1's image. +permutation pick_fx s1b s1k s2k s1r wake s2done chk +permutation pick_hp s1b s1k s2k s1r wake s2done chk +permutation pick_fx s1b s1k s2k s1c wake s2done chk +permutation pick_hp s1b s1k s2k s1c wake s2done chk +# Copy of the old row: s1 writes after s2's decision. +permutation pick_fx s2k2 s1b s1k wake s1r s2done back chk +permutation pick_hp s2k2 s1b s1k wake s1r s2done back chk diff --git a/src/test/modules/injection_points/specs/flux_update_redecide_queue.spec b/src/test/modules/injection_points/specs/flux_update_redecide_queue.spec new file mode 100644 index 0000000000000..26274996299bc --- /dev/null +++ b/src/test/modules/injection_points/specs/flux_update_redecide_queue.spec @@ -0,0 +1,87 @@ +# An UPDATE that waits for a writer of its row keeps its place in the queue +# of transactions waiting for that row, as heap_update does by holding the +# tuple lock until it is done. FLUX decides an UPDATE's index work from the +# image it reads before waiting; when the writer it waited for rolls back, +# the row is another version and the UPDATE decides again +# (flux-update-redecide). It must still hold the tuple lock then, or a +# DELETE queued behind it deletes the row first. +# +# s1 changes k, s2 sets k to the same value and waits for s1, s3 deletes the +# row and queues behind s2; s1 rolls back. On heap s2 updates the row and +# s3 then deletes s2's version. s2 and s3 run in autocommit, so neither +# needs a commit step that could wait behind the other. The step sync waits +# until s2 is at the injection point (or done) and s3 is either done or +# blocked by s2, whichever happens; wake then lets s2 go on, and s3sel +# reads the table once s3 is done. Each permutation runs on FLUX (pick_fx) +# and heap (pick_hp), which must return the same rows; heap's s2 does not +# stop at the injection point, so its steps complete right after s1r. + +setup +{ + CREATE EXTENSION injection_points; + CREATE TABLE ur_fx (id int, k int, v int) USING flux; + CREATE TABLE ur_hp (id int, k int, v int) USING heap; + CREATE INDEX ON ur_fx (k); + CREATE INDEX ON ur_hp (k); + INSERT INTO ur_fx SELECT g, g, 0 FROM generate_series(1, 5) g; + INSERT INTO ur_hp SELECT g, g, 0 FROM generate_series(1, 5) g; +} +teardown +{ + DROP TABLE IF EXISTS kw, ur_fx, ur_hp; + DROP EXTENSION injection_points; +} + +session s1 +step pick_fx { ALTER TABLE ur_fx RENAME TO kw; } +step pick_hp { ALTER TABLE ur_hp RENAME TO kw; } +step s1b { BEGIN; } +step s1k { UPDATE kw SET k = 100 WHERE id = 3; } +step s1r { ROLLBACK; } + +session s2 +setup { + SELECT injection_points_set_local(); + SELECT injection_points_attach('flux-update-redecide', 'wait'); +} +step s2k { UPDATE kw SET k = 100, v = 2 WHERE id = 3 RETURNING id, k, v; } +step s2det { SELECT injection_points_detach('flux-update-redecide'); } + +session s3 +step s3d { DELETE FROM kw WHERE id = 3 RETURNING id, k, v; } +step s3sel { SELECT id, k, v FROM kw ORDER BY id; } + +session s4 +step sync { + DO $$ + DECLARE + n int := 0; + BEGIN + LOOP + PERFORM pg_stat_clear_snapshot(); + EXIT WHEN EXISTS ( + SELECT FROM pg_stat_activity a2, pg_stat_activity a3 + WHERE a2.application_name LIKE '%/s2' + AND a3.application_name LIKE '%/s3' + AND (a2.wait_event = 'flux-update-redecide' OR a2.state = 'idle') + AND (a3.state = 'idle' OR a2.pid = ANY (pg_blocking_pids(a3.pid)))); + n := n + 1; + IF n > 30000 THEN + RAISE EXCEPTION 's2 and s3 did not settle'; + END IF; + PERFORM pg_sleep(0.01); + END LOOP; + END $$; +} +step wake { + DO $$ + BEGIN + IF EXISTS (SELECT FROM pg_stat_activity + WHERE wait_event = 'flux-update-redecide') THEN + PERFORM injection_points_wakeup('flux-update-redecide'); + END IF; + END $$; +} + +permutation pick_fx s1b s1k s2k(wake) s3d(s2k) s1r sync wake s3sel s2det +permutation pick_hp s1b s1k s2k(wake) s3d(s2k) s1r sync wake s3sel s2det diff --git a/src/test/modules/injection_points/specs/repack_other_table.spec b/src/test/modules/injection_points/specs/repack_other_table.spec new file mode 100644 index 0000000000000..6c97c497715d3 --- /dev/null +++ b/src/test/modules/injection_points/specs/repack_other_table.spec @@ -0,0 +1,83 @@ +# REPACK (CONCURRENTLY) of one table while another table changes. +# +# REPACK's decoding worker decodes the WAL of the whole database but must +# keep only the changes of the table it repacks (and its TOAST table); each +# rmgr's decode routine asks change_useless_for_repack() to drop the rest. +# The repacked table here is heap; the other table is FLUX in one run and +# heap (the control) in the other. Both must leave the repacked table as it +# was, and the other table's changes in the other table only. + +setup +{ + CREATE EXTENSION amcheck; + CREATE EXTENSION injection_points; + CREATE TABLE r (id int PRIMARY KEY, k int, v text) USING heap; + CREATE INDEX r_k ON r (k); + INSERT INTO r SELECT g, g, 'r' FROM generate_series(1, 20) g; + -- v is PLAIN and the first page full, so growing v moves the row + CREATE TABLE ofx (id int PRIMARY KEY, k int, v text) USING flux + WITH (fillfactor = 100); + CREATE TABLE ohp (id int PRIMARY KEY, k int, v text) USING heap + WITH (fillfactor = 100); + ALTER TABLE ofx ALTER COLUMN v SET STORAGE PLAIN; + ALTER TABLE ohp ALTER COLUMN v SET STORAGE PLAIN; + INSERT INTO ofx SELECT g, g * 100, repeat('o', 100) + FROM generate_series(1, 120) g; + INSERT INTO ohp SELECT * FROM ofx; + -- COPY FROM inserts in batches (FLUX: XLOG_FLUX_MULTI_INSERT) + DO $$ BEGIN + EXECUTE format('COPY (SELECT g, g, %L FROM generate_series(1030, 1039) g) TO %L', + 'copied', current_setting('data_directory') || '/repack_other_table.dat'); + END $$; +} + +teardown +{ + DROP TABLE IF EXISTS r, ofx, ohp, o; + DROP EXTENSION amcheck; + DROP EXTENSION injection_points; +} + +session s +setup +{ + SELECT injection_points_set_local(); + SELECT injection_points_attach('repack-concurrently-before-lock', 'wait'); +} +step rp { REPACK (CONCURRENTLY) r; } +step chk { + SELECT count(*), sum(id), sum(k), string_agg(DISTINCT v, ',') AS v FROM r; + SELECT bt_index_check(indexrelid, true) FROM pg_index + WHERE indrelid = 'r'::regclass; + SELECT count(*), sum(id), sum(k) FROM o; +} +teardown +{ + SELECT injection_points_detach('repack-concurrently-before-lock'); +} + +session w +step pick_fx { ALTER TABLE ofx RENAME TO o; } +step pick_hp { ALTER TABLE ohp RENAME TO o; } +# Keys that also exist in r, so that a change replayed onto r would find a row. +step w_dml { + INSERT INTO o VALUES (1001, 1001, 'other'); + UPDATE o SET k = 2222, v = 'other' WHERE id = 2; + UPDATE o SET id = 1003 WHERE id = 3; + DELETE FROM o WHERE id = 4; + INSERT INTO o SELECT g, g, 'other' FROM generate_series(1010, 1019) g; + INSERT INTO o VALUES (5, 0, 'other') ON CONFLICT (id) DO UPDATE SET k = 5555; + INSERT INTO o VALUES (1020, 0, 'other') ON CONFLICT (id) DO NOTHING; + UPDATE o SET v = repeat('x', 2000) WHERE id = 6; + SELECT (ctid::text::point)[0] > 0 AS moved FROM o WHERE id = 6; + DO $$ BEGIN + EXECUTE format('COPY o FROM %L', + current_setting('data_directory') || '/repack_other_table.dat'); + END $$; +} +step wake { + SELECT injection_points_wakeup('repack-concurrently-before-lock'); +} + +permutation pick_fx rp w_dml wake chk +permutation pick_hp rp w_dml wake chk diff --git a/src/test/modules/injection_points/specs/vacuum_progress.spec b/src/test/modules/injection_points/specs/vacuum_progress.spec new file mode 100644 index 0000000000000..234399f8148fa --- /dev/null +++ b/src/test/modules/injection_points/specs/vacuum_progress.spec @@ -0,0 +1,102 @@ +# pg_stat_progress_vacuum as another session sees it while VACUUM is in its +# index-vacuum phase. The same steps run on a heap and a FLUX table, and +# the expected values are the same for both. +# +# VACUUM waits in vac_bulkdel_one_index: its table scan has collected the +# dead TIDs and the first index is about to be vacuumed. A cost limit of 1 +# makes the scan sleep at every page, so delay_time must be positive. + +setup +{ + CREATE EXTENSION injection_points; + CREATE TABLE vp_heap (id int, v int) USING heap + WITH (autovacuum_enabled = off, fillfactor = 10); + CREATE TABLE vp_flux (id int, v int) USING flux + WITH (autovacuum_enabled = off, fillfactor = 10); + INSERT INTO vp_heap SELECT g, g FROM generate_series(1, 3000) g; + INSERT INTO vp_flux SELECT g, g FROM generate_series(1, 3000) g; + CREATE INDEX vp_heap_id ON vp_heap (id); + CREATE INDEX vp_flux_id ON vp_flux (id); + DELETE FROM vp_heap WHERE id % 3 = 0; + DELETE FROM vp_flux WHERE id % 3 = 0; + + -- Wait until the VACUUM is at the injection point in index-vacuum + -- round "round" (counting from 0), then show its progress. + CREATE FUNCTION vp_progress(round int) + RETURNS TABLE (rel regclass, phase text, total_is_size bool, + scanned text, vacuumed text, index_vacuum_count bigint, + max_dead_tuple_bytes bigint, have_dead_bytes bool, + num_dead_item_ids bigint, indexes_total bigint, + indexes_processed bigint, at_index bool, delayed bool, + mode text, started_by text) + LANGUAGE plpgsql AS $$ + BEGIN + FOR i IN 1 .. 18000 LOOP + EXIT WHEN EXISTS ( + SELECT FROM pg_stat_progress_vacuum p + JOIN pg_stat_activity a USING (pid) + WHERE p.datname = current_database() + AND p.index_vacuum_count = round + AND a.wait_event = 'vacuum-bulkdel-one-index'); + PERFORM pg_sleep(0.01); + END LOOP; + RETURN QUERY + SELECT p.relid::regclass, p.phase, + p.heap_blks_total = pg_relation_size(p.relid) / + current_setting('block_size')::int, + CASE WHEN p.heap_blks_scanned = 0 THEN 'none' + WHEN p.heap_blks_scanned < p.heap_blks_total THEN 'some' + ELSE 'all' END, + CASE WHEN p.heap_blks_vacuumed = 0 THEN 'none' + WHEN p.heap_blks_vacuumed < p.heap_blks_total THEN 'some' + ELSE 'all' END, + p.index_vacuum_count, p.max_dead_tuple_bytes, + p.dead_tuple_bytes > 0, p.num_dead_item_ids, + p.indexes_total, p.indexes_processed, + p.current_index_relid = (SELECT indexrelid FROM pg_index + WHERE indrelid = p.relid), + p.delay_time > 0, p.mode, p.started_by + FROM pg_stat_progress_vacuum p + WHERE p.datname = current_database(); + END $$; +} + +teardown +{ + DROP TABLE vp_heap; + DROP TABLE vp_flux; + DROP FUNCTION vp_progress(int); + DROP EXTENSION injection_points; +} + +session s1 +setup +{ + SELECT injection_points_set_local(); + SELECT injection_points_attach('vacuum-bulkdel-one-index', 'wait'); + SET maintenance_work_mem = '1MB'; + -- Cost-based delay must throttle the table scan. + SET track_cost_delay_timing = on; + SET vacuum_cost_delay = '1ms'; + SET vacuum_cost_limit = 1; +} +step s1_vac_heap { VACUUM (PARALLEL 0) vp_heap; } +step s1_vac_flux { VACUUM (PARALLEL 0) vp_flux; } +step s1_done +{ + SELECT count(*) AS progress_rows FROM pg_stat_progress_vacuum + WHERE pid = pg_backend_pid(); +} +step s1_rows_heap { SELECT count(*), sum(id) FROM vp_heap; } +step s1_rows_flux { SELECT count(*), sum(id) FROM vp_flux; } +teardown +{ + SELECT injection_points_detach('vacuum-bulkdel-one-index'); +} + +session s2 +step s2_round0 { SELECT * FROM vp_progress(0); } +step s2_wake { SELECT injection_points_wakeup('vacuum-bulkdel-one-index'); } + +permutation s1_vac_heap s2_round0 s2_wake s1_done s1_rows_heap +permutation s1_vac_flux s2_round0 s2_wake s1_done s1_rows_flux diff --git a/src/test/modules/test_deletemark/meson.build b/src/test/modules/test_deletemark/meson.build index f0ec193c45335..95b2767c2ecf9 100644 --- a/src/test/modules/test_deletemark/meson.build +++ b/src/test/modules/test_deletemark/meson.build @@ -33,6 +33,7 @@ tests += { 'tap': { 'tests': [ 't/001_deletemark_recovery.pl', + 't/002_deletemark_lp_dead.pl', ], }, } diff --git a/src/test/modules/test_deletemark/t/002_deletemark_lp_dead.pl b/src/test/modules/test_deletemark/t/002_deletemark_lp_dead.pl new file mode 100644 index 0000000000000..94cbd544bbd8d --- /dev/null +++ b/src/test/modules/test_deletemark/t/002_deletemark_lp_dead.pl @@ -0,0 +1,90 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +# nbtree delete-marking and the LP_DEAD hint. +# +# (1) Delete-marking an entry that already carries LP_DEAD must not produce an +# LP_DEAD tombstone: _bt_simpledel_pass asserts that a tombstone is never +# LP_DEAD, and an LP_DEAD tombstone would also be skipped by the very +# old-snapshot scans it exists for. +# (2) Demoting a tombstone back to a plain entry (rollback) must leave a live +# entry on the primary and on a streaming standby alike: the primary must +# clear LP_DEAD just as redo does, or the restored entry stays hidden from +# index scans on the primary only. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf('postgresql.conf', 'wal_consistency_checking = btree'); +$primary->start; +$primary->safe_psql('postgres', 'CREATE EXTENSION test_deletemark'); +$primary->safe_psql('postgres', 'CREATE EXTENSION amcheck'); +$primary->safe_psql( + 'postgres', q{ +CREATE TABLE dm_d (k int, v int) WITH (autovacuum_enabled = off); +INSERT INTO dm_d SELECT g, g FROM generate_series(1, 20) g; +CREATE INDEX dm_didx ON dm_d (k); +}); + +$primary->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->start; + +# (2) An LP_DEAD tombstone demoted by a rollback. +my $tid15 = + $primary->safe_psql('postgres', "SELECT ctid FROM dm_d WHERE k = 15"); +is($primary->safe_psql('postgres', "SELECT dm_mark('dm_didx', '$tid15')"), + 't', 'entry for k=15 delete-marked'); +is( $primary->safe_psql( + 'postgres', "SELECT dm_set_dead('dm_didx', '$tid15')"), + 't', + 'LP_DEAD set on tombstone'); +is( $primary->safe_psql('postgres', "SELECT dm_unmark('dm_didx', '$tid15')"), + 't', + 'tombstone demoted'); +is( $primary->safe_psql('postgres', "SELECT dm_is_dead('dm_didx', '$tid15')"), + 'f', + 'demoted entry is not LP_DEAD on the primary'); + +$primary->wait_for_catchup($standby, 'replay'); +is( $standby->safe_psql('postgres', "SELECT dm_is_dead('dm_didx', '$tid15')"), + 'f', + 'demoted entry is not LP_DEAD on the standby'); + +my $q = "SET enable_seqscan = off; SET enable_bitmapscan = off; " + . "SELECT count(*) FROM dm_d WHERE k = 15"; +my $p = $primary->safe_psql('postgres', $q); +my $s = $standby->safe_psql('postgres', $q); +is($p, '1', 'primary index scan finds the restored entry'); +is($s, $p, 'standby index scan agrees with the primary'); + + +# (1) LP_DEAD plain entry, then delete-mark it. +my $tid10 = + $primary->safe_psql('postgres', "SELECT ctid FROM dm_d WHERE k = 10"); +is( $primary->safe_psql( + 'postgres', "SELECT dm_set_dead('dm_didx', '$tid10')"), + 't', + 'LP_DEAD set on plain entry'); +is($primary->safe_psql('postgres', "SELECT dm_mark('dm_didx', '$tid10')"), + 'f', 'delete-marking an LP_DEAD entry reports "not present"'); +is( $primary->safe_psql( + 'postgres', "SELECT dm_classify('dm_didx', '$tid10')"), + 'plain', + 'LP_DEAD entry was not turned into a tombstone'); + +# Fill the leaf so an insert runs the LP_DEAD simple-deletion pass over it. +my ($ret, $out, $err) = $primary->psql('postgres', + "INSERT INTO dm_d SELECT g, g FROM generate_series(21, 1500) g"); +is($ret, 0, 'insert that runs _bt_simpledel_pass succeeds'); +is($primary->safe_psql('postgres', "SELECT bt_index_check('dm_didx')"), + '', 'bt_index_check clean after simple deletion'); + +$standby->stop; +$primary->stop; +done_testing(); diff --git a/src/test/modules/test_deletemark/test_deletemark--1.0.sql b/src/test/modules/test_deletemark/test_deletemark--1.0.sql index ada7272c8354b..c201d57d414b0 100644 --- a/src/test/modules/test_deletemark/test_deletemark--1.0.sql +++ b/src/test/modules/test_deletemark/test_deletemark--1.0.sql @@ -18,3 +18,15 @@ AS 'MODULE_PATHNAME' LANGUAGE C; CREATE FUNCTION dm_scan_recheck(idx regclass) RETURNS boolean STRICT AS 'MODULE_PATHNAME' LANGUAGE C; + +CREATE FUNCTION dm_set_dead(idx regclass, t tid) +RETURNS boolean STRICT +AS 'MODULE_PATHNAME' LANGUAGE C; + +CREATE FUNCTION dm_is_dead(idx regclass, t tid) +RETURNS boolean STRICT +AS 'MODULE_PATHNAME' LANGUAGE C; + +CREATE FUNCTION dm_unmark(idx regclass, t tid) +RETURNS boolean STRICT +AS 'MODULE_PATHNAME' LANGUAGE C; diff --git a/src/test/modules/test_deletemark/test_deletemark.c b/src/test/modules/test_deletemark/test_deletemark.c index 26903ade49b50..c7532a26d3f44 100644 --- a/src/test/modules/test_deletemark/test_deletemark.c +++ b/src/test/modules/test_deletemark/test_deletemark.c @@ -247,3 +247,101 @@ dm_scan_recheck(PG_FUNCTION_ARGS) index_close(irel, AccessShareLock); PG_RETURN_BOOL(any_recheck); } + +/* + * dm_set_dead(idx, heaptid) -- set the LP_DEAD hint on the leaf entry for + * heaptid, the way _bt_killitems does. Lets a test build the state where a + * plain entry or a tombstone carries LP_DEAD. + */ +PG_FUNCTION_INFO_V1(dm_set_dead); +Datum +dm_set_dead(PG_FUNCTION_ARGS) +{ + Oid idxoid = PG_GETARG_OID(0); + ItemPointer target = (ItemPointer) PG_GETARG_POINTER(1); + Relation irel = index_open(idxoid, RowExclusiveLock); + OffsetNumber off; + Buffer buf; + Page page; + + buf = find_leaf_entry(irel, target, &off); + if (buf == InvalidBuffer) + { + index_close(irel, RowExclusiveLock); + PG_RETURN_BOOL(false); + } + /* Tiny single-session test indexes: the entry cannot move meanwhile. */ + LockBuffer(buf, BUFFER_LOCK_UNLOCK); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + ItemIdMarkDead(PageGetItemId(page, off)); + BTPageGetOpaque(page)->btpo_flags |= BTP_HAS_GARBAGE; + MarkBufferDirtyHint(buf, true); + UnlockReleaseBuffer(buf); + index_close(irel, RowExclusiveLock); + PG_RETURN_BOOL(true); +} + +/* dm_is_dead(idx, heaptid) -- does the leaf entry for heaptid carry LP_DEAD? */ +PG_FUNCTION_INFO_V1(dm_is_dead); +Datum +dm_is_dead(PG_FUNCTION_ARGS) +{ + Oid idxoid = PG_GETARG_OID(0); + ItemPointer target = (ItemPointer) PG_GETARG_POINTER(1); + Relation irel = index_open(idxoid, AccessShareLock); + OffsetNumber off; + Buffer buf; + bool dead; + + buf = find_leaf_entry(irel, target, &off); + if (buf == InvalidBuffer) + { + index_close(irel, AccessShareLock); + PG_RETURN_NULL(); + } + dead = ItemIdIsDead(PageGetItemId(BufferGetPage(buf), off)); + UnlockReleaseBuffer(buf); + index_close(irel, AccessShareLock); + PG_RETURN_BOOL(dead); +} + +/* + * dm_unmark(idx, heaptid) -- demote the tombstone for heaptid back to a plain + * entry through amundomark(clear_mark = true), as a rollback does. + */ +PG_FUNCTION_INFO_V1(dm_unmark); +Datum +dm_unmark(PG_FUNCTION_ARGS) +{ + Oid idxoid = PG_GETARG_OID(0); + ItemPointer target = (ItemPointer) PG_GETARG_POINTER(1); + Relation irel = index_open(idxoid, RowExclusiveLock); + Relation heapRel; + OffsetNumber off; + Buffer buf; + Datum values[INDEX_MAX_KEYS]; + bool isnull[INDEX_MAX_KEYS]; + IndexTuple copy; + bool ok; + + heapRel = relation_open(irel->rd_index->indrelid, RowExclusiveLock); + buf = find_leaf_entry(irel, target, &off); + if (buf == InvalidBuffer) + { + relation_close(heapRel, RowExclusiveLock); + index_close(irel, RowExclusiveLock); + PG_RETURN_BOOL(false); + } + copy = CopyIndexTuple((IndexTuple) PageGetItem(BufferGetPage(buf), + PageGetItemId(BufferGetPage(buf), off))); + UnlockReleaseBuffer(buf); + index_deform_tuple(copy, RelationGetDescr(irel), values, isnull); + + ok = irel->rd_indam->amundomark(irel, heapRel, values, isnull, target, + true, false); + + relation_close(heapRel, RowExclusiveLock); + index_close(irel, RowExclusiveLock); + PG_RETURN_BOOL(ok); +} diff --git a/src/test/modules/test_fileops/t/008_fileops_standby_abort.pl b/src/test/modules/test_fileops/t/008_fileops_standby_abort.pl index 0c2718aff73c9..75ae70f28c3d6 100644 --- a/src/test/modules/test_fileops/t/008_fileops_standby_abort.pl +++ b/src/test/modules/test_fileops/t/008_fileops_standby_abort.pl @@ -16,7 +16,8 @@ my $standby = PostgreSQL::Test::Cluster->new('standby'); $standby->init_from_backup($primary, 'backup', has_streaming => 1); $standby->start; -$primary->safe_psql('postgres', q{ +$primary->safe_psql( + 'postgres', q{ BEGIN; SELECT test_fileops_create('base.dat', 384); SELECT test_fileops_write('base.dat', 0, 'committed-base'); @@ -30,7 +31,8 @@ BEGIN; SELECT test_fileops_write('base.dat', 0, 'OVERWRITTEN!!!'); ROLLBACK; BEGIN; SELECT test_fileops_truncate('base.dat', 3); ROLLBACK; }); -$primary->safe_psql('postgres', q{ +$primary->safe_psql( + 'postgres', q{ BEGIN; SELECT test_fileops_write('base.dat', 0, 'prepared'); SELECT test_fileops_truncate('base.dat', 2); @@ -46,8 +48,10 @@ if ($ENV{enable_injection_points} eq 'yes') { $standby->safe_psql('postgres', - "SELECT injection_points_attach('fileops-abort-after-flush', 'wait')"); - $primary->safe_psql('postgres', q{ + "SELECT injection_points_attach('fileops-abort-after-flush', 'wait')" + ); + $primary->safe_psql( + 'postgres', q{ BEGIN; SELECT test_fileops_create('standby-window.dat', 384); ROLLBACK; @@ -70,8 +74,10 @@ my $name = $node->name; ok(!-e "$dir/abort.dat", "$stage $name: aborted create absent"); ok(!-e "$dir/abortdir", "$stage $name: aborted mkdir absent"); - is((stat("$dir/base.dat"))[2] & 07777, 0600, "$stage $name: original mode"); - is(slurp_file("$dir/base.dat"), 'committed-base', "$stage $name: original bytes"); + is((stat("$dir/base.dat"))[2] & 07777, + 0600, "$stage $name: original mode"); + is(slurp_file("$dir/base.dat"), + 'committed-base', "$stage $name: original bytes"); } } $standby->stop; diff --git a/src/test/modules/test_fileops/t/010_fileops_abort_redo.pl b/src/test/modules/test_fileops/t/010_fileops_abort_redo.pl index 2a4cd2ef81941..756e8ae0bd02e 100644 --- a/src/test/modules/test_fileops/t/010_fileops_abort_redo.pl +++ b/src/test/modules/test_fileops/t/010_fileops_abort_redo.pl @@ -7,11 +7,14 @@ my $node = PostgreSQL::Test::Cluster->new('abort_redo'); $node->init; -$node->append_conf('postgresql.conf', "wal_level = replica\nautovacuum = off\ncheckpoint_timeout = '1h'\nmax_logical_revert_workers = 0\nmax_prepared_transactions = 5\n"); +$node->append_conf('postgresql.conf', + "wal_level = replica\nautovacuum = off\ncheckpoint_timeout = '1h'\nmax_logical_revert_workers = 0\nmax_prepared_transactions = 5\n" +); $node->start; $node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); my $dir = $node->data_dir; -$node->safe_psql('postgres', q{ +$node->safe_psql( + 'postgres', q{ BEGIN; SELECT test_fileops_create('base.dat', 384); SELECT test_fileops_write('base.dat', 0, 'committed-base'); @@ -36,10 +39,12 @@ }); my $xattrs = $node->safe_psql('postgres', - "SELECT test_fileops_setxattr('base.dat', 'user.abort_test', 'original')") eq 't'; + "SELECT test_fileops_setxattr('base.dat', 'user.abort_test', 'original')") + eq 't'; if ($xattrs) { - $node->safe_psql('postgres', q{ + $node->safe_psql( + 'postgres', q{ BEGIN; SELECT test_fileops_setxattr('base.dat', 'user.abort_test', 'changed'); ROLLBACK; @@ -48,11 +53,11 @@ ROLLBACK; }); } -my ($uid, $gid) = (stat("$dir/base.dat"))[4, 5]; +my ($uid, $gid) = (stat("$dir/base.dat"))[ 4, 5 ]; # An unprivileged test can chown to its own identity; a different uid needs # a privileged harness. This still exercises CHOWN inverse serialization. $node->safe_psql('postgres', - "BEGIN; SELECT test_fileops_chown('base.dat', $uid, $gid); ROLLBACK;"); + "BEGIN; SELECT test_fileops_chown('base.dat', $uid, $gid); ROLLBACK;"); for my $restart (0 .. 2) { if ($restart) @@ -66,17 +71,23 @@ ok(!-e "$dir/abort.dat", "$restart: aborted create absent"); ok(!-e "$dir/abortdir", "$restart: aborted mkdir absent"); is((stat("$dir/base.dat"))[2] & 07777, 0600, "$restart: original mode"); - is((stat("$dir/base.dat"))[4], $uid, "$restart: owner after chown inverse"); - is((stat("$dir/base.dat"))[5], $gid, "$restart: group after chown inverse"); - SKIP: + is((stat("$dir/base.dat"))[4], + $uid, "$restart: owner after chown inverse"); + is((stat("$dir/base.dat"))[5], + $gid, "$restart: group after chown inverse"); + SKIP: { skip 'extended attributes not supported', 1 unless $xattrs; - is($node->safe_psql('postgres', - "SELECT test_fileops_getxattr('base.dat', 'user.abort_test')"), - 'original', "$restart: xattr inverses restore value"); + is( $node->safe_psql( + 'postgres', + "SELECT test_fileops_getxattr('base.dat', 'user.abort_test')" + ), + 'original', + "$restart: xattr inverses restore value"); } is(-s "$dir/base.dat", 14, "$restart: original size"); - is(slurp_file("$dir/base.dat"), 'committed-base', "$restart: original bytes"); + is(slurp_file("$dir/base.dat"), + 'committed-base', "$restart: original bytes"); } # Stop with a durable abort decision but no inverses applied yet. A @@ -86,12 +97,17 @@ $node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); for my $checkpoint (0, 1, 2) { - my $finish = $checkpoint == 2 ? "PREPARE TRANSACTION 'window'; ROLLBACK PREPARED 'window';" : 'ROLLBACK;'; + my $finish = + $checkpoint == 2 + ? "PREPARE TRANSACTION 'window'; ROLLBACK PREPARED 'window';" + : 'ROLLBACK;'; $node->safe_psql('postgres', 'CHECKPOINT'); $node->safe_psql('postgres', - "SELECT injection_points_attach('fileops-abort-after-flush', 'wait')"); + "SELECT injection_points_attach('fileops-abort-after-flush', 'wait')" + ); my $bg = $node->background_psql('postgres', on_error_stop => 0); - $bg->query_until(qr/start/, qq(\\echo start + $bg->query_until( + qr/start/, qq(\\echo start BEGIN; SELECT test_fileops_create('mid.dat', 384); SELECT test_fileops_write('mid.dat', 0, 'uncommitted'); @@ -103,31 +119,38 @@ if ($checkpoint == 1) { $ckpt = $node->background_psql('postgres', on_error_stop => 0); - $ckpt->query_until(qr/checkpoint/, "\\echo checkpoint\nCHECKPOINT;\n"); + $ckpt->query_until(qr/checkpoint/, + "\\echo checkpoint\nCHECKPOINT;\n"); $node->wait_for_event('checkpointer', 'CheckpointDelayStart'); } $node->stop('immediate'); $bg->{run}->finish; $ckpt->{run}->finish if $ckpt; $node->start; - ok(!-e "$dir/mid.dat", "$checkpoint: interrupted abort removed create"); - is(slurp_file("$dir/base.dat"), 'committed-base', + ok(!-e "$dir/mid.dat", + "$checkpoint: interrupted abort removed create"); + is(slurp_file("$dir/base.dat"), + 'committed-base', "$checkpoint: interrupted abort restored shrink bytes"); $node->stop('immediate'); $node->start; - ok(!-e "$dir/mid.dat", "$checkpoint: repeated recovery keeps create absent"); + ok(!-e "$dir/mid.dat", + "$checkpoint: repeated recovery keeps create absent"); } } if ($ENV{enable_injection_points} eq 'yes') { $node->safe_psql('postgres', 'CHECKPOINT'); - $node->safe_psql('postgres', q{ + $node->safe_psql( + 'postgres', q{ SELECT injection_points_attach('createdb-wal-log-after-copy-relation', 'error'); SELECT injection_points_attach('fileops-abort-after-flush', 'wait'); }); my $db = $node->background_psql('postgres', on_error_stop => 0); - $db->query_until(qr/start/, "\\echo start\nCREATE DATABASE aborted_tree STRATEGY WAL_LOG OID 90000;\n"); + $db->query_until(qr/start/, + "\\echo start\nCREATE DATABASE aborted_tree STRATEGY WAL_LOG OID 90000;\n" + ); $node->wait_for_event('client backend', 'fileops-abort-after-flush'); # createdb's error cleanup removes the online directory already. Redo # recreates it from forward WAL, so the embedded tree inverse must remove @@ -149,21 +172,26 @@ my $log_offset = -s $node->logfile; $failed->query('ROLLBACK'); $failed->quit; -like(slurp_file($node->logfile, $log_offset), +like( + slurp_file($node->logfile, $log_offset), qr/WARNING:.*FILEOPS UNDO TRUNCATE: could not open/, 'failed inverse reports a warning'); chmod(0600, "$dir/base.dat") or die "chmod: $!"; -is(slurp_file("$dir/base.dat"), 'com', 'failed inverse remains unapplied online'); +is(slurp_file("$dir/base.dat"), + 'com', 'failed inverse remains unapplied online'); $node->stop('immediate'); $node->start; -is(slurp_file("$dir/base.dat"), 'committed-base', - 'abort redo retries failed inverse after crash'); +is(slurp_file("$dir/base.dat"), + 'committed-base', 'abort redo retries failed inverse after crash'); # Refuse excessive before-images before touching the file. my ($ret, $out, $err) = $node->psql('postgres', "SELECT test_fileops_write('base.dat', 0, repeat('x', 1048577))"); -like($err, qr/ERROR:.*excessive transactional file write range/, +like( + $err, + qr/ERROR:.*excessive transactional file write range/, 'oversized write is an ordinary error'); -is(slurp_file("$dir/base.dat"), 'committed-base', 'refused write changes nothing'); +is(slurp_file("$dir/base.dat"), + 'committed-base', 'refused write changes nothing'); $node->stop; done_testing(); diff --git a/src/test/modules/test_fileops/t/011_fileops_undo_ownership.pl b/src/test/modules/test_fileops/t/011_fileops_undo_ownership.pl index d7539f70cf454..707ffbe9df861 100644 --- a/src/test/modules/test_fileops/t/011_fileops_undo_ownership.pl +++ b/src/test/modules/test_fileops/t/011_fileops_undo_ownership.pl @@ -10,14 +10,16 @@ my $node = PostgreSQL::Test::Cluster->new('ownership'); $node->init; -$node->append_conf('postgresql.conf', q{ +$node->append_conf( + 'postgresql.conf', q{ wal_level = replica autovacuum = off max_logical_revert_workers = 0 logical_revert_naptime = 100ms }); $node->start; -$node->safe_psql('postgres', q{ +$node->safe_psql( + 'postgres', q{ CREATE EXTENSION injection_points; CREATE EXTENSION test_fileops; CREATE EXTENSION test_idxundo; @@ -28,9 +30,10 @@ # This point must precede the abort decision. Otherwise recovery forgets # the non-FILEOPS chain before it was applied or registered in the ATM. $node->safe_psql('postgres', - "SELECT injection_points_attach('undo-xact-abort-before-apply', 'wait')"); + "SELECT injection_points_attach('undo-xact-abort-before-apply', 'wait')"); my $bg = $node->background_psql('postgres', on_error_stop => 0); -$bg->query_until(qr/start/, qq(\\echo start +$bg->query_until( + qr/start/, qq(\\echo start BEGIN; INSERT INTO t SELECT generate_series(1, 10); SELECT test_fileops_create('first.dat', 384); @@ -41,31 +44,34 @@ $bg->{run}->finish; $node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); $node->start; -ok($node->poll_query_until('postgres', - 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), 'crash rollback drained'); +ok( $node->poll_query_until( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + 'crash rollback drained'); # The worker walks the index chain but leaves its entries for VACUUM (see # nbtree_undo_apply()). -is($node->safe_psql('postgres', - "SELECT idxundo_count_dead_all('t_pkey')"), '0', - 'crash before generic apply leaves the index entries for VACUUM'); +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '0', 'crash before generic apply leaves the index entries for VACUUM'); ok(!-e $node->data_dir . '/first.dat', 'crash rollback removes file'); $node->append_conf('postgresql.conf', 'max_logical_revert_workers = 0'); $node->restart; $bg = $node->background_psql('postgres'); $bg->query_safe('SELECT injection_points_set_local()'); -$bg->query_safe("SELECT injection_points_attach('undo-apply-after-batch', 'error')"); +$bg->query_safe( + "SELECT injection_points_attach('undo-apply-after-batch', 'error')"); $bg->query_safe('BEGIN; SAVEPOINT s'); $bg->query_safe("SELECT test_fileops_create('reuse.dat', 384)"); $bg->query_safe('INSERT INTO t VALUES (11)'); $bg->query('ROLLBACK TO s'); $bg->query_safe('COMMIT'); $bg->quit; -is($node->safe_psql('postgres', - 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), 't', - 'failed bounded walk is queued after parent commits'); +is( $node->safe_psql( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), + 't', + 'failed bounded walk is queued after parent commits'); ok(!-e $node->data_dir . '/reuse.dat', 'subabort removed file'); -$node->safe_psql('postgres', q{ +$node->safe_psql( + 'postgres', q{ BEGIN; SELECT test_fileops_create('reuse.dat', 384); SELECT test_fileops_write('reuse.dat', 0, 'T2-committed'); @@ -75,36 +81,40 @@ # The checkpoint/restart also checks that ownership survives ATM persistence. $node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); $node->restart; -ok($node->poll_query_until('postgres', - 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), 'bounded ATM entry drained'); +ok( $node->poll_query_until( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + 'bounded ATM entry drained'); my $path = $node->data_dir . '/reuse.dat'; -is(-e $path ? slurp_file($path) : '', 'T2-committed', - 'bounded worker leaves committed same-path reuse intact'); +is(-e $path ? slurp_file($path) : '', + 'T2-committed', 'bounded worker leaves committed same-path reuse intact'); # The inline walk killed entry 11 before the injected error; the worker # leaves index entries alone. -is($node->safe_psql('postgres', - "SELECT idxundo_count_dead_all('t_pkey')"), '1', - 'only the inline subtransaction walk marked an index entry dead'); +is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '1', 'only the inline subtransaction walk marked an index entry dead'); $node->safe_psql('postgres', - "SELECT injection_points_attach('fileops-write-short', 'error')"); + "SELECT injection_points_attach('fileops-write-short', 'error')"); my ($ret, $out, $err) = $node->psql('postgres', - "SELECT test_fileops_write('reuse.dat', 0, 'partialwrite')"); + "SELECT test_fileops_write('reuse.dat', 0, 'partialwrite')"); like($err, qr/could not write/, 'short write reports error'); $node->safe_psql('postgres', - "SELECT injection_points_detach('fileops-write-short')"); + "SELECT injection_points_detach('fileops-write-short')"); is(slurp_file($path), 'T2-committed', 'short write restores before-image'); # A first write inside a savepoint must assign the subxid before failing. $bg = $node->background_psql('postgres', on_error_stop => 0); $bg->query_safe('BEGIN; SAVEPOINT short_write'); $node->safe_psql('postgres', - "SELECT injection_points_attach('fileops-write-short', 'error')"); + "SELECT injection_points_attach('fileops-write-short', 'error')"); $bg->query("SELECT test_fileops_write('reuse.dat', 20, 'extension')"); -like($bg->{stderr}, qr/could not write/, 'short extending write reports error'); +like( + $bg->{stderr}, + qr/could not write/, + 'short extending write reports error'); $bg->{stderr} = ''; $bg->query_safe('ROLLBACK TO short_write; COMMIT'); $bg->quit; $node->safe_psql('postgres', - "SELECT injection_points_detach('fileops-write-short')"); -is(slurp_file($path), 'T2-committed', 'short extending subxact write restores length'); + "SELECT injection_points_detach('fileops-write-short')"); +is(slurp_file($path), 'T2-committed', + 'short extending subxact write restores length'); $node->stop; done_testing(); diff --git a/src/test/modules/test_fileops/t/012_fileops_contracts.pl b/src/test/modules/test_fileops/t/012_fileops_contracts.pl index bdcebd5d70ac5..a566b9d242053 100644 --- a/src/test/modules/test_fileops/t/012_fileops_contracts.pl +++ b/src/test/modules/test_fileops/t/012_fileops_contracts.pl @@ -15,6 +15,8 @@ is(scalar @syncs, 2, 'inverse syncs file and parent'); # 016_fileops_inverse_fsync.pl tests what a failed inverse sync does. my $header = slurp_file("$src/include/access/xlog_internal.h"); -unlike($header, qr/#define XLOG_PAGE_MAGIC 0xD125\b/, - 'incompatible FILEOPS and ATM records change WAL magic'); +unlike( + $header, + qr/#define XLOG_PAGE_MAGIC 0xD125\b/, + 'incompatible FILEOPS and ATM records change WAL magic'); done_testing(); diff --git a/src/test/modules/test_fileops/t/013_fileops_loser_reuse.pl b/src/test/modules/test_fileops/t/013_fileops_loser_reuse.pl index 83fcb5023eb7f..932c913f1267e 100644 --- a/src/test/modules/test_fileops/t/013_fileops_loser_reuse.pl +++ b/src/test/modules/test_fileops/t/013_fileops_loser_reuse.pl @@ -12,14 +12,16 @@ { my $node = PostgreSQL::Test::Cluster->new("loser_$crash_again"); $node->init; - $node->append_conf('postgresql.conf', q{ + $node->append_conf( + 'postgresql.conf', q{ wal_level = replica autovacuum = off max_logical_revert_workers = 0 logical_revert_naptime = 100ms }); $node->start; - $node->safe_psql('postgres', q{ + $node->safe_psql( + 'postgres', q{ CREATE EXTENSION test_fileops; CREATE EXTENSION test_idxundo; CREATE TABLE t (id int primary key) WITH (index_undo = on); @@ -35,10 +37,12 @@ $node->start; my $path = $node->data_dir . '/loser.dat'; ok(!-e $path, "$crash_again: startup removed loser file"); - is($node->safe_psql('postgres', - 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), 't', + is( $node->safe_psql( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), + 't', "$crash_again: index chain deferred"); - $node->safe_psql('postgres', q{ + $node->safe_psql( + 'postgres', q{ BEGIN; SELECT test_fileops_create('loser.dat', 384); SELECT test_fileops_write('loser.dat', 0, 'T2-committed'); @@ -50,23 +54,28 @@ # its subsequent WAL while the worker is still disabled. $node->stop('immediate'); $node->start; - is(-e $path ? slurp_file($path) : '', 'T2-committed', + is(-e $path ? slurp_file($path) : '', + 'T2-committed', 'second crash preserves committed reuse before worker runs'); - is($node->safe_psql('postgres', - 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), 't', + is( $node->safe_psql( + 'postgres', + 'SELECT test_fileops_atm_oldest_lsn() IS NOT NULL'), + 't', 'second crash retains deferred index chain'); } $node->safe_psql('postgres', 'CHECKPOINT'); $node->append_conf('postgresql.conf', 'max_logical_revert_workers = 2'); $node->restart; - ok($node->poll_query_until('postgres', - 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), + ok( $node->poll_query_until( + 'postgres', 'SELECT test_fileops_atm_oldest_lsn() IS NULL'), "$crash_again: worker drained ATM"); # The worker leaves index entries for VACUUM (see nbtree_undo_apply()). - is($node->safe_psql('postgres', - "SELECT idxundo_count_dead_all('t_pkey')"), '0', + is( $node->safe_psql( + 'postgres', "SELECT idxundo_count_dead_all('t_pkey')"), + '0', "$crash_again: worker left index entries for VACUUM"); - is(-e $path ? slurp_file($path) : '', 'T2-committed', + is(-e $path ? slurp_file($path) : '', + 'T2-committed', "$crash_again: committed reuse survives deferred loser revert"); $node->stop; } diff --git a/src/test/modules/test_idxundo/expected/test_idxundo.out b/src/test/modules/test_idxundo/expected/test_idxundo.out index d64392e55317b..88e682cc6e953 100644 --- a/src/test/modules/test_idxundo/expected/test_idxundo.out +++ b/src/test/modules/test_idxundo/expected/test_idxundo.out @@ -343,7 +343,33 @@ SELECT bt_index_check('iu_late_idx', heapallindexed => true); (1 row) -DROP TABLE iu_btree, iu_off, iu_sub, iu_split, iu_hash, iu_late; +-- =========================================================================== +-- Mutual exclusion with delete-marking. +-- +-- A delete-marking table AM drives index cleanup from its own table UNDO, so +-- RelationUsesIndexUndo() must refuse index UNDO for it no matter what the +-- reloption says -- otherwise the same entry would be reverted twice. +-- =========================================================================== +CREATE TABLE iu_flux (id int, val text) USING flux; +CREATE INDEX iu_flux_idx ON iu_flux (id); +BEGIN; +INSERT INTO iu_flux SELECT g, 'x' FROM generate_series(1, 50) g; +-- FLUX writes its own table UNDO, so a chain exists -- but no INDEX undo is in +-- it. The check that matters is that rollback leaves a consistent index. +ROLLBACK; +SELECT bt_index_check('iu_flux_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +SELECT count(*) AS heap_rows FROM iu_flux; + heap_rows +----------- + 0 +(1 row) + +DROP TABLE iu_btree, iu_off, iu_sub, iu_split, iu_hash, iu_late, iu_flux; -- =========================================================================== -- DEFERRED BATCHING SAFETY -- diff --git a/src/test/modules/test_idxundo/meson.build b/src/test/modules/test_idxundo/meson.build index f1661d03afae2..2302b1aec7588 100644 --- a/src/test/modules/test_idxundo/meson.build +++ b/src/test/modules/test_idxundo/meson.build @@ -48,7 +48,9 @@ tests += { 't/007_idxundo_2pc_database.pl', 't/008_idxundo_revert_liveness.pl', 't/009_idxundo_recovery_first_batch.pl', + 't/010_slog_abort.pl', 't/011_idxundo_wal_consistency.pl', + 't/012_flux_delete_nowait.pl', ], }, } diff --git a/src/test/modules/test_idxundo/sql/test_idxundo.sql b/src/test/modules/test_idxundo/sql/test_idxundo.sql index d5b00c1491de0..1093aaa26d8e8 100644 --- a/src/test/modules/test_idxundo/sql/test_idxundo.sql +++ b/src/test/modules/test_idxundo/sql/test_idxundo.sql @@ -190,7 +190,25 @@ SELECT count(*) AS preexisting_via_index FROM iu_late WHERE id BETWEEN 1 AND 100 RESET enable_seqscan; SELECT bt_index_check('iu_late_idx', heapallindexed => true); -DROP TABLE iu_btree, iu_off, iu_sub, iu_split, iu_hash, iu_late; +-- =========================================================================== +-- Mutual exclusion with delete-marking. +-- +-- A delete-marking table AM drives index cleanup from its own table UNDO, so +-- RelationUsesIndexUndo() must refuse index UNDO for it no matter what the +-- reloption says -- otherwise the same entry would be reverted twice. +-- =========================================================================== + +CREATE TABLE iu_flux (id int, val text) USING flux; +CREATE INDEX iu_flux_idx ON iu_flux (id); +BEGIN; +INSERT INTO iu_flux SELECT g, 'x' FROM generate_series(1, 50) g; +-- FLUX writes its own table UNDO, so a chain exists -- but no INDEX undo is in +-- it. The check that matters is that rollback leaves a consistent index. +ROLLBACK; +SELECT bt_index_check('iu_flux_idx', heapallindexed => true); +SELECT count(*) AS heap_rows FROM iu_flux; + +DROP TABLE iu_btree, iu_off, iu_sub, iu_split, iu_hash, iu_late, iu_flux; -- =========================================================================== -- DEFERRED BATCHING SAFETY diff --git a/src/test/modules/test_idxundo/t/001_idxundo_crash.pl b/src/test/modules/test_idxundo/t/001_idxundo_crash.pl index b5b82b15102ea..1830b69e9904b 100644 --- a/src/test/modules/test_idxundo/t/001_idxundo_crash.pl +++ b/src/test/modules/test_idxundo/t/001_idxundo_crash.pl @@ -86,7 +86,8 @@ my $bg = $node->background_psql('postgres'); $bg->query_safe('BEGIN'); $bg->query_safe( - q{INSERT INTO ct SELECT g, 'aborted-' || g FROM generate_series(10001, 10300) g}); + q{INSERT INTO ct SELECT g, 'aborted-' || g FROM generate_series(10001, 10300) g} +); # The rows are visible to their own transaction, so the insert really happened # and its UNDO is in WAL. @@ -107,8 +108,10 @@ $node->start; # The killed transaction never committed, so none of its rows may be visible. -my $after = $node->safe_psql('postgres', 'SELECT count(*) FROM ct WHERE id >= 10001'); -is($after, '0', 'rows from the killed transaction are not visible after recovery'); +my $after = + $node->safe_psql('postgres', 'SELECT count(*) FROM ct WHERE id >= 10001'); +is($after, '0', + 'rows from the killed transaction are not visible after recovery'); # The committed rows survived. is($node->safe_psql('postgres', 'SELECT count(*) FROM ct'), @@ -146,10 +149,13 @@ # The worker leaves the killed transaction's index entries for VACUUM: after # the abort is visible their heap TIDs could hold committed rows, so index UNDO # is applied only by the aborting backend itself. -is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('ct_pkey')"), - '0', 'killed transaction\'s primary key entries are left for VACUUM'); -is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('ct_val_idx')"), - '0', 'killed transaction\'s secondary index entries are left for VACUUM'); +is( $node->safe_psql('postgres', "SELECT idxundo_count_dead_all('ct_pkey')"), + '0', + 'killed transaction\'s primary key entries are left for VACUUM'); +is( $node->safe_psql( + 'postgres', "SELECT idxundo_count_dead_all('ct_val_idx')"), + '0', + 'killed transaction\'s secondary index entries are left for VACUUM'); # The property that matters: every index agrees with the heap. A forced # index-only path must not produce a row the heap scan does not, and amcheck @@ -196,19 +202,23 @@ SELECT count(*) FROM cc WHERE id BETWEEN 1 AND 300; }), '300', - 'committed rows with deferred index UNDO still reachable through the index'); -is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('cc_pkey')"), - '0', 'no index entry of the committed transaction was marked dead'); -is($node->safe_psql('postgres', "SELECT idxundo_count_dead_all('cs_pkey')"), + 'committed rows with deferred index UNDO still reachable through the index' +); +is( $node->safe_psql('postgres', "SELECT idxundo_count_dead_all('cc_pkey')"), + '0', + 'no index entry of the committed transaction was marked dead'); +is( $node->safe_psql('postgres', "SELECT idxundo_count_dead_all('cs_pkey')"), '0', - 'no index entry of the committed subtransaction-first writer was marked dead'); + 'no index entry of the committed subtransaction-first writer was marked dead' +); is( $node->safe_psql( 'postgres', q{ SET enable_seqscan = off; SELECT count(*) FROM cs WHERE id BETWEEN 1 AND 310; }), '310', - 'committed rows first written in a subtransaction reachable through the index'); + 'committed rows first written in a subtransaction reachable through the index' +); $node->stop; done_testing(); diff --git a/src/test/modules/test_idxundo/t/005_idxundo_atm_retention.pl b/src/test/modules/test_idxundo/t/005_idxundo_atm_retention.pl index 6cb1687d74785..cf908f168109a 100644 --- a/src/test/modules/test_idxundo/t/005_idxundo_atm_retention.pl +++ b/src/test/modules/test_idxundo/t/005_idxundo_atm_retention.pl @@ -60,8 +60,8 @@ sub revert_and_check "revert worker drained the ATM ($what)"); # The worker leaves index entries for VACUUM (see nbtree_undo_apply()). - is( $node->safe_psql('postgres', - "SELECT idxundo_count_dead_all('${table}_pkey')"), + is( $node->safe_psql( + 'postgres', "SELECT idxundo_count_dead_all('${table}_pkey')"), '0', "the $what chain's index entries are left for VACUUM"); @@ -76,8 +76,10 @@ sub revert_and_check INSERT INTO a SELECT g FROM generate_series(1, $nrows) g; ROLLBACK; }); -is($node->safe_psql('postgres', 'SELECT count(*) FROM idxundo_atm_entries()'), - '1', 'large abort is queued in the ATM'); +is( $node->safe_psql( + 'postgres', 'SELECT count(*) FROM idxundo_atm_entries()'), + '1', + 'large abort is queued in the ATM'); recycle_wal(); revert_and_check('a', 'aborted'); @@ -94,7 +96,8 @@ sub revert_and_check recycle_wal(); revert_and_check('p', 'rolled-back prepared'); -unlike(slurp_file($node->logfile), +unlike( + slurp_file($node->logfile), qr/could not read batch|no longer exists \(recycled/, 'no UNDO batch was recycled before it was applied'); diff --git a/src/test/modules/test_idxundo/t/008_idxundo_revert_liveness.pl b/src/test/modules/test_idxundo/t/008_idxundo_revert_liveness.pl index 2bfdcaf76a891..453866200bebe 100644 --- a/src/test/modules/test_idxundo/t/008_idxundo_revert_liveness.pl +++ b/src/test/modules/test_idxundo/t/008_idxundo_revert_liveness.pl @@ -29,8 +29,8 @@ # The oldest entry has a malformed chain: it points at a checkpoint record. # Its xid wrote nothing, so treating it as aborted changes no data. -my $bad = $node->safe_psql('postgres', - 'BEGIN; SELECT pg_current_xact_id(); COMMIT;'); +my $bad = + $node->safe_psql('postgres', 'BEGIN; SELECT pg_current_xact_id(); COMMIT;'); ($bad) = $bad =~ /(\d+)\s*$/; $node->safe_psql('postgres', 'CHECKPOINT'); is( $node->safe_psql( diff --git a/src/test/modules/test_idxundo/t/010_slog_abort.pl b/src/test/modules/test_idxundo/t/010_slog_abort.pl new file mode 100644 index 0000000000000..448e7f9e9e99d --- /dev/null +++ b/src/test/modules/test_idxundo/t/010_slog_abort.pl @@ -0,0 +1,45 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; +my $node = PostgreSQL::Test::Cluster->new('slog_abort'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +for my $am ('heap', 'flux') +{ + $node->safe_psql('postgres', + "CREATE TABLE t(id int PRIMARY KEY) USING $am"); + for my $body ( + 'INSERT INTO t SELECT g FROM generate_series(1,20) g', + 'SAVEPOINT a; INSERT INTO t SELECT g FROM generate_series(1,20) g; ROLLBACK TO a', + 'INSERT INTO t VALUES (1) ON CONFLICT DO NOTHING', + 'SAVEPOINT a; INSERT INTO t VALUES (1) ON CONFLICT DO NOTHING; ROLLBACK TO a' + ) + { + $node->safe_psql('postgres', "BEGIN; $body; ROLLBACK;" x 30); + is($node->safe_psql('postgres', 'SELECT count(*) FROM t'), + '0', "$am aborted rows invisible: $body"); + } + $node->safe_psql('postgres', 'DROP TABLE t'); +} +# Force the relation OID to differ from its physical file number. +$node->safe_psql( + 'postgres', q{ +CREATE TABLE replay(id int) USING flux; +TRUNCATE replay; +CHECKPOINT; +INSERT INTO replay VALUES (1); +COPY replay FROM STDIN; +2 +\. +}); +$node->stop('immediate'); +$node->start; +is($node->safe_psql('postgres', q{SELECT count(*) FROM replay}), + '2', 'crash replay restored INSERT and COPY rows'); +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/t/012_flux_delete_nowait.pl b/src/test/modules/test_idxundo/t/012_flux_delete_nowait.pl new file mode 100644 index 0000000000000..1ae7cccdb4d67 --- /dev/null +++ b/src/test/modules/test_idxundo/t/012_flux_delete_nowait.pl @@ -0,0 +1,65 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A table AM's DELETE called with wait = false (REPACK (CONCURRENTLY) +# applies its deletes that way) must neither wait for nor misreport a +# writer or locker still in progress: heap_delete returns TM_BeingModified +# with that transaction in tmfd->xmax, as tableam.h documents. heap is the +# control for every case. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_delete_nowait'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION test_idxundo'); + +foreach my $am ('heap', 'flux') +{ + foreach my $case ( + [ 'DELETE', 'DELETE FROM t_AM WHERE id = 1' ], + [ 'UPDATE', 'UPDATE t_AM SET v = v + 1 WHERE id = 1' ], + [ 'FOR UPDATE', 'SELECT * FROM t_AM WHERE id = 1 FOR UPDATE' ], + [ 'FOR SHARE', 'SELECT * FROM t_AM WHERE id = 1 FOR SHARE' ]) + { + my ($what, $sql) = @$case; + $sql =~ s/_AM/_$am/g; + $node->safe_psql( + 'postgres', qq{ +DROP TABLE IF EXISTS t_$am; +CREATE TABLE t_$am (id int, v int) USING $am; +INSERT INTO t_$am VALUES (1, 10), (2, 20); +}); + my $tid = + $node->safe_psql('postgres', "SELECT ctid FROM t_$am WHERE id = 1"); + my $s1 = $node->background_psql('postgres'); + $s1->query_safe('BEGIN'); + my $xid = $s1->query_safe('SELECT txid_current()'); + $s1->query_safe($sql); + is( $node->safe_psql( + 'postgres', "SELECT idxundo_delete_nowait('t_$am', '$tid')"), + "BeingModified $xid", + "$am: delete without waiting, $what in progress"); + $s1->query_safe('ROLLBACK'); + $s1->quit; + is( $node->safe_psql('postgres', "SELECT v FROM t_$am WHERE id = 1"), + '10', + "$am: delete without waiting deleted nothing ($what)"); + } + + # Nothing in progress: the delete is done. + my $tid = + $node->safe_psql('postgres', "SELECT ctid FROM t_$am WHERE id = 1"); + is( $node->safe_psql( + 'postgres', "SELECT idxundo_delete_nowait('t_$am', '$tid')"), + 'Ok', + "$am: delete without waiting, no conflict"); + is($node->safe_psql('postgres', "SELECT count(*) FROM t_$am"), + '1', "$am: deleted"); +} + +$node->stop; +done_testing(); diff --git a/src/test/modules/test_idxundo/test_idxundo--1.0.sql b/src/test/modules/test_idxundo/test_idxundo--1.0.sql index bc5f2e3993edf..9feab42b05f9b 100644 --- a/src/test/modules/test_idxundo/test_idxundo--1.0.sql +++ b/src/test/modules/test_idxundo/test_idxundo--1.0.sql @@ -35,3 +35,12 @@ RETURNS void AS 'MODULE_PATHNAME' LANGUAGE C STRICT; -- (needs shared_preload_libraries = 'test_idxundo'). CREATE FUNCTION idxundo_write_ordered_undo(n int) RETURNS void AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- FLUX's count of active transactions (oldest-active timestamp slots). +CREATE FUNCTION idxundo_flux_active_xacts() +RETURNS int AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- table_tuple_delete of t with wait = false: the result and the xid it +-- reports. +CREATE FUNCTION idxundo_delete_nowait(rel regclass, t tid) +RETURNS text AS 'MODULE_PATHNAME' LANGUAGE C STRICT; diff --git a/src/test/modules/test_idxundo/test_idxundo.c b/src/test/modules/test_idxundo/test_idxundo.c index 21851c26f43fb..59f8bda93e2bd 100644 --- a/src/test/modules/test_idxundo/test_idxundo.c +++ b/src/test/modules/test_idxundo/test_idxundo.c @@ -16,11 +16,14 @@ #include "postgres.h" #include "access/atm.h" +#include "access/flux.h" #include "access/hash.h" #include "access/nbtree.h" #include "access/relation.h" #include "access/table.h" +#include "access/tableam.h" #include "access/slog.h" +#include "access/slog_internal.h" #include "access/xact.h" #include "access/undormgr.h" #include "access/xactundo.h" @@ -38,6 +41,8 @@ #include "utils/pg_lsn.h" #include "utils/rel.h" #include "utils/tuplestore.h" +#include "utils/snapmgr.h" +#include "utils/builtins.h" PG_MODULE_MAGIC; @@ -187,6 +192,7 @@ idxundo_atm_add(PG_FUNCTION_ARGS) TransactionId xid = DatumGetTransactionId(PG_GETARG_DATUM(0)); XLogRecPtr lsn = PG_GETARG_LSN(1); + ATMEnsureCreated(); PG_RETURN_BOOL(ATMAddAborted(xid, MyDatabaseId, lsn, lsn, false)); } @@ -326,14 +332,64 @@ idxundo_write_ordered_undo(PG_FUNCTION_ARGS) PG_RETURN_VOID(); } +/* + * idxundo_flux_active_xacts() -> FLUX's count of active transactions + * + * The count FLUX keeps of transactions that registered an oldest-active + * timestamp slot. A PREPARE that forgets to release its slot shows up as a + * count that grows with every prepared transaction. + */ +PG_FUNCTION_INFO_V1(idxundo_flux_active_xacts); +Datum +idxundo_flux_active_xacts(PG_FUNCTION_ARGS) +{ + uint64 current_ts; + uint64 oldest_ts; + int active; + + FluxGetMvccStats(¤t_ts, &oldest_ts, &active); + PG_RETURN_INT32(active); +} + +/* + * idxundo_delete_nowait(rel regclass, t tid) -> text + * + * table_tuple_delete of t with wait = false, as REPACK (CONCURRENTLY) applies + * a delete; no SQL statement deletes without waiting. Returns the result's + * name and, when it is not TM_Ok, the xid reported in tmfd->xmax. + */ +PG_FUNCTION_INFO_V1(idxundo_delete_nowait); +Datum +idxundo_delete_nowait(PG_FUNCTION_ARGS) +{ + static const char *const names[] = { + "Ok", "Invisible", "SelfModified", "Updated", "Deleted", + "BeingModified", "WouldBlock" + }; + Relation rel = table_open(PG_GETARG_OID(0), RowExclusiveLock); + TM_FailureData tmfd; + TM_Result res; + + res = table_tuple_delete(rel, PG_GETARG_ITEMPOINTER(1), + GetCurrentCommandId(true), 0, GetActiveSnapshot(), + InvalidSnapshot, false, &tmfd); + table_close(rel, NoLock); + + Assert(res >= 0 && res < lengthof(names)); + if (res == TM_Ok) + PG_RETURN_TEXT_P(cstring_to_text(names[res])); + PG_RETURN_TEXT_P(cstring_to_text(psprintf("%s %u", names[res], + tmfd.xmax))); +} + /* Throw with both a buffer pin and an LWLock outstanding. */ extern PGDLLEXPORT void idxundo_error_with_resources(const char *name, - const void *private_data, - void *arg); + const void *private_data, + void *arg); void idxundo_error_with_resources(const char *name, const void *private_data, void *arg) { - Relation rel = table_open(RelationRelationId, AccessShareLock); + Relation rel = table_open(RelationRelationId, AccessShareLock); (void) ReadBuffer(rel, 0); LWLockAcquire(ProcArrayLock, LW_SHARED); @@ -341,7 +397,7 @@ idxundo_error_with_resources(const char *name, const void *private_data, void *a } extern PGDLLEXPORT void idxundo_recovery_error(const char *name, - const void *private_data, void *arg); + const void *private_data, void *arg); void idxundo_recovery_error(const char *name, const void *private_data, void *arg) { diff --git a/src/test/recovery/Makefile b/src/test/recovery/Makefile index cb169662fb6a9..b3a145191375b 100644 --- a/src/test/recovery/Makefile +++ b/src/test/recovery/Makefile @@ -9,11 +9,17 @@ # #------------------------------------------------------------------------- -EXTRA_INSTALL=contrib/pg_prewarm \ +EXTRA_INSTALL=contrib/amcheck \ + contrib/pg_buffercache \ + contrib/pg_walinspect \ + contrib/pageinspect \ + contrib/pg_visibility \ + contrib/pg_prewarm \ contrib/pg_stat_statements \ contrib/test_decoding \ src/test/modules/injection_points \ src/test/modules/test_fileops \ + src/test/modules/test_idxundo \ 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 4a1eacf9be726..3388e364101c2 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -77,19 +77,72 @@ tests += { 't/073_perbackend_2pc_rollback.pl', 't/074_perbackend_subxact.pl', 't/075_perbackend_nonpermanent.pl', + 't/076_flux_deferred_rollback.pl', + 't/077_flux_undo_idempotency.pl', + 't/078_flux_undo_restore_fail.pl', + 't/079_flux_vacuum_aborted_insert.pl', 't/083_perbackend_multi_update.pl', 't/084_perbackend_helping_once.pl', 't/085_perbackend_worker_done.pl', + 't/086_flux_standby_helping.pl', + 't/087_flux_index_undo_fail.pl', 't/089_perbackend_rewrite_rollback.pl', 't/090_fileops_createdb_crash.pl', + 't/093_flux_toast_supersede.pl', 't/094_flux_tombstone_prune.pl', + 't/095_flux_rewrite_cutoffs.pl', 't/096_perbackend_redo_header_image.pl', + 't/100_flux_vacuum_recorded_tids.pl', + 't/102_flux_insert_redo_consistency.pl', + 't/097_flux_vacuum_truncate.pl', + 't/101_flux_external_wal.pl', + 't/103_flux_nonpermanent_wal.pl', + 't/108_flux_tuple_layout.pl', + 't/109_flux_savepoint_lock_2pc.pl', + 't/106_flux_rewrite_pending_revert.pl', + 't/107_flux_rewrite_2pc.pl', + 't/110_flux_unique_pending_revert.pl', + 't/111_flux_index_build_pending_revert.pl', + 't/104_flux_index_aba_revert.pl', + 't/112_flux_index_no_orphan.pl', + 't/113_flux_prepared_row_locks.pl', 't/114_undo_recovery_error.pl', + 't/145_flux_slog_storage.pl', + 't/115_flux_freeze.pl', + 't/116_flux_minimal.pl', 't/117_undo_rewind_snapshot.pl', + 't/118_flux_minimal_undo_log.pl', + 't/119_flux_standby_conflict.pl', + 't/140_flux_spec_insert_vm.pl', + 't/141_flux_index_exact_keys.pl', + 't/142_flux_vm_clear_redo.pl', + 't/143_flux_update_page_lsn.pl', + 't/149_flux_partial_index_member.pl', + 't/150_flux_vm_stale_size.pl', + 't/151_flux_vm_set_fpi.pl', 't/121_flux_deletemark_race.pl', + 't/122_flux_amcheck_deletemark.pl', + 't/123_flux_tombstone_size.pl', + 't/146_flux_subxact_abort_dropped_storage.pl', + 't/120_flux_undo_remark_split.pl', + 't/125_flux_undo_meta_nonpermanent.pl', 't/126_atm_full_abort.pl', + 't/127_flux_rollback_prepared_crash.pl', 't/124_index_undo_tid_reuse.pl', + 't/128_flux_delete_superseded_redo.pl', + 't/129_undo_redo_no_fpw.pl', + 't/130_flux_single_user_abort.pl', + 't/132_flux_rowlock_store.pl', + 't/133_flux_insert_markers.pl', 't/134_undo_shmem_on_demand.pl', + 't/135_flux_rowlock_filter.pl', + 't/136_flux_rowlock_oom.pl', + 't/131_flux_vacuum_rounds.pl', + 't/137_flux_vacuum_parallel.pl', + 't/138_flux_vacuum_toast.pl', + 't/139_flux_toast_delete.pl', + 't/147_flux_repack_concurrently.pl', + 't/148_flux_pg_visibility.pl', 't/152_undo_attach_order_after_checkpoint.pl', ], }, diff --git a/src/test/recovery/t/035_standby_logical_decoding.pl b/src/test/recovery/t/035_standby_logical_decoding.pl index 88893f7135082..4aa48b3a07bb1 100644 --- a/src/test/recovery/t/035_standby_logical_decoding.pl +++ b/src/test/recovery/t/035_standby_logical_decoding.pl @@ -814,7 +814,7 @@ sub wait_until_vacuum_can_remove # This should trigger the conflict $node_primary->safe_psql('testdb', - qq[CREATE TABLE prun(id integer, s char(2000)) WITH (fillfactor = 75, user_catalog_table = true);] + qq[CREATE TABLE prun(id integer, s char(2000)) USING heap WITH (fillfactor = 75, user_catalog_table = true);] ); $node_primary->safe_psql('testdb', qq[INSERT INTO prun VALUES (1, 'A');]); $node_primary->safe_psql('testdb', qq[UPDATE prun SET s = 'B';]); diff --git a/src/test/recovery/t/076_flux_deferred_rollback.pl b/src/test/recovery/t/076_flux_deferred_rollback.pl new file mode 100644 index 0000000000000..d410ffb6cb2db --- /dev/null +++ b/src/test/recovery/t/076_flux_deferred_rollback.pl @@ -0,0 +1,287 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX visibility while a rollback is still pending. +# +# FLUX updates in place: the page holds the newest version and the older +# ones live in the per-backend UNDO log. When a transaction aborts, its +# in-place images stay on the page until the UNDO is applied. Small live +# rollbacks apply inline, but a rollback handed to an undo apply worker +# (undo_rollback_overflow_size) and every crash loser are reverted later. +# Until then, readers must treat the aborted writer as invisible and walk +# past its versions to the last committed one. +# +# We keep the undo apply worker from ever starting: max_worker_processes is +# exactly the number of static background workers (discard worker, UNDO +# worker, undo worker launcher), so the launcher can never register one. +# That gate depends on the static worker count, so the test checks it +# directly: no "too many background workers" at startup, no undo apply worker +# in pg_stat_activity, and the aborted images still on the page. A change in +# the worker count makes the test fail instead of passing without testing +# anything. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_deferred'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +max_logical_replication_workers = 0 +max_logical_revert_workers = 0 +max_parallel_workers = 0 +max_parallel_workers_per_gather = 0 +# discard worker + UNDO worker + undo worker launcher: no room for an +# undo apply worker +max_worker_processes = 3 +# every rollback request goes to the (starved) undo apply worker +undo_rollback_overflow_size = 0 +)); +$node->start; + +if (!$node->check_extension('pageinspect')) +{ + plan skip_all => 'Extension pageinspect not installed'; +} + +unlike( + slurp_file($node->logfile), + qr/too many background workers/, + 'every static background worker registered'); + +# Row 1's aborted value is 16908801 = 0x01020201, whose bytes read the same +# in either byte order, so the raw page can be searched for it. +# The gate holds: no undo apply worker ran, and the aborted image is still +# on the page of table $tab. +sub gate_holds +{ + my ($label, $tab) = @_; + + is( $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + '0', + "$label: no undo apply worker"); + is( $node->safe_psql( + 'postgres', + "SELECT position('\\x01020201'::bytea IN get_raw_page('$tab', 0)) > 0" + ), + 't', + "$label: aborted image still on the page"); +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE TABLE f (id int PRIMARY KEY, v int) USING flux; +INSERT INTO f SELECT g, g * 10 FROM generate_series(1, 10) g; +UPDATE f SET v = 31 WHERE id = 3; +}); + +my $seq = 'SET enable_indexscan = off; SET enable_bitmapscan = off; '; +my $idx = 'SET enable_seqscan = off; SET enable_bitmapscan = off; '; + +sub check_rows +{ + my ($label, %expect) = @_; + + is($node->safe_psql('postgres', $seq . 'SELECT count(*) FROM f'), + '10', "$label: seqscan count unchanged"); + foreach my $id (sort keys %expect) + { + is( $node->safe_psql( + 'postgres', $seq . "SELECT v FROM f WHERE id = $id"), + $expect{$id}, + "$label: seqscan sees committed value of row $id"); + is( $node->safe_psql( + 'postgres', $idx . "SELECT v FROM f WHERE id = $id"), + $expect{$id}, + "$label: index scan sees committed value of row $id"); + } +} + +# --------------------------------------------------------------------------- +# Live aborts whose rollback is deferred to the undo apply worker. +# --------------------------------------------------------------------------- +$node->safe_psql( + 'postgres', q{ +BEGIN; UPDATE f SET v = 16908801 WHERE id = 1; ROLLBACK; +BEGIN; DELETE FROM f WHERE id = 2; ROLLBACK; +BEGIN; UPDATE f SET v = 32 WHERE id = 3; UPDATE f SET v = 33 WHERE id = 3; ROLLBACK; +}); + +gate_holds('deferred rollback', 'f'); +check_rows('deferred rollback', 1 => '10', 2 => '20', 3 => '31'); + +# --------------------------------------------------------------------------- +# Crash with an in-place UPDATE in flight. +# --------------------------------------------------------------------------- +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +$bg->query_safe('UPDATE f SET v = 4000 WHERE id = 4'); + +# Write the loser's page images out, so recovery does not have to redo the +# in-place UPDATEs (the CAS redo path is tested separately) and the pages +# hold the aborted and in-flight images at restart. +$node->safe_psql('postgres', 'CHECKPOINT'); + +$node->stop('immediate'); +$bg->{run}->kill_kill; +$node->start; + +gate_holds('crash loser', 'f'); +check_rows('crash loser', 1 => '10', 2 => '20', 3 => '31', 4 => '40'); + +# --------------------------------------------------------------------------- +# Crash with an in-place UPDATE in flight, and no checkpoint: recovery has to +# redo the UPDATE (same-size CAS fast path), including the version-chain +# pointer it stamped. Row 5 has never been updated, so its old t_verptr is +# invalid. +# --------------------------------------------------------------------------- +$node->append_conf('postgresql.conf', 'wal_consistency_checking = flux'); +$node->restart; + +# Take the page's first full-page image since the checkpoint on another +# row. Otherwise row 5's UPDATE record would carry that FPI, redo would +# restore the page from it, and the record's byte-diff (the part that was +# missing t_verptr) would never be replayed. +$node->safe_psql('postgres', 'UPDATE f SET v = 61 WHERE id = 6'); + +$bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +$bg->query_safe('UPDATE f SET v = 5000 WHERE id = 5'); + +# A commit flushes the WAL written before it, including the UPDATE above. +$node->safe_psql('postgres', 'CREATE TABLE flush_marker (x int)'); + +$node->stop('immediate'); +$bg->{run}->kill_kill; +$node->start; + +check_rows('crash loser, redo', 4 => '40', 5 => '50', 6 => '61'); +unlike( + slurp_file($node->logfile), + qr/inconsistent page found/, + 'FLUX redo reproduces the primary page'); + +# --------------------------------------------------------------------------- +# Writers that meet an aborted, not-yet-reverted version must not depend on +# the undo apply worker: they revert that version themselves ("helping") and +# then proceed against the last committed version. +# --------------------------------------------------------------------------- +$node->safe_psql( + 'postgres', q{ +CREATE TABLE h (id int PRIMARY KEY, v int, k int) USING flux; +CREATE INDEX h_k ON h (k); +INSERT INTO h SELECT g, g * 10, g FROM generate_series(1, 8) g; +BEGIN; +UPDATE h SET v = 16908801 WHERE id = 1; +UPDATE h SET v = v + 1000 WHERE id IN (2, 3, 4); +UPDATE h SET v = v + 1000 WHERE id = 4; +UPDATE h SET k = k + 100 WHERE id = 5; +DELETE FROM h WHERE id IN (6, 7); +ROLLBACK; +}); +is( $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + '0', + 'helping: no undo apply worker'); +gate_holds('helping', 'h'); + +is( $node->safe_psql( + 'postgres', 'UPDATE h SET v = v + 1 WHERE id = 1 RETURNING v'), + '11', + 'helping: UPDATE over an aborted in-place UPDATE'); +is($node->safe_psql('postgres', 'DELETE FROM h WHERE id = 2 RETURNING v'), + '20', 'helping: DELETE over an aborted in-place UPDATE'); +is( $node->safe_psql( + 'postgres', 'BEGIN; SELECT v FROM h WHERE id = 3 FOR UPDATE; COMMIT;' + ), + '30', + 'helping: SELECT FOR UPDATE over an aborted in-place UPDATE'); +is( $node->safe_psql( + 'postgres', 'UPDATE h SET v = v + 1 WHERE id = 4 RETURNING v'), + '41', + 'helping: UPDATE over a chain of two aborted in-place UPDATEs'); +is( $node->safe_psql( + 'postgres', 'UPDATE h SET v = v + 1 WHERE k = 5 RETURNING v'), + '51', + 'helping: UPDATE through the index over an aborted key UPDATE'); +is( $node->safe_psql( + 'postgres', 'UPDATE h SET v = v + 1 WHERE id = 6 RETURNING v'), + '61', + 'helping: UPDATE over an aborted DELETE'); +is($node->safe_psql('postgres', 'DELETE FROM h WHERE id = 7 RETURNING v'), + '70', 'helping: DELETE over an aborted DELETE'); +is( $node->safe_psql( + 'postgres', $idx . 'SELECT count(*) FROM h WHERE k = 105'), + '0', + 'helping: the aborted key UPDATE left no live index entry'); + +# Now let the undo apply worker run. It must not clobber what the writers +# committed on top of the versions they reverted: each record a writer +# already reverted must be skipped by the idempotency key (t_verptr no +# longer points at it). The log at DEBUG2 counts those skips. +$node->append_conf('postgresql.conf', + "max_worker_processes = 8\nlog_min_messages = debug2"); +my $log_offset = -s $node->logfile; +$node->restart; + +# The worker restores table f's deferred rollbacks: wait until row 1's +# aborted image is gone from the page. +$node->poll_query_until('postgres', + q{SELECT position('\\x01020201'::bytea IN get_raw_page('f', 0)) = 0}) + or die 'undo apply worker did not run'; +$node->poll_query_until( + 'postgres', + q{SELECT count(*) = 0 FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'} +) or die 'undo apply worker did not finish'; + +# The worker did run (table f's images are restored, checked above), and for +# table h it found that writers had already reverted the aborted versions: +# it skipped those records by the idempotency key. +# Writers reverted 8 of h's records (rows 1-7, row 4 twice); f's records +# were all still current. So the worker skips at least those 8 (it may +# meet a record twice if a request is retried). +my $wlog = substr(slurp_file($node->logfile), $log_offset); +my $skips = () = + $wlog =~ + /undo apply worker.*FLUX UNDO: record at \d+ for .* was already undone/g; +cmp_ok($skips, '>=', 8, + 'helping: the undo apply worker skipped every record a writer reverted'); +unlike($wlog, qr/cannot restore/, 'helping: no failed restore'); +$node->append_conf('postgresql.conf', 'log_min_messages = warning'); +$node->reload; + +is( $node->safe_psql( + 'postgres', + $seq + . q{SELECT string_agg(id || ':' || v || ':' || k, ',' ORDER BY id) FROM h} + ), + '1:11:1,3:30:3,4:41:4,5:51:5,6:61:6,8:80:8', + 'helping: undo apply worker left the helped rows alone'); +is( $node->safe_psql( + 'postgres', + $idx + . q{SELECT string_agg(id::text, ',' ORDER BY id) FROM h WHERE k < 100} + ), + '1,3,4,5,6,8', + 'helping: index agrees with the table'); +check_rows( + 'after undo apply worker', + 1 => '10', + 2 => '20', + 3 => '31', + 4 => '40', + 5 => '50', + 6 => '61'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/077_flux_undo_idempotency.pl b/src/test/recovery/t/077_flux_undo_idempotency.pl new file mode 100644 index 0000000000000..83436eb8185a0 --- /dev/null +++ b/src/test/recovery/t/077_flux_undo_idempotency.pl @@ -0,0 +1,56 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX UNDO apply must be idempotent: a record whose change was already +# undone (by ROLLBACK TO SAVEPOINT, or by a writer that reverted it) must not +# be applied again on top of a version some other transaction committed +# since. flux_undo_apply checks that the tuple still carries the version the +# record produced (t_verptr == the record's pointer for UPDATE, or a DELETE +# mark by the record's writer) and otherwise leaves the tuple alone. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_idem'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +$node->safe_psql( + 'postgres', q{ +CREATE TABLE f (id int PRIMARY KEY, v int) USING flux; +INSERT INTO f SELECT g, g * 10 FROM generate_series(1, 6) g; +}); + +# T1 changes r2 (UPDATE) and r5 (DELETE) in a savepoint and rolls the +# savepoint back; T2 then commits its own change to both rows; T1 writes +# again and rolls back. The top-level rollback walks every record T1 wrote, +# including the ones ROLLBACK TO SAVEPOINT already undid. +my $t1 = $node->background_psql('postgres'); +$t1->query_safe('BEGIN'); +$t1->query_safe('UPDATE f SET v = 11 WHERE id = 1'); +$t1->query_safe('SAVEPOINT s'); +$t1->query_safe('UPDATE f SET v = 22 WHERE id = 2'); +$t1->query_safe('DELETE FROM f WHERE id = 5'); +$t1->query_safe('ROLLBACK TO s'); + +$node->safe_psql('postgres', 'UPDATE f SET v = 200 WHERE id = 2'); +$node->safe_psql('postgres', 'UPDATE f SET v = 500 WHERE id = 5'); + +# Write again at the top level, so the top-level chain starts after the +# savepoint's records. +$t1->query_safe('RELEASE s'); +$t1->query_safe('UPDATE f SET v = 33 WHERE id = 3'); +$t1->query_safe('ROLLBACK'); +$t1->quit; + +is( $node->safe_psql( + 'postgres', + 'SELECT string_agg(id || \':\' || v, \',\' ORDER BY id) FROM f'), + '1:10,2:200,3:30,4:40,5:500,6:60', + 'top-level rollback did not re-apply records undone by ROLLBACK TO'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/078_flux_undo_restore_fail.pl b/src/test/recovery/t/078_flux_undo_restore_fail.pl new file mode 100644 index 0000000000000..3cc741ccc3708 --- /dev/null +++ b/src/test/recovery/t/078_flux_undo_restore_fail.pl @@ -0,0 +1,139 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX UNDO apply that cannot restore a before-image must not report +# success. +# +# An in-place UPDATE never shrinks a tuple's slot, so a current record's +# before-image always fits. If a restore cannot be done anyway, +# flux_undo_apply must return UNDO_APPLY_ERROR, not SUCCESS: the rollback +# request stays, and is retried once the cause is gone. Before this, the +# restore was skipped with a WARNING and reported as done, so the aborted +# image was left on the page for good. The injection point +# flux-undo-restore-no-room simulates the failure. + +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('flux_x1'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +shared_preload_libraries = 'injection_points' +)); +$node->start; + +if ( !$node->check_extension('injection_points') + || !$node->check_extension('pageinspect')) +{ + plan skip_all => 'injection_points or pageinspect not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE TABLE f (id int PRIMARY KEY, v int) USING flux; +INSERT INTO f SELECT g, g * 10 FROM generate_series(1, 4) g; +SELECT injection_points_attach('flux-undo-restore-no-room', 'notice'); +}); + +# 0x01020201 reads the same in either byte order, so the raw page can be +# searched for it. +my $on_page = + q{SELECT position('\x01020201'::bytea IN get_raw_page('f', 0)) > 0}; + +# The rollback cannot restore the row. It must not be marked done: the +# aborted image is still on the page, the transaction header still says +# "not applied", and the request is still queued. +my $start_lsn = $node->safe_psql('postgres', 'SELECT pg_current_wal_lsn()'); +$node->safe_psql('postgres', + 'BEGIN; UPDATE f SET v = 16908801 WHERE id = 1; ROLLBACK;'); +$node->safe_psql('postgres', 'CHECKPOINT'); # flush the rollback's WAL +my $end_lsn = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); +is($node->safe_psql('postgres', $on_page), + 't', 'failed restore left the aborted image on the page'); + +# A small rollback is applied inline by the aborting backend; no undo apply +# worker took part in it. +is( $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + '0', + 'rollback was applied inline, not by a worker'); + +# Marking the rollback complete writes XLOG_UNDO_APPLY_PROGRESS. It must not +# be written for a rollback that left a record unapplied. +my ($stdout, $stderr); +IPC::Run::run [ + 'pg_waldump', + '--path' => $node->data_dir . '/pg_wal', + '--start' => $start_lsn, + '--end' => $end_lsn, + '--rmgr' => 'UndoAction' + ], + '>' => \$stdout, + '2>' => \$stderr; +like($stdout . $stderr, qr/rmgr: UndoAction|^$/s, 'pg_waldump ran'); +unlike($stdout, qr/APPLY_PROGRESS/, + 'rollback with an unapplied record is not marked complete'); + +# Readers are not affected: the aborted writer is invisible. +is($node->safe_psql('postgres', 'SELECT v FROM f WHERE id = 1'), + '10', 'reader sees the committed value'); + +# Once the cause is gone, the outstanding rollback is completed. A writer +# running into the row reverts it itself (helping) and builds on the +# committed value; the queued request then finds nothing left to do. +$node->safe_psql('postgres', + "SELECT injection_points_detach('flux-undo-restore-no-room')"); +is( $node->safe_psql( + 'postgres', 'UPDATE f SET v = v + 1 WHERE id = 1 RETURNING v'), + '11', + 'writer builds on the committed value'); +is($node->safe_psql('postgres', $on_page), 'f', 'aborted image is gone'); + +# The same failure inside ROLLBACK TO SAVEPOINT. The record must not be +# marked applied: it stays due, so the transaction refuses to commit (which +# would keep the aborted image) and its rollback applies the record. +$node->safe_psql('postgres', + "SELECT injection_points_attach('flux-undo-restore-no-room', 'notice')"); +my ($ret, $out, $err) = $node->psql( + 'postgres', q{ +BEGIN; +SAVEPOINT s; +UPDATE f SET v = 16908801 WHERE id = 2; +ROLLBACK TO s; +SELECT injection_points_detach('flux-undo-restore-no-room'); +COMMIT; +}); +like( + $err, + qr/rollback to a savepoint was incomplete/, + 'commit after an incomplete ROLLBACK TO is refused'); +is($node->safe_psql('postgres', $on_page), + 'f', 'the rollback that followed applied the unapplied record'); +is( $node->safe_psql( + 'postgres', 'UPDATE f SET v = v + 1 WHERE id = 2 RETURNING v'), + '21', + 'writer builds on the committed value'); + +$node->restart; +is( $node->safe_psql( + 'postgres', + 'SELECT string_agg(id || \':\' || v, \',\' ORDER BY id) FROM f'), + '1:11,2:21,3:30,4:40', + 'no later apply clobbered the committed value'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/079_flux_vacuum_aborted_insert.pl b/src/test/recovery/t/079_flux_vacuum_aborted_insert.pl new file mode 100644 index 0000000000000..5278fd05f510f --- /dev/null +++ b/src/test/recovery/t/079_flux_vacuum_aborted_insert.pl @@ -0,0 +1,111 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX VACUUM reclaims rows of aborted INSERTs. +# +# INSERT writes no UNDO; an aborted inserter's rows are hidden by CLOG +# alone and nothing marks them DELETED. VACUUM used to count them as live +# and never removed them, so the space was lost for good. They must be +# reclaimed like heap reclaims them: dead once the inserter is known +# aborted. Rows of an in-place UPDATE or DELETE whose writer aborted are +# not dead (the row lives on at its before-image) and must stay. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_x2'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +if (!$node->check_extension('pageinspect')) +{ + plan skip_all => 'Extension pageinspect not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE TABLE a (id int PRIMARY KEY, pad text) USING flux; +INSERT INTO a SELECT g, 'live' FROM generate_series(1, 10) g; +-- aborted top-level insert +BEGIN; +INSERT INTO a SELECT g, repeat('x', 200) FROM generate_series(1001, 2000) g; +ROLLBACK; +-- savepoint-aborted insert in a committed transaction +BEGIN; +INSERT INTO a VALUES (11, 'keep'); +SAVEPOINT s; +INSERT INTO a SELECT g, repeat('y', 200) FROM generate_series(2001, 3000) g; +ROLLBACK TO s; +COMMIT; +}); + +my $normal_items = q{ +SELECT count(*) +FROM generate_series(0, pg_relation_size('a') / 8192 - 1) b, + heap_page_items(get_raw_page('a', b::int)) +WHERE lp_flags = 1}; + +is($node->safe_psql('postgres', $normal_items), + '2011', 'aborted rows are on the pages before VACUUM'); + +$node->safe_psql('postgres', 'VACUUM a'); + +is($node->safe_psql('postgres', $normal_items), + '11', 'VACUUM removed the aborted INSERTs'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM a'), + '11', 'committed rows are all still there'); +is( $node->safe_psql( + 'postgres', + 'SET enable_seqscan = off; SET enable_bitmapscan = off; ' + . 'SELECT count(*) FROM a WHERE id > 1000'), + '0', + 'no index entry leads to a removed row'); + +# The space is reused: re-inserting the same volume does not grow the table. +my $pages = $node->safe_psql('postgres', "SELECT pg_relation_size('a')"); +$node->safe_psql('postgres', + "INSERT INTO a SELECT g, repeat('z', 200) FROM generate_series(1001, 2000) g" +); +is($node->safe_psql('postgres', "SELECT pg_relation_size('a')"), + $pages, 'freed space is reused'); + +# An aborted UPDATE / DELETE of a committed row must survive VACUUM. +$node->safe_psql( + 'postgres', q{ +BEGIN; UPDATE a SET pad = 'aborted' WHERE id = 1; DELETE FROM a WHERE id = 2; ROLLBACK; +VACUUM a; +}); +is( $node->safe_psql( + 'postgres', + 'SELECT string_agg(id || pad, \',\' ORDER BY id) FROM a WHERE id <= 2' + ), + '1live,2live', + 'VACUUM kept rows whose UPDATE / DELETE aborted'); + +# VACUUM truncates the empty pages it leaves at the end of the table, as heap +# does: all of them, not just the last one. +$node->safe_psql( + 'postgres', q{ +CREATE TABLE tr (id int, pad text) USING flux; +INSERT INTO tr VALUES (1, 'first'); +BEGIN; +INSERT INTO tr SELECT g, repeat('t', 200) FROM generate_series(2, 20000) g; +ROLLBACK; +}); +cmp_ok($node->safe_psql('postgres', "SELECT pg_relation_size('tr') / 8192"), + '>', 100, 'aborted rows fill many pages'); +$node->safe_psql('postgres', 'VACUUM tr'); +is($node->safe_psql('postgres', "SELECT pg_relation_size('tr') / 8192"), + '1', 'VACUUM truncated every trailing empty page'); +is($node->safe_psql('postgres', 'SELECT id, pad FROM tr'), + '1|first', 'the live row survives'); +$node->safe_psql('postgres', 'DELETE FROM tr; VACUUM tr'); +is($node->safe_psql('postgres', "SELECT pg_relation_size('tr')"), + '0', 'an empty table is truncated to nothing'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/086_flux_standby_helping.pl b/src/test/recovery/t/086_flux_standby_helping.pl new file mode 100644 index 0000000000000..181ec9ac487b6 --- /dev/null +++ b/src/test/recovery/t/086_flux_standby_helping.pl @@ -0,0 +1,98 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX helping (a writer reverting an aborted, not-yet-reverted version) is +# for writers on the primary only. A hot standby must not change a page +# itself: every change has to arrive through the WAL, or the standby's page +# diverges from the primary's. Its readers see the right row anyway, from +# CLOG and the version chain. The SnapshotAny row fetch that helps on the +# primary (flux_tuple_fetch_row_version) is reachable on a standby through a +# TID scan's fetch; it must not help there. +# +# The undo apply worker is held at the injection point +# pbu-undo-worker-before-apply, so the rollback stays pending. +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 $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +wal_consistency_checking = all +shared_preload_libraries = 'injection_points' +)); +$primary->start; +if ( !$primary->check_extension('injection_points') + || !$primary->check_extension('pageinspect')) +{ + plan skip_all => 'injection_points or pageinspect not installed'; +} +$primary->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE TABLE f (id int PRIMARY KEY, v int) USING flux; +INSERT INTO f SELECT g, g * 10 FROM generate_series(1, 4) g; +SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait'); +)); +$primary->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->start; + +$primary->safe_psql('postgres', + 'BEGIN; UPDATE f SET v = 16908801 WHERE id = 1; ROLLBACK;'); +$primary->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +# An abort does not flush its WAL, so wait for the insert position: the flush +# position wait_for_replay_catchup uses can still be short of the UPDATE. +$primary->wait_for_catchup($standby, 'replay', $primary->lsn('insert')); + +my $on_page = + q{SELECT position('\x01020201'::bytea IN get_raw_page('f', 0)) > 0}; +my $page_lsn = q{SELECT lsn FROM page_header(get_raw_page('f', 0))}; +is($primary->safe_psql('postgres', $on_page), + 't', 'primary: rollback still pending'); +is($standby->safe_psql('postgres', $on_page), 't', 'standby: same page'); + +is($standby->safe_psql('postgres', 'SELECT v FROM f WHERE id = 1'), + '10', 'standby reads the committed value'); +my $lsn_before = $standby->safe_psql('postgres', $page_lsn); +my $ctid = $standby->safe_psql('postgres', 'SELECT ctid FROM f WHERE id = 1'); +is($standby->safe_psql('postgres', "SELECT v FROM f WHERE ctid = '$ctid'"), + '10', 'standby TID fetch returns the committed value'); +is($standby->safe_psql('postgres', $on_page), + 't', 'standby did not revert the aborted version'); +is($standby->safe_psql('postgres', $page_lsn), + $lsn_before, 'standby did not change the page'); + +# A writer on the primary helps; the standby replays that. +is( $primary->safe_psql( + 'postgres', 'UPDATE f SET v = v + 1 WHERE id = 1 RETURNING v'), + '11', + 'primary writer builds on the committed value'); +$primary->safe_psql( + 'postgres', + "SELECT injection_points_wakeup('pbu-undo-worker-before-apply'); + SELECT injection_points_detach('pbu-undo-worker-before-apply');"); +$primary->wait_for_catchup($standby, 'replay', $primary->lsn('insert')); +is($standby->safe_psql('postgres', 'SELECT v FROM f WHERE id = 1'), + '11', 'standby replays the helped row'); +is($standby->safe_psql('postgres', $on_page), + 'f', 'aborted image gone on the standby'); +unlike( + slurp_file($standby->logfile), + qr/inconsistent page found|TRAP|PANIC/, + 'standby pages match the primary'); + +$standby->stop; +$primary->stop; +done_testing(); diff --git a/src/test/recovery/t/087_flux_index_undo_fail.pl b/src/test/recovery/t/087_flux_index_undo_fail.pl new file mode 100644 index 0000000000000..16db182de99b6 --- /dev/null +++ b/src/test/recovery/t/087_flux_index_undo_fail.pl @@ -0,0 +1,103 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Rolling back a FLUX in-place UPDATE of an indexed column also reverses the +# index: the new (k_new, TID) entry is killed and the old (k_old, TID) entry +# un-delete-marked. That reversal evaluates index expressions, which can +# fail. A failure must leave the record to be retried, and the retry must +# redo the index reversal too. If the tuple were restored first, the retry +# would find the record already undone and skip it, leaving the index broken +# for good. +# +# The index expression fails while ctl.fail is set. The rollback goes to +# the undo apply worker (undo_rollback_overflow_size = 0), held at the +# injection point pbu-undo-worker-before-apply until ctl.fail is set; it +# then fails, and succeeds on a retry once ctl.fail is cleared. +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('flux_index_undo_fail'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +)); +$node->start; +if ( !$node->check_extension('amcheck') + || !$node->check_extension('injection_points')) +{ + plan skip_all => 'amcheck or injection_points not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION amcheck; +CREATE EXTENSION injection_points; +CREATE TABLE ctl (fail bool); +INSERT INTO ctl VALUES (false); +CREATE FUNCTION kf(int) RETURNS int LANGUAGE plpgsql IMMUTABLE AS + $$ BEGIN + IF $1 >= 100 AND (SELECT fail FROM public.ctl) THEN + RAISE EXCEPTION 'kf fails'; + END IF; + RETURN $1; + END $$; +CREATE TABLE f (id int PRIMARY KEY, k int) USING flux; +CREATE INDEX f_kf ON f (kf(k)); +INSERT INTO f SELECT g, g FROM generate_series(1, 4) g; +SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait'); +}); + +my $off = -s $node->logfile; +$node->safe_psql('postgres', + 'BEGIN; UPDATE f SET k = 100 WHERE id = 1; ROLLBACK;'); +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +$node->safe_psql( + 'postgres', q{ +UPDATE ctl SET fail = true; +SELECT injection_points_detach('pbu-undo-worker-before-apply'); +SELECT injection_points_wakeup('pbu-undo-worker-before-apply'); +}); +ok( $node->wait_for_log( + qr/undo apply worker.*ERROR: .*\n.*PL\/pgSQL function kf/, $off), + 'the index reversal fails in the undo apply worker'); + +$node->safe_psql('postgres', 'UPDATE ctl SET fail = false'); +my $idx = 'SET enable_seqscan = off; SET enable_bitmapscan = off; '; +ok( $node->poll_query_until( + 'postgres', 'SELECT count(*) = 4 FROM f WHERE k < 100'), + 'the retry rolled the row back'); +is( $node->safe_psql( + 'postgres', $idx . 'SELECT count(*) FROM f WHERE kf(k) = 1'), + '1', + 'the k_old index entry is live again'); +is( $node->safe_psql( + 'postgres', $idx . 'SELECT count(*) FROM f WHERE kf(k) = 100'), + '0', + 'no k_new index entry'); +$node->poll_query_until( + 'postgres', + q{SELECT count(*) = 0 FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'} +) or die 'undo apply worker did not finish'; +is($node->safe_psql('postgres', q{SELECT bt_index_check('f_kf', true)}), + '', 'index matches the table'); +is( $node->safe_psql( + 'postgres', + q{SELECT string_agg(id || ':' || k, ',' ORDER BY id) FROM f}), + '1:1,2:2,3:3,4:4', + 'rows rolled back'); +unlike(slurp_file($node->logfile, $off), + qr/giving up/, 'the worker did not give up'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/089_perbackend_rewrite_rollback.pl b/src/test/recovery/t/089_perbackend_rewrite_rollback.pl index b5c2a0b89bd83..18fc274170221 100644 --- a/src/test/recovery/t/089_perbackend_rewrite_rollback.pl +++ b/src/test/recovery/t/089_perbackend_rewrite_rollback.pl @@ -19,19 +19,44 @@ $node->append_conf('postgresql.conf', "autovacuum = off\nrestart_after_crash = on\n"); $node->start; +if (!$node->check_extension('pageinspect')) +{ + plan skip_all => 'Extension pageinspect not installed'; +} + +# The tables live in rr, so that the undo apply worker, which connects to the +# database it reverts, can be kept out of it (ALLOW_CONNECTIONS false) while +# a rollback handed to it is checked to be still pending. +$node->safe_psql('postgres', 'CREATE DATABASE rr'); +$node->safe_psql( + 'rr', q{ +CREATE EXTENSION pageinspect; +CREATE TABLE mk (v int) USING flux; +INSERT INTO mk VALUES (0); +VACUUM (FREEZE) mk; +}); + +# Freeze the marker before capturing its bytes: DML before-images can freeze +# globally visible xmin, so an unfrozen baseline is not byte-stable. +# +# A deferred transaction updates the marker row mk first, so that its undo +# record is the last of the request to be applied: while mk's raw bytes +# differ from the committed image the rollback is pending, and once they are +# back the worker has applied all of it. # # The comparison is exact except for the two CLOG hint bits in the high byte -# of t_flags (byte 37 of the FLUX tuple header): FLUX_TUPLE_XMIN_COMMITTED +# of t_flags (byte 33 of the FLUX tuple header): FLUX_TUPLE_XMIN_COMMITTED # (0x02) and FLUX_TUPLE_XMAX_COMMITTED (0x04). The UPDATE's visibility # check may set them on the page after the committed image was read here and # before the UPDATE copies its before-image, so the image the worker puts # back can carry a hint the committed image did not. A hint bit only caches # what CLOG says about t_xmin or t_xmax, which both images share; it is not # WAL-logged and wal_consistency_checking masks it. Every other byte, -# including the reserved word at bytes 16-19, must match. -my $raw_mk = q{SELECT encode(set_byte(t, 37, get_byte(t, 37) & ~6), 'hex') +# must match. +my $raw_mk = q{SELECT encode(set_byte(t, 33, get_byte(t, 33) & ~6), 'hex') FROM (SELECT substr(get_raw_page('mk', 0), lp_off + 1, lp_len) AS t FROM heap_page_items(get_raw_page('mk', 0)) WHERE lp = 1) s}; +my $orig_mk = $node->safe_psql('rr', $raw_mk); my %cases = ( 'a_update_truncate_insert' => sub { @@ -71,15 +96,17 @@ ROLLBACK;); },); -# The worker path applies undo in the background; give it a moment. +# The cases whose top-level ROLLBACK goes to the worker on the worker path. +my %deferred = (a => 1, b => 1, d => 1); + +# The worker path applies undo in the background; wait for it. sub check { my ($t, $what) = @_; - ok( $node->poll_query_until( - 'postgres', - "SELECT count(*) || ',' || count(*) FILTER (WHERE v = 'orig') FROM $t", - '10,10'), - $what); + my $q = + "SELECT count(*) || ',' || count(*) FILTER (WHERE v = 'orig') FROM $t"; + ok($node->poll_query_until('rr', $q, '10,10'), $what) + or diag($node->safe_psql('rr', $q)); } foreach my $path ('inline', 'worker') @@ -92,25 +119,50 @@ sub check } my @tables; + my $bg = $node->background_psql('rr', on_error_stop => 0); foreach my $am ('flux') { foreach my $case (sort keys %cases) { - my $t = "${path}_${am}_" . substr($case, 0, 1); + my $c = substr($case, 0, 1); + my $t = "${path}_${am}_$c"; my $what = "$path, $am, $case"; - $node->poll_query_until('postgres', 'SELECT true'); - $node->safe_psql( - 'postgres', qq( + my $defer = $path eq 'worker' && $deferred{$c}; + $bg->query_safe( + qq( CREATE TABLE $t (id int primary key, v text) USING $am; INSERT INTO $t SELECT g, 'orig' FROM generate_series(1, 10) g; )); - my ($ret, $out, $err) = - $node->psql('postgres', $cases{$case}->($t)); - is($ret, 0, "$what: no error") or diag($err); + my $sql = $cases{$case}->($t); + if ($defer) + { + $sql =~ s/BEGIN;/BEGIN;\nUPDATE mk SET v = v + 1;/; + $node->safe_psql('postgres', + 'ALTER DATABASE rr ALLOW_CONNECTIONS false'); + ok( $node->poll_query_until( + 'postgres', + q{SELECT count(*) = 0 FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + "$what: no undo apply worker is connected"); + } + $bg->{stderr} = ''; + $bg->query($sql); + is($bg->{stderr}, '', "$what: no error"); + if ($defer) + { + isnt($bg->query_safe($raw_mk), + $orig_mk, "$what: the rollback is left to the worker"); + $node->safe_psql('postgres', + 'ALTER DATABASE rr ALLOW_CONNECTIONS true'); + ok( $node->poll_query_until( + 'rr', "SELECT ($raw_mk) = '$orig_mk'"), + "$what: the worker applied the rollback"); + } check($t, "$what: rows restored"); push @tables, [ $t, $what ]; } } + $bg->quit; $node->restart; check($_->[0], "$_->[1]: rows restored after restart") foreach @tables; diff --git a/src/test/recovery/t/093_flux_toast_supersede.pl b/src/test/recovery/t/093_flux_toast_supersede.pl new file mode 100644 index 0000000000000..2d97e39e9594e --- /dev/null +++ b/src/test/recovery/t/093_flux_toast_supersede.pl @@ -0,0 +1,135 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# FLUX in-place UPDATE must not leak superseded TOAST values. +# +# An in-place UPDATE overwrites the row at its TID and keeps the old version +# only as an UNDO before-image. The old version's external TOAST values must +# then be deleted exactly like heap deletes them: by the updating transaction +# (so they survive for snapshot-toast readers of the before-image, and an +# abort keeps them), and physically reclaimed by VACUUM once no snapshot can +# reach the before-image. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_toast'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +if (!$node->check_extension('pageinspect')) +{ + plan skip_all => 'Extension pageinspect not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE TABLE t (id int PRIMARY KEY, n int, big text) USING flux; +ALTER TABLE t ALTER big SET STORAGE EXTERNAL; +INSERT INTO t VALUES (1, 0, repeat('a', 10000)), (2, 0, repeat('z', 10000)); +}); +my $toast = $node->safe_psql('postgres', + "SELECT reltoastrelid::regclass FROM pg_class WHERE relname = 't'"); + +# Values visible to an MVCC snapshot, and chunks physically on the pages. +my $values = "SELECT count(DISTINCT chunk_id) FROM $toast"; +my $physical = qq{ +SELECT (SELECT count(*) + FROM generate_series(0, pg_relation_size('$toast') / 8192 - 1) b, + heap_page_items(get_raw_page('$toast', b::int)) + WHERE lp_flags = 1) = (SELECT count(*) FROM $toast)}; + +# Five UPDATEs of the wide column, five of a narrow column. +for my $i (1 .. 5) +{ + $node->safe_psql('postgres', + "UPDATE t SET big = repeat(chr(97 + $i), 10000) WHERE id = 1"); + $node->safe_psql('postgres', "UPDATE t SET n = n + 1 WHERE id = 1"); +} +is($node->safe_psql('postgres', $values), + '2', 'superseded TOAST values are deleted by the UPDATE'); +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $physical), + 't', 'VACUUM reclaimed the superseded TOAST chunks'); +is( $node->safe_psql( + 'postgres', + 'SELECT n, big = repeat(\'f\', 10000) FROM t WHERE id = 1'), + '5|t', + 'current value intact'); + +# A REPEATABLE READ snapshot taken before the UPDATE still detoasts the old +# value through the before-image, even across a VACUUM. +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); +$bg->query_safe('SELECT count(*) FROM t'); +$node->safe_psql('postgres', + "UPDATE t SET big = repeat('g', 10000) WHERE id = 1"); +$node->safe_psql('postgres', 'VACUUM t'); +is($bg->query_safe("SELECT big = repeat('f', 10000) FROM t WHERE id = 1"), + 't', 'old snapshot detoasts the superseded value'); +$bg->query_safe('COMMIT'); +$bg->quit; +$node->safe_psql('postgres', 'VACUUM t'); +is($node->safe_psql('postgres', $physical), + 't', 'superseded value reclaimed once the old snapshot ended'); +is($node->safe_psql('postgres', $values), '2', 'two live values remain'); + +# An aborted UPDATE (top level and savepoint) restores a detoastable value. +$node->safe_psql( + 'postgres', q{ +BEGIN; UPDATE t SET big = repeat('h', 10000) WHERE id = 1; ROLLBACK; +BEGIN; SAVEPOINT s; UPDATE t SET big = repeat('i', 10000) WHERE id = 1; +ROLLBACK TO s; COMMIT; +VACUUM t; +}); +is( $node->safe_psql( + 'postgres', "SELECT big = repeat('g', 10000) FROM t WHERE id = 1"), + 't', + 'aborted UPDATE keeps the restored value detoastable'); +is($node->safe_psql('postgres', $values), + '2', 'aborted UPDATEs left no values'); +is($node->safe_psql('postgres', $physical), + 't', 'aborted UPDATEs left no chunks after VACUUM'); + +# Taking another row's TOAST pointer must copy the value, not share it. +$node->safe_psql( + 'postgres', q{ +UPDATE t SET big = (SELECT big FROM t WHERE id = 2) WHERE id = 1; +DELETE FROM t WHERE id = 2; +VACUUM t; +}); +is( $node->safe_psql( + 'postgres', "SELECT big = repeat('z', 10000) FROM t WHERE id = 1"), + 't', + 'value copied from another row survives that row\'s removal'); +is($node->safe_psql('postgres', $values), '1', 'one live value remains'); + +# A row that grows past its full page after toasting falls back to an +# out-of-place UPDATE; the abandoned in-place attempt must not leave the +# value it externalized behind. +$node->safe_psql( + 'postgres', q{ +CREATE TABLE g (id int, a text, b text) USING flux; +ALTER TABLE g ALTER a SET STORAGE EXTERNAL, ALTER b SET STORAGE EXTERNAL; +INSERT INTO g SELECT i, '', repeat('p', 150) FROM generate_series(1, 200) i; +UPDATE g SET a = repeat('q', 5000), b = repeat('r', 1500) WHERE id = 1; +}); +my $gtoast = $node->safe_psql('postgres', + "SELECT reltoastrelid::regclass FROM pg_class WHERE relname = 'g'"); +is( $node->safe_psql( + 'postgres', + "SELECT ctid <> '(0,1)', a = repeat('q', 5000) FROM g WHERE id = 1"), + 't|t', + 'row moved out of place'); +is( $node->safe_psql( + 'postgres', "SELECT count(DISTINCT chunk_id) FROM $gtoast"), + '1', + 'abandoned in-place attempt left no TOAST value'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/095_flux_rewrite_cutoffs.pl b/src/test/recovery/t/095_flux_rewrite_cutoffs.pl new file mode 100644 index 0000000000000..59fc1ce6eae10 --- /dev/null +++ b/src/test/recovery/t/095_flux_rewrite_cutoffs.pl @@ -0,0 +1,51 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# VACUUM FULL and CLUSTER of a FLUX table must leave valid relfrozenxid / +# relminmxid behind, on the table and on its TOAST table, so that a later +# VACUUM (which asserts them normal) and anti-wraparound bookkeeping work. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_rewrite'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +$node->safe_psql( + 'postgres', q{ +CREATE TABLE vf (id int PRIMARY KEY, v text) USING flux; +INSERT INTO vf SELECT g, repeat('x', 3000 + g) FROM generate_series(1, 50) g; +DELETE FROM vf WHERE id % 2 = 0; +}); + +my $cutoffs = q{ +SELECT c.relfrozenxid::text::bigint > 2, c.relminmxid::text::bigint >= 1, + t.relfrozenxid::text::bigint > 2, t.relminmxid::text::bigint >= 1 +FROM pg_class c JOIN pg_class t ON t.oid = c.reltoastrelid +WHERE c.relname = 'vf'}; + +foreach my $cmd ('VACUUM FULL vf', 'CLUSTER vf USING vf_pkey') +{ + $node->safe_psql('postgres', $cmd); + is($node->safe_psql('postgres', $cutoffs), + 't|t|t|t', "$cmd: valid cutoffs"); + $node->safe_psql('postgres', 'VACUUM vf'); + is( $node->safe_psql( + 'postgres', q{SELECT count(*), sum(length(v)) FROM vf}), + '25|75625', + "$cmd: then VACUUM: rows intact"); +} + +# FREEZE and a database-wide VACUUM (advances datfrozenxid) work too. +$node->safe_psql('postgres', 'VACUUM (FREEZE) vf'); +$node->safe_psql('postgres', 'VACUUM'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM vf'), + '25', 'rows after FREEZE'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/097_flux_vacuum_truncate.pl b/src/test/recovery/t/097_flux_vacuum_truncate.pl new file mode 100644 index 0000000000000..6c636e84d7492 --- /dev/null +++ b/src/test/recovery/t/097_flux_vacuum_truncate.pl @@ -0,0 +1,107 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# FLUX: VACUUM truncates an empty tail with heap's protocol. A session +# holding ROW EXCLUSIVE makes the AccessExclusiveLock attempt give up, and +# VACUUM VERBOSE must then report no pages truncated; once the lock is free +# the whole empty tail goes and VERBOSE reports the pages it truncated. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_truncate'); +$node->init; +$node->append_conf('postgresql.conf', "autovacuum = off\n"); +$node->append_conf('postgresql.conf', + "shared_preload_libraries = 'injection_points'\n") + if ($ENV{enable_injection_points} // '') eq 'yes'; +$node->start; + +my $stderr; +$node->safe_psql( + 'postgres', q{ +CREATE TABLE ft (id int, pad text) USING flux; +INSERT INTO ft SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +DELETE FROM ft WHERE id > 1; +}); +my $fpages = + $node->safe_psql('postgres', q{SELECT pg_relation_size('ft') / 8192}); +my $holder = $node->background_psql('postgres'); +$holder->query_safe('BEGIN'); +$holder->query_safe('LOCK ft IN ROW EXCLUSIVE MODE'); +$node->psql('postgres', 'VACUUM VERBOSE ft', stderr => \$stderr); +like( + $stderr, + qr/stopping truncate due to conflicting lock request/, + 'FLUX: truncation gives up behind a conflicting lock'); +like( + $stderr, + qr/truncated 0 pages/, + 'FLUX: VACUUM VERBOSE reports no truncation when it gave up'); +is($node->safe_psql('postgres', q{SELECT pg_relation_size('ft') / 8192}), + $fpages, 'FLUX: the table kept its pages'); +$holder->query_safe('COMMIT'); +$holder->quit; +$node->psql('postgres', 'VACUUM VERBOSE ft', stderr => \$stderr); +my $want = $fpages - 1; +like( + $stderr, + qr/truncated $want pages/, + 'FLUX: VACUUM VERBOSE reports the pages it truncated'); +is($node->safe_psql('postgres', q{SELECT pg_relation_size('ft') / 8192}), + '1', 'FLUX: empty tail truncated'); + +# Rows written into the empty tail after VACUUM found it, but before it +# truncates, must survive: the truncation gives up when the relation grew and +# otherwise recounts the tail under its AccessExclusiveLock. Hold VACUUM +# there with an injection point and write as many rows as were deleted, so +# that they reach the last page whether they go to the free pages or extend +# the relation. +SKIP: +{ + skip 'injection points not supported by this build', 2 + if ($ENV{enable_injection_points} // '') ne 'yes'; + skip 'Extension injection_points not installed', 2 + if !$node->check_extension('injection_points'); + + $node->safe_psql( + 'postgres', q{ +CREATE EXTENSION injection_points; +CREATE TABLE fr (id int, pad text) USING flux WITH (autovacuum_enabled = off); +INSERT INTO fr SELECT g, repeat('r', 200) FROM generate_series(1, 20000) g; +DELETE FROM fr WHERE id > 1; +SELECT injection_points_attach('flux-vacuum-before-truncate', 'wait'); +}); + my $vacuumer = $node->background_psql('postgres'); + $vacuumer->query_until( + qr/started/, q{ +\echo started +VACUUM fr; +}); + $node->wait_for_event('client backend', 'flux-vacuum-before-truncate'); + $node->safe_psql( + 'postgres', q{ +INSERT INTO fr SELECT g, repeat('w', 200) FROM generate_series(100001, 120000) g; +}); + $node->safe_psql( + 'postgres', q{ +SELECT injection_points_wakeup('flux-vacuum-before-truncate'); +SELECT injection_points_detach('flux-vacuum-before-truncate'); +}); + $vacuumer->quit; + + is($node->safe_psql('postgres', q{SELECT count(*) FROM fr}), + '20001', 'FLUX: rows written during VACUUM survive truncation'); + is( $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM fr WHERE id > 100000 AND pad = repeat('w', 200)} + ), + '20000', + 'FLUX: their values are intact'); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/100_flux_vacuum_recorded_tids.pl b/src/test/recovery/t/100_flux_vacuum_recorded_tids.pl new file mode 100644 index 0000000000000..77c051740782f --- /dev/null +++ b/src/test/recovery/t/100_flux_vacuum_recorded_tids.pl @@ -0,0 +1,142 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX VACUUM Phase III frees a dead line pointer only if Phase I recorded +# it, so that Phase II removed the index entries naming it. A row that dies +# between Phase I and Phase III stays LP_DEAD for the next VACUUM; were it +# set LP_UNUSED, the next INSERT would reuse a TID an index still names. +# +# The row that dies late is a committed DELETE that an older snapshot still +# saw at Phase I (recently dead, not recorded) and that opportunistic pruning +# marks LP_DEAD while VACUUM waits between Phase II and Phase III. +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('flux_vacuum_recorded'); +$node->init; +$node->append_conf('postgresql.conf', + "autovacuum = off\n" . "shared_preload_libraries = 'injection_points'\n"); +$node->start; +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE EXTENSION amcheck; +CREATE EXTENSION pg_visibility; +CREATE EXTENSION injection_points; +CREATE TABLE ab (id int, pad text) USING flux WITH (autovacuum_enabled = off); +CREATE INDEX ab_id ON ab (id); +INSERT INTO ab VALUES (0, repeat('x', 200)); +CREATE FUNCTION release_one(p text) RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity WHERE wait_event = p) THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup(p); + EXCEPTION WHEN OTHERS THEN + NULL; + END; + RETURN false; +END $$; +}); + +# lp_flags of line pointer $lp on block 0 of $t; 0 if absent, -1 if the +# relation has no block 0 (so truncation fails an assertion, not the test). +my $lp_flags = sub { + my ($t, $lp) = @_; + return -1 + if $node->safe_psql('postgres', "SELECT pg_relation_size('$t')") == 0; + return $node->safe_psql('postgres', + "SELECT coalesce((SELECT lp_flags FROM heap_page_items(get_raw_page('$t', 0)) WHERE lp = $lp), 0)" + ); +}; + +# Fill page 0 so that opportunistic pruning runs on it (it only prunes a +# page with under 10% free space). Row 1 is lp 2. +$node->safe_psql('postgres', + "INSERT INTO ab SELECT g, repeat('x', 200) FROM generate_series(1, 34) g" +); +cmp_ok( + $node->safe_psql( + 'postgres', + "SELECT count(*) FROM heap_page_items(get_raw_page('ab', 0))"), + '>=', 30, + 'page 0 is nearly full'); + +# An old snapshot keeps row 1 visible while it is deleted, so VACUUM's +# Phase I sees it recently dead and does not record it. +my $old = $node->background_psql('postgres'); +$old->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); +$old->query_safe('SELECT count(*) FROM ab'); +$node->safe_psql('postgres', 'DELETE FROM ab WHERE id = 1'); + +$node->safe_psql('postgres', + "SELECT injection_points_attach('flux-vacuum-before-phase3', 'wait')"); +my $vac = $node->background_psql('postgres'); +$vac->query_until(qr/start/, "\\echo start\nVACUUM ab;\n"); +$node->wait_for_event('client backend', 'flux-vacuum-before-phase3'); + +# The snapshot goes away; a seq scan prunes row 1 with a fresh horizon, +# leaving it LP_DEAD while its index entry, never recorded, survives. +$old->query_safe('COMMIT'); +$old->quit; +$node->safe_psql('postgres', 'SELECT count(*) FROM ab'); +is($lp_flags->('ab', 2), '3', + 'pruning left row 1 LP_DEAD while VACUUM waits'); + +$node->safe_psql('postgres', + "SELECT injection_points_detach('flux-vacuum-before-phase3')"); +$node->poll_query_until('postgres', + "SELECT release_one('flux-vacuum-before-phase3')") + or die 'VACUUM was not released'; +$vac->query_safe('SELECT 1'); # VACUUM has finished + +isnt($lp_flags->('ab', 2), + '0', 'VACUUM does not free a line pointer that Phase I did not record'); + +# The LP_DEAD slot left behind keeps the page out of the visibility map, so +# the next VACUUM visits the page and frees the slot, as heap's does. +is( $node->safe_psql( + 'postgres', + "SELECT all_visible OR all_frozen FROM pg_visibility_map('ab', 0)"), + 'f', + 'a page with an LP_DEAD slot is not marked all-visible'); +$node->safe_psql('postgres', 'VACUUM ab'); +is($lp_flags->('ab', 2), '0', 'the next VACUUM frees the LP_DEAD slot'); + +# Reuse: the freed TID is reused only now that no index entry names it. +$node->safe_psql('postgres', + "INSERT INTO ab SELECT g, 'y' FROM generate_series(100, 140) g"); +is( $node->safe_psql( + 'postgres', q{SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM ab WHERE id = 1}), + '0', + 'the stale index entry for id 1 leads to no row'); +is( $node->safe_psql( + 'postgres', q{SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM ab}), + $node->safe_psql( + 'postgres', q{SET enable_indexscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM ab}), + 'index scan and seq scan agree'); +is($node->safe_psql('postgres', "SELECT bt_index_check('ab_id', true)"), + '', 'bt_index_check heapallindexed passes'); + +$node->safe_psql('postgres', 'VACUUM ab'); +is($node->safe_psql('postgres', "SELECT bt_index_check('ab_id', true)"), + '', 'bt_index_check passes after a further VACUUM'); +$vac->quit; +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/101_flux_external_wal.pl b/src/test/recovery/t/101_flux_external_wal.pl new file mode 100644 index 0000000000000..6cc7d150016e6 --- /dev/null +++ b/src/test/recovery/t/101_flux_external_wal.pl @@ -0,0 +1,94 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Rows with out-of-line (EXTERNAL) columns survive WAL replay on FLUX: +# INSERT, a re-storing UPDATE, an indexed-column UPDATE, DELETE and VACUUM of +# ~12 kB EXTERNAL rows. Replay runs on a streaming standby and in crash +# recovery, both with wal_consistency_checking = all. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', q{ +wal_consistency_checking = all +autovacuum = off +}); +$primary->start; +$primary->backup('bkp'); + +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bkp', has_streaming => 1); +$standby->start; + +my @ams = ('flux'); + +# About 12.8 kB of incompressible text, stored out of line. +sub big +{ + my ($seed) = @_; + return "(SELECT string_agg(md5($seed || x::text), '') " + . "FROM generate_series(1, 400) x)"; +} +my $ins_big = big('g::text'); +my $upd_big = big("'u' || id::text"); + +foreach my $am (@ams) +{ + my $t = "ovf_$am"; + $primary->safe_psql( + 'postgres', qq( +CREATE TABLE $t (id int PRIMARY KEY, b int, big text) USING $am; +ALTER TABLE $t ALTER COLUMN big SET STORAGE EXTERNAL; +CREATE INDEX ${t}_b ON $t (b); +INSERT INTO $t SELECT g, g * 10, $ins_big FROM generate_series(1, 6) g; +UPDATE $t SET big = $upd_big WHERE id IN (2, 4); +UPDATE $t SET b = b + 1 WHERE id IN (1, 3); +DELETE FROM $t WHERE id = 5; +VACUUM $t; +DELETE FROM $t WHERE id = 6; +VACUUM $t; +UPDATE $t SET b = b + 1 WHERE id = 3; +)); +} + +sub rowsum +{ + my ($node, $am) = @_; + return $node->safe_psql('postgres', + "SELECT count(*) || ' ' || md5(string_agg(id || ':' || b || ':' || big, ',' ORDER BY id)) " + . "FROM ovf_$am"); +} + +my %want = map { $_ => rowsum($primary, $_) } @ams; +like($want{$_}, qr/^4 /, "$_: four rows on the primary") for @ams; + +$primary->wait_for_catchup($standby); +is(rowsum($standby, $_), $want{$_}, "$_: standby matches the primary") + for @ams; + +foreach my $round (1 .. 2) +{ + $primary->stop('immediate'); + $primary->start; + is(rowsum($primary, $_), + $want{$_}, "$_: unchanged after crash restart $round") + for @ams; +} + +# The standby replays the same WAL again from its own restart point. +$standby->stop('immediate'); +$standby->start; +$primary->wait_for_catchup($standby); +is(rowsum($standby, $_), + $want{$_}, "$_: standby unchanged after crash restart") + for @ams; + +ok(!$primary->log_contains('PANIC'), 'primary replayed without PANIC'); +ok(!$standby->log_contains('PANIC'), 'standby replayed without PANIC'); + +done_testing(); diff --git a/src/test/recovery/t/102_flux_insert_redo_consistency.pl b/src/test/recovery/t/102_flux_insert_redo_consistency.pl new file mode 100644 index 0000000000000..39a549af8efa8 --- /dev/null +++ b/src/test/recovery/t/102_flux_insert_redo_consistency.pl @@ -0,0 +1,67 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# An INSERT into a page whose free space holds stale bytes must replay to a +# page that wal_consistency_checking accepts. +# +# PageAddItem copies exactly the item's length and leaves the alignment +# padding up to the next MAXALIGN boundary as it was. On the primary that +# is whatever an earlier tuple left there before VACUUM compacted the page; +# on a standby that restored the page from a full-page image, whose hole is +# zero-filled, it is zero. The padding is not part of any tuple, so the +# masking function must ignore it, as heap_mask does. Heap is the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); +$node->append_conf('postgresql.conf', + "autovacuum = off\nwal_consistency_checking = all\n"); +$node->start; +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->start; + +foreach my $am (qw(heap flux)) +{ + my $t = "t_$am"; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, pad text) USING $am WITH (autovacuum_enabled = off); +INSERT INTO $t SELECT g, repeat('x', 200) FROM generate_series(1, 30) g; +DELETE FROM $t WHERE id % 3 <> 0; +}); + # VACUUM leaves LP_UNUSED slots and compacts the page; the stale 'x' + # bytes of the deleted rows are now in the page's free space. + $node->safe_psql('postgres', "VACUUM $t"); + # The next change to the page after a checkpoint carries a full-page + # image, which a standby restores with a zeroed hole. + $node->safe_psql('postgres', 'CHECKPOINT'); + $node->safe_psql('postgres', "INSERT INTO $t VALUES (100, 'a')"); + # These land in the old free space. Lengths 1..8 make sure some items + # are not MAXALIGNed and so are followed by padding. + $node->safe_psql('postgres', + "INSERT INTO $t SELECT 100 + g, repeat('y', g) FROM generate_series(1, 8) g" + ); + + # wait_for_catchup dies if the standby's startup process has exited. + my $caught_up = eval { $node->wait_for_catchup($standby); 1 }; + ok($caught_up && $standby->is_alive, + "$am: standby replays INSERT into a compacted page"); + last unless $caught_up && $standby->is_alive; + is( $standby->safe_psql( + 'postgres', "SELECT count(*), sum(length(pad)) FROM $t"), + $node->safe_psql( + 'postgres', "SELECT count(*), sum(length(pad)) FROM $t"), + "$am: standby sees the primary's rows"); +} + +ok(!$standby->log_contains('inconsistent page found'), + 'standby found no inconsistent page'); + +$standby->stop('fast', fail_ok => 1); +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/103_flux_nonpermanent_wal.pl b/src/test/recovery/t/103_flux_nonpermanent_wal.pl new file mode 100644 index 0000000000000..3f17e025f5f76 --- /dev/null +++ b/src/test/recovery/t/103_flux_nonpermanent_wal.pl @@ -0,0 +1,246 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# DML on an UNLOGGED or TEMP table writes no WAL proportional to the rows it +# touches, for FLUX as for heap: not for the table, not for its indexes or +# TOAST table, not for the per-backend undo that FLUX writes for it, and not +# for rolling that undo back, whether the backend or an undo apply worker does +# it. After a crash an unlogged FLUX table is empty and a temp one is gone, +# and recovery does not roll back the undo either left behind. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('nonperm_wal'); +$node->init(allows_streaming => 'logical'); +$node->append_conf( + 'postgresql.conf', q( +autovacuum = off +checkpoint_timeout = 1h +max_wal_size = 10GB +)); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION pg_walinspect'); + +# Each session first changes a permanent FLUX table, so it holds a permanent +# undo log whose meta precedes the next checkpoint: its first undo insert after +# that checkpoint would WAL-log the meta of whatever undo log it uses. +$node->safe_psql('postgres', + 'CREATE TABLE p (v int) USING flux; INSERT INTO p VALUES (0)'); + +# Has the discard worker discarded all of the unlogged undo? It cannot get +# past a rollback that has not been applied yet. +my $unlogged_discarded = q( +SELECT coalesce(bool_and(discard = insert), true) FROM pg_stat_get_undo_logs() + WHERE persistence = 'unlogged'); + +# The WAL each statement writes is recorded in the session's temp table lsn; +# writing a temp table writes no WAL itself. +sub measured +{ + my ($op, $sql) = @_; + return qq( +INSERT INTO lsn VALUES ('$op', pg_current_wal_insert_lsn(), NULL); +$sql; +UPDATE lsn SET e = pg_current_wal_insert_lsn() WHERE op = '$op'; +); +} + +my %bytes; +foreach my $overflow (16, 0) +{ + # 0 hands every unlogged rollback to an undo apply worker. + $node->safe_psql('postgres', + "ALTER SYSTEM SET undo_rollback_overflow_size = $overflow"); + $node->reload; + foreach my $am ('heap', 'flux') + { + foreach my $pers ('unlogged', 'temp') + { + foreach my $n (10000, 100000) + { + my $case = "$am $pers $n overflow $overflow"; + my $s = $node->background_psql('postgres'); + $s->query_safe('UPDATE p SET v = v + 1'); + $node->safe_psql('postgres', 'CHECKPOINT'); + $s->query_safe( + qq( +SET client_min_messages = warning; +CREATE $pers TABLE w (id int, v text, t text) USING $am; +CREATE INDEX w_id ON w (id); +CREATE INDEX w_v ON w USING hash (v); +CREATE TEMP TABLE lsn (op text, s pg_lsn, e pg_lsn); +) + . measured( + 'insert', + "INSERT INTO w SELECT g, 'x' || g, CASE WHEN g % 100 = 0 + THEN repeat(md5(g::text), 300) END + FROM generate_series(1, $n) g") + . measured('update', "UPDATE w SET v = v || 'u'") + . measured( + 'rollback', + "BEGIN; UPDATE w SET v = v || 'r', t = t || 'r'; ROLLBACK" + ) + . measured( + 'aborted insert', + "BEGIN; INSERT INTO w SELECT -g, 'n' FROM generate_series(1, $n) g; ROLLBACK" + ) + . measured( + 'savepoint', + "BEGIN; SAVEPOINT s; UPDATE w SET v = v || 's'; DELETE FROM w WHERE id % 2 = 0; + ROLLBACK TO s; COMMIT") + . measured('delete', 'DELETE FROM w') . q( +INSERT INTO lsn VALUES ('settle', pg_current_wal_insert_lsn(), NULL); +)); + + # Let an undo apply worker finish the rollbacks and the + # discard worker discard their undo: whatever WAL they write + # for it is counted too. + $node->poll_query_until('postgres', $unlogged_discarded) + or die "$case: unlogged undo not discarded"; + $s->query_safe( + "UPDATE lsn SET e = pg_current_wal_insert_lsn() WHERE op = 'settle'" + ); + + # Flush the WAL, for pg_walinspect to read it. Commits that + # wrote no WAL are not flushed. + $node->safe_psql('postgres', 'CHECKPOINT'); + + # Every op restored or kept what it should. + is($s->query_safe("SELECT count(*) FROM w"), + '0', "$case: rows deleted"); + + # Full-page images for hint bits are of catalog pages, never of + # unlogged ones, and come and go with checkpoints: leave them + # out. + foreach my $row ( + split /\n/, + $s->query_safe( + q( +SELECT op || '|' || ((e - s) - CASE WHEN e > s THEN + (SELECT coalesce(sum(record_length + fpi_length), 0) + FROM pg_get_wal_records_info(s, e) + WHERE record_type = 'FPI_FOR_HINT') ELSE 0 END) + FROM lsn ORDER BY s))) + { + my ($op, $b) = split /\|/, $row; + $bytes{$case}{$op} = $b; + cmp_ok($b, '<', 65536, "$case: $op WAL is small"); + } + + # No WAL record in the whole run references a block of the + # table, its indexes, its TOAST table or a non-permanent undo + # log. + is( $s->query_safe( + q( +WITH r AS (SELECT oid FROM pg_class + WHERE oid IN ('w'::regclass, (SELECT reltoastrelid FROM pg_class WHERE oid = 'w'::regclass))), + f AS (SELECT pg_relation_filenode(oid) AS fn FROM r + UNION ALL + SELECT pg_relation_filenode(indexrelid) FROM pg_index + WHERE indrelid IN (SELECT oid FROM r)) +SELECT count(*) FROM pg_get_wal_block_info( + (SELECT s FROM lsn WHERE op = 'insert'), + (SELECT e FROM lsn WHERE op = 'settle')) + WHERE relfilenode IN (SELECT fn FROM f) + OR (reldatabase = 9 AND relfilenode IN + (SELECT logno FROM pg_stat_get_undo_logs() WHERE persistence <> 'permanent')) +)), + '0', + "$case: no WAL references the relation or its undo"); + + # Nor does any undo log record name a non-permanent undo log, + # except the one that creates it: the creation of any undo log + # is WAL-logged, which keeps replay's log numbers in step. + is( $s->query_safe( + q( +SELECT string_agg(record_type || ' ' || description, ', ') + FROM pg_get_wal_records_info( + (SELECT s FROM lsn WHERE op = 'insert'), + (SELECT e FROM lsn WHERE op = 'settle')) + WHERE resource_manager = 'UndoLog' AND record_type <> 'CREATE' + AND substring(description from 'logno (\d+)')::oid IN + (SELECT logno FROM pg_stat_get_undo_logs() WHERE persistence <> 'permanent') +)), + '', + "$case: no undo log record names a non-permanent undo log" + ); + $s->query_safe('DROP TABLE w'); + $s->quit; + } + + # The WAL does not grow with the number of rows. + my $small = "$am $pers 10000 overflow $overflow"; + my $big = "$am $pers 100000 overflow $overflow"; + foreach my $op (sort keys %{ $bytes{$big} }) + { + cmp_ok($bytes{$big}{$op}, '<', $bytes{$small}{$op} + 16384, + "$am $pers overflow $overflow: $op WAL does not grow with rows" + ); + } + } + } +} +$node->safe_psql('postgres', + 'ALTER SYSTEM RESET undo_rollback_overflow_size'); +$node->reload; + +# A crash with FLUX transactions on an unlogged and on a temp table in +# flight. Recovery resets the unlogged table to empty and removes the temp +# table's files, and must not roll back the undo either of them left behind: +# that undo was never WAL-logged and describes pages that are gone. +$node->safe_psql( + 'postgres', q( +CREATE UNLOGGED TABLE cu (id int, v text) USING flux; +CREATE INDEX ON cu (id); +INSERT INTO cu SELECT g, 'orig' || g FROM generate_series(1, 10000) g; +)); +my $ut = $node->background_psql('postgres'); +$ut->query_safe( + "BEGIN; UPDATE cu SET v = 'inflight'; DELETE FROM cu WHERE id % 3 = 0;"); +my $tt = $node->background_psql('postgres'); +$tt->query_safe( + q( +CREATE TEMP TABLE ct (id int, v text) USING flux; +CREATE INDEX ON ct (id); +INSERT INTO ct SELECT g, 'orig' || g FROM generate_series(1, 10000) g; +BEGIN; +UPDATE ct SET v = 'inflight'; +)); +my $tpath = $node->data_dir . '/' + . $tt->query_safe("SELECT pg_relation_filepath('ct')"); +ok(-f $tpath, 'temp table file exists before the crash'); + +my $logstart = -s $node->logfile; +$node->stop('immediate'); +$ut->{run}->kill_kill; +$tt->{run}->kill_kill; +$node->start; + +is($node->safe_psql('postgres', 'SELECT count(*) FROM cu'), + '0', 'unlogged FLUX table is empty after the crash'); +ok(!-e $tpath, 'temp FLUX table files are gone after the crash'); +my $rlog = slurp_file($node->logfile, $logstart); +unlike( + $rlog, + qr/UNDO recovery: rolling back/, + 'crash recovery rolls back no undo of the unlogged and temp tables'); +unlike($rlog, qr/TRAP|PANIC/, + 'no assertion failure or PANIC after the crash'); + +# The reset table works. +$node->safe_psql( + 'postgres', q( +INSERT INTO cu SELECT g, 'orig' || g FROM generate_series(1, 100) g; +BEGIN; +UPDATE cu SET v = 'upd'; +ROLLBACK; +)); +is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM cu WHERE v = 'orig' || id"), + '100', + 'unlogged FLUX table rolls back after the crash'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/104_flux_index_aba_revert.pl b/src/test/recovery/t/104_flux_index_aba_revert.pl new file mode 100644 index 0000000000000..633169370087f --- /dev/null +++ b/src/test/recovery/t/104_flux_index_aba_revert.pl @@ -0,0 +1,203 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX: an indexed key that goes A -> B -> A in a transaction that rolls +# back, when the rollback is not applied at once. +# +# A FLUX UPDATE of an indexed column keeps the row at its TID: it inserts or +# revives (k_new, TID) and delete-marks (k_old, TID). The second UPDATE +# revives the (A, TID) the first one delete-marked. Reverting the second +# UPDATE must put (A, TID) back the way the first one left it, +# delete-marked, for reverting the first one to make it live again; removing +# it instead loses the row from the index. +# +# undo_rollback_overflow_size = 0 sends the rollback to the undo apply +# workers, which are held at the injection point pbu-undo-worker-before-apply +# while the row is read, and while a writer runs into the aborted version and +# reverts it (helping). Then the workers revert the rest. Each FLUX table +# has a heap twin that gets the same statements; heap never reuses a TID, so +# it is the control. +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('flux_index_aba'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +)); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('pageinspect') + || !$node->check_extension('amcheck')) +{ + plan skip_all => 'injection_points, pageinspect or amcheck not installed'; +} +$node->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE EXTENSION amcheck; +-- Wake one held undo apply worker; true once none is held any more. +CREATE FUNCTION release_one() RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE wait_event = 'pbu-undo-worker-before-apply') THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup('pbu-undo-worker-before-apply'); + EXCEPTION WHEN OTHERS THEN + NULL; -- it left the injection point meanwhile + END; + RETURN false; +END $$; +-- ids of the rows with k = key, by index scan and by seqscan +CREATE FUNCTION by_k(t regclass, key int, idx bool) RETURNS text +LANGUAGE plpgsql AS $$ +DECLARE + r text; +BEGIN + PERFORM set_config('enable_seqscan', CASE WHEN idx THEN 'off' ELSE 'on' END, true); + PERFORM set_config('enable_indexscan', CASE WHEN idx THEN 'on' ELSE 'off' END, true); + PERFORM set_config('enable_bitmapscan', 'off', true); + PERFORM set_config('enable_indexonlyscan', 'off', true); + EXECUTE format('SELECT coalesce(string_agg(id::text, '','' ORDER BY id), '''') FROM %s WHERE k = $1', t) + INTO r USING key; + RETURN r; +END $$; +CREATE TABLE fx (id int PRIMARY KEY, j int, k int) USING flux; +CREATE TABLE hp (id int PRIMARY KEY, j int, k int) USING heap; +INSERT INTO fx SELECT g, 0, g FROM generate_series(1, 20) g; +INSERT INTO hp SELECT g, 0, g FROM generate_series(1, 20) g; +CREATE INDEX fx_k ON fx (k); +CREATE INDEX hp_k ON hp (k); +CREATE UNIQUE INDEX fx_uk ON fx (k); +CREATE UNIQUE INDEX hp_uk ON hp (k); +)); + +# Every key a row has held, for both tables and both scans. +my $agree = sub { + my ($what, @keys) = @_; + foreach my $key (@keys) + { + my $r = $node->safe_psql('postgres', + "SELECT by_k('fx', $key, true), by_k('fx', $key, false), " + . "by_k('hp', $key, true), by_k('hp', $key, false)"); + my ($fi, $fs, $hi, $hs) = split /\|/, $r, -1; + is("$fi|$fs|$hi", "$hs|$hs|$hs", + "$what: k = $key: FLUX index scan, FLUX seqscan, heap index scan match heap seqscan" + ); + } +}; +my $amcheck = sub { + my $what = shift; + foreach my $i ('fx_k', 'fx_uk', 'fx_pkey') + { + my ($ret, $out, $err) = + $node->psql('postgres', "SELECT bt_index_check('$i', true)"); + is($ret . $err, '0', "$what: bt_index_check($i, heapallindexed)"); + } +}; +my $release = sub { + $node->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-worker-before-apply')"); + $node->poll_query_until('postgres', 'SELECT release_one()') + or die 'held undo apply workers were not released'; +}; + +# T1: rows 12 and 13 go A -> B -> A; the last UPDATE also sets j to +# 16908801 = 0x01020201, which reads the same in either byte order, so the +# page can be searched for the aborted version. +my $pending = + "SELECT position('\\x01020201'::bytea IN get_raw_page('fx', 0)) > 0"; +$node->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait')"); +foreach my $t ('fx', 'hp') +{ + $node->safe_psql( + 'postgres', qq( +BEGIN; +UPDATE $t SET k = k + 3000000 WHERE id IN (12, 13); +UPDATE $t SET k = k - 3000000, j = 16908801 WHERE id IN (12, 13); +ROLLBACK; +)); +} +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +is($node->safe_psql('postgres', $pending), 't', "T1's rollback is pending"); +$agree->('revert pending', 12, 13, 3000012, 3000013); + +# A writer runs into row 13's aborted version and reverts it first. +$node->safe_psql('postgres', 'UPDATE fx SET j = 1 WHERE id = 13'); +$node->safe_psql('postgres', 'UPDATE hp SET j = 1 WHERE id = 13'); +$agree->('row 13 reverted by a writer', 12, 13, 3000012, 3000013); + +# The worker reverts row 12. +$release->(); +ok($node->poll_query_until('postgres', "SELECT NOT ($pending)"), + "worker applied T1's rollback"); +$agree->('worker applied the rollback', 12, 13, 3000012, 3000013); +$amcheck->('worker applied the rollback'); + +# Row 12's key is taken: no other row may take it, nor row 13's. +foreach my $t ('fx', 'hp') +{ + foreach my $key (12, 13) + { + my ($ret, $out, $err) = + $node->psql('postgres', "UPDATE $t SET k = $key WHERE id = 1"); + like( + $err, + qr/duplicate key value violates unique constraint/, + "$t: k = $key is still taken"); + } +} + +# And the same for a key moved away and back by a committed transaction, +# then by an aborted one whose rollback a writer reverts: the committed +# move leaves k = 14 free. +$node->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait')"); +foreach my $t ('fx', 'hp') +{ + $node->safe_psql( + 'postgres', qq( +UPDATE $t SET k = 4000014 WHERE id = 14; +BEGIN; +UPDATE $t SET k = 14 WHERE id = 14; +UPDATE $t SET k = 4000014, j = 16908801 WHERE id = 14; +UPDATE $t SET k = 14 WHERE id = 14; +ROLLBACK; +)); +} +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +$agree->('committed move, aborted return pending', 14, 4000014); +$release->(); +ok($node->poll_query_until('postgres', "SELECT NOT ($pending)"), + "worker applied the aborted return"); +$agree->('committed move, aborted return applied', 14, 4000014); +$amcheck->('committed move, aborted return applied'); +foreach my $t ('fx', 'hp') +{ + is( $node->safe_psql( + 'postgres', "UPDATE $t SET k = 14 WHERE id = 2 RETURNING id"), + '2', + "$t: k = 14 is free once the row moved off it"); +} +$agree->('another row took the freed key', 2, 14, 4000014); + +$node->safe_psql('postgres', 'VACUUM fx, hp'); +$agree->('after VACUUM', 2, 12, 13, 14, 3000012, 3000013, 4000014); +$amcheck->('after VACUUM'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/106_flux_rewrite_pending_revert.pl b/src/test/recovery/t/106_flux_rewrite_pending_revert.pl new file mode 100644 index 0000000000000..58a835d29fc96 --- /dev/null +++ b/src/test/recovery/t/106_flux_rewrite_pending_revert.pl @@ -0,0 +1,442 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A FLUX table rewrite while an aborted transaction's revert is still pending. +# +# FLUX updates in place, so an aborted transaction whose rollback was handed +# to an undo apply worker (undo_rollback_overflow_size = 0) or that lost a +# crash leaves its images on the page until the undo is applied. Readers +# walk past them. A rewrite must copy exactly the committed state too: the +# undo records name the old relfilenode, so once the rewrite commits nothing +# can apply them any more. An aborted DELETE's row must not be lost, an +# aborted UPDATE's value must not be copied, an aborted INSERT must not come +# back, and the rewritten table must be indistinguishable from a heap twin +# that ran the same statements, on the primary and on a standby. +# +# Rewrites: VACUUM FULL, CLUSTER (sort and index scan), REPACK, ALTER TABLE +# ... SET TABLESPACE (a file copy), ALTER TABLE ... SET ACCESS METHOD heap, +# ALTER TABLE ... ALTER COLUMN ... TYPE, and REPACK (CONCURRENTLY), whose copy +# reads through the decoding's snapshot while the revert is pending. +# +# The live variant holds the undo apply worker at the injection point +# pbu-undo-worker-before-apply (see 084_perbackend_helping_once.pl). The +# crash variant keeps the worker from starting at all, as +# 076_flux_deferred_rollback.pl does, so that the revert is still pending +# after the restart. +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'; +} + +# name => rewrite statements, with T for the table +my @rewrites = ( + [ vacfull => 'VACUUM FULL T' ], + [ + cluster_sort => + "SET enable_indexscan = off;\nCLUSTER (VERBOSE) T USING T_pkey;\nRESET enable_indexscan" + ], + [ + cluster_index => + "SET random_page_cost = 0;\nSET seq_page_cost = 1000;\nCLUSTER (VERBOSE) T USING T_pkey;\nRESET random_page_cost;\nRESET seq_page_cost" + ], + [ repack => 'REPACK T' ], + [ repack_conc => 'REPACK (CONCURRENTLY) T' ], + # Copies the file as it is (relation_copy_data) to a new relfilenumber. + [ set_tablespace => 'ALTER TABLE T SET TABLESPACE ts' ], + [ set_am_heap => 'ALTER TABLE T SET ACCESS METHOD heap' ], + [ alter_type => 'ALTER TABLE T ALTER COLUMN v TYPE bigint' ], + # The rewriting transaction reads its own new rows before it commits. + [ + alter_type_xact => + "BEGIN;\nALTER TABLE T ALTER COLUMN v TYPE bigint;\nUPDATE T SET pad = pad || '+';\nCOMMIT" + ],); + +# Run $sql once per table, T replaced by the table name. +sub each_tab +{ + my ($sql, @tabs) = @_; + return join("\n", map { (my $x = $sql) =~ s/\bT(?=\b|_)/$_/g; $x } @tabs); +} + +# Row 3's aborted value, 16908801 = 0x01020201, reads the same in either byte +# order, so the raw page can be searched for it. +sub on_page +{ + my $t = shift; + return + "SELECT position('\\x01020201'::bytea IN get_raw_page('$t', 0)) > 0"; +} + +# Content of a table as count and md5, through a seqscan and through the +# primary key. +sub content +{ + my ($node, $t) = @_; + my $q = + "SELECT count(*) || ':' || md5(coalesce(string_agg(x::text, ',' ORDER BY id), '')) FROM $t x"; + return $node->safe_psql('postgres', + "SET enable_indexscan = off; SET enable_bitmapscan = off; $q;\n" + . "SET enable_seqscan = off; SET enable_indexscan = on; $q;"); +} + +# bt_index_check (heapallindexed) of one relation. bt_index_check returns +# void and reports corruption with an ERROR, so it passes when psql succeeds. +# Returns psql's stderr, empty on success. +sub bt_check +{ + my ($node, $rel) = @_; + my ($out, $err); + my $rc = $node->psql( + 'postgres', + "SELECT bt_index_check('$rel'::regclass, heapallindexed => true)", + stdout => \$out, + stderr => \$err, + on_error_stop => 1); + return $rc == 0 && $err eq '' ? '' : "rc=$rc: $err"; +} + +# Every row's key is taken: a unique check (dirty snapshot) must see it. +sub dup_count +{ + my ($node, $t) = @_; + return $node->safe_psql('postgres', + "WITH i AS (INSERT INTO $t SELECT * FROM $t ON CONFLICT DO NOTHING RETURNING 1) SELECT count(*) FROM i" + ); +} + +# Check the rewritten FLUX table against its heap twin. +sub check_twin +{ + my ($node, $f, $h, $what) = @_; + is(content($node, $f), content($node, $h), "$f: same rows as $h, $what"); + is(dup_count($node, $f), '0', "$f: unique checks see every row, $what"); + my @idx = split /\n/, + $node->safe_psql('postgres', + "SELECT c.relname FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid WHERE i.indrelid = '$f'::regclass ORDER BY 1" + ); + is(join(',', @idx), "${f}_k_idx,${f}_pkey", "$f: both indexes, $what"); + is(bt_check($node, $_), '', "$_: bt_index_check, $what") foreach @idx; + is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM verify_heapam('$h')"), + '0', + "$h: verify_heapam, $what"); +} + +# The statements every table pair runs. First committed history: a +# committed DELETE and UPDATE, and a committed transaction with an +# UPDATE, DELETE and INSERT in a savepoint that is rolled back. +my $setup = q( +CREATE TABLE T (id int PRIMARY KEY, v int, k int, pad text) USING AM; +CREATE INDEX T_k_idx ON T (k); +INSERT INTO T SELECT g, g, g, 'p' || g FROM generate_series(1, 30) g; +DELETE FROM T WHERE id = 13; +UPDATE T SET v = v + 1 WHERE id = 14; +); +my $committed_sp = q( +BEGIN; +UPDATE T SET v = v + 1000 WHERE id = 10; +SAVEPOINT s; +UPDATE T SET v = 16974593 WHERE id = 11; +DELETE FROM T WHERE id = 12; +INSERT INTO T VALUES (104, 104, 104, 'sp'); +ROLLBACK TO s; +COMMIT; +); + +# The aborted transaction: DELETE; in-place UPDATE (same size, larger, of an +# indexed column, twice); a primary key UPDATE (new TID); INSERT; an INSERT +# that is then updated and deleted; and a savepoint with an UPDATE, DELETE +# and INSERT, rolled back to and released. +my $aborted = q( +DELETE FROM T WHERE id IN (1, 2); +UPDATE T SET v = 16908801 WHERE id = 3; +UPDATE T SET v = v + 100, pad = repeat('w', 60) WHERE id = 4; +UPDATE T SET k = k + 1000 WHERE id = 5; +UPDATE T SET id = 1006 WHERE id = 6; +UPDATE T SET v = 71 WHERE id = 7; +UPDATE T SET v = 72 WHERE id = 7; +INSERT INTO T VALUES (101, 101, 101, 'ins'); +INSERT INTO T VALUES (102, 102, 102, 'ins'); +UPDATE T SET v = 1020 WHERE id = 102; +DELETE FROM T WHERE id = 102; +SAVEPOINT s; +UPDATE T SET v = 999 WHERE id = 8; +DELETE FROM T WHERE id = 9; +INSERT INTO T VALUES (103, 103, 103, 'sp'); +ROLLBACK TO s; +RELEASE s; +UPDATE T SET v = v + 1 WHERE id = 15; +UPDATE T SET v = v + 1 WHERE id = 19; +DELETE FROM T WHERE id = 19; +); + +# After the rewrite, more DML on the rewritten table: nothing the stale undo +# or sLog state of the old file holds may interfere with it. +my $after = q( +UPDATE T SET v = v + 1; +DELETE FROM T WHERE id % 5 = 0; +INSERT INTO T SELECT g, g, g, 'a' FROM generate_series(201, 210) g; +UPDATE T SET k = k + 1 WHERE id IN (3, 4, 201); +); + +sub tabs_of +{ + my ($prefix) = @_; + return (map { ("${prefix}f_$_->[0]", "${prefix}h_$_->[0]") } @rewrites); +} + +sub create_tables +{ + my ($node, $prefix) = @_; + foreach my $rw (@rewrites) + { + foreach my $am ('flux', 'heap') + { + my $t = $prefix . substr($am, 0, 1) . "_$rw->[0]"; + (my $s = $setup) =~ s/\bAM\b/$am/; + $node->safe_psql('postgres', each_tab($s . $committed_sp, $t)); + } + } +} + +sub run_rewrites +{ + my ($node, $prefix) = @_; + foreach my $rw (@rewrites) + { + foreach my $am ('f', 'h') + { + my $t = "$prefix${am}_$rw->[0]"; + my ($out, $err); + my $rc = $node->psql( + 'postgres', + "SET client_min_messages = info;\n" . each_tab($rw->[1], $t), + stdout => \$out, + stderr => \$err, + on_error_stop => 1); + is($rc, 0, "$t: $rw->[0] ran") or diag($err); + like( + $err, + qr/"public\.$t" using sequential scan and sort/, + "$t: CLUSTER sorts") if $rw->[0] eq 'cluster_sort'; + like( + $err, + qr/"public\.$t" using index scan/, + "$t: CLUSTER reads through the index" + ) if $rw->[0] eq 'cluster_index'; + } + } +} + +sub check_all +{ + my ($node, $prefix, $what) = @_; + # Control: bt_check must be able to fail. + like( + bt_check($node, "${prefix}h_vacfull"), + qr/ERROR: "${prefix}h_vacfull" is not an index/, + "control: bt_index_check of a table fails, $what"); + foreach my $rw (@rewrites) + { + check_twin($node, "${prefix}f_$rw->[0]", "${prefix}h_$rw->[0]", + $what); + } +} + +# Is any undo, or any ATM entry, still holding WAL back? +my $retained = q( +SELECT (SELECT count(*) FROM pg_stat_get_undo_retention()) = 0 + AND (SELECT bool_and(discard = insert) FROM pg_stat_get_undo_logs() + WHERE persistence = 'permanent')); + +########################################################################### +# Live: the undo apply worker is held while the rewrites run. A standby +# replays all of it. +########################################################################### +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +allow_in_place_tablespaces = on +)); +$primary->start; +if ( !$primary->check_extension('injection_points') + || !$primary->check_extension('pageinspect') + || !$primary->check_extension('amcheck')) +{ + plan skip_all => 'injection_points, pageinspect or amcheck not installed'; +} +$primary->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE EXTENSION amcheck; +CREATE TABLESPACE ts LOCATION ''; +-- Wake one held undo apply worker; true once none is held any more. +CREATE FUNCTION release_one() RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE wait_event = 'pbu-undo-worker-before-apply') THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup('pbu-undo-worker-before-apply'); + EXCEPTION WHEN OTHERS THEN + NULL; -- it left the injection point meanwhile + END; + RETURN false; +END $$; +)); +$primary->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->start; + +create_tables($primary, ''); +my @tabs = tabs_of(''); +my @ftabs = grep { /^f_/ } @tabs; + +$primary->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait')"); +$primary->safe_psql('postgres', + "BEGIN;\n" . each_tab($aborted, @tabs) . "\nROLLBACK;"); +$primary->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); + +foreach my $t (@ftabs) +{ + is($primary->safe_psql('postgres', on_page($t)), + 't', "$t: the rollback is pending"); + (my $h = $t) =~ s/^f_/h_/; + is( content($primary, $t), + content($primary, $h), + "$t: readers see the committed rows before the rewrite"); +} + +run_rewrites($primary, ''); +check_all($primary, '', 'rewritten while the revert was pending'); +foreach my $t (@ftabs) +{ + is($primary->safe_psql('postgres', on_page($t)), + 'f', "$t: the aborted value was not copied"); +} +$primary->wait_for_replay_catchup($standby); +foreach my $t (@tabs) +{ + is( content($standby, $t), + content($primary, $t), + "$t: standby agrees after the rewrite"); +} + +# Let the held workers go: they find the records' file gone. +$primary->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-worker-before-apply')"); +ok($primary->poll_query_until('postgres', 'SELECT release_one()'), + 'held undo apply workers released'); +ok( $primary->poll_query_until('postgres', $retained), + 'no undo and no ATM entry left holding WAL'); +check_all($primary, '', 'after the undo apply worker'); + +$primary->safe_psql('postgres', each_tab($after, @tabs)); +check_all($primary, '', 'after more DML'); + +$primary->wait_for_replay_catchup($standby); +foreach my $t (@tabs) +{ + is( content($standby, $t), + content($primary, $t), + "$t: standby agrees with the primary"); +} + +$standby->stop; +$primary->stop; + +########################################################################### +# Crash: the revert is still pending after the restart, while no undo +# apply worker can start (see 076_flux_deferred_rollback.pl). +########################################################################### +# REPACK (CONCURRENTLY) needs wal_level replica and a background worker for +# its decoding, neither of which this node has; the live variant covers it. +@rewrites = grep { $_->[0] ne 'repack_conc' } @rewrites; +my $node = PostgreSQL::Test::Cluster->new('crash'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +max_logical_replication_workers = 0 +max_logical_revert_workers = 0 +max_parallel_workers = 0 +max_parallel_workers_per_gather = 0 +max_parallel_maintenance_workers = 0 +# discard worker + UNDO worker + undo worker launcher: no room for an +# undo apply worker +max_worker_processes = 3 +allow_in_place_tablespaces = on +)); +$node->start; +unlike( + slurp_file($node->logfile), + qr/too many background workers/, + 'every static background worker registered'); +$node->safe_psql('postgres', + "CREATE EXTENSION pageinspect; CREATE EXTENSION amcheck; CREATE TABLESPACE ts LOCATION '';" +); +create_tables($node, 'c'); +@tabs = tabs_of('c'); +@ftabs = grep { /^cf_/ } @tabs; + +# One aborted transaction and one that the crash kills. +$node->safe_psql('postgres', + "BEGIN;\n" . each_tab($aborted, @tabs) . "\nROLLBACK;"); +my $bg = $node->background_psql('postgres'); +$bg->query_safe( + "BEGIN;\n" + . each_tab( + "UPDATE T SET v = v + 500 WHERE id IN (16, 17);\nDELETE FROM T WHERE id = 18;\nINSERT INTO T VALUES (105, 105, 105, 'loser');", + @tabs)); +$node->safe_psql('postgres', 'CHECKPOINT'); +$node->stop('immediate'); +$bg->{run}->kill_kill; +$node->start; + +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'undo apply worker'" + ), + '0', + 'no undo apply worker ran'); +foreach my $t (@ftabs) +{ + is($node->safe_psql('postgres', on_page($t)), + 't', "$t: the rollback is pending after the crash"); +} + +run_rewrites($node, 'c'); +check_all($node, 'c', 'rewritten after the crash, revert pending'); + +# Now let the undo apply worker run. +$node->append_conf('postgresql.conf', 'max_worker_processes = 8'); +$node->restart; +ok( $node->poll_query_until('postgres', $retained), + 'crash: no undo and no ATM entry left holding WAL'); +check_all($node, 'c', 'crash, after the undo apply worker'); +$node->safe_psql('postgres', each_tab($after, @tabs)); +check_all($node, 'c', 'crash, after more DML'); + +foreach my $n ($primary, $standby, $node) +{ + unlike( + slurp_file($n->logfile), + qr/TRAP|PANIC|does not match aborted version|giving up on rolling back/, + $n->name . ': no failed assertion, mismatched or abandoned undo'); +} +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/107_flux_rewrite_2pc.pl b/src/test/recovery/t/107_flux_rewrite_2pc.pl new file mode 100644 index 0000000000000..8befed5bc4bf4 --- /dev/null +++ b/src/test/recovery/t/107_flux_rewrite_2pc.pl @@ -0,0 +1,176 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX: PREPARE TRANSACTION after rows were written and the relation's storage +# was then replaced in the same transaction (SET ACCESS METHOD, ALTER COLUMN +# TYPE, CLUSTER, TRUNCATE), then COMMIT PREPARED or ROLLBACK PREPARED from +# another backend, with and without a restart in between. Heap is the +# control: every FLUX table must end up as its heap twin does, on the primary +# and on a streaming standby. +# +# FLUX remembers the tuples a transaction wrote by relation OID and TID. At +# PREPARE it marks the rows a rolled-back savepoint inserted as deleted; after +# a rewrite that work landed on the new storage, where the same TIDs name +# other rows (or are past its end). src/test/regress/sql/flux_rewrite_xact.sql +# covers COMMIT and ROLLBACK. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +max_prepared_transactions = 100 +)); +$node->start; +my $have_amcheck = $node->check_extension('amcheck'); +$node->safe_psql('postgres', 'CREATE EXTENSION amcheck') if $have_amcheck; +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->start; + +$node->safe_psql( + 'postgres', q{ +CREATE FUNCTION rx_val(k int) RETURNS text LANGUAGE sql IMMUTABLE + RETURN repeat(md5(k::text), 150); +-- What a reader sees: rows, their ids and keys, intact values. +CREATE FUNCTION rx_seen(rel regclass) RETURNS text LANGUAGE plpgsql AS $$ +DECLARE n bigint; ids bigint; ks bigint; good bigint; +BEGIN + EXECUTE format('SELECT count(*), sum(id), sum(k), + count(*) FILTER (WHERE w = rx_val(k)) FROM %s', rel) + INTO n, ids, ks, good; + RETURN format('rows=%s ids=%s ks=%s good=%s', n, ids, ks, good); +EXCEPTION WHEN OTHERS THEN + RETURN 'ERROR: ' || SQLERRM; +END $$; +-- What a writer finds, rolled back so the standby keeps the same rows. +CREATE FUNCTION rx_found(rel regclass) RETURNS text LANGUAGE plpgsql AS $$ +DECLARE lck bigint; upd bigint; del bigint; r text; +BEGIN + BEGIN + EXECUTE format('SELECT count(*) FROM (SELECT 1 FROM %s FOR UPDATE) x', rel) + INTO lck; + EXECUTE format('WITH x AS (UPDATE %s SET w = w RETURNING 1) + SELECT count(*) FROM x', rel) INTO upd; + EXECUTE format('WITH x AS (DELETE FROM %s RETURNING 1) + SELECT count(*) FROM x', rel) INTO del; + r := format('lock=%s upd=%s del=%s', lck, upd, del); + RAISE EXCEPTION USING ERRCODE = 'P0099', MESSAGE = r; + EXCEPTION WHEN SQLSTATE 'P0099' THEN + RETURN SQLERRM; + END; +EXCEPTION WHEN OTHERS THEN + RETURN 'ERROR: ' || SQLERRM; +END $$; +}); + +# name => transaction body; $T is the table, $AM its access method. 20 rows +# are committed first. The rows written in the transaction have the same +# width as those, so the TIDs of the old and the new storage collide. +my $ins21 = + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g'; +my $ins41 = + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(41, 60) g'; +my $refill = + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g'; +my $sp = 'SAVEPOINT a; ' . $ins21 . '; ROLLBACK TO a'; +my %cases = ( + fh => "$ins21; ALTER TABLE \$T SET ACCESS METHOD heap", + fhf => "$ins21; ALTER TABLE \$T SET ACCESS METHOD heap; " + . "ALTER TABLE \$T SET ACCESS METHOD \$AM", + sp_fh => "$sp; $ins41; ALTER TABLE \$T SET ACCESS METHOD heap", + trunc => "TRUNCATE \$T; $refill", + sp_trunc => "$sp; TRUNCATE \$T; $refill", + act => "$ins21; ALTER TABLE \$T ALTER COLUMN id TYPE bigint", + sp_act => "$sp; $ins41; ALTER TABLE \$T ALTER COLUMN id TYPE bigint", + cluster => + 'UPDATE $T SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0; ' + . "DELETE FROM \$T WHERE id % 5 = 0; $ins21; CLUSTER \$T USING \$T_pkey", + sp_trunc_act => 'SAVEPOINT a; ' + . 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 400) g; ' + . 'ROLLBACK TO a; TRUNCATE $T; ' + . "ALTER TABLE \$T ALTER COLUMN id TYPE bigint; $refill",); + +# Each case on each AM, then COMMIT PREPARED or ROLLBACK PREPARED, before or +# after a restart. +my @runs; +foreach my $name (sort keys %cases) +{ + foreach my $fin ('commit', 'rollback') + { + foreach my $when ('live', 'restart') + { + foreach my $am ('heap', 'flux') + { + my $t = "rx_${name}_${fin}_${when}_$am"; + (my $body = $cases{$name}) =~ s/\$T/$t/g; + $body =~ s/\$AM/$am/g; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int PRIMARY KEY, k int, w text) USING $am; +INSERT INTO $t SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g; +}); + my ($ret, $out, $err) = $node->psql('postgres', + "BEGIN; $body; PREPARE TRANSACTION '$t';"); + is($ret, 0, "$t: PREPARE TRANSACTION") + or diag($err); + push @runs, [ $name, $fin, $when, $am, $t ]; + } + } + } +} + +sub resolve +{ + my ($when) = @_; + foreach my $r (grep { $_->[2] eq $when } @runs) + { + my ($name, $fin, $w, $am, $t) = @$r; + my ($ret, $out, $err) = $node->psql('postgres', + ($fin eq 'commit' ? 'COMMIT' : 'ROLLBACK') . " PREPARED '$t'"); + is($ret, 0, "$t: $fin prepared from another backend") + or diag($err); + } +} +resolve('live'); +$node->restart; +resolve('restart'); +$node->wait_for_replay_catchup($standby); + +foreach my $r (grep { $_->[3] eq 'heap' } @runs) +{ + my ($name, $fin, $when, $am, $heap) = @$r; + (my $flux = $heap) =~ s/_heap$/_flux/; + my $what = "$name, $fin prepared, $when"; + + my $want = $node->safe_psql('postgres', "SELECT rx_seen('$heap')"); + is($node->safe_psql('postgres', "SELECT rx_seen('$flux')"), + $want, "$what: rows on the primary"); + is($standby->safe_psql('postgres', "SELECT rx_seen('$flux')"), + $want, "$what: rows on the standby"); + is( $node->safe_psql('postgres', "SELECT rx_found('$flux')"), + $node->safe_psql('postgres', "SELECT rx_found('$heap')"), + "$what: rows UPDATE, DELETE and FOR UPDATE find"); + + # A rewrite to heap must leave a valid heap. + if ($have_amcheck + && $node->safe_psql('postgres', + "SELECT a.amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam " + . "WHERE c.oid = '$flux'::regclass") eq 'heap') + { + is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM verify_heapam('$flux')"), + '0', + "$what: verify_heapam"); + } +} + +is($node->safe_psql('postgres', 'SELECT count(*) FROM pg_prepared_xacts'), + '0', 'no prepared transactions left'); + +done_testing(); diff --git a/src/test/recovery/t/108_flux_tuple_layout.pl b/src/test/recovery/t/108_flux_tuple_layout.pl new file mode 100644 index 0000000000000..72739b3e58013 --- /dev/null +++ b/src/test/recovery/t/108_flux_tuple_layout.pl @@ -0,0 +1,209 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Check the compact header and tuple layout through rollback and replay. +# The next UPDATE must still use byte-range CAS WAL. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); +$node->append_conf('postgresql.conf', + "autovacuum = off\nwal_consistency_checking = all\n"); +$node->start; +if ( !$node->check_extension('pageinspect') + || !$node->check_extension('pg_walinspect')) +{ + plan skip_all => 'pageinspect or pg_walinspect not installed'; +} + +# The undo apply worker connects to the database it reverts. The worker +# case uses its own database, rr, so that the worker can be kept out of it +# (ALLOW_CONNECTIONS false) while its rollback is checked to be pending. +$node->safe_psql('postgres', 'CREATE DATABASE rr'); +foreach my $db ('postgres', 'rr') +{ + $node->safe_psql($db, + 'CREATE EXTENSION pageinspect; CREATE EXTENSION pg_walinspect;'); +} +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->start; + +# The first change to the page after VACUUM marked it all-visible clears +# the visibility map in its own record, which the CAS record cannot do, so it +# takes the exclusive path. Make it to the other row, so that the UPDATEs +# below find a page that is not all-visible. +sub make_table +{ + my ($db, $t) = @_; + $node->safe_psql( + $db, qq{ +CREATE TABLE $t (id int, v int) USING flux; +INSERT INTO $t VALUES (1, 0), (2, 0); +VACUUM $t; +UPDATE $t SET v = 0 WHERE id = 2; +}); +} + +# Two int columns: 36-byte header, bitmap and alignment to 40, then 8 bytes. +sub layout +{ + my ($n, $db, $t, $id) = @_; + return $n->safe_psql( + $db, qq{ +SELECT lp_len || ':' || get_byte(get_raw_page('$t', 0), lp_off + 36) + FROM heap_page_items(get_raw_page('$t', 0)) + WHERE lp = (SELECT (ctid::text::point)[1]::int FROM $t WHERE id = $id)}); +} + +# The FLUX WAL records that $sql emits. +sub flux_records +{ + my ($db, $sql) = @_; + my $s = $node->safe_psql($db, 'SELECT pg_current_wal_insert_lsn()'); + $node->safe_psql($db, $sql); + my $e = $node->safe_psql($db, 'SELECT pg_current_wal_insert_lsn()'); + return $node->safe_psql( + $db, qq{ +SELECT string_agg(record_type, ',') FROM pg_get_wal_records_info('$s', '$e') + WHERE resource_manager = 'FLUX'}); +} + +my $rows = sub { + my $t = shift; + return "SELECT string_agg(id || ':' || v, ',' ORDER BY id) FROM $t"; +}; + +# What is left on the page once a rollback is done. +sub check_row +{ + my ($db, $t, $id, $want, $what) = @_; + is($node->safe_psql($db, $rows->($t)), $want, "$what: rows restored"); + is(layout($node, $db, $t, $id), + '48:255', "$what: header and bitmap layout matches on the primary"); + $node->wait_for_catchup($standby); + is(layout($standby, $db, $t, $id), + '48:255', "$what: header and bitmap layout matches on the standby"); + is(flux_records($db, "UPDATE $t SET v = v + 1 WHERE id = $id"), + 'CAS_UPDATE_UNDO', "$what: the next UPDATE takes the CAS path"); + is(layout($node, $db, $t, $id), + '48:255', + "$what: header and bitmap layout matches after that UPDATE"); +} + +# Inline ROLLBACK. The control shows that an UPDATE of the row takes the +# CAS path in this setup in the first place. +make_table('postgres', 'ti'); +is(flux_records('postgres', 'UPDATE ti SET v = v + 1 WHERE id = 1'), + 'CAS_UPDATE_UNDO', + 'control: an UPDATE of a fresh row takes the CAS path'); +$node->safe_psql('postgres', + 'BEGIN; UPDATE ti SET v = v + 10 WHERE id = 1; ROLLBACK;'); +check_row('postgres', 'ti', 1, '1:1,2:0', 'inline ROLLBACK'); + +# Two in a row: on the old code the second UPDATE took the slow path over +# the stale word, and its rollback restored the stale word again. +$node->safe_psql('postgres', + 'BEGIN; UPDATE ti SET v = v + 10 WHERE id = 1; ROLLBACK;'); +$node->safe_psql('postgres', + 'BEGIN; UPDATE ti SET v = v + 10 WHERE id = 1; ROLLBACK;'); +check_row('postgres', 'ti', 1, '1:2,2:0', 'two inline ROLLBACKs'); + +# ROLLBACK TO SAVEPOINT. +make_table('postgres', 'ts'); +$node->safe_psql('postgres', + 'BEGIN; SAVEPOINT s; UPDATE ts SET v = v + 10 WHERE id = 1; ROLLBACK TO s; COMMIT;' +); +check_row('postgres', 'ts', 1, '1:0,2:0', 'ROLLBACK TO SAVEPOINT'); + +# An ERROR inside what used to be the locked window: the write-skew UPDATE +# fails in CheckForSerializableConflictIn, after the eligibility checks and +# before the page is written. +make_table('postgres', 'tx'); +my $s1 = $node->background_psql('postgres', on_error_stop => 0); +my $s2 = $node->background_psql('postgres', on_error_stop => 0); +$s1->query_safe( + 'BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT v FROM tx WHERE id = 1;'); +$s2->query_safe( + 'BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT v FROM tx WHERE id = 2;'); +$s1->query_safe('UPDATE tx SET v = 1 WHERE id = 2;'); +$s1->query_safe('COMMIT;'); +$s2->{stderr} = ''; +$s2->query('UPDATE tx SET v = 1 WHERE id = 1;'); +like( + $s2->{stderr}, + qr/could not serialize access.*during write/s, + 'SSI: the write-skew UPDATE fails while writing'); +$s2->{stderr} = ''; +$s2->query_safe('ROLLBACK;'); +$s1->quit; +$s2->quit; +check_row('postgres', 'tx', 1, '1:0,2:1', 'SSI serialization failure'); + +# Crash recovery. A committed transaction flushes the WAL, the open +# UPDATE's record with it, so the restarted server has it to undo. Its +# value, 16908801 = 0x01020201, reads the same in either byte order, so the +# page can be searched for it. +my $aborted = sub { + my $t = shift; + return + "SELECT position('\\x01020201'::bytea IN get_raw_page('$t', 0)) > 0"; +}; +make_table('postgres', 'tc'); +my $s3 = $node->background_psql('postgres', on_error_stop => 0); +$s3->query_safe('BEGIN; UPDATE tc SET v = 16908801 WHERE id = 1;'); +$node->safe_psql('postgres', 'CREATE TABLE flush_wal ()'); +is($node->safe_psql('postgres', $aborted->('tc')), + 't', 'crash: the open UPDATE is on the page'); +$node->stop('immediate'); +eval { $s3->quit; }; +$node->start; +ok($node->poll_query_until('postgres', $aborted->('tc'), 'f'), + 'crash: the UPDATE was undone'); +check_row('postgres', 'tc', 1, '1:0,2:0', 'crash recovery'); + +# The undo apply worker. +$node->safe_psql('postgres', + 'ALTER SYSTEM SET undo_rollback_overflow_size = 0'); +$node->reload; +ok($node->poll_query_until('rr', 'SHOW undo_rollback_overflow_size', '0'), + 'worker: undo_rollback_overflow_size is 0'); +make_table('rr', 'tw'); +my $bg = $node->background_psql('rr', on_error_stop => 0); +$node->safe_psql('postgres', 'ALTER DATABASE rr ALLOW_CONNECTIONS false'); +ok( $node->poll_query_until( + 'postgres', q{SELECT count(*) = 0 FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + 'worker: no undo apply worker is connected'); +$bg->query_safe('BEGIN; UPDATE tw SET v = 16908801 WHERE id = 1; ROLLBACK;'); +is($bg->query_safe($aborted->('tw')), + 't', 'worker: the rollback is left to the worker'); +$bg->quit; +$node->safe_psql('postgres', 'ALTER DATABASE rr ALLOW_CONNECTIONS true'); +ok($node->poll_query_until('rr', $aborted->('tw'), 'f'), + 'worker: the worker applied the rollback'); +check_row('rr', 'tw', 1, '1:0,2:0', 'undo apply worker'); + +$node->wait_for_catchup($standby); +foreach my $c ( + [ 'postgres', 'ti' ], + [ 'postgres', 'ts' ], + [ 'postgres', 'tx' ], + [ 'postgres', 'tc' ], + [ 'rr', 'tw' ]) +{ + is( $standby->safe_psql($c->[0], $rows->($c->[1])), + $node->safe_psql($c->[0], $rows->($c->[1])), + "$c->[1]: the standby sees the primary's rows"); +} +ok($standby->is_alive, 'standby is alive'); +ok(!$standby->log_contains('inconsistent page found'), + 'standby found no inconsistent page'); + +$standby->stop('fast', fail_ok => 1); +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/109_flux_savepoint_lock_2pc.pl b/src/test/recovery/t/109_flux_savepoint_lock_2pc.pl new file mode 100644 index 0000000000000..19c1445664302 --- /dev/null +++ b/src/test/recovery/t/109_flux_savepoint_lock_2pc.pl @@ -0,0 +1,137 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A row lock taken in a savepoint that is rolled back leaves nothing behind, +# however the transaction ends: COMMIT, PREPARE TRANSACTION and COMMIT +# PREPARED, or ROLLBACK PREPARED. Another session updates the row and +# commits after the savepoint rollback. isolation/flux_savepoint_lock_ +# rollback runs more variants without two-phase commit. Heap is the control. +# +# FLUX used to mark the other session's committed image DELETED when the +# locker committed or prepared, on the primary only and with no WAL: a plain +# SELECT still returned the row, but UPDATE, DELETE and row locks did not +# find it, and the standby had a different page. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +wal_consistency_checking = all +max_prepared_transactions = 5 +)); +$node->start; +if ( !$node->check_extension('pageinspect') + || !$node->check_extension('amcheck')) +{ + plan skip_all => 'pageinspect or amcheck not installed'; +} +$node->safe_psql('postgres', + 'CREATE EXTENSION pageinspect; CREATE EXTENSION amcheck;'); +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->start; + +# The FLUX_TUPLE_DELETED bit (0x0004 of t_flags, at byte 32) of row 1. +sub deleted_bit +{ + my ($n, $t) = @_; + return $n->safe_psql( + 'postgres', qq{ +SELECT get_byte(get_raw_page('$t', 0), lp_off + 32) & 4 + FROM heap_page_items(get_raw_page('$t', 0)) + WHERE lp = (SELECT (ctid::text::point)[1]::int FROM $t WHERE id = 1)}); +} + +my $s1 = $node->background_psql('postgres', on_error_stop => 0); +my $n = 0; +my @tables; +foreach my $am ('heap', 'flux') +{ + foreach my $mode ('FOR UPDATE', 'FOR NO KEY UPDATE', 'FOR SHARE', + 'FOR KEY SHARE') + { + foreach my $end ('COMMIT', 'COMMIT PREPARED', 'ROLLBACK PREPARED') + { + $n++; + my $t = "t_${am}_$n"; + my $what = "$am, $mode, $end"; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int PRIMARY KEY, v int) USING $am; +INSERT INTO $t VALUES (1, 0), (2, 0); +VACUUM $t; +}); + $s1->query_safe( + "BEGIN; SAVEPOINT a; SELECT v FROM $t WHERE id = 1 $mode; ROLLBACK TO a;" + ); + $node->safe_psql('postgres', + "UPDATE $t SET v = v + 1 WHERE id = 1"); + if ($end eq 'COMMIT') + { + $s1->query_safe('COMMIT'); + } + else + { + $s1->query_safe("PREPARE TRANSACTION 'p$n'"); + $node->safe_psql('postgres', "$end 'p$n'"); + } + + is( $node->safe_psql('postgres', "SELECT v FROM $t WHERE id = 1"), + '1', + "$what: SELECT finds the row"); + is( $node->safe_psql( + 'postgres', "SELECT v FROM $t WHERE id = 1 FOR KEY SHARE" + ), + '1', + "$what: FOR KEY SHARE finds the row"); + is( $node->safe_psql( + 'postgres', "SELECT v FROM $t WHERE id = 1 FOR UPDATE"), + '1', + "$what: FOR UPDATE finds the row"); + is( $node->safe_psql( + 'postgres', qq{ +BEGIN; +UPDATE $t SET v = v + 100 WHERE id = 1; +DELETE FROM $t WHERE id = 1 RETURNING v; +ROLLBACK;}), + '101', + "$what: UPDATE, then DELETE of its new version"); + is( $node->safe_psql( + 'postgres', "SELECT bt_index_check('${t}_pkey', true)"), + '', + "$what: amcheck finds every row in the index"); + is(deleted_bit($node, $t), '0', "$what: row 1 is not DELETED") + if $am eq 'flux'; + push @tables, [ $t, $what, $am ]; + } + } +} +$s1->quit; + +$node->wait_for_catchup($standby); +foreach my $c (@tables) +{ + my ($t, $what, $am) = @$c; + is( $standby->safe_psql( + 'postgres', + "SELECT string_agg(id || ':' || v, ',' ORDER BY id) FROM $t"), + '1:1,2:0', + "$what: the standby has the rows"); + is( deleted_bit($standby, $t), + deleted_bit($node, $t), + "$what: the standby's row 1 has the primary's DELETED bit" + ) if $am eq 'flux'; +} +ok($standby->is_alive, 'standby is alive'); +ok(!$standby->log_contains('inconsistent page found'), + 'standby found no inconsistent page'); + +$standby->stop('fast', fail_ok => 1); +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/110_flux_unique_pending_revert.pl b/src/test/recovery/t/110_flux_unique_pending_revert.pl new file mode 100644 index 0000000000000..340f1d1c51db0 --- /dev/null +++ b/src/test/recovery/t/110_flux_unique_pending_revert.pl @@ -0,0 +1,462 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX uniqueness and exclusion checks while an aborted transaction's revert +# is still pending. +# +# FLUX updates in place, so an aborted transaction whose rollback was handed +# to an undo apply worker (undo_rollback_overflow_size = 0) leaves its images +# on the page until the undo is applied; an in-place UPDATE also re-stamps +# t_xmin to the updater. A uniqueness check (SnapshotDirty, SnapshotSelf) +# must still see the row at its committed before-image: a PRIMARY KEY, a +# UNIQUE constraint (immediate and deferred), an exclusion constraint (gist, +# hash) and ON CONFLICT must all behave as on a heap twin that ran the same +# statements. The same with an inline rollback, with the conflicting writer +# still in progress (the check must wait for it), after the undo apply +# worker has applied the rollback, and on a standby. +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'; +} + +# The aborted value, 16908801 = 0x01020201, reads the same in either byte +# order, so the raw page can be searched for it. +my $X = 16908801; + +# name, DDL (T = the table), aborted statements, probe, expected probe result +# ("ok:" and the rows it returned, or the SQLSTATE of its error). +my @cases = ( + [ + upd_nonkey => 'id int PRIMARY KEY, v int', + "UPDATE T SET v = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + [ + upd_key_old => 'id int PRIMARY KEY, v int', + "UPDATE T SET id = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + [ + upd_key_new => 'id int PRIMARY KEY, v int', + "UPDATE T SET id = $X WHERE id = 3", + "INSERT INTO T VALUES ($X, 333) RETURNING *", "ok:$X|333" + ], + [ + uq_inplace_old => 'id int, v int UNIQUE', + "UPDATE T SET v = $X WHERE id = 3", + 'INSERT INTO T VALUES (33, 3)', '23505' + ], + [ + uq_inplace_new => 'id int, v int UNIQUE', + "UPDATE T SET v = $X WHERE id = 3", + "INSERT INTO T VALUES (33, $X) RETURNING *", "ok:33|$X" + ], + [ + del_then_ins => 'id int PRIMARY KEY, v int', + "DELETE FROM T WHERE id = 3; INSERT INTO T VALUES (3, $X)", + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + [ + del => 'id int PRIMARY KEY, v int', + 'DELETE FROM T WHERE id = 3', + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + [ + oc_nothing => 'id int PRIMARY KEY, v int', + "UPDATE T SET v = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333) ON CONFLICT DO NOTHING RETURNING *', + 'ok:' + ], + [ + oc_update => 'id int PRIMARY KEY, v int', + "UPDATE T SET v = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333) ON CONFLICT (id) DO UPDATE SET v = T.v + 1000 RETURNING *', + 'ok:3|1003' + ], + [ + oc_update_key_old => 'id int PRIMARY KEY, v int', + "UPDATE T SET id = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333) ON CONFLICT (id) DO UPDATE SET v = T.v + 1000 RETURNING *', + 'ok:3|1003' + ], + [ + oc_update_key_new => 'id int PRIMARY KEY, v int', + "UPDATE T SET id = $X WHERE id = 3", + "INSERT INTO T VALUES ($X, 333) ON CONFLICT (id) DO UPDATE SET v = T.v + 1000 RETURNING *", + "ok:$X|333" + ], + [ + oc_nothing_key_old => 'id int PRIMARY KEY, v int', + "UPDATE T SET id = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333) ON CONFLICT DO NOTHING RETURNING *', + 'ok:' + ], + [ + oc_nothing_key_new => 'id int PRIMARY KEY, v int', + "UPDATE T SET id = $X WHERE id = 3", + "INSERT INTO T VALUES ($X, 333) ON CONFLICT DO NOTHING RETURNING *", + "ok:$X|333" + ], + [ + oc_update_uq_old => 'id int PRIMARY KEY, v int UNIQUE', + "UPDATE T SET v = $X WHERE id = 3", + 'INSERT INTO T VALUES (33, 3) ON CONFLICT (v) DO UPDATE SET id = T.id + 1000 RETURNING *', + 'ok:1003|3' + ], + # An in-place UPDATE of another indexed column: the primary key entry + # is live, the row's image says "keys updated". + [ + sec_index => 'id int PRIMARY KEY, v int, k int UNIQUE', + "UPDATE T SET k = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + # The same with the other index created in the aborted transaction. + [ + sec_index_aborted => 'id int PRIMARY KEY, v int, k int', + "CREATE INDEX ON T (k); UPDATE T SET k = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + [ + deferrable => + 'id int, v int, UNIQUE (id) DEFERRABLE INITIALLY DEFERRED', + "UPDATE T SET v = $X WHERE id = 3", + 'BEGIN; INSERT INTO T VALUES (3, 333); COMMIT', '23505' + ], + [ + deferrable_key => + 'id int, v int, UNIQUE (id) DEFERRABLE INITIALLY DEFERRED', + "UPDATE T SET id = $X WHERE id = 3", + 'BEGIN; INSERT INTO T VALUES (3, 333); COMMIT', '23505' + ], + [ + gist_excl => 'id int, v int, EXCLUDE USING gist (id WITH =)', + "UPDATE T SET v = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23P01' + ], + [ + gist_excl_key_old => 'id int, v int, EXCLUDE USING gist (id WITH =)', + "UPDATE T SET id = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23P01' + ], + [ + gist_excl_key_new => 'id int, v int, EXCLUDE USING gist (id WITH =)', + "UPDATE T SET id = $X WHERE id = 3", + "INSERT INTO T VALUES ($X, 333) RETURNING *", "ok:$X|333" + ], + [ + btree_excl_key_old => + 'id int, v int, EXCLUDE USING btree (id WITH =)', + "UPDATE T SET id = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23P01' + ], + [ + btree_excl_key_new => + 'id int, v int, EXCLUDE USING btree (id WITH =)', + "UPDATE T SET id = $X WHERE id = 3", + "INSERT INTO T VALUES ($X, 333) RETURNING *", "ok:$X|333" + ], + [ + hash_excl => 'id int, v int, EXCLUDE USING hash (id WITH =)', + "UPDATE T SET v = $X WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23P01' + ], + [ + upd_grow => 'id int PRIMARY KEY, v int, pad text', + "UPDATE T SET v = $X, pad = repeat('x', 3000) WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + [ + upd_del => 'id int PRIMARY KEY, v int', + "UPDATE T SET v = $X WHERE id = 3; DELETE FROM T WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23505' + ], + [ + upd_twice => 'id int PRIMARY KEY, v int', + "UPDATE T SET v = $X WHERE id = 3; UPDATE T SET id = 1003 WHERE id = 3", + 'INSERT INTO T VALUES (3, 333)', '23505' + ],); + +# Run $sql with T replaced by the table name. +sub tab +{ + my ($sql, $t) = @_; + $sql =~ s/\bT\b/$t/g; + return $sql; +} + +# Run a probe: "ok:" and its rows, or the SQLSTATE of its error. +sub probe +{ + my ($node, $sql) = @_; + my ($out, $err); + $node->psql( + 'postgres', "\\set VERBOSITY sqlstate\n$sql", + stdout => \$out, + stderr => \$err); + return $1 if $err =~ /ERROR:\s+([0-9A-Z]{5})/; + return "ok:" . join(',', split(/\n/, $out)); +} + +# Content of a table through a seqscan and through each index. +sub content +{ + my ($node, $t) = @_; + my $q = + "SELECT count(*) || ':' || coalesce(string_agg(id || '/' || v, ',' ORDER BY id, v), '') FROM $t"; + return $node->safe_psql('postgres', + "SET enable_indexscan = off; SET enable_bitmapscan = off; $q;\n" + . "SET enable_seqscan = off; SET enable_indexscan = on; $q;"); +} + +sub on_page +{ + my ($node, $t) = @_; + return $node->safe_psql('postgres', + "SELECT bool_or(position('\\x01020201'::bytea IN get_raw_page('$t', b::int)) > 0) FROM generate_series(0, pg_relation_size('$t') / 8192 - 1) b" + ); +} + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +)); +$primary->start; +if ( !$primary->check_extension('injection_points') + || !$primary->check_extension('pageinspect') + || !$primary->check_extension('amcheck') + || !$primary->check_extension('btree_gist')) +{ + plan skip_all => + 'injection_points, pageinspect, amcheck or btree_gist not installed'; +} +$primary->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE EXTENSION amcheck; +CREATE EXTENSION btree_gist; +-- Wake one held undo apply worker; true once none is held any more. +CREATE FUNCTION release_one() RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE wait_event = 'pbu-undo-worker-before-apply') THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup('pbu-undo-worker-before-apply'); + EXCEPTION WHEN OTHERS THEN + NULL; -- it left the injection point meanwhile + END; + RETURN false; +END $$; +)); +$primary->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->start; + +my @all; # every table, for the later checks + +# One aborted transaction per case and access method, then the probe. +sub run_cases +{ + my ($mode) = @_; + foreach my $c (@cases) + { + my ($name, $cols, $aborted, $probe, $expect) = @$c; + my %res; + foreach my $am ('flux', 'heap') + { + my $t = "${mode}_${name}_" . substr($am, 0, 1); + push @all, $t; + $primary->safe_psql('postgres', + "CREATE TABLE $t ($cols) USING $am;\n" + . "INSERT INTO $t (id, v) SELECT g, g FROM generate_series(1, 5) g;\n" + . "BEGIN;\n" + . tab($aborted, $t) + . ";\nROLLBACK;"); + is(on_page($primary, $t), 't', "$t: the rollback is pending") + if $mode eq 'deferred' && $am eq 'flux' && $aborted =~ /$X/; + $res{$am} = probe($primary, tab($probe, $t)); + is($res{$am}, $expect, "$t: probe"); + } + my $f = "${mode}_${name}_f"; + (my $h = $f) =~ s/_f$/_h/; + is($res{flux}, $res{heap}, "$f: probe as heap"); + is(content($primary, $f), content($primary, $h), "$f: rows as heap"); + } +} + +# A uniqueness check against a writer that is still running must wait for +# it, and then decide by its outcome. +sub run_waits +{ + my ($mode) = @_; + my $pk = 'id int PRIMARY KEY, v int'; + my $ins = 'INSERT INTO T VALUES (3, 333)'; + my $ocn = 'INSERT INTO T VALUES (3, 333) ON CONFLICT DO NOTHING'; + my $ocu = + 'INSERT INTO T VALUES (3, 333) ON CONFLICT (id) DO UPDATE SET v = T.v + 1000'; + my $upd = "UPDATE T SET v = $X WHERE id = 3"; + my $updk = "UPDATE T SET id = $X WHERE id = 3"; + my $del = 'DELETE FROM T WHERE id = 3'; + my @waits = ( + [ 'upd_nonkey_abort', $pk, $upd, 'ROLLBACK', $ins, '23505' ], + [ 'upd_key_abort', $pk, $updk, 'ROLLBACK', $ins, '23505' ], + [ 'upd_nonkey_commit', $pk, $upd, 'COMMIT', $ins, '23505' ], + [ 'upd_key_commit', $pk, $updk, 'COMMIT', $ins, 'ok:' ], + [ 'del_abort', $pk, $del, 'ROLLBACK', $ins, '23505' ], + [ 'del_commit', $pk, $del, 'COMMIT', $ins, 'ok:' ], + [ 'upd_key_abort_ocn', $pk, $updk, 'ROLLBACK', $ocn, 'ok:' ], + [ 'upd_key_commit_ocn', $pk, $updk, 'COMMIT', $ocn, 'ok:' ], + [ 'upd_key_abort_ocu', $pk, $updk, 'ROLLBACK', $ocu, 'ok:' ], + [ 'upd_key_commit_ocu', $pk, $updk, 'COMMIT', $ocu, 'ok:' ], + [ + 'uq_key_abort', 'id int, v int UNIQUE', + "UPDATE T SET v = $X WHERE id = 3", 'ROLLBACK', + 'INSERT INTO T VALUES (33, 3)', '23505' + ], + [ + 'gist_key_abort', 'id int, v int, EXCLUDE USING gist (id WITH =)', + $updk, 'ROLLBACK', $ins, '23P01' + ], + [ + 'gist_key_commit', + 'id int, v int, EXCLUDE USING gist (id WITH =)', + $updk, 'COMMIT', $ins, 'ok:' + ],); + + foreach my $w (@waits) + { + my ($name, $cols, $writer, $end, $probe, $expect) = @$w; + my %res; + foreach my $am ('flux', 'heap') + { + my $t = "${mode}_wait_${name}_" . substr($am, 0, 1); + push @all, $t; + $primary->safe_psql('postgres', + "CREATE TABLE $t ($cols) USING $am;\n" + . "INSERT INTO $t SELECT g, g FROM generate_series(1, 5) g;" + ); + my $s1 = $primary->background_psql('postgres'); + my $s2 = + $primary->background_psql('postgres', on_error_stop => 0); + $s1->query_safe("BEGIN; " . tab($writer, $t)); + $s2->query_safe("\\set VERBOSITY sqlstate"); + my $pid = $s2->query_safe('SELECT pg_backend_pid()'); + $s2->query_until(qr/go/, "\\echo go\n" . tab($probe, $t) . ";\n"); + ok( $primary->poll_query_until( + 'postgres', + "SELECT wait_event_type = 'Lock' FROM pg_stat_activity WHERE pid = $pid" + ), + "$t: the unique check waits for the writer"); + $s1->query_safe($end); + $s2->query("SELECT 'synced'"); + $res{$am} = + $s2->{stderr} =~ /ERROR:\s+([0-9A-Z]{5})/ ? $1 : 'ok:'; + $s1->quit; + $s2->quit; + is($res{$am}, $expect, "$t: outcome after the writer ended"); + } + my $f = "${mode}_wait_${name}_f"; + (my $h = $f) =~ s/_f$/_h/; + is($res{flux}, $res{heap}, "$f: outcome as heap"); + is(content($primary, $f), content($primary, $h), "$f: rows as heap"); + } +} + +# Inline rollback first, then with the undo apply worker held. +$primary->safe_psql('postgres', + "ALTER SYSTEM SET undo_rollback_overflow_size = '1GB'; SELECT pg_reload_conf()" +); +run_cases('inline'); +run_waits('inline'); +$primary->safe_psql('postgres', + "ALTER SYSTEM RESET undo_rollback_overflow_size; SELECT pg_reload_conf()" +); +$primary->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait')"); +run_cases('deferred'); +run_waits('deferred'); +ok( $primary->safe_psql( + 'postgres', + "SELECT count(*) > 0 FROM pg_stat_activity WHERE wait_event = 'pbu-undo-worker-before-apply'" + ), + 'undo apply workers are held'); + +# A standby sees what the primary sees. +$primary->wait_for_replay_catchup($standby); +foreach my $t (@all) +{ + is(content($standby, $t), content($primary, $t), "$t: standby agrees"); +} + +# Let the undo apply workers go, then check again: same rows as the heap +# twin, every key still taken, every btree index intact. +$primary->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-worker-before-apply')"); +ok($primary->poll_query_until('postgres', 'SELECT release_one()'), + 'held undo apply workers released'); +ok( $primary->poll_query_until( + 'postgres', + 'SELECT (SELECT count(*) FROM pg_stat_get_undo_retention()) = 0'), + 'every rollback applied'); +foreach my $t (grep { /_f$/ } @all) +{ + (my $h = $t) =~ s/_f$/_h/; + is( content($primary, $t), + content($primary, $h), + "$t: rows as heap after the undo apply worker"); + is( $primary->safe_psql( + 'postgres', + "WITH i AS (INSERT INTO $t SELECT * FROM $t ON CONFLICT DO NOTHING RETURNING 1) SELECT count(*) FROM i" + ), + '0', + "$t: unique checks see every row after the undo apply worker" + ) unless $t =~ /deferrable/; + is( probe($primary, "INSERT INTO $t VALUES (3, 3)"), + probe($primary, "INSERT INTO $h VALUES (3, 3)"), + "$t: key 3 as heap after the undo apply worker"); + foreach my $i ( + split /\n/, + $primary->safe_psql( + 'postgres', + "SELECT i.indexrelid::regclass FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_am a ON a.oid = c.relam WHERE i.indrelid = '$t'::regclass AND a.amname = 'btree'" + )) + { + my ($out, $err); + my $rc = $primary->psql( + 'postgres', + "SELECT bt_index_check('$i'::regclass, heapallindexed => true)", + stdout => \$out, + stderr => \$err); + is("$rc:$err", '0:', "$i: bt_index_check"); + } +} +$primary->wait_for_replay_catchup($standby); +foreach my $t (@all) +{ + is( content($standby, $t), + content($primary, $t), + "$t: standby agrees after the undo apply worker"); +} + +foreach my $n ($primary, $standby) +{ + unlike( + slurp_file($n->logfile), + qr/TRAP|PANIC|does not match aborted version|giving up on rolling back/, + $n->name . ': no failed assertion, mismatched or abandoned undo'); +} +$standby->stop; +$primary->stop; +done_testing(); diff --git a/src/test/recovery/t/111_flux_index_build_pending_revert.pl b/src/test/recovery/t/111_flux_index_build_pending_revert.pl new file mode 100644 index 0000000000000..fc91a01a749bd --- /dev/null +++ b/src/test/recovery/t/111_flux_index_build_pending_revert.pl @@ -0,0 +1,420 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Index builds, ANALYZE and TABLESAMPLE on a FLUX table while an aborted +# transaction's revert is still pending. +# +# FLUX updates in place, so a transaction whose rollback was handed to an +# undo apply worker (undo_rollback_overflow_size = 0) leaves its images on +# the page until the worker applies the undo. A reader that looks at the +# page itself instead of through an MVCC snapshot must still see the row as +# the rollback will leave it: an aborted UPDATE's row at its before-image, an +# aborted DELETE's row alive, an aborted INSERT's row gone. An index built +# from the aborted images stays wrong after the revert, because the revert +# reverses index changes the new index never had. +# +# Each FLUX table has a heap twin that runs the same statements; the heap +# twin is the control. The undo apply worker is held at the injection point +# pbu-undo-worker-before-apply (see 106_flux_rewrite_pending_revert.pl), the +# checks run while it is held, after it has applied the undo, and after more +# DML. +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'; +} + +# Index kinds. idx creates the index T_i on table T; pred(x) is a qual the +# index answers for the key x; order, if set, is an ORDER BY the index +# answers (with where, its qual); bt says amcheck can check it. +my %kind = ( + btree => { + idx => 'CREATE INDEX T_i ON T (k)', + pred => sub { "k = $_[0]" }, + order => 'k', + bt => 1 + }, + hash => { + idx => 'CREATE INDEX T_i ON T USING hash (k)', + pred => sub { "k = $_[0]" } + }, + gist => { + idx => "CREATE INDEX T_i ON T USING gist (int4range(k, k, '[]'))", + pred => + sub { "int4range(k, k, '[]') && int4range($_[0], $_[0], '[]')" } + }, + unique => { + idx => 'CREATE UNIQUE INDEX T_i ON T (k)', + pred => sub { "k = $_[0]" }, + order => 'k', + bt => 1 + }, + expr => { + idx => 'CREATE INDEX T_i ON T ((k * 2))', + pred => sub { "k * 2 = 2 * $_[0]" }, + order => 'k * 2', + bt => 1 + }, + partial => { + idx => 'CREATE INDEX T_i ON T (k) WHERE k < 1000', + pred => sub { "k = $_[0] AND k < 1000" }, + order => 'k', + where => 'k < 1000', + bt => 1 + }, + multi => { + idx => 'CREATE INDEX T_i ON T (k, v)', + pred => sub { "k = $_[0]" }, + order => 'k, v', + bt => 1 + },); + +# name => [index kind, index exists before the abort, statement after it] +my @cases = ( + (map { [ "create_$_", $_, 0, $kind{$_}{idx} ] } sort keys %kind), + [ reindex_index => 'btree', 1, 'REINDEX INDEX T_i' ], + [ reindex_table => 'btree', 1, 'REINDEX TABLE T' ], + [ reindex_conc => 'btree', 1, 'REINDEX INDEX CONCURRENTLY T_i' ], + # Its validate scan (table_index_validate_scan) runs as well. + [ cic => 'btree', 0, 'CREATE INDEX CONCURRENTLY T_i ON T (k)' ], + [ + parallel => 'btree', + 0, + "SET client_min_messages = debug1;\nCREATE INDEX T_i ON T (k)" + ], + [ + parallel_unique => 'unique', + 0, + "SET client_min_messages = debug1;\nCREATE UNIQUE INDEX T_i ON T (k)" + ],); + +# Run $sql once per table, T replaced by the table name. +sub each_tab +{ + my ($sql, @tabs) = @_; + return join("\n", map { (my $x = $sql) =~ s/\bT(?=\b|_)/$_/g; $x } @tabs); +} + +# 2000 rows, about 60 pages: enough for a parallel build. v = id % 50, so +# every value of v is equally common. +my $setup = q( +CREATE TABLE T (id int PRIMARY KEY, k int, v int, pad text) USING AM; +INSERT INTO T SELECT g, g, g % 50, repeat('p', 200) FROM generate_series(1, 2000) g; +); + +# The aborted transaction. 424242 and 31337 are values no committed row +# has; v = 13 is a value it deletes. +my $aborted = q( +UPDATE T SET k = k + 1000000, v = 424242 WHERE id IN (3, 4, 1500); +UPDATE T SET k = 20 WHERE id = 8; +UPDATE T SET v = 424242 WHERE id BETWEEN 100 AND 160; +DELETE FROM T WHERE id IN (5, 1600); +DELETE FROM T WHERE v = 13; +INSERT INTO T SELECT g, g, 31337, 'ins' FROM generate_series(5001, 5060) g; +UPDATE T SET k = k + 1 WHERE id = 5001; +UPDATE T SET v = 71 WHERE id = 7; +UPDATE T SET v = 72 WHERE id = 7; +UPDATE T SET k = k + 2000000 WHERE id = 9; +DELETE FROM T WHERE id = 9; +); + +my $after = q( +UPDATE T SET k = k + 10000 WHERE id IN (3, 4, 8, 120, 1500); +DELETE FROM T WHERE id IN (7, 1600); +INSERT INTO T VALUES (6001, 6001, 1, 'a'); +); + +# Committed and aborted keys. +my @probes = ( + 3, 4, 5, 7, 8, 9, 13, 20, 63, 100, 113, 120, 160, 999, 1500, 1600, 2000, + 1000003, 1000004, 1001500, 2000009, 5001, 5002, 5060, 10003, 6001); + +# enable_sort = off only for ORDER BY: with it, the probes' string_agg +# (... ORDER BY) would rather read presorted rows through the primary key. +my $idx_probe = "SET enable_seqscan = off; SET enable_bitmapscan = off;\n"; +my $idx_on = $idx_probe . "SET enable_sort = off;\n"; +my $seq_on = "SET enable_indexscan = off; SET enable_bitmapscan = off;\n"; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +max_worker_processes = 16 +max_parallel_workers = 8 +max_parallel_maintenance_workers = 2 +min_parallel_table_scan_size = 0 +maintenance_work_mem = '256MB' +)); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('amcheck')) +{ + plan skip_all => 'injection_points or amcheck not installed'; +} +$node->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION amcheck; +-- Wake one held undo apply worker; true once none is held any more. +CREATE FUNCTION release_one() RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE wait_event = 'pbu-undo-worker-before-apply') THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup('pbu-undo-worker-before-apply'); + EXCEPTION WHEN OTHERS THEN + NULL; -- it left the injection point meanwhile + END; + RETURN false; +END $$; +)); + +# stdout of $sql, and stderr if there is any. Never dies. +sub run +{ + my ($sql) = @_; + my ($out, $err) = ('', ''); + my $rc = $node->psql( + 'postgres', $sql, + stdout => \$out, + stderr => \$err, + on_error_stop => 1); + return $err eq '' && $rc == 0 ? $out : "$out\nrc=$rc stderr: $err"; +} + +# bt_index_check (heapallindexed) of one index: '' on success. +sub bt_check +{ + my ($rel) = @_; + my ($out, $err) = ('', ''); + my $rc = $node->psql( + 'postgres', + "SELECT bt_index_check('$rel'::regclass, heapallindexed => true)", + stdout => \$out, + stderr => \$err, + on_error_stop => 1); + return $rc == 0 && $err eq '' ? '' : "rc=$rc: $err"; +} + +sub content +{ + my ($t) = @_; + return run($seq_on + . "SELECT count(*) || ':' || md5(coalesce(string_agg(id || ':' || k || ':' || v || ':' || pad, ',' ORDER BY id), '')) FROM $t" + ); +} + +# One probe: the rows the qual finds for key $x. +sub probe +{ + my ($t, $kind, $x) = @_; + return + "SELECT '$x=' || coalesce(string_agg(r, ',' ORDER BY r), '') FROM (SELECT id || ':' || k || ':' || v AS r FROM $t WHERE " + . $kind{$kind}{pred}->($x) . ') s;'; +} + +# Every probe. +sub probes +{ + my ($t, $kind) = @_; + return join("\n", map { probe($t, $kind, $_) } @probes); +} + +sub ordered +{ + my ($t, $kind) = @_; + my $k = $kind{$kind}; + my $w = $k->{where} ? "WHERE $k->{where} " : ''; + return "SELECT id, k, v FROM $t ${w}ORDER BY $k->{order}"; +} + +sub check_case +{ + my ($c, $what) = @_; + my ($name, $kind) = @$c; + my ($f, $h) = ("f_$name", "h_$name"); + + is(content($f), content($h), "$f: same rows as $h, $what"); + + foreach my $t ($f, $h) + { + like( + run($idx_probe . 'EXPLAIN (COSTS OFF) ' . probe($t, $kind, 3)), + qr/Index Scan using ${t}_i /, + "$t: the probes use the index, $what"); + } + my $fi = run($idx_probe . probes($f, $kind)); + is( $fi, + run($seq_on . probes($f, $kind)), + "$f: index scan finds what a seqscan finds, $what"); + is( $fi, + run($idx_probe . probes($h, $kind)), + "$f: index scan finds what $h finds, $what"); + + if ($kind{$kind}{order}) + { + like( + run($idx_on . 'EXPLAIN (COSTS OFF) ' . ordered($f, $kind)), + qr/^\s*Index Scan using ${f}_i /m, + "$f: ORDER BY uses the index, $what"); + my $fo = run($idx_on . ordered($f, $kind)); + is( $fo, + run($seq_on . ordered($f, $kind)), + "$f: ORDER BY through the index = sorted seqscan, $what"); + is( $fo, + run($idx_on . ordered($h, $kind)), + "$f: ORDER BY through the index = $h, $what"); + } + + if ($kind{$kind}{bt}) + { + foreach my $t ($f, $h) + { + is(bt_check($_), '', "$_: bt_index_check, $what") + foreach ("${t}_i", "${t}_pkey"); + } + } +} + +# ANALYZE must not see the aborted values, and must see the rows the +# aborted DELETEs did not delete. +my $stats_q = q( +SELECT attname, null_frac, n_distinct, most_common_vals, most_common_freqs, + histogram_bounds +FROM pg_stats WHERE schemaname = 'public' AND tablename = 'T' AND attname IN ('k', 'v') +ORDER BY attname; +SELECT reltuples FROM pg_class WHERE relname = 'T'; +); + +sub check_stats +{ + my ($what) = @_; + $node->safe_psql('postgres', 'ANALYZE f_stats; ANALYZE h_stats'); + my $fs = run(each_tab($stats_q, 'f_stats')); + is( $fs, + run(each_tab($stats_q, 'h_stats')), + "f_stats: ANALYZE statistics = h_stats, $what"); + unlike( + $fs, + qr/424242|31337|100000[34]|1001500/, + "f_stats: no aborted value in the statistics, $what"); + + foreach my $m ('BERNOULLI (100)', 'SYSTEM (100)') + { + my $q = + "SELECT count(*) || ':' || md5(string_agg(id || ':' || k || ':' || v, ',' ORDER BY id)) FROM T TABLESAMPLE $m"; + is( run(each_tab($q, 'f_stats')), + run(each_tab($q, 'h_stats')), + "f_stats: TABLESAMPLE $m = h_stats, $what"); + } +} + +my $retained = q( +SELECT (SELECT count(*) FROM pg_stat_get_undo_retention()) = 0 + AND (SELECT bool_and(discard = insert) FROM pg_stat_get_undo_logs() + WHERE persistence = 'permanent')); + +my $held = + "SELECT count(*) FROM pg_stat_activity WHERE wait_event = 'pbu-undo-worker-before-apply'"; + +########################################################################### +# Set up, abort, build while the revert is pending. +########################################################################### +my @tabs; +foreach my $c (@cases, [ 'stats', undef, 0 ]) +{ + foreach my $am ('flux', 'heap') + { + my $t = substr($am, 0, 1) . "_$c->[0]"; + (my $s = $setup) =~ s/\bAM\b/$am/; + $s .= $kind{ $c->[1] }{idx} . ";\n" if $c->[2]; + $node->safe_psql('postgres', each_tab($s, $t)); + push @tabs, $t; + } +} + +$node->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait')"); +$node->safe_psql('postgres', + "BEGIN;\n" . each_tab($aborted, @tabs) . "\nROLLBACK;"); +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); + +foreach my $c (@cases) +{ + foreach my $t ("f_$c->[0]", "h_$c->[0]") + { + my ($out, $err) = ('', ''); + my $rc = $node->psql( + 'postgres', each_tab($c->[3], $t), + stdout => \$out, + stderr => \$err, + on_error_stop => 1); + is($rc, 0, "$t: $c->[0] ran") or diag($err); + like( + $err, + qr/building index "${t}_i" on table "$t" with request for 2 parallel workers/, + "$t: the build is parallel") if $c->[0] =~ /^parallel/; + } +} + +is($node->safe_psql('postgres', $held), + '1', 'the undo apply worker is still held'); +check_case($_, 'built while the revert was pending') foreach @cases; +check_stats('while the revert is pending'); +is($node->safe_psql('postgres', $held), + '1', 'the undo apply worker is still held after the checks'); + +# get_actual_variable_endpoint (selfuncs.c) reads the index with +# SnapshotNonVacuumable through the index-only-scan machinery, so it would +# see whatever the table AM reports for that snapshot. It is not reachable +# for a FLUX table: get_relation_info sets canreturn = false for every +# index of a delete-marking table (a delete-marked entry can carry a stale +# key), and get_actual_variable_range skips indexes with !canreturn[0]. +# Show that: no index-only scan for FLUX, while the heap twin has one. +{ + my $q = "${idx_on}EXPLAIN (COSTS OFF) SELECT k FROM T WHERE k > 1999000"; + unlike( + run(each_tab($q, 'f_create_btree')), + qr/Index Only Scan/, + 'FLUX: no index-only scan, so no endpoint probe'); + like( + run(each_tab($q, 'h_create_btree')), + qr/Index Only Scan/, + 'heap: index-only scan (control)'); +} + +# Control: bt_check must be able to fail. +like( + bt_check('h_stats'), + qr/"h_stats" is not an index/, + 'control: bt_index_check of a table fails'); + +########################################################################### +# Let the undo apply worker go. +########################################################################### +$node->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-worker-before-apply')"); +ok($node->poll_query_until('postgres', 'SELECT release_one()'), + 'held undo apply worker released'); +ok($node->poll_query_until('postgres', $retained), 'the undo was applied'); +check_case($_, 'after the undo apply worker') foreach @cases; +check_stats('after the undo apply worker'); + +$node->safe_psql('postgres', each_tab($after, @tabs)); +check_case($_, 'after more DML') foreach @cases; + +unlike( + slurp_file($node->logfile), + qr/TRAP|PANIC|does not match aborted version|giving up on rolling back/, + 'no failed assertion, mismatched or abandoned undo'); +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/112_flux_index_no_orphan.pl b/src/test/recovery/t/112_flux_index_no_orphan.pl new file mode 100644 index 0000000000000..d19fc699a5177 --- /dev/null +++ b/src/test/recovery/t/112_flux_index_no_orphan.pl @@ -0,0 +1,355 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# Inspect physical leaf entries, not just rows visible through an index scan. +# No VACUUM is run: undo must remove every newly inserted aborted key itself. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +plan skip_all => 'injection points required' + if $ENV{enable_injection_points} ne 'yes'; +my $node = PostgreSQL::Test::Cluster->new('flux_no_orphan', port => 5890); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +shared_preload_libraries = 'injection_points' +wal_consistency_checking = all +)); +$node->start; +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE EXTENSION injection_points; +CREATE EXTENSION amcheck; +CREATE EXTENSION pg_walinspect; +CREATE EXTENSION pg_buffercache; +CREATE TABLE flush_marker(i int); +}); +$node->backup('base'); +my $standby = + PostgreSQL::Test::Cluster->new('flux_no_orphan_standby', port => 5891); +$standby->init_from_backup($node, 'base', has_streaming => 1); +$standby->start; + +# Byte-order-independent integer keys, including on big-endian machines. +my ($a, $b, $c) = (16843009, 33686018, 50529027); +my $idx = 'SET enable_seqscan = off; SET enable_bitmapscan = off; ' + . 'SET enable_indexonlyscan = off; '; + +sub make_table +{ + my ($t) = @_; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t(k int, j int, marker int, padding text) USING flux; +CREATE INDEX ${t}_k ON $t(k); +INSERT INTO $t VALUES ($a, $a, 0, repeat('x', 500)); +}); +} + +sub key_count +{ + my ($t, $hex) = @_; + return "SELECT count(*) FROM bt_page_items('${t}_k', 1) " + . "WHERE data LIKE '$hex $hex $hex $hex%'"; +} + +sub check_clean +{ + my ($t, $what) = @_; + $node->safe_psql('postgres', 'INSERT INTO flush_marker VALUES (0)'); + $node->wait_for_catchup($standby); + foreach my $n ($node, $standby) + { + is($n->safe_psql('postgres', key_count($t, '02')), + '0', "$what: no B entry on " . $n->name); + is($n->safe_psql('postgres', key_count($t, '03')), + '0', "$what: no C entry on " . $n->name); + is( $n->safe_psql( + 'postgres', qq{ +SELECT count(*) FROM bt_page_items('${t}_k', 1) +WHERE data LIKE '01 01 01 01%' AND itemlen = 16 AND NOT dead +}), + '1', + "$what: exactly one live A entry on " . $n->name); + is( $n->safe_psql( + 'postgres', $idx . "SELECT count(*) FROM $t WHERE k = $a"), + '1', + "$what: A reaches row on " . $n->name); + } +} + +sub wait_reverted +{ + my ($t) = @_; + # Raw page inspection cannot help revert the row itself. + ok( $node->poll_query_until( + 'postgres', + "SELECT position('\\x04040404'::bytea IN get_raw_page('$t', 0)) = 0" + ), + "$t: worker restored the tuple"); +} +make_table('inline_one'); +$node->safe_psql('postgres', "BEGIN; UPDATE inline_one SET k = $b; ROLLBACK"); +check_clean('inline_one', 'inline single update'); + +foreach my $path ([ 'aba', [ $b, $a ] ], [ 'abca', [ $b, $c, $a ] ]) +{ + my ($t, $keys) = @$path; + make_table($t); + $node->safe_psql('postgres', + 'BEGIN; ' + . join('; ', map { "UPDATE $t SET k = $_" } @$keys) + . '; ROLLBACK'); + check_clean($t, $t); +} +make_table('savepoint_one'); +$node->safe_psql( + 'postgres', qq{ +BEGIN; SAVEPOINT s; UPDATE savepoint_one SET k = $b; +ROLLBACK TO s; COMMIT; +}); +check_clean('savepoint_one', 'savepoint'); + +$node->append_conf('postgresql.conf', 'undo_rollback_overflow_size = 0'); +$node->restart; +make_table('worker_one'); +$node->safe_psql( + 'postgres', qq{ +SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait'); +BEGIN; UPDATE worker_one SET k = $b, marker = 67372036; ROLLBACK; +}); +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +is($node->safe_psql('postgres', key_count('worker_one', '02')), + '1', 'worker rollback has not run'); +$node->safe_psql( + 'postgres', q{ +SELECT injection_points_detach('pbu-undo-worker-before-apply'); +SELECT injection_points_wakeup('pbu-undo-worker-before-apply'); +}); +wait_reverted('worker_one'); +check_clean('worker_one', 'background worker'); + +make_table('crash_one'); +my $bg = $node->background_psql('postgres'); +$bg->query_safe("BEGIN; UPDATE crash_one SET k = $b, marker = 67372036"); +$node->safe_psql('postgres', 'INSERT INTO flush_marker VALUES (1)'); +$node->stop('immediate'); +$bg->{run}->kill_kill; +$node->start; +wait_reverted('crash_one'); +check_clean('crash_one', 'crash rollback'); + +$node->append_conf('postgresql.conf', 'undo_rollback_overflow_size = 1024'); +$node->restart; +make_table('historic'); +my $old = $node->background_psql('postgres'); +$old->query_safe( + 'BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM historic'); +$node->safe_psql( + 'postgres', qq{ +UPDATE historic SET k = $b; +BEGIN; UPDATE historic SET k = $a; ROLLBACK; +}); +is($old->query_safe($idx . "SELECT count(*) FROM historic WHERE k = $a"), + '1', 'older snapshot finds A after aborted revival'); +is($node->safe_psql('postgres', key_count('historic', '01')), + '1', 'rollback preserves the historic A tombstone'); +$old->query_safe('COMMIT'); +$old->quit; + +# In the second UPDATE k revives A, but j inserts C for the first time. +make_table('mixed'); +$node->safe_psql( + 'postgres', qq{ +CREATE INDEX mixed_j ON mixed(j); +BEGIN; +UPDATE mixed SET k = $b, j = $b; +UPDATE mixed SET k = $a, j = $c; +ROLLBACK; +}); +check_clean('mixed', 'mixed revived and inserted indexes'); +is( $node->safe_psql( + 'postgres', q{SELECT count(*) FROM bt_page_items('mixed_j', 1)}), + '1', + 'second changed index has only its original entry'); + +make_table('dropped'); +$node->safe_psql( + 'postgres', qq{ +BEGIN; +SAVEPOINT before_update; +UPDATE dropped SET k = $b; +SAVEPOINT before_drop; +DROP INDEX dropped_k; +ROLLBACK TO before_update; +COMMIT; +}); +check_clean('dropped', 'index dropped inside savepoint'); + +# Crash after logging intent but before changing the index leaf. A was +# delete-marked by a committed update; the incomplete revival must retain it. +make_table('ordered'); +$node->safe_psql( + 'postgres', qq{ +UPDATE ordered SET k = $b; +SELECT injection_points_attach('flux-index-after-amend', 'wait'); +}); +$bg = $node->background_psql('postgres'); +$bg->query_until(qr/starting/, + "BEGIN; SELECT 'starting'; UPDATE ordered SET k = $a, marker = 67372036;\n" +); +$node->wait_for_event('client backend', 'flux-index-after-amend'); +$node->safe_psql('postgres', 'INSERT INTO flush_marker VALUES (2)'); +$node->stop('immediate'); +$bg->{run}->kill_kill; +$node->start; +wait_reverted('ordered'); +$node->safe_psql('postgres', 'INSERT INTO flush_marker VALUES (0)'); +$node->wait_for_catchup($standby); + +foreach my $n ($node, $standby) +{ + is($n->safe_psql('postgres', key_count('ordered', '01')), + '1', 'crash before revive retains A on ' . $n->name); + is( $n->safe_psql( + 'postgres', $idx . "SELECT count(*) FROM ordered WHERE k = $b"), + '1', + 'crash before revive restores B on ' . $n->name); +} +# A future undo page can reach disk before the crash while replay starts +# before the original insert. Replaying that insert must not zero its list +# and then skip the amendment because the page LSN is already newer. +make_table('future_page'); +$bg = $node->background_psql('postgres'); +$bg->query_safe("UPDATE future_page SET k = $b"); +$node->safe_psql('postgres', 'CHECKPOINT'); +$bg->query_safe('UPDATE future_page SET marker = 1'); +$bg->query_safe("BEGIN; UPDATE future_page SET k = $a, marker = 67372036"); +$node->safe_psql('postgres', 'SELECT pg_buffercache_evict_all()'); +$node->stop('immediate'); +$bg->{run}->kill_kill; +$node->start; +wait_reverted('future_page'); +is($node->safe_psql('postgres', key_count('future_page', '01')), + '1', 'replay preserves the amended list on an already-flushed undo page'); + +# Every accepted near-page-size inline row must also support full-image undo. +# Discover the INSERT boundary rather than baking in tuple header sizes. +foreach my $nindexes (1, 20) +{ + my $t = "wide_$nindexes"; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t(k int, pad text STORAGE PLAIN) USING flux; +} . join('; ', map { "CREATE INDEX ${t}_k$_ ON $t(k)" } 1 .. $nindexes)); + my $limit; + for my $pad (8000 .. 8192) + { + my ($out, $err) = ('', ''); + my $ret = $node->psql( + 'postgres', + "INSERT INTO $t VALUES (1, repeat('x', $pad))", + stdout => \$out, + stderr => \$err); + if ($ret != 0) + { + like( + $err, + qr/row is too big|tuple is too big|row size|tuple size/, + "$nindexes indexes: INSERT reaches row size limit"); + $limit = $pad - 1; + last; + } + $ret = $node->psql( + 'postgres', qq{ +BEGIN; +UPDATE $t SET k = 2, pad = repeat('y', $pad); +ROLLBACK; +}, + stdout => \$out, + stderr => \$err); + is($ret, 0, "$nindexes indexes: pad $pad key update and rollback") + or diag $err; + is( $node->safe_psql( + 'postgres', + "SELECT k = 1 AND pad = repeat('x', $pad) FROM $t"), + 't', + "$nindexes indexes: pad $pad before-image restored"); + $node->safe_psql( + 'postgres', + join('; ', + map { "SELECT bt_index_check('${t}_k$_', true)" } + 1 .. $nindexes)); + $node->safe_psql('postgres', "TRUNCATE $t"); + } + ok(defined $limit && $limit >= 8000, + "$nindexes indexes: tested through the INSERT limit"); + $node->safe_psql('postgres', + "INSERT INTO $t VALUES (1, repeat('x', $limit))"); + my $wide = $node->background_psql('postgres'); + $wide->query_safe( + "BEGIN; UPDATE $t SET k = 2, pad = repeat('y', $limit)"); + my $wal_start = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + # Stay in one transaction so later records cannot include a previous + # transaction header page. Vary the starting byte with full before-images. + for (1 .. 128) + { + $wide->query_safe( + qq{ +UPDATE $t SET k = 1, pad = repeat('x', $limit); +UPDATE $t SET k = 2, pad = repeat('y', $limit); +}); + } + $wide->query_safe('ROLLBACK'); + $wide->quit; + ok( $node->safe_psql( + 'postgres', qq{ +SELECT EXISTS ( + SELECT start_lsn FROM pg_get_wal_block_info('$wal_start', pg_current_wal_lsn(), false) + WHERE reldatabase = 9 AND resource_manager = 'FLUX' + GROUP BY start_lsn HAVING count(*) = 3 +) +}) eq 't', + "$nindexes indexes: full before-image spans three undo pages"); + is( $node->safe_psql( + 'postgres', "SELECT k = 1 AND pad = repeat('x', $limit) FROM $t"), + 't', + "$nindexes indexes: three-page rollback restores row"); + $node->safe_psql( + 'postgres', + join('; ', + map { "SELECT bt_index_check('${t}_k$_', true)" } 1 .. $nindexes) + ); +} + +# A large reserved area and many record positions exercise page boundaries. +make_table('boundary'); +$node->safe_psql('postgres', + join('; ', map { "CREATE INDEX boundary_k$_ ON boundary(k)" } 1 .. 31)); +my $start = + $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); +$bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +for (1 .. 256) +{ + $bg->query_safe("UPDATE boundary SET k = $b; UPDATE boundary SET k = $a"); +} +$bg->query_safe('ROLLBACK'); +$bg->quit; +$node->safe_psql('postgres', 'INSERT INTO flush_marker VALUES (3)'); +ok( $node->safe_psql( + 'postgres', qq{ +SELECT count(*) > 0 FROM pg_get_wal_records_info('$start', pg_current_wal_lsn()) +WHERE record_type = 'AMEND_RECORD' AND description = 'blocks 2' +}) eq 't', + 'revive list amendment crosses an undo page boundary'); +check_clean('boundary', 'cross-page amendment'); +$node->safe_psql('postgres', q{SELECT bt_index_check('boundary_k', true)}); + +$standby->stop; +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/113_flux_prepared_row_locks.pl b/src/test/recovery/t/113_flux_prepared_row_locks.pl new file mode 100644 index 0000000000000..d2d33bfe31bad --- /dev/null +++ b/src/test/recovery/t/113_flux_prepared_row_locks.pl @@ -0,0 +1,380 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# The row locks and row changes of a PREPARED transaction survive an +# immediate restart and a standby promotion, as heap's do. Heap is the +# control for every case. +# +# FLUX keeps a row lock's owner and strength only in the shared-memory tuple +# sLog; the page carries an ownerless FLUX_TUPLE_LOCKED bit. A restart +# empties the sLog, so a prepared transaction's FLUX row locks used to +# vanish: an UPDATE of a FLUX row the prepared transaction had locked FOR +# UPDATE succeeded after the restart, while the same UPDATE of a heap row +# still blocked. Each PREPARE also leaked one count of FLUX's active +# transactions. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +max_prepared_transactions = 40 +)); +$node->start; +if ( !$node->check_extension('amcheck') + || !$node->check_extension('test_idxundo')) +{ + plan skip_all => 'amcheck or test_idxundo not installed'; +} +$node->safe_psql('postgres', + 'CREATE EXTENSION amcheck; CREATE EXTENSION test_idxundo;'); + +my @ams = ('heap', 'flux'); +foreach my $am (@ams) +{ + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE t_$am (id int PRIMARY KEY, v int) USING $am; +INSERT INTO t_$am SELECT g, g FROM generate_series(1, 80) g;}); +} + +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->start; + +# Run sql in a transaction that is rolled back, giving up after a short +# lock_timeout. Reports whether it had to wait for a lock. +sub probe +{ + my ($n, $sql) = @_; + my ($ret, $stdout, $stderr) = $n->psql('postgres', + "BEGIN; SET LOCAL lock_timeout = '300ms'; $sql; ROLLBACK;"); + return 'blocks' if $stderr =~ /canceling statement due to lock timeout/; + return 'proceeds' if $stderr eq ''; + return "error: $stderr"; +} + +sub prepare +{ + my ($gid, @sql) = @_; + $node->safe_psql('postgres', + join(";\n", 'BEGIN', @sql, "PREPARE TRANSACTION '$gid'")); +} + +my @modes = ('FOR UPDATE', 'FOR NO KEY UPDATE', 'FOR SHARE', 'FOR KEY SHARE'); +my @probes = ( + [ 'non-key UPDATE', 'UPDATE %s SET v = v + 1 WHERE id = %d' ], + [ 'DELETE', 'DELETE FROM %s WHERE id = %d' ], + [ 'FOR UPDATE', 'SELECT 1 FROM %s WHERE id = %d FOR UPDATE' ], + [ 'FOR SHARE', 'SELECT 1 FROM %s WHERE id = %d FOR SHARE' ], + [ 'FOR KEY SHARE', 'SELECT 1 FROM %s WHERE id = %d FOR KEY SHARE' ],); +# A key-changing UPDATE is left out: FLUX does not make it wait for a FOR KEY +# SHARE lock even without two-phase commit, a separate bug. +# Which probes a lock in each mode blocks: heap's tuple lock conflict table. +my %blocks = ( + 'FOR UPDATE' => [ 1, 1, 1, 1, 1 ], + 'FOR NO KEY UPDATE' => [ 1, 1, 1, 1, 0 ], + 'FOR SHARE' => [ 1, 1, 1, 0, 0 ], + 'FOR KEY SHARE' => [ 0, 1, 1, 0, 0 ],); + +# Run every probe against row $id of t_$am and compare with what a lock in +# $mode should block ($mode undef: nothing is locked). +sub check_probes +{ + my ($n, $am, $id, $mode, $what) = @_; + for my $p (0 .. $#probes) + { + my ($pname, $fmt) = @{ $probes[$p] }; + my $want = + (defined $mode && $blocks{$mode}[$p]) ? 'blocks' : 'proceeds'; + is(probe($n, sprintf($fmt, "t_$am", $id)), + $want, "$what: $pname of the $am row"); + } +} + +# Visible rows of the rows the DML transactions touch, by seq scan and by +# index scan. +my $dmlrows = '41, 42, 51, 52, 90, 91'; +# $n is a node or, to read in an open transaction, a background session. +sub dml_state +{ + my ($n, $am) = @_; + my $q = + "SELECT string_agg(id || ':' || v, ',' ORDER BY id) FROM t_$am WHERE id IN ($dmlrows)"; + my @sql = ( + "SET enable_indexscan = off; SET enable_bitmapscan = off; $q", + "SET enable_seqscan = off; SET enable_indexscan = on; $q"); + my @out = map { + $n->isa('PostgreSQL::Test::Cluster') + ? $n->safe_psql('postgres', $_) + : $n->query_safe($_) + } @sql; + $n->query_safe( + 'RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan' + ) unless $n->isa('PostgreSQL::Test::Cluster'); + return "seq $out[0] idx $out[1]"; +} + +# Row $id of t_$am is locked in mode $i by gid lock_$am_$i, resolved after +# the restart; rows 11..14 by gid early_$am_$i, resolved before it. +foreach my $am (@ams) +{ + for my $i (0 .. $#modes) + { + prepare("lock_${am}_$i", + "SELECT 1 FROM t_$am WHERE id = " . (1 + $i) . " $modes[$i]"); + prepare("early_${am}_$i", + "SELECT 1 FROM t_$am WHERE id = " . (11 + $i) . " $modes[$i]"); + } + + # Two prepared transactions share a FOR SHARE lock on row 20. + prepare("share_a_$am", "SELECT 1 FROM t_$am WHERE id = 20 FOR SHARE"); + prepare("share_b_$am", "SELECT 1 FROM t_$am WHERE id = 20 FOR SHARE"); + + # Prepared row changes: one transaction is committed after the restart, + # the other rolled back. + prepare( + "dml_c_$am", + "INSERT INTO t_$am VALUES (90, 90)", + "UPDATE t_$am SET v = v + 100 WHERE id = 41", + "DELETE FROM t_$am WHERE id = 42"); + prepare( + "dml_r_$am", + "INSERT INTO t_$am VALUES (91, 91)", + "UPDATE t_$am SET v = v + 100 WHERE id = 51", + "DELETE FROM t_$am WHERE id = 52"); +} + +foreach my $am (@ams) +{ + prepare( + "upgrade_$am", + "SELECT 1 FROM t_$am WHERE id = 75 FOR KEY SHARE", + 'SAVEPOINT a', + "SELECT 1 FROM t_$am WHERE id = 75 FOR UPDATE", + 'ROLLBACK TO a'); + check_probes( + $node, $am, 75, + 'FOR KEY SHARE', + 'rolled-back upgrade before restart'); +} + +# One transaction locks a heap row and a FLUX row. +prepare( + 'mixed', + 'SELECT 1 FROM t_heap WHERE id = 30 FOR UPDATE', + 'SELECT 1 FROM t_flux WHERE id = 30 FOR UPDATE'); + +# Resolved on the promoted standby. +prepare( + 'sb', + 'SELECT 1 FROM t_heap WHERE id = 60 FOR UPDATE', + 'SELECT 1 FROM t_flux WHERE id = 60 FOR UPDATE', + 'SELECT 1 FROM t_heap WHERE id = 61 FOR KEY SHARE', + 'SELECT 1 FROM t_flux WHERE id = 61 FOR KEY SHARE'); + +# Before the restart. +foreach my $am (@ams) +{ + for my $i (0 .. $#modes) + { + check_probes($node, $am, 1 + $i, $modes[$i], + "before restart, $modes[$i]"); + + my $early = 11 + $i; + is(probe($node, "DELETE FROM t_$am WHERE id = $early"), + 'blocks', "before restart, early $modes[$i]: $am row locked"); + $node->safe_psql('postgres', + ($i % 2 ? 'COMMIT' : 'ROLLBACK') . " PREPARED 'early_${am}_$i'"); + check_probes($node, $am, $early, undef, + "before restart, early $modes[$i] resolved"); + } + + is(probe($node, "SELECT 1 FROM t_$am WHERE id = 20 FOR SHARE"), + 'proceeds', "before restart, shared FOR SHARE: $am FOR SHARE"); + is(probe($node, "UPDATE t_$am SET v = v WHERE id = 20"), + 'blocks', "before restart, shared FOR SHARE: $am UPDATE"); + + is( dml_state($node, $am), + 'seq 41:41,42:42,51:51,52:52 idx 41:41,42:42,51:51,52:52', + "before restart, prepared row changes are invisible in $am"); +} + +# Immediate restart. The checkpoint leaves nothing to redo, so whatever +# FLUX needs must come from the pages, CLOG and the two-phase state files. +$node->safe_psql('postgres', 'CHECKPOINT'); +$node->stop('immediate'); +$node->start; + +foreach my $am (@ams) +{ + for my $i (0 .. $#modes) + { + check_probes($node, $am, 1 + $i, $modes[$i], + "after restart, $modes[$i]"); + } + + # Shared FOR SHARE: still blocked after one of the two holders goes. + is(probe($node, "SELECT 1 FROM t_$am WHERE id = 20 FOR SHARE"), + 'proceeds', "after restart, shared FOR SHARE: $am FOR SHARE"); + is(probe($node, "UPDATE t_$am SET v = v WHERE id = 20"), + 'blocks', "after restart, shared FOR SHARE: $am UPDATE"); + $node->safe_psql('postgres', "COMMIT PREPARED 'share_a_$am'"); + is(probe($node, "UPDATE t_$am SET v = v WHERE id = 20"), + 'blocks', + "after restart, shared FOR SHARE, one committed: $am UPDATE"); + $node->safe_psql('postgres', "ROLLBACK PREPARED 'share_b_$am'"); + is(probe($node, "UPDATE t_$am SET v = v WHERE id = 20"), + 'proceeds', + "after restart, shared FOR SHARE, both resolved: $am UPDATE"); + + # Prepared row changes: invisible, and writers wait for them. + is( dml_state($node, $am), + 'seq 41:41,42:42,51:51,52:52 idx 41:41,42:42,51:51,52:52', + "after restart, prepared row changes are invisible in $am"); + foreach my $id (41, 51) + { + is(probe($node, "UPDATE t_$am SET v = v WHERE id = $id"), + 'blocks', "after restart, UPDATE of a prepared UPDATE's $am row"); + is(probe($node, "DELETE FROM t_$am WHERE id = $id"), + 'blocks', "after restart, DELETE of a prepared UPDATE's $am row"); + } + foreach my $id (42, 52) + { + is(probe($node, "UPDATE t_$am SET v = v WHERE id = $id"), + 'blocks', "after restart, UPDATE of a prepared DELETE's $am row"); + } + foreach my $id (90, 91) + { + is(probe($node, "INSERT INTO t_$am VALUES ($id, 0)"), + 'blocks', "after restart, INSERT of a prepared INSERT's $am key"); + } +} + +foreach my $am (@ams) +{ + check_probes( + $node, $am, 75, + 'FOR KEY SHARE', + 'rolled-back upgrade after restart'); + $node->safe_psql('postgres', "ROLLBACK PREPARED 'upgrade_$am'"); + check_probes($node, $am, 75, undef, 'rolled-back upgrade resolved'); +} + +# A session waiting on the mixed transaction's locks proceeds when it +# commits. +my %waiter; +foreach my $am (@ams) +{ + $waiter{$am} = $node->background_psql('postgres'); + $waiter{$am}->query_until(qr/start/, + "\\echo start\nUPDATE t_$am SET v = v + 1000 WHERE id = 30;\n"); +} +ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 2 FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND query LIKE 'UPDATE t_% SET v = v + 1000%'" + ), + 'after restart, UPDATEs of the mixed rows wait'); +$node->safe_psql('postgres', "COMMIT PREPARED 'mixed'"); +foreach my $am (@ams) +{ + $waiter{$am}->query_safe('SELECT 1'); + $waiter{$am}->quit; + is( $node->safe_psql('postgres', "SELECT v FROM t_$am WHERE id = 30"), + '1030', + "the waiter's UPDATE of the $am row ran after COMMIT PREPARED"); +} + +# REPEATABLE READ snapshots taken after the restart, before COMMIT PREPARED, +# must keep seeing the prepared row changes as uncommitted. +my %rr; +foreach my $am (@ams) +{ + $rr{$am} = $node->background_psql('postgres'); + $rr{$am}->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + $rr{$am}->query_safe("SELECT count(*) FROM t_$am"); +} + +# Resolve the rest. +foreach my $am (@ams) +{ + for my $i (0 .. $#modes) + { + $node->safe_psql('postgres', + ($i % 2 ? 'COMMIT' : 'ROLLBACK') . " PREPARED 'lock_${am}_$i'"); + check_probes($node, $am, 1 + $i, undef, + "after restart, $modes[$i] resolved"); + } + + $node->safe_psql('postgres', "COMMIT PREPARED 'dml_c_$am'"); + $node->safe_psql('postgres', "ROLLBACK PREPARED 'dml_r_$am'"); + is( dml_state($node, $am), + 'seq 41:141,51:51,52:52,90:90 idx 41:141,51:51,52:52,90:90', + "after restart, COMMIT and ROLLBACK PREPARED row changes in $am"); + # Read every row again, setting whatever hints the readers set. + $node->safe_psql('postgres', "SELECT * FROM t_$am"); + $node->safe_psql('postgres', + "SELECT * FROM t_$am WHERE id IN ($dmlrows)"); + is( dml_state($rr{$am}, $am), + 'seq 41:41,42:42,51:51,52:52 idx 41:41,42:42,51:51,52:52', + "after restart, an older snapshot does not see the resolved $am changes" + ); + $rr{$am}->query_safe('COMMIT'); + $rr{$am}->quit; + foreach my $id (41, 51, 52, 90) + { + is(probe($node, "UPDATE t_$am SET v = v WHERE id = $id"), + 'proceeds', + "after restart and resolution, UPDATE of $am row $id"); + } + is(probe($node, "INSERT INTO t_$am VALUES (91, 0)"), + 'proceeds', "after restart and resolution, INSERT of $am key 91"); +} +is( $node->safe_psql( + 'postgres', + 'SELECT count(*) FROM (SELECT * FROM t_heap EXCEPT SELECT * FROM t_flux) x' + ), + '0', + 'heap and FLUX tables match'); +is( $node->safe_psql('postgres', 'SELECT count(*) FROM t_heap'), + $node->safe_psql('postgres', 'SELECT count(*) FROM t_flux'), + 'heap and FLUX row counts match'); +$node->safe_psql('postgres', "SELECT bt_index_check('t_flux_pkey', true)"); + +# FLUX's count of active transactions does not grow with each PREPARE. +my $active0 = + $node->safe_psql('postgres', 'SELECT idxundo_flux_active_xacts()'); +for my $i (1 .. 3) +{ + prepare("leak_$i", 'UPDATE t_flux SET v = v WHERE id = 70'); + $node->safe_psql('postgres', "COMMIT PREPARED 'leak_$i'"); +} +is($node->safe_psql('postgres', 'SELECT idxundo_flux_active_xacts()'), + $active0, 'PREPARE does not leak a FLUX active transaction'); + +# The standby promoted with 'sb' still prepared holds its locks. +$node->wait_for_replay_catchup($standby); +$standby->promote; +foreach my $am (@ams) +{ + check_probes($standby, $am, 60, 'FOR UPDATE', + 'promoted standby, FOR UPDATE'); + check_probes( + $standby, $am, 61, + 'FOR KEY SHARE', + 'promoted standby, FOR KEY SHARE'); +} +$standby->safe_psql('postgres', "COMMIT PREPARED 'sb'"); +foreach my $am (@ams) +{ + check_probes($standby, $am, 60, undef, 'promoted standby, resolved'); + check_probes($standby, $am, 61, undef, 'promoted standby, resolved'); +} + +done_testing(); diff --git a/src/test/recovery/t/115_flux_freeze.pl b/src/test/recovery/t/115_flux_freeze.pl new file mode 100644 index 0000000000000..39eeec81ff780 --- /dev/null +++ b/src/test/recovery/t/115_flux_freeze.pl @@ -0,0 +1,184 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# CLOG truncation must not invalidate frozen rows or UNDO before-images. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq{ +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +vacuum_freeze_min_age = 0 +wal_consistency_checking = all +full_page_writes = off +statement_timeout = '120s' +}); +$node->start; +$node->safe_psql( + 'postgres', + 'CREATE EXTENSION amcheck; CREATE EXTENSION xid_wraparound; CREATE EXTENSION injection_points; +ALTER DATABASE template0 ALLOW_CONNECTIONS true;'); +for my $am (qw(heap flux)) +{ + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE frozen_$am (id int PRIMARY KEY, v int) USING $am; +CREATE TABLE chain_$am (id int PRIMARY KEY, v int) USING $am; +INSERT INTO frozen_$am VALUES (1,10); +INSERT INTO chain_$am VALUES (1,10); +}); +} +my $old_xid = $node->safe_psql('postgres', 'SELECT xmin FROM chain_flux'); +my $old_segment = sprintf('%04X', int($old_xid / (8192 * 4 * 32))); +ok(-f $node->data_dir . "/pg_xact/$old_segment", + 'original CLOG segment exists'); +$node->safe_psql('postgres', 'SELECT consume_xids(2200000)'); +$node->safe_psql('postgres', 'SELECT pg_current_xact_id()'); +$node->backup('base'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'base', has_streaming => 1); +$standby->append_conf('postgresql.conf', + "wal_consistency_checking = all\nmax_standby_streaming_delay = 0\n"); +$standby->start; +my $reader = $node->background_psql('postgres'); +$reader->query_safe( + 'BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT pg_current_snapshot();'); +# Do not read the old row here: doing so could set the missing commit hint. +for my $am (qw(heap flux)) +{ + $node->safe_psql('postgres', "UPDATE chain_$am SET v=20;"); +} +my $before = $node->safe_psql('postgres', + 'SELECT datfrozenxid::text::bigint FROM pg_database WHERE datname=current_database()' +); +for my $db (qw(template0 template1 postgres)) +{ + $node->safe_psql($db, 'VACUUM FREEZE'); +} +$node->safe_psql('postgres', 'CHECKPOINT'); +my $after = $node->safe_psql('postgres', + 'SELECT datfrozenxid::text::bigint FROM pg_database WHERE datname=current_database()' +); +cmp_ok($after, '>', $before, 'database freeze horizon advances'); +ok( !-f $node->data_dir . "/pg_xact/$old_segment", + 'original CLOG segment actually truncated'); +for my $am (qw(heap flux)) +{ + is($reader->query_safe("SELECT v FROM chain_$am"), + '10', "$am old snapshot reads before-image after truncation"); + is($node->safe_psql('postgres', "SELECT v FROM frozen_$am"), + '10', "$am frozen row survives truncation"); +} +$reader->query_safe('COMMIT'); +$reader->quit; +$node->wait_for_replay_catchup($standby); +for my $am (qw(heap flux)) +{ + is($standby->safe_psql('postgres', "SELECT v FROM frozen_$am"), + '10', "$am standby frozen row"); + is($standby->safe_psql('postgres', "SELECT v FROM chain_$am"), + '20', "$am standby current version"); + for my $n ($node, $standby) + { + is( $n->safe_psql( + 'postgres', + "SELECT bt_index_check('frozen_${am}_pkey'); SELECT bt_index_check('chain_${am}_pkey');" + ), + "\n", + "$am indexes pass amcheck on " . $n->name); + } +} +# A non-freezing VACUUM setting ALL_VISIBLE must also resolve conflicts. +my $vm_reader = $standby->background_psql('postgres', on_error_stop => 0); +$vm_reader->query_safe( + 'BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT pg_current_snapshot();'); +$node->safe_psql('postgres', 'SELECT pg_current_xact_id()'); +$node->safe_psql('postgres', 'INSERT INTO frozen_flux VALUES (2,30)'); +$node->safe_psql('postgres', 'SELECT pg_current_xact_id()'); +$node->safe_psql('postgres', + 'SET vacuum_freeze_min_age=100000000; VACUUM frozen_flux'); +is( $node->safe_psql( + 'postgres', + "SELECT relallvisible FROM pg_class WHERE oid='frozen_flux'::regclass" + ), + '1', + 'nonfreezing vacuum made the page all-visible'); +# VACUUM's last VM record need not yet be flushed. Flush before waiting. +$node->safe_psql('postgres', 'CHECKPOINT'); +$node->wait_for_replay_catchup($standby); +eval { $vm_reader->query('SELECT v FROM frozen_flux WHERE id=2'); }; +like( + $vm_reader->{stderr}, + qr/conflict with recovery/, + 'all-visible WAL resolves older standby snapshots'); +eval { $vm_reader->quit; }; +# A DML-normalized before-image must cancel an older standby snapshot. +# Disable feedback so the primary can advance beyond that snapshot. +my $old_standby = $standby->background_psql('postgres', on_error_stop => 0); +$old_standby->query_safe( + 'BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT pg_current_snapshot();'); +$node->safe_psql('postgres', 'INSERT INTO chain_flux VALUES (2,30)'); +$node->safe_psql('postgres', 'UPDATE chain_flux SET v=40 WHERE id=2'); +$node->wait_for_replay_catchup($standby); +eval { $old_standby->query('SELECT v FROM chain_flux WHERE id=2'); }; +# An idle-in-transaction conflict terminates the connection, not just a query. +like( + $old_standby->{stderr}, + qr/conflict with recovery/, + 'before-image freezing resolves older standby snapshots'); +eval { $old_standby->quit; }; +for my $am (qw(heap flux)) +{ + $node->safe_psql('postgres', + "CREATE TABLE parent_$am (id int PRIMARY KEY) USING $am; CREATE TABLE child_$am (id int PRIMARY KEY, p int REFERENCES parent_$am(id) DEFERRABLE INITIALLY DEFERRED) USING $am; INSERT INTO parent_$am VALUES (1); INSERT INTO child_$am VALUES (1,1);" + ); + is( $node->safe_psql( + 'postgres', + "BEGIN; UPDATE child_$am SET p=1; UPDATE child_$am SET p=1; SET CONSTRAINTS ALL IMMEDIATE; COMMIT; SELECT count(*) FROM child_$am" + ), + '1', + "$am deferred checks survive normalized before-images"); +} +$node->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait');" +); +$node->safe_psql('postgres', + "BEGIN; DELETE FROM frozen_heap WHERE id=1; DELETE FROM frozen_flux WHERE id=1; ROLLBACK;" +); +$node->wait_for_event('undo apply worker', 'pbu-undo-worker-before-apply'); +$node->safe_psql('postgres', + 'SELECT consume_xids(100); SELECT pg_current_xact_id()'); +for my $am (qw(heap flux)) +{ + $node->safe_psql('postgres', "VACUUM (FREEZE) frozen_$am"); + is($node->safe_psql('postgres', "SELECT v FROM frozen_$am WHERE id=1"), + '10', "$am VACUUM preserves a row with an aborted delete"); + is( $node->safe_psql( + 'postgres', + "SELECT age(relfrozenxid) <= 1 FROM pg_class WHERE oid='frozen_$am'::regclass" + ), + 't', + "$am aborted delete does not pin freezing"); +} +$node->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-worker-before-apply'); SELECT injection_points_wakeup('pbu-undo-worker-before-apply');" +); +$standby->stop; +$node->stop('immediate'); +$node->start; +for my $am (qw(heap flux)) +{ + is($node->safe_psql('postgres', "SELECT v FROM frozen_$am WHERE id=1"), + '10', "$am frozen row survives crash recovery"); + $node->safe_psql('postgres', + "BEGIN; UPDATE frozen_$am SET v=99 WHERE id=1; ROLLBACK;"); + is($node->safe_psql('postgres', "SELECT v FROM frozen_$am WHERE id=1"), + '10', "$am rollback restores a frozen row"); +} +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/116_flux_minimal.pl b/src/test/recovery/t/116_flux_minimal.pl new file mode 100644 index 0000000000000..979ebcabb2a8a --- /dev/null +++ b/src/test/recovery/t/116_flux_minimal.pl @@ -0,0 +1,40 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# A new relation synchronized without WAL must survive loss of its undo. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +for my $am (qw(heap flux)) +{ + my $node = PostgreSQL::Test::Cluster->new($am); + $node->init; + $node->append_conf( + 'postgresql.conf', qq[ +wal_level = minimal +wal_skip_threshold = 0 +]); + $node->start; + $node->safe_psql( + 'postgres', qq[ + BEGIN; + CREATE TABLE upd (id int, id2 int) USING $am; + INSERT INTO upd SELECT g, g FROM generate_series(1,10000) g; + COPY upd FROM STDIN; +10001 10001 +\\. + UPDATE upd SET id2 = id2 + 1; + DELETE FROM upd; + COMMIT; + ]); + $node->stop('immediate'); + $node->start; + my ($ret, $stdout, $stderr) = + $node->psql('postgres', 'SELECT count(*) FROM upd'); + is($ret, 0, "$am recovered table is readable") or diag $stderr; + is($stdout, '0', "$am committed delete survives recovery"); + $node->stop; +} +done_testing(); diff --git a/src/test/recovery/t/118_flux_minimal_undo_log.pl b/src/test/recovery/t/118_flux_minimal_undo_log.pl new file mode 100644 index 0000000000000..209c02705f0f7 --- /dev/null +++ b/src/test/recovery/t/118_flux_minimal_undo_log.pl @@ -0,0 +1,100 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# At wal_level = minimal a relation created in the same transaction skips +# WAL: it is synchronized at commit. FLUX still writes undo for it, and that +# undo must not end up in an undo log that crash recovery replays, because +# nothing in the WAL describes it. Two consequences are checked, with a heap +# control: +# +# - A later WAL-logged change whose undo lands in the same log must replay +# after a crash. +# - Once the discard worker has discarded that undo, the discard is +# WAL-logged; after a crash the log must still be consistent, the discard +# worker must answer a shutdown request, and new undo must be writable. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +for my $am (qw(heap flux)) +{ + my $node = PostgreSQL::Test::Cluster->new("minimal_$am"); + $node->init; + $node->append_conf( + 'postgresql.conf', qq[ +wal_level = minimal +max_wal_senders = 0 +wal_skip_threshold = 0 +autovacuum = off +]); + $node->start; + $node->safe_psql( + 'postgres', qq[ + CREATE TABLE old (id int PRIMARY KEY, v int) USING $am; + INSERT INTO old SELECT g, 0 FROM generate_series(1, 100) g; + CHECKPOINT; + ]); + + # One session: a transaction that skips WAL, then a WAL-logged one. + $node->safe_psql( + 'postgres', qq[ + BEGIN; + CREATE TABLE neu (id int, v int) USING $am; + INSERT INTO neu SELECT g, g FROM generate_series(1, 5000) g; + UPDATE neu SET v = v + 1; + COMMIT; + UPDATE old SET v = v + 1 WHERE id <= 50; + ]); + $node->stop('immediate'); + + my $ret = $node->start(fail_ok => 1); + ok($ret, + "$am: crash recovery replays a change logged after a transaction that skipped WAL" + ); + if (!$ret) + { + my ($panic) = slurp_file($node->logfile) =~ /(PANIC:.*)/; + diag($panic // 'no PANIC'); + next; + } + is( $node->safe_psql( + 'postgres', + 'SELECT sum(v) FROM old UNION ALL SELECT sum(v) FROM neu'), + "50\n12507500", + "$am: both transactions survive the crash"); + + # Again, but let the discard worker discard the undo before the crash. + $node->safe_psql( + 'postgres', qq[ + BEGIN; + CREATE TABLE neu2 (id int, v int) USING $am; + INSERT INTO neu2 SELECT g, g FROM generate_series(1, 5000) g; + UPDATE neu2 SET v = v + 1; + DELETE FROM neu2; + COMMIT; + ]); + ok( $node->poll_query_until( + 'postgres', q( + SELECT coalesce(bool_and(discard = insert), true) + FROM pg_stat_get_undo_logs())), + "$am: the discard worker discards the undo"); + $node->stop('immediate'); + $node->start; + is($node->safe_psql('postgres', 'SELECT count(*) FROM neu2'), + '0', "$am: committed delete of a table that skipped WAL survives"); + is( $node->safe_psql( + 'postgres', q( + SELECT count(*) FROM pg_stat_get_undo_logs() + WHERE discard > insert)), + '0', + "$am: no undo log is discarded past its insert point after recovery"); + my ($uret, $uout, $uerr) = + $node->psql('postgres', 'UPDATE old SET v = v + 1 WHERE id <= 10'); + is($uret, 0, "$am: undo is writable after recovery") or diag $uerr; + my $stopped = $node->stop('fast', fail_ok => 1); + ok($stopped, "$am: fast shutdown completes after recovery"); + $node->stop('immediate') if !$stopped; +} + +done_testing(); diff --git a/src/test/recovery/t/119_flux_standby_conflict.pl b/src/test/recovery/t/119_flux_standby_conflict.pl new file mode 100644 index 0000000000000..42471cffd4b61 --- /dev/null +++ b/src/test/recovery/t/119_flux_standby_conflict.pl @@ -0,0 +1,158 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Removing tuples on the primary must resolve recovery conflicts on a hot +# standby, as heap pruning does. A standby query whose snapshot can still see +# the removed rows has to be cancelled before the removal is replayed: it must +# never read the page with the rows gone. Replay that compacts a page also +# needs a cleanup lock, so a standby backend holding a pin on that page is +# cancelled with a buffer pin conflict. heap is the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; +use Time::HiRes qw(usleep); + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', qq[ +autovacuum = off +]); +$primary->start; +$primary->backup('bk'); + +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->append_conf( + 'postgresql.conf', qq[ +hot_standby_feedback = off +max_standby_streaming_delay = 50ms +]); +$standby->start; + +# Poll a condition for a bounded time, so a missing conflict fails fast. +sub poll_true +{ + my ($cond) = @_; + + for (my $i = 0; $i < 300; $i++) + { + return 1 if $cond->(); + usleep(100_000); + } + return 0; +} + +sub catchup +{ + # VACUUM's last record need not be flushed yet: wait for the insert LSN. + $primary->wait_for_catchup($standby, 'replay', $primary->lsn('insert')); +} + +sub conflicts +{ + my ($db, $kind) = @_; + + return $standby->safe_psql($db, + "SELECT confl_$kind FROM pg_stat_database_conflicts WHERE datname = '$db'" + ); +} + +# Open a REPEATABLE READ transaction on the standby that reads every row of +# table $tab, run $removal on the primary, then read again in the same +# transaction. It must either have been cancelled or still see every row. +sub snapshot_case +{ + my ($am, $db, $tab, $what, $removal) = @_; + + $primary->safe_psql( + $db, qq[ +CREATE TABLE $tab (id int, v int, pad char(200)) USING $am + WITH (fillfactor = 100); +INSERT INTO $tab SELECT g, g, 'x' FROM generate_series(1, 100) g; +]); + catchup(); + + my $s = $standby->background_psql($db, on_error_stop => 0); + is( $s->query_safe( + "BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*), sum(v) FROM $tab;" + ), + '100|5050', + "$am $what: standby snapshot sees every row"); + + $primary->safe_psql($db, "DELETE FROM $tab WHERE id % 2 = 0;"); + $primary->safe_psql($db, $_) for @$removal; + catchup(); + + my $out = ''; + eval { $out = $s->query("SELECT count(*), sum(v) FROM $tab;"); }; + my $err = $s->{stderr} // ''; + ok( $err =~ /conflict with recovery/ || $out eq '100|5050', + "$am $what: standby query cancelled or still sees every row (got '$out')" + ); + eval { $s->quit; }; + return $err =~ /conflict with recovery/; +} + +for my $am (qw(heap flux)) +{ + my $db = "db_$am"; + + $primary->safe_psql('postgres', "CREATE DATABASE $db"); + catchup(); + + ## (a) VACUUM removes rows a standby snapshot still sees. + snapshot_case($am, $db, 'vac', 'VACUUM', ['VACUUM vac;']); + ok(poll_true(sub { conflicts($db, 'snapshot') >= 1 }), + "$am VACUUM: snapshot conflict counted"); + + ## (b) On-access pruning: a scan of full pages with deleted rows. + snapshot_case( + $am, $db, 'prn', + 'on-access pruning', + [ 'SELECT count(*) FROM prn;', 'SELECT count(*) FROM prn;' ]); + + ## (c) Buffer pin: replay of the removal needs a cleanup lock. + # The removed row is an aborted insert older than the standby snapshot, + # so it is not a snapshot conflict. + $primary->safe_psql( + $db, qq[ +CREATE TABLE pin (a int, b int) USING $am; +INSERT INTO pin SELECT i % 3, 0 FROM generate_series(1, 20) i; +BEGIN; +INSERT INTO pin VALUES (1, 0); +ROLLBACK; +BEGIN; LOCK pin; COMMIT; +]); + catchup(); + + my $s = $standby->background_psql($db, on_error_stop => 0); + like( + $s->query_safe( + "BEGIN; DECLARE c CURSOR FOR SELECT b FROM pin; FETCH FORWARD FROM c;" + ), + qr/^0$/m, + "$am buffer pin: cursor holds a pin on the page"); + my $log_offset = -s $standby->logfile; + $primary->safe_psql($db, 'VACUUM pin;'); + catchup(); + ok( poll_true( + sub { + $standby->log_contains( + qr/User was holding shared buffer pin for too long/, + $log_offset); + }), + "$am buffer pin: conflict logged"); + ok(poll_true(sub { conflicts($db, 'bufferpin') >= 1 }), + "$am buffer pin: conflict counted"); + eval { $s->quit; }; + + is($standby->safe_psql($db, 'SELECT count(*) FROM pin'), + '20', "$am buffer pin: standby reads the table after replay"); +} + +$standby->stop; +$primary->stop; +done_testing(); diff --git a/src/test/recovery/t/120_flux_undo_remark_split.pl b/src/test/recovery/t/120_flux_undo_remark_split.pl new file mode 100644 index 0000000000000..f3ee2a58c8b52 --- /dev/null +++ b/src/test/recovery/t/120_flux_undo_remark_split.pl @@ -0,0 +1,212 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Rolling back a FLUX in-place UPDATE that revived an index tombstone, when +# the leaf has filled up in between, must delete-mark the entry again +# atomically. +# +# A row moved from key A to B and committed leaves a delete-marked (A, TID) +# entry. An UPDATE moving it back to A revives that entry: clearing the mark +# shrinks the tuple by its heap TID trailer. If the UPDATE rolls back, the +# entry is delete-marked again, which needs the trailer's room back. Here a +# concurrent transaction fills the leaf in between, so the room is gone and +# undo has to split the leaf. The marked tuple must replace the plain one in +# that split: a reader whose snapshot predates the A -> B UPDATE needs the +# tombstone, and must never find (A, TID) missing. +# +# Checked for an inline ROLLBACK, with such a reader looking while the undo +# action waits at the injection point nbtree-deletemark-no-room, and for a +# rollback after a crash, which the undo apply worker performs. Each case +# checks that the split was logged as a replace split, that the row is +# reachable under B and not A, and amcheck with heapallindexed. A final +# crash replays all of it with wal_consistency_checking. +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('flux_undo_remark_split'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +wal_consistency_checking = all +)); +$node->start; + +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} + +# fill_leaf() fills block 1 of an index on (k text) exactly, so that fewer +# than 8 bytes (one heap TID trailer) remain free and nothing splits. A key +# of L bytes takes MAXALIGN(9 + L) + 4 bytes including its line pointer, so +# the entry sizes are 8m + 4, and the last one is sized to leave 0 or 4 bytes. +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE EXTENSION amcheck; +CREATE EXTENSION pg_walinspect; +CREATE FUNCTION fill_leaf(t regclass, idx regclass) RETURNS int +LANGUAGE plpgsql AS $f$ +DECLARE gap int; s int; n int := 100000; +BEGIN + LOOP + SELECT upper - lower INTO gap FROM page_header(get_raw_page(idx::text, 1)); + EXIT WHEN gap < 8; + IF gap >= 40 THEN s := 20; + ELSIF gap % 8 = 4 THEN s := gap; + ELSE s := gap - 4; + END IF; + n := n + 1; + EXECUTE format('INSERT INTO %s VALUES ($1, $2)', t) + USING n, 'c' || lpad(n::text, s - 14, '0'); + END LOOP; + RETURN gap; +END $f$; +}); + +my $idx = 'SET enable_seqscan = off; SET enable_bitmapscan = off; ' + . 'SET enable_indexonlyscan = off; '; + +sub nblocks +{ + my ($t) = @_; + return $node->safe_psql('postgres', + "SELECT pg_relation_size('${t}_k') / 8192"); +} + +sub lsn +{ + return $node->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); +} + +sub check_rolled_back +{ + my ($t, $what) = @_; + is( $node->safe_psql( + 'postgres', $idx . "SELECT count(id) FROM $t WHERE k = 'b'"), + '1', + "$what: row reachable under its restored key"); + is( $node->safe_psql( + 'postgres', $idx . "SELECT count(id) FROM $t WHERE k = 'a'"), + '0', + "$what: row not reachable under the rolled-back key"); + my ($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_parent_check('${t}_k', true)"); + is($rc . $stderr, '0', "$what: amcheck heapallindexed"); +} + +# The rollback's undo split the leaf with a replace split record, and did not +# delete the plain entry first (a DELETE_MARK record with setmark set; the +# un-marking of (B, TID) is one with setmark clear). +sub check_replace_split +{ + my ($t, $start, $what) = @_; + is(nblocks($t), '4', "$what: undo split the leaf"); + # The rollback's records need not be flushed yet; a checkpoint does that. + $node->safe_psql('postgres', 'CHECKPOINT'); + my $out = $node->safe_psql( + 'postgres', qq{ +SELECT string_agg(record_type || ' ' || description, E'\n') + FROM pg_get_wal_records_info('$start', pg_current_wal_flush_lsn()) + WHERE resource_manager = 'Btree'}); + like( + $out, + qr/SPLIT_[LR] .*replace: T/, + "$what: the entry was replaced in the split"); + like($out, qr/DELETE_MARK .*setmark: F/, "$what: (B, TID) was un-marked"); + unlike( + $out, + qr/DELETE_MARK .*setmark: T/, + "$what: the entry was not deleted first"); +} + +# Move the row from A to B and commit, revive (A, TID) in an open transaction, +# then fill the leaf from another session. Returns the open session. +sub revive_and_fill +{ + my ($t) = @_; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, k text) USING flux; +CREATE INDEX ${t}_k ON $t (k) WITH (deduplicate_items = off); +INSERT INTO $t VALUES (1, 'a'); +}); + my $reader = $node->background_psql('postgres'); + $reader->query_safe($idx); + $reader->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + is($reader->query_safe("SELECT count(id) FROM $t WHERE k = 'a'"), + '1', "$t: old reader sees the row under A"); + $node->safe_psql('postgres', "UPDATE $t SET k = 'b' WHERE id = 1"); + + my $bg = $node->background_psql('postgres'); + $bg->query_safe("BEGIN; UPDATE $t SET k = 'a' WHERE id = 1"); + cmp_ok($node->safe_psql('postgres', "SELECT fill_leaf('$t', '${t}_k')"), + '<', 8, "$t: leaf is full"); + is(nblocks($t), '2', "$t: filling did not split the leaf"); + return ($bg, $reader); +} + +# Inline ROLLBACK. The rolling-back session waits right after re-marking +# (A, TID), while the old reader looks it up. +my ($bg, $reader) = revive_and_fill('inline_one'); +my $start = lsn(); +$bg->query_safe( + q{SELECT injection_points_set_local(); + SELECT injection_points_attach('nbtree-deletemark-no-room', 'wait')}); +$bg->query_until( + qr/start/, q{ +\echo start +ROLLBACK; +}); +$node->wait_for_event('client backend', 'nbtree-deletemark-no-room'); +is($reader->query_safe("SELECT count(id) FROM inline_one WHERE k = 'a'"), + '1', 'inline rollback: old reader sees the row under A during undo'); +$node->safe_psql('postgres', + "SELECT injection_points_wakeup('nbtree-deletemark-no-room')"); +$bg->query_safe( + "SELECT injection_points_detach('nbtree-deletemark-no-room')"); +$bg->quit; +is($reader->query_safe("SELECT count(id) FROM inline_one WHERE k = 'a'"), + '1', 'inline rollback: old reader sees the row under A after undo'); +$reader->query_safe('COMMIT'); +$reader->quit; +check_replace_split('inline_one', $start, 'inline rollback'); +check_rolled_back('inline_one', 'inline rollback'); + +# Crash with the transaction open; the undo apply worker rolls it back. +($bg, $reader) = revive_and_fill('crash_one'); +$node->safe_psql('postgres', 'CHECKPOINT'); +$start = lsn(); +$node->stop('immediate'); +$bg->{run}->kill_kill; +$reader->{run}->kill_kill; +$node->start; +ok( $node->poll_query_until( + 'postgres', "SELECT k = 'b' FROM crash_one WHERE id = 1"), + 'crash: row rolled back'); +ok( $node->poll_query_until( + 'postgres', q{SELECT count(*) = 0 FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + 'crash: undo apply worker finished'); +check_replace_split('crash_one', $start, 'crash rollback'); +check_rolled_back('crash_one', 'crash rollback'); + +# Replay both rollbacks' splits; wal_consistency_checking compares the +# replayed pages against the logged images. +$node->stop('immediate'); +$node->start; +check_rolled_back('inline_one', 'inline rollback after replay'); +check_rolled_back('crash_one', 'crash rollback after replay'); +ok(!$node->log_contains('inconsistent page'), 'no inconsistent page in redo'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/122_flux_amcheck_deletemark.pl b/src/test/recovery/t/122_flux_amcheck_deletemark.pl new file mode 100644 index 0000000000000..bdc2c0602181d --- /dev/null +++ b/src/test/recovery/t/122_flux_amcheck_deletemark.pl @@ -0,0 +1,71 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# amcheck on a FLUX table whose nbtree indexes hold delete-marked entries. +# +# A FLUX UPDATE that changes an indexed key keeps the row's TID and +# delete-marks the old (k_old, TID) entry. The tombstone is the only entry +# under which a snapshot that still sees k_old reaches the row, and next to it +# there may be a live entry with the same key for a different row. Neither +# state is corruption: +# +# - bt_index_check(heapallindexed) run while the UPDATE is uncommitted sees +# the k_old version, so it must find the tombstone's fingerprint; +# - bt_index_check(checkunique) must not count a tombstone as a live +# duplicate of a new row that took the old key. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_amcheck_deletemark'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION amcheck; +CREATE TABLE t (id int, k int) USING flux; +INSERT INTO t SELECT g, g FROM generate_series(1, 100) g; +CREATE INDEX t_k ON t (k); +CREATE TABLE u (id int, k int) USING flux; +INSERT INTO u SELECT g, g FROM generate_series(1, 100) g; +CREATE UNIQUE INDEX u_k ON u (k); +}); + +# heapallindexed while an in-place key UPDATE is in progress. +my $upd = $node->background_psql('postgres'); +$upd->query_safe('BEGIN'); +$upd->query_safe('UPDATE t SET k = k + 1000 WHERE id = 50'); + +my ($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_check('t_k', true)"); +is($rc . $stderr, '0', 'heapallindexed passes during an in-place key UPDATE'); + +$upd->query_safe('COMMIT'); +$upd->quit; + +($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_check('t_k', true)"); +is($rc . $stderr, '0', 'heapallindexed passes after the UPDATE commits'); + +# checkunique: row id=7 moves off k=7, a new row takes k=7. The index holds +# a tombstone (7, TID of id=7) next to the live (7, TID of the new row). +$node->safe_psql('postgres', 'UPDATE u SET k = 1007 WHERE id = 7'); +$node->safe_psql('postgres', 'INSERT INTO u VALUES (1000, 7)'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM u WHERE k = 7'), + '1', 'one live row has k = 7'); + +($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_check('u_k', true, true)"); +is( $rc . $stderr, + '0', + 'checkunique ignores a tombstone next to a live entry with the same key'); +($rc, $stdout, $stderr) = $node->psql('postgres', + "SELECT bt_index_parent_check('u_k', true, true, true)"); +is($rc . $stderr, + '0', 'bt_index_parent_check(rootdescend, checkunique) passes'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/123_flux_tombstone_size.pl b/src/test/recovery/t/123_flux_tombstone_size.pl new file mode 100644 index 0000000000000..03a94d80bb8d8 --- /dev/null +++ b/src/test/recovery/t/123_flux_tombstone_size.pl @@ -0,0 +1,83 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# The largest entry a delete-marking table's nbtree index accepts must still +# fit after it becomes a tombstone. Delete-marking grows a leaf entry by a +# MAXALIGN'd heap TID trailer, so an entry right at BTMaxItemSize would turn +# into a leaf tuple larger than the limit (which amcheck reports), or the +# UPDATE that delete-marks it would fail. Such a key has to be rejected when +# it is inserted, like any other key that is too long. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_tombstone_size'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +$node->safe_psql('postgres', 'CREATE EXTENSION amcheck'); + +# BTMaxItemSize, assuming MAXALIGN 8: one third of the usable page space, +# rounded down to MAXALIGN, less one MAXALIGN'd heap TID. A text key of L +# bytes (4-byte varlena header, not compressed) makes a leaf entry of +# MAXALIGN(8 + 4 + L) bytes. +my $bs = $node->safe_psql('postgres', "SELECT current_setting('block_size')"); +my $max = int(($bs - 40 - 16) / 3 / 8) * 8 - 8; +my $big = $max - 12; # entry of exactly BTMaxItemSize +my $ok = $big - 8; # entry of BTMaxItemSize - 8: its tombstone fits + +# Random hex does not compress, so the index entry keeps its full size. +$node->safe_psql( + 'postgres', q{ +CREATE FUNCTION rnd(n int) RETURNS text LANGUAGE sql AS +$$ SELECT left(string_agg(md5(random()::text), ''), n) + FROM generate_series(1, n / 32 + 1) $$; +CREATE TABLE t (id int, k text) USING flux; +CREATE INDEX t_k ON t (k); +}); + +my ($rc, $stdout, $stderr) = + $node->psql('postgres', "INSERT INTO t VALUES (1, rnd($big))"); +like( + $stderr, + qr/index row size \d+ exceeds btree version 4 maximum/, + 'a key whose tombstone would not fit is rejected at insert'); + +$node->safe_psql('postgres', "INSERT INTO t VALUES (2, rnd($ok))"); +my $ctid = $node->safe_psql('postgres', 'SELECT ctid FROM t WHERE id = 2'); + +# Change every row's key in place; each old entry becomes a tombstone. +$node->safe_psql('postgres', "UPDATE t SET k = rnd(length(k))"); +is($node->safe_psql('postgres', 'SELECT ctid FROM t WHERE id = 2'), + $ctid, 'the key UPDATE kept the TID'); + +($rc, $stdout, $stderr) = + $node->psql('postgres', "SELECT bt_index_check('t_k', true)"); +is($rc . $stderr, '0', 'bt_index_check passes after the key UPDATE'); + +# CREATE INDEX enforces the same limit. +$node->safe_psql( + 'postgres', qq{ +CREATE TABLE b (id int, k text) USING flux; +INSERT INTO b VALUES (1, rnd($big)); +}); +($rc, $stdout, $stderr) = + $node->psql('postgres', 'CREATE INDEX b_k ON b (k)'); +like( + $stderr, + qr/index row size \d+ exceeds btree version 4 maximum/, + 'CREATE INDEX rejects a key whose tombstone would not fit'); + +# A heap table keeps the full limit. +$node->safe_psql( + 'postgres', qq{ +CREATE TABLE h (id int, k text) USING heap; +CREATE INDEX h_k ON h (k); +INSERT INTO h VALUES (1, rnd($big)); +}); +pass('a heap table accepts a key of exactly BTMaxItemSize'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/125_flux_undo_meta_nonpermanent.pl b/src/test/recovery/t/125_flux_undo_meta_nonpermanent.pl new file mode 100644 index 0000000000000..027a0a08e7c24 --- /dev/null +++ b/src/test/recovery/t/125_flux_undo_meta_nonpermanent.pl @@ -0,0 +1,143 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Undo of a temporary, unlogged or WAL-skipping relation goes to an undo log +# that recovery never replays. The undo-log meta-data record +# (XLOG_UNDOLOG_META) that maps a transaction to its undo log must therefore +# only ever be written for the permanent undo log: if it named the +# non-permanent log, replay would map the transaction to that log and the +# replay of the same transaction's later permanent undo would fail. +# +# Each case runs in one transaction: a change to a permanent table (attaches +# the permanent undo log), a checkpoint from another session (so the next undo +# insert is the first after the checkpoint), a change to the non-permanent +# relation, and another change to the permanent table. Crash recovery, and +# for the temporary and unlogged cases a streaming standby, must replay that. +# Heap is the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +# Wait until $standby replayed up to $lsn or logged a PANIC. +sub standby_replays +{ + my ($standby, $lsn) = @_; + + foreach (1 .. $PostgreSQL::Test::Utils::timeout_default) + { + return 0 if slurp_file($standby->logfile) =~ /PANIC/; + my ($ret, $out) = $standby->psql('postgres', + "SELECT pg_last_wal_replay_lsn() >= '$lsn'::pg_lsn"); + return 1 if $ret == 0 && $out eq 't'; + sleep 1; + } + return 0; +} + +sub run_case +{ + my ($node, $standby, $am, $kind) = @_; + my $s = $node->background_psql('postgres'); + my $nonperm; + + if ($kind eq 'temp') + { + $s->query_safe( + "CREATE TEMP TABLE np (id int, v int) USING $am; INSERT INTO np VALUES (1, 0)" + ); + $nonperm = 'UPDATE np SET v = v + 1'; + } + elsif ($kind eq 'unlogged') + { + $node->safe_psql('postgres', + "CREATE UNLOGGED TABLE np (id int, v int) USING $am; INSERT INTO np VALUES (1, 0)" + ); + $nonperm = 'UPDATE np SET v = v + 1'; + } + else + { + $nonperm = + "CREATE TABLE np (id int, v int) USING $am; INSERT INTO np VALUES (1, 0); UPDATE np SET v = v + 1"; + } + + $s->query_safe('BEGIN'); + $s->query_safe('UPDATE perm SET v = v + 1 WHERE id = 1'); + $node->safe_psql('postgres', 'CHECKPOINT'); + $s->query_safe($nonperm); + $s->query_safe('UPDATE perm SET v = v + 1 WHERE id = 2'); + $s->query_safe('COMMIT'); + $s->quit; + + if ($standby) + { + my $ok = ok( + standby_replays($standby, $node->lsn('insert')), + "$am $kind: standby replays the transaction"); + if (!$ok) + { + my ($panic) = slurp_file($standby->logfile) =~ /(PANIC:.*)/; + diag($panic // 'no PANIC'); + } + else + { + is($standby->safe_psql('postgres', 'SELECT sum(v) FROM perm'), + '2', "$am $kind: standby sees the permanent changes"); + } + } + + $node->stop('immediate'); + my $ret = $node->start(fail_ok => 1); + ok($ret, "$am $kind: crash recovery replays the transaction"); + if (!$ret) + { + my ($panic) = slurp_file($node->logfile) =~ /(PANIC:.*)/; + diag($panic // 'no PANIC'); + return; + } + is($node->safe_psql('postgres', 'SELECT sum(v) FROM perm'), + '2', "$am $kind: permanent changes survive the crash"); +} + +for my $am (qw(heap flux)) +{ + for my $kind (qw(temp unlogged skip-WAL)) + { + my $node = PostgreSQL::Test::Cluster->new("${am}_$kind"); + my $standby; + + if ($kind eq 'skip-WAL') + { + # A permanent relation created in the same transaction skips WAL. + $node->init; + $node->append_conf( + 'postgresql.conf', qq[ +wal_level = minimal +max_wal_senders = 0 +wal_skip_threshold = 0 +]); + } + else + { + $node->init(allows_streaming => 1); + } + $node->append_conf('postgresql.conf', 'autovacuum = off'); + $node->start; + $node->safe_psql('postgres', + "CREATE TABLE perm (id int PRIMARY KEY, v int) USING $am; INSERT INTO perm SELECT g, 0 FROM generate_series(1, 10) g" + ); + if ($kind ne 'skip-WAL') + { + $node->backup('bkp'); + $standby = + PostgreSQL::Test::Cluster->new("${am}_${kind}_standby"); + $standby->init_from_backup($node, 'bkp', has_streaming => 1); + $standby->start; + } + run_case($node, $standby, $am, $kind); + $standby->stop('immediate', fail_ok => 1) if $standby; + $node->stop('immediate', fail_ok => 1); + } +} + +done_testing(); diff --git a/src/test/recovery/t/127_flux_rollback_prepared_crash.pl b/src/test/recovery/t/127_flux_rollback_prepared_crash.pl new file mode 100644 index 0000000000000..b5dad34d1096d --- /dev/null +++ b/src/test/recovery/t/127_flux_rollback_prepared_crash.pl @@ -0,0 +1,114 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A crash between ROLLBACK PREPARED's abort record and the apply of its +# per-backend UNDO must still roll the transaction back. +# +# ROLLBACK PREPARED applies a prepared transaction's per-backend UNDO in a +# 2PC postabort callback, after the abort record. Once that record is +# durable no prepared transaction is left to roll back again, so after a +# crash in between it is the per-backend UNDO recovery that must find the +# chain and roll it back. FLUX updates in place, so a missed rollback leaves +# the new values on the page. Readers would not see them (the writer did not +# commit), so the test also looks for them in the raw pages: they must be gone +# once the undo apply worker has run. +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('flux_rollback_prepared_crash'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +max_prepared_transactions = 10 +shared_preload_libraries = 'injection_points' +)); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('pageinspect')) +{ + plan skip_all => 'injection_points or pageinspect not installed'; +} +$node->safe_psql( + 'postgres', q( +CREATE EXTENSION injection_points; +CREATE EXTENSION pageinspect; +CREATE TABLE t (id int PRIMARY KEY, v text) USING flux; +INSERT INTO t SELECT g, 'origorig' || g FROM generate_series(1, 500) g; +CHECKPOINT; +BEGIN; +-- Same length, so the update stays in place. +UPDATE t SET v = 'zQnewQz_' || id; +DELETE FROM t WHERE id > 450; +PREPARE TRANSACTION 'p1'; +)); + +my $expected = $node->safe_psql('postgres', + q(SELECT count(*), sum(length(v)) FROM t WHERE v LIKE 'origorig%')); +is($expected, '500|5392', + 'prepared changes are invisible before the rollback'); + +# Pages of t that still hold an updated value. +my $dirty_pages = q( +SELECT count(*) FROM generate_series(0, pg_relation_size('t') / 8192 - 1) b +WHERE position('zQnewQz_'::bytea IN get_raw_page('t', b::int)) > 0); +isnt($node->safe_psql('postgres', $dirty_pages), + '0', 'the updated values are on the pages before the rollback'); + +# Hold ROLLBACK PREPARED just after its abort record, then crash. +$node->safe_psql('postgres', + q(SELECT injection_points_attach('rollback-prepared-after-abort-record', 'wait')) +); +my $h = $node->background_psql('postgres', on_error_stop => 0); +$h->query_until( + qr/start/, q(\echo start +ROLLBACK PREPARED 'p1'; +)); +$node->wait_for_event('client backend', + 'rollback-prepared-after-abort-record'); +$node->stop('immediate'); +$h->{run}->finish; + +$node->start; +is($node->safe_psql('postgres', 'SELECT count(*) FROM pg_prepared_xacts'), + '0', 'no prepared transaction after the crash'); + +sub check_rows +{ + my ($what) = @_; + is( $node->safe_psql( + 'postgres', + q(SELECT count(*), sum(length(v)) FROM t WHERE v LIKE 'origorig%') + ), + $expected, + "every row has its old value $what"); + is($node->safe_psql('postgres', q(SELECT count(*) FROM t)), + '500', "no row is missing $what"); + is( $node->safe_psql( + 'postgres', q( +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM t WHERE id BETWEEN 1 AND 500 AND v = 'origorig' || id;)), + '500', + "index scans agree $what"); +} +check_rows('after the restart'); + +# The undo apply worker reverts what recovery deferred. +ok($node->poll_query_until('postgres', "SELECT ($dirty_pages) = 0", 't'), + 'the rollback is applied to the pages'); +check_rows('after the undo apply worker'); + +# And after another clean restart. +$node->restart; +check_rows('after a clean restart'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/128_flux_delete_superseded_redo.pl b/src/test/recovery/t/128_flux_delete_superseded_redo.pl new file mode 100644 index 0000000000000..faf5127e27284 --- /dev/null +++ b/src/test/recovery/t/128_flux_delete_superseded_redo.pl @@ -0,0 +1,155 @@ + + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX DELETE after an aborted out-of-place UPDATE, under +# wal_consistency_checking. +# +# An out-of-place UPDATE leaves the old version DELETED | SUPERSEDED with +# t_ctid naming the new version. If it aborts and its rollback is deferred, +# that image stays on the page. A later DELETE must not act on it: it reverts +# the aborted version first (flux_help_revert), so it deletes the restored +# before-image, which carries neither SUPERSEDED nor the forward link. The +# DELETE and its redo leave SUPERSEDED clear and t_ctid at the tuple's own +# TID; flux_mask compares SUPERSEDED, and t_ctid with it. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 1); + +# No undo apply worker can start (max_worker_processes is the number of +# static workers, see 076_flux_deferred_rollback.pl), and every rollback is +# deferred to it, so the aborted UPDATE stays on the page. +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +wal_consistency_checking = all +max_logical_replication_workers = 0 +max_logical_revert_workers = 0 +max_parallel_workers = 0 +max_parallel_workers_per_gather = 0 +max_worker_processes = 3 +undo_rollback_overflow_size = 0 +)); +$node->start; +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->start; + +sub dump_tup +{ + my ($n, $rel, $label, $tid) = @_; + my ($blk, $off) = $tid =~ /\((\d+),(\d+)\)/; + my $hex = $n->safe_psql('postgres', + "SELECT encode(get_raw_page('$rel', $blk), 'hex')"); + my $raw = pack('H*', $hex); + my $lp = unpack('V', substr($raw, 24 + 4 * ($off - 1), 4)); + my $lpoff = $lp & 0x7fff; + my $flags = unpack('v', substr($raw, $lpoff + 32, 2)); + my ($bhi, $blo, $o) = unpack('vvv', substr($raw, $lpoff + 24, 6)); + my $xmax = unpack('V', substr($raw, $lpoff, 4)); + my $ctid = sprintf("(%u,%u)", ($bhi << 16) | $blo, $o); + note sprintf("%s %s: t_flags=0x%04x t_ctid=%s xmax=%u", + $label, $tid, $flags, $ctid, $xmax); + is($ctid, $tid, "$label: t_ctid is the own TID") if !($flags & 0x1000); + return $flags; +} + +# t: a deferrable unique constraint cannot be maintained in place, so a +# change of k takes the out-of-place (new-TID) path. u: the same, on a row a +# committed in-place key UPDATE left KEYS_UPDATED (the constraint is added +# afterwards), for which a keyless uniqueness fetch does not take the +# committed-version path. In both, a uniqueness check meets the aborted +# version before the DELETE. +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE TABLE t (id int PRIMARY KEY, k int UNIQUE DEFERRABLE, v int) USING flux; +INSERT INTO t SELECT g, g, g * 10 FROM generate_series(1, 10) g; +CREATE TABLE u (id int PRIMARY KEY, k int, v int) USING flux; +CREATE INDEX u_k ON u (k); +INSERT INTO u SELECT g, g, g * 10 FROM generate_series(1, 10) g; +UPDATE u SET k = 1001 WHERE id = 1; +ALTER TABLE u ADD UNIQUE (k) DEFERRABLE; +CHECKPOINT; +}); + +foreach my $tab (qw(t u)) +{ + my $old_tid = + $node->safe_psql('postgres', "SELECT ctid FROM $tab WHERE id = 1"); + + $node->safe_psql('postgres', + "BEGIN; UPDATE $tab SET k = 100 WHERE id = 1; ROLLBACK;"); + ok( dump_tup($node, $tab, "$tab after abort", $old_tid) & 0x1000, + "$tab: the aborted out-of-place UPDATE left SUPERSEDED on the page"); + + # The PK uniqueness check meets the old version: the row is live. + my ($ret, $out, $err) = + $node->psql('postgres', "INSERT INTO $tab VALUES (1, 1000, 0)"); + like($err, qr/duplicate key value/, + "$tab: row 1 is live after the abort"); + dump_tup($node, $tab, "$tab after unique check", $old_tid); + + is( $node->safe_psql( + 'postgres', "DELETE FROM $tab WHERE id = 1 RETURNING ctid"), + $old_tid, + "$tab: DELETE removes the old version"); + ok(!(dump_tup($node, $tab, "$tab after delete", $old_tid) & 0x1000), + "$tab: primary: the DELETE cleared SUPERSEDED"); + + $node->wait_for_replay_catchup($standby); + is( $standby->safe_psql( + 'postgres', "SELECT count(*) FROM $tab WHERE id = 1"), + '0', + "$tab: standby: row 1 is deleted"); + ok( !( dump_tup($standby, $tab, "$tab standby after delete", $old_tid) & + 0x1000), + "$tab: standby: the DELETE redo cleared SUPERSEDED"); +} +is( $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM pg_stat_activity + WHERE backend_type = 'undo apply worker'}), + '0', + 'no undo apply worker'); +unlike( + slurp_file($standby->logfile), + qr/inconsistent page found/, + 'standby: FLUX DELETE redo reproduces the primary page'); + +# A non-key move records compatibility on the old version. Exercise both +# the first change after a checkpoint and a later non-FPI SUPERSEDE record. +$node->safe_psql( + 'postgres', q{ +CREATE TABLE nk (id int, v int, PRIMARY KEY (id) INCLUDE (v)) USING flux; +INSERT INTO nk VALUES (1, 1), (2, 1); +CHECKPOINT; +}); +foreach my $id (1, 2) +{ + my $tid = + $node->safe_psql('postgres', "SELECT ctid FROM nk WHERE id=$id"); + $node->safe_psql('postgres', "UPDATE nk SET v=2 WHERE id=$id"); + ok(dump_tup($node, 'nk', "non-key move $id primary", $tid) & 0x2000, + "non-key move $id records key compatibility"); + $node->wait_for_replay_catchup($standby); + ok(dump_tup($standby, 'nk', "non-key move $id standby", $tid) & 0x2000, + "non-key move $id replays key compatibility"); +} +is($standby->safe_psql('postgres', 'SELECT sum(v) FROM nk'), + '4', 'standby sees both moved rows'); +unlike( + slurp_file($standby->logfile), + qr/inconsistent page found/, + 'non-key move redo reproduces the primary page'); + +$standby->stop; +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/129_undo_redo_no_fpw.pl b/src/test/recovery/t/129_undo_redo_no_fpw.pl new file mode 100644 index 0000000000000..7c04ce747de44 --- /dev/null +++ b/src/test/recovery/t/129_undo_redo_no_fpw.pl @@ -0,0 +1,102 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Undo redo must not rewrite an undo page that already holds a later change. +# +# FLUX redo re-runs the undo insert carried on each forward record, while +# XLOG_UNDO_AMEND_RECORD patches an inserted record under the page LSN. An +# indexed key that goes A -> B -> A revives the (A, TID) entry the first +# UPDATE delete-marked, and says so in the second UPDATE's undo record +# (FLUX_UNDO_INDEX_REVIVED) by such an amendment. Without full-page writes, +# an undo page written out after the amendment carries the amendment's LSN, +# and recovery from a redo pointer before the insert must neither re-insert +# the record over it nor, of course, skip the amendment and lose it. If it +# did, reverting the second UPDATE would remove (A, TID) instead of +# delete-marking it again, and reverting the first would find nothing to +# make live: the row would drop out of the index. +# +# The undo apply worker cannot start (see 076_flux_deferred_rollback.pl), so +# the crash loser is reverted by the writer that meets it ("helping"). + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +full_page_writes = off +max_logical_replication_workers = 0 +max_logical_revert_workers = 0 +max_parallel_workers = 0 +max_parallel_workers_per_gather = 0 +max_worker_processes = 3 +undo_rollback_overflow_size = 0 +)); +$node->start; + +if ( !$node->check_extension('pg_buffercache') + || !$node->check_extension('amcheck')) +{ + plan skip_all => 'pg_buffercache or amcheck not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION pg_buffercache; +CREATE EXTENSION amcheck; +CREATE TABLE f (id int PRIMARY KEY, k int, v int) USING flux; +CREATE INDEX f_k ON f (k); +INSERT INTO f SELECT g, g, g * 10 FROM generate_series(1, 10) g; +CHECKPOINT; +}); + +# The redo pointer is now before the loser's undo inserts. +my $bg = $node->background_psql('postgres'); +$bg->query_safe('BEGIN'); +$bg->query_safe('UPDATE f SET k = 100, v = 11 WHERE id = 1'); +$bg->query_safe('UPDATE f SET k = 1 WHERE id = 1'); + +# Write the undo pages out, carrying the amendment's LSN, without a +# checkpoint. The undo logs live in database 9 (UndoLogDatabaseOid). +cmp_ok( + $node->safe_psql( + 'postgres', q{ +SELECT count(*) FROM pg_buffercache b +WHERE b.reldatabase = 9 AND (pg_buffercache_evict(b.bufferid)).buffer_flushed} + ), + '>', + 0, + 'undo pages written out'); + +$node->stop('immediate'); +$bg->{run}->kill_kill; +$node->start; + +my $seq = 'SET enable_indexscan = off; SET enable_bitmapscan = off; '; +my $idx = 'SET enable_seqscan = off; SET enable_bitmapscan = off; '; + +is( $node->safe_psql( + 'postgres', + $seq . 'UPDATE f SET v = v + 1 WHERE id = 1 RETURNING k, v'), + '1|11', + 'a writer reverts the loser and updates the committed version'); +is($node->safe_psql('postgres', $idx . 'SELECT v FROM f WHERE k = 1'), + '11', 'index scan finds the row under its key'); +is( $node->safe_psql( + 'postgres', $idx . 'SELECT count(*) FROM f WHERE k = 100'), + '0', + 'no live entry under the aborted key'); +is( $node->safe_psql('postgres', $idx . 'SELECT count(*) FROM f WHERE k > 0'), + '10', + 'index scan count matches the table'); +my ($ret, $out, $err) = + $node->psql('postgres', "SELECT bt_index_check('f_k', true)"); +is($ret . $err, '0', 'bt_index_check(f_k, heapallindexed)'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/130_flux_single_user_abort.pl b/src/test/recovery/t/130_flux_single_user_abort.pl new file mode 100644 index 0000000000000..b346199bf115c --- /dev/null +++ b/src/test/recovery/t/130_flux_single_user_abort.pl @@ -0,0 +1,86 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# FLUX in single-user mode: a transaction updates rows in place, then +# replaces or drops storage the update touched, then rolls back. There is +# no undo worker, so the backend itself must finish the rollback once the +# transaction's catalog changes are undone. Then the same update applied +# again must give what it gives on heap, and amcheck must find the indexes +# intact. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if ($windows_os) +{ + plan skip_all => 'single-user mode is not tested on this platform'; +} + +my $node = PostgreSQL::Test::Cluster->new('flux_single_user_abort'); +$node->init; +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION amcheck'); +$node->stop; + +foreach my $ddl ( + 'DROP INDEX t_k', + 'REINDEX INDEX t_k', + 'ALTER TABLE t ALTER COLUMN k TYPE bigint', + 'TRUNCATE t', 'CLUSTER t USING t_k', + 'DROP TABLE t') +{ + foreach my $am ('heap', 'flux') + { + # With -j, a statement ends with a semicolon and an empty line. + my $sql = <<"SQL"; +DROP TABLE IF EXISTS t; + +CREATE TABLE t (id int PRIMARY KEY, k int) USING $am; + +CREATE INDEX t_k ON t (k); + +INSERT INTO t SELECT g, g FROM generate_series(1, 200) g; + +BEGIN; + +UPDATE t SET k = k + 1000 WHERE id <= 50; + +$ddl; + +ROLLBACK; + +UPDATE t SET k = k + 1000 WHERE id <= 50; + +SELECT 'r:' || count(*) || '|' || sum(k) AS r FROM t; + +SET enable_seqscan = off; + +SELECT 'i:' || count(*) AS i FROM t WHERE k > 1000; + +SELECT 'c:' || bt_index_check('t_k', true)::text || bt_index_check('t_pkey', true)::text AS c; + +SQL + my ($out, $err); + my $ok = IPC::Run::run( + [ + 'postgres', '--single', '-F', '-j', + '-c' => 'exit_on_error=true', + '-D' => $node->data_dir, + 'postgres' + ], + '<' => \$sql, + '>' => \$out, + '2>' => \$err); + ok($ok, "$am, $ddl: single-user session succeeds") + or diag($err); + my @got = $out =~ /"([ric]:[^"]*)"/g; + is( join(' ', @got), + 'r:200|70100 i:50 c:', + "$am, $ddl: rows, index scan and amcheck after the rollback"); + unlike($err, qr/WARNING/, "$am, $ddl: no warning"); + } +} + +done_testing(); diff --git a/src/test/recovery/t/131_flux_vacuum_rounds.pl b/src/test/recovery/t/131_flux_vacuum_rounds.pl new file mode 100644 index 0000000000000..8944defbb3481 --- /dev/null +++ b/src/test/recovery/t/131_flux_vacuum_rounds.pl @@ -0,0 +1,193 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# VACUUM with more dead TIDs than maintenance_work_mem holds. Heap's VACUUM +# stops its table scan when dead_items is full, vacuums the indexes and the +# table pages scanned so far, and scans on. FLUX must do the same: neither +# stop early nor drop TIDs, and free a line pointer only in the round whose +# index vacuum removed its index entries. Each case runs on a heap twin +# with the same expectations. +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('flux_vacuum_rounds'); +$node->init; +$node->append_conf('postgresql.conf', + "autovacuum = off\n" . "shared_preload_libraries = 'injection_points'\n"); +$node->start; +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} + +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION amcheck; +CREATE EXTENSION pageinspect; +CREATE EXTENSION injection_points; +CREATE FUNCTION release_one(p text) RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity WHERE wait_event = p) THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup(p); + EXCEPTION WHEN OTHERS THEN + NULL; + END; + RETURN false; +END $$; +}); + +# Line pointers on the table that are neither normal nor unused: what a +# VACUUM left behind for the next one. +my $nonunused = sub { + my ($t) = @_; + return $node->safe_psql( + 'postgres', qq{ +SELECT count(*) FROM generate_series(0, pg_relation_size('$t') / 8192 - 1) b, + heap_page_items(get_raw_page('$t', b::int)) WHERE lp_flags IN (2, 3)}); +}; + +foreach my $am ('heap', 'flux') +{ + my $t = "vr_$am"; + + # 60000 rows with two indexes, one row in two deleted: 30000 dead TIDs + # take several times the 64 kB minimum maintenance_work_mem. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, v int) USING $am WITH (autovacuum_enabled = off); +INSERT INTO $t SELECT g, g FROM generate_series(1, 60000) g; +CREATE INDEX ${t}_id ON $t (id); +CREATE INDEX ${t}_v ON $t (v); +DELETE FROM $t WHERE id % 2 = 0; +}); + + # VACUUM waits at each ambulkdelete: index_vacuum_count says which + # round it is in. In round 1 (the second), delete rows from the part + # of the table the first round already compacted and from the part not + # yet scanned. + $node->safe_psql('postgres', + "SELECT injection_points_attach('vacuum-bulkdel-one-index', 'wait')"); + my $vac = $node->background_psql('postgres'); + $vac->query_until(qr/start/, + "\\echo start\nSET maintenance_work_mem = 64;\nVACUUM (PARALLEL 0) $t;\n" + ); + + # Each stop is at a distinct (round, index) pair, which tells it from + # the stop just released while the wait event is still shown. + $node->wait_for_event('client backend', 'vacuum-bulkdel-one-index'); + my $stops = 0; + my $deleted_late = 0; + my $max_bytes = 0; + my $limit = 0; + while (1) + { + my $prog = $node->safe_psql( + 'postgres', qq{ +SELECT index_vacuum_count, indexes_processed, heap_blks_scanned, + heap_blks_total, dead_tuple_bytes, max_dead_tuple_bytes +FROM pg_stat_progress_vacuum WHERE relid = '$t'::regclass}); + last if $prog eq ''; + my ($ivc, $done, $scanned, $total, $bytes, $max) = split /\|/, $prog; + $max_bytes = $bytes if $bytes > $max_bytes; + $limit = $max; + + if ($ivc == 1 && !$deleted_late) + { + # Rows 1 and 59999: one in a block already vacuumed, one + # not yet scanned. Neither is recorded in this round. + $node->safe_psql('postgres', + "DELETE FROM $t WHERE id IN (1, 59999)"); + $deleted_late = 1; + cmp_ok($scanned, '<', $total, + "$am: round 1 starts before the scan reaches the end"); + } + $node->safe_psql('postgres', + "SELECT injection_points_wakeup('vacuum-bulkdel-one-index')"); + die 'too many index vacuum stops' if ++$stops > 1000; + + # Next stop, or the end of the VACUUM. + $node->poll_query_until( + 'postgres', qq{ +SELECT NOT EXISTS (SELECT FROM pg_stat_progress_vacuum + WHERE relid = '$t'::regclass) + OR EXISTS (SELECT FROM pg_stat_progress_vacuum p + JOIN pg_stat_activity a USING (pid) + WHERE p.relid = '$t'::regclass + AND a.wait_event = 'vacuum-bulkdel-one-index' + AND (p.index_vacuum_count, p.indexes_processed) + <> ($ivc, $done))}) or die 'VACUUM neither stopped nor finished'; + } + note "$am: $stops index vacuum stops, dead items up to $max_bytes bytes"; + + # A round ends once dead_items exceeds the limit, so it can overshoot + # by one page's TIDs, but not grow with the number of dead rows. + cmp_ok($max_bytes, '<=', 2 * $limit, + "$am: dead_items stays within maintenance_work_mem"); + $vac->query_safe('SELECT 1'); + $vac->quit; + $node->safe_psql('postgres', + "SELECT injection_points_detach('vacuum-bulkdel-one-index')"); + + ok($deleted_late, + "$am: VACUUM did more than one round of index vacuuming"); + + # Every row deleted before VACUUM is gone from the table and from both + # indexes. The two late deletes may leave their TIDs dead (not + # unused) for the next VACUUM, but never free a TID an index names. + cmp_ok($nonunused->($t), '<=', 2, + "$am: VACUUM freed every TID deleted before it started"); + is( $node->safe_psql( + 'postgres', qq{ +SELECT count(*), sum(id) FROM $t}), + '29998|899940000', + "$am: rows after VACUUM"); + foreach my $idx ("${t}_id", "${t}_v") + { + is( $node->safe_psql( + 'postgres', "SELECT bt_index_check('$idx', true)"), + '', + "$am: bt_index_check heapallindexed on $idx"); + } + + # Reusing the freed TIDs must not resurrect index entries. + $node->safe_psql('postgres', + "INSERT INTO $t SELECT g, -g FROM generate_series(100001, 130000) g"); + is( $node->safe_psql( + 'postgres', qq{ +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*), sum(id) FROM $t WHERE id <= 60000}), + '29998|899940000', + "$am: index scan after TID reuse sees only the live rows"); + is( $node->safe_psql( + 'postgres', qq{ +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM $t WHERE v < 0}), + '30000', + "$am: the new rows are reached through the second index"); + + # The next VACUUM frees the late deletes too. INDEX_CLEANUP ON keeps + # heap from bypassing index vacuuming for so few dead items. + $node->safe_psql('postgres', "VACUUM (INDEX_CLEANUP ON) $t"); + is($nonunused->($t), '0', "$am: a second VACUUM frees the rest"); + foreach my $idx ("${t}_id", "${t}_v") + { + is( $node->safe_psql( + 'postgres', "SELECT bt_index_check('$idx', true)"), + '', + "$am: bt_index_check heapallindexed on $idx after reuse"); + } +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/132_flux_rowlock_store.pl b/src/test/recovery/t/132_flux_rowlock_store.pl new file mode 100644 index 0000000000000..c848dd6a432d7 --- /dev/null +++ b/src/test/recovery/t/132_flux_rowlock_store.pl @@ -0,0 +1,116 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX row locks: no fixed shared memory, no fixed capacity. +# +# FLUX keeps a row lock's owner and strength out of the tuple (the page has +# only an ownerless FLUX_TUPLE_LOCKED bit). They used to live in a shared +# flat hash sized at startup: 1.3 GB at max_connections = 100 whether or not +# FLUX was ever used, and still too small for one transaction to lock 200k +# rows ("could not store row lock in sLog"). The locks now live in a DSA +# created on first use. Heap is the control for every case. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +max_connections = 20 +)); +$node->start; + +# Nothing is allocated for FLUX row locks until a FLUX row is locked. +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_shmem_allocations WHERE name ~* 'slog flat|tuple slog'" + ), + '0', + 'no fixed shared memory for FLUX row locks'); +is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_dsm_registry_allocations WHERE name ~ '^slog_tuple_lock'" + ), + '0', + 'no FLUX row-lock DSA before any FLUX row lock'); + +my $n = 1000000; +foreach my $am ('heap', 'flux') +{ + $node->safe_psql( + 'postgres', qq( + CREATE TABLE par_$am (id int PRIMARY KEY) USING $am; + INSERT INTO par_$am SELECT generate_series(1, $n); + CREATE TABLE chi_$am (pid int REFERENCES par_$am) USING $am; + )); +} + +foreach my $am ('heap', 'flux') +{ + # One transaction locks a million rows and holds them. + my $locker = $node->background_psql('postgres', on_error_stop => 0); + $locker->query_safe('BEGIN'); + my $cnt = $locker->query( + "SELECT count(*) FROM (SELECT 1 FROM par_$am FOR KEY SHARE) s"); + is($cnt, $n, "$am: one transaction locks $n rows FOR KEY SHARE"); + + # A DELETE of a locked row waits for the locker. + my ($ret, $out, $err) = $node->psql('postgres', + "SET lock_timeout = '1s'; DELETE FROM par_$am WHERE id = " + . ($n - 3)); + like( + $err, + qr/lock timeout/, + "$am: DELETE of a row locked FOR KEY SHARE waits"); + # A non-key UPDATE does not conflict with KEY SHARE. + ($ret, $out, $err) = $node->psql('postgres', + "SET lock_timeout = '1s'; SELECT 1 FROM par_$am WHERE id = 7 FOR NO KEY UPDATE" + ); + is($ret, 0, + "$am: FOR NO KEY UPDATE of a KEY SHARE locked row does not wait"); + + if ($am eq 'flux') + { + is( $node->safe_psql( + 'postgres', + "SELECT count(*) > 0 FROM pg_dsm_registry_allocations WHERE name ~ '^slog_tuple_lock'" + ), + 't', + 'flux: the row-lock DSA exists once a FLUX row is locked'); + } + + $locker->query('COMMIT'); + $locker->quit; + + # After COMMIT no lock remains. + ($ret, $out, $err) = $node->psql('postgres', + "SET lock_timeout = '1s'; DELETE FROM par_$am WHERE id = " + . ($n - 3)); + is($ret, 0, "$am: after COMMIT the row can be deleted"); + + # A bulk FK insert locks every parent row; a concurrent parent DELETE + # must wait, and no orphan may result. + my $fk = $node->background_psql('postgres', on_error_stop => 0); + $fk->query_safe('BEGIN'); + $fk->query("INSERT INTO chi_$am SELECT generate_series(1, 300000)"); + ($ret, $out, $err) = $node->psql('postgres', + "SET lock_timeout = '1s'; DELETE FROM par_$am WHERE id = 299997"); + like( + $err, + qr/lock timeout/, + "$am: parent DELETE waits for a 300k-row FK insert"); + $fk->query('COMMIT'); + $fk->quit; + is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM chi_$am c WHERE NOT EXISTS (SELECT 1 FROM par_$am p WHERE p.id = c.pid)" + ), + '0', + "$am: no orphaned child rows"); +} + +done_testing(); diff --git a/src/test/recovery/t/133_flux_insert_markers.pl b/src/test/recovery/t/133_flux_insert_markers.pl new file mode 100644 index 0000000000000..a3f9951623215 --- /dev/null +++ b/src/test/recovery/t/133_flux_insert_markers.pl @@ -0,0 +1,115 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX inserts are judged by t_xmin, as heap's are; no per-tuple marker in +# shared memory is needed. Heap is the control for every case. +# +# Redo used to register every uncommitted inserted FLUX tuple in the shared +# tuple sLog, which a standby never needed: t_xmin, CLOG and the +# known-assigned xids decide visibility there. Once that fixed table filled, +# replay logged one "SLOG_LOST_OP" WARNING per tuple (half a million for two +# 400k-row inserts). +# +# A rolled-back subtransaction's inserts, and a speculative insert, were also +# given shared markers. t_xmin is the subtransaction's xid, which CLOG +# records as aborted, and a speculative insert keeps its token in t_ctid as +# heap's does; the cases below check unique and ON CONFLICT inserts that meet +# such rows. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf('postgresql.conf', + "autovacuum = off\nmax_connections = 20"); +$primary->start; +# A slot keeps the WAL the standby still needs: the inserts below write a +# lot of it, more still under wal_consistency_checking. +$primary->safe_psql('postgres', + "SELECT pg_create_physical_replication_slot('standby')"); +$primary->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bkp', has_streaming => 1); +$standby->append_conf('postgresql.conf', "primary_slot_name = 'standby'"); +$standby->start; + +my $n = 400000; +foreach my $am ('heap', 'flux') +{ + $primary->safe_psql('postgres', + "CREATE TABLE t_$am (id int) USING $am; INSERT INTO t_$am VALUES (0);" + ); + + # Two uncommitted inserts: one will commit, one will roll back. + my $a = $primary->background_psql('postgres'); + my $b = $primary->background_psql('postgres'); + $a->query_safe('BEGIN'); + $a->query_safe("INSERT INTO t_$am SELECT generate_series(1, $n)"); + $b->query_safe('BEGIN'); + $b->query_safe("INSERT INTO t_$am SELECT generate_series(1, $n)"); + $primary->wait_for_replay_catchup($standby); + is($standby->safe_psql('postgres', "SELECT count(*) FROM t_$am"), + '1', "$am: standby does not see uncommitted inserts"); + + $a->query_safe('COMMIT'); + $b->query_safe('ROLLBACK'); + $a->quit; + $b->quit; + $primary->wait_for_replay_catchup($standby); + is($standby->safe_psql('postgres', "SELECT count(*) FROM t_$am"), + $n + 1, "$am: standby sees the committed insert only"); +} + +# Unique checks against rows of an open transaction's rolled-back +# subtransaction, and against an in-progress speculative insert. +foreach my $am ('heap', 'flux') +{ + $primary->safe_psql('postgres', + "CREATE TABLE u_$am (k int PRIMARY KEY) USING $am"); + my $a = $primary->background_psql('postgres', on_error_stop => 0); + my $try = sub { + my ($sql) = @_; + my ($ret, $out, $err) = + $primary->psql('postgres', "SET lock_timeout = '1s'; $sql"); + return $err =~ /lock timeout/ ? 'waits' : $ret == 0 ? 'ok' : $err; + }; + + $a->query_safe('BEGIN'); + $a->query_safe('SAVEPOINT s; INSERT INTO u_' + . $am + . ' VALUES (1), (2); ROLLBACK TO s'); + is($a->query_safe("SELECT count(*) FROM u_$am"), + '0', "$am: rows of a rolled-back savepoint are gone"); + is($try->("INSERT INTO u_$am VALUES (1)"), + 'ok', "$am: unique insert does not wait for a rolled-back savepoint"); + $a->query_safe('SAVEPOINT s; INSERT INTO u_' + . $am + . ' VALUES (4) ON CONFLICT DO NOTHING; ROLLBACK TO s'); + is($try->("INSERT INTO u_$am VALUES (4) ON CONFLICT DO NOTHING"), 'ok', + "$am: ON CONFLICT does not wait for a rolled-back speculative insert" + ); + $a->query_safe("INSERT INTO u_$am VALUES (3) ON CONFLICT DO NOTHING"); + is($try->("INSERT INTO u_$am VALUES (3) ON CONFLICT DO NOTHING"), + 'waits', + "$am: ON CONFLICT waits for an in-progress speculative insert"); + $a->query_safe("INSERT INTO u_$am VALUES (2) ON CONFLICT DO NOTHING"); + $a->query_safe('ROLLBACK'); + is($try->("INSERT INTO u_$am VALUES (3) ON CONFLICT DO NOTHING"), + 'ok', + "$am: ON CONFLICT inserts after the speculative inserter aborts"); + is( $primary->safe_psql( + 'postgres', + "SELECT string_agg(k::text, ',' ORDER BY k) FROM u_$am"), + '1,3,4', + "$am: final contents"); + $a->quit; +} + +my $log = slurp_file($standby->logfile); +my $warnings = () = $log =~ /WARNING/g; +is($warnings, 0, 'standby logged no WARNING during replay'); + +done_testing(); diff --git a/src/test/recovery/t/135_flux_rowlock_filter.pl b/src/test/recovery/t/135_flux_rowlock_filter.pl new file mode 100644 index 0000000000000..7b598e98f7dcf --- /dev/null +++ b/src/test/recovery/t/135_flux_rowlock_filter.pl @@ -0,0 +1,528 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Row-lock conflict probes must never miss a lock, whatever lets them skip +# holders. +# +# A conflict probe skips the holders whose filters say they hold no lock in +# the probed tuple's block. The filters are hashed and saturate, so they +# may say "maybe" wrongly but must never say "no" for a lock that exists. +# One session locks a row on each of many more blocks than the filter has +# bits, in two relations, so that the probes below hit every collision; a +# second session must then find each locked row locked and each other row +# free. Then the same through a rolled-back savepoint, a prepared +# transaction, and a restart with the prepared transaction still there. +# +# A holder keeps its first few locked rows in records of its own slot, read +# without any lock, and the rest in trees; a row's locks are in exactly one +# of the two places. So the probes also run on transactions that lock +# 1, 8 and 9 rows (the records hold 8), with a savepoint level stacked on +# one of them, prepared and restored, after the holder's earlier +# transactions (whose emptied tables it may keep), and under concurrent FK +# inserts and cascading parent deletes, which must leave no orphan. +# +# Past the records a slot keeps a relation's locks in a hash table of its +# own that grows: so also many holders of the same rows and of rows on +# different blocks, released one at a time; a transaction that locks 1600 +# rows, with a savepoint that upgrades 500 of them and rolls back, prepared +# and restored; a savepoint across the boundary between records and table; +# a relation truncated while the transaction holds locks in it and in +# another; and FK inserts of 25 rows per transaction. +# +# Heap is the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +max_prepared_transactions = 2 +)); +$node->start; + +# 600 blocks per relation at 20 rows per block (fillfactor 10). +my $nblocks = 600; + +# Rows locked: offset 1 of every even block. Not locked: offset 1 of every +# odd block, and offset 2 of every even block (same block as a lock). +sub probe_counts +{ + my ($am) = @_; + my %got; + foreach my $rel ("a_$am", "b_$am") + { + my ($stdout, $stderr); + $node->psql( + 'postgres', qq{ +SET client_min_messages = notice; +DO \$\$ +DECLARE + r record; locked int := 0; free_odd int := 0; free_even int := 0; +BEGIN + FOR r IN SELECT ctid, (ctid::text::point)[0]::int AS blk, + (ctid::text::point)[1]::int AS off + FROM $rel WHERE (ctid::text::point)[1] IN (1, 2) LOOP + BEGIN + PERFORM 1 FROM $rel WHERE ctid = r.ctid FOR UPDATE NOWAIT; + IF r.blk % 2 = 1 THEN free_odd := free_odd + 1; + ELSIF r.off = 2 THEN free_even := free_even + 1; + END IF; + EXCEPTION WHEN lock_not_available THEN + IF r.blk % 2 = 0 AND r.off = 1 THEN locked := locked + 1; END IF; + END; + END LOOP; + RAISE NOTICE 'counts % % %', locked, free_odd, free_even; +END \$\$; +}, + stdout => \$stdout, + stderr => \$stderr); + $stderr =~ /counts (\d+) (\d+) (\d+)/ + or die "no counts for $rel: $stderr"; + $got{$rel} = "$1 $2 $3"; + } + return \%got; +} + +my $half = $nblocks / 2; +foreach my $am ('heap', 'flux') +{ + foreach my $rel ("a_$am", "b_$am") + { + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $rel (id int PRIMARY KEY, pad text) USING $am WITH (fillfactor = 10); +INSERT INTO $rel SELECT g, repeat('x', 300) FROM generate_series(1, $nblocks * 20) g; +}); + } + my $nblk = $node->safe_psql('postgres', + "SELECT count(DISTINCT (ctid::text::point)[0]) FROM a_$am"); + cmp_ok($nblk, '>=', 300, + "$am: relation spans many more blocks than the filter has bits"); + my $even = $node->safe_psql('postgres', + "SELECT count(DISTINCT (ctid::text::point)[0]) FROM a_$am WHERE (ctid::text::point)[0]::int % 2 = 0" + ); + # offsets 1 and 2 of each odd block are free + my $odd = 2 * ($nblk - $even); + my $lock_sql = join( + '', + map { + "SELECT count(*) FROM (SELECT 1 FROM $_ WHERE (ctid::text::point)[1] = 1 " + . "AND (ctid::text::point)[0]::int % 2 = 0 FOR KEY SHARE) s;" + } ("a_$am", "b_$am")); + + # Plain transaction. + my $h = $node->background_psql('postgres'); + $h->query_safe('BEGIN'); + $h->query_safe($lock_sql); + my $c = probe_counts($am, 'plain'); + is( $c->{"a_$am"}, + "$even $odd $even", + "$am a: every lock found, no false lock (plain)"); + is( $c->{"b_$am"}, + "$even $odd $even", + "$am b: every lock found, no false lock (plain)"); + $h->query_safe('COMMIT'); + $c = probe_counts($am, 'after commit'); + is($c->{"a_$am"}, "0 $odd $even", "$am a: locks gone after commit"); + + # Locks taken in a rolled-back savepoint are dead; outer ones stay. + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM a_$am WHERE (ctid::text::point)[1] = 1 AND (ctid::text::point)[0]::int % 2 = 0 FOR KEY SHARE) s" + ); + $h->query_safe('SAVEPOINT s'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM b_$am WHERE (ctid::text::point)[1] = 1 AND (ctid::text::point)[0]::int % 2 = 0 FOR UPDATE) s" + ); + $h->query_safe('ROLLBACK TO s'); + $c = probe_counts($am, 'savepoint'); + is( $c->{"a_$am"}, + "$even $odd $even", + "$am a: outer locks kept after ROLLBACK TO"); + is($c->{"b_$am"}, "0 $odd $even", + "$am b: savepoint locks dead after ROLLBACK TO"); + $h->query_safe('ROLLBACK'); + + # Prepared, and across a restart. + $h->query_safe('BEGIN'); + $h->query_safe($lock_sql); + $h->query_safe("PREPARE TRANSACTION 'p_$am'"); + $h->quit; + $c = probe_counts($am, 'prepared'); + is($c->{"a_$am"}, "$even $odd $even", "$am a: prepared locks found"); + is($c->{"b_$am"}, "$even $odd $even", "$am b: prepared locks found"); + $node->restart; + $c = probe_counts($am, 'prepared after restart'); + is( $c->{"a_$am"}, + "$even $odd $even", + "$am a: prepared locks found after restart"); + is( $c->{"b_$am"}, + "$even $odd $even", + "$am b: prepared locks found after restart"); + $node->safe_psql('postgres', "COMMIT PREPARED 'p_$am'"); + $c = probe_counts($am, 'after commit prepared'); + is($c->{"b_$am"}, "0 $odd $even", + "$am b: locks gone after COMMIT PREPARED"); +} + +# Few rows: the first SLOG_TL_NINLINE (8) TIDs of a transaction are kept +# inline, the rest in trees. Lock k rows of c_$am, then probe all of them. +sub probe_rows +{ + my ($am, $lo, $hi, $mode, $rel) = @_; + my ($stdout, $stderr); + $rel //= "c_$am"; + $node->psql( + 'postgres', qq{ +SET client_min_messages = notice; +DO \$\$ +DECLARE i int; blocked int := 0; free int := 0; +BEGIN + FOR i IN $lo..$hi LOOP + BEGIN + PERFORM 1 FROM $rel WHERE id = i FOR $mode NOWAIT; + free := free + 1; + EXCEPTION WHEN lock_not_available THEN blocked := blocked + 1; + END; + END LOOP; + RAISE NOTICE 'rows % %', blocked, free; +END \$\$; +}, + stdout => \$stdout, + stderr => \$stderr); + $stderr =~ /rows (\d+) (\d+)/ or die "no rows for $rel: $stderr"; + return "$1 $2"; +} + +foreach my $am ('heap', 'flux') +{ + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE c_$am (id int PRIMARY KEY, v int) USING $am; +INSERT INTO c_$am SELECT g, g FROM generate_series(1, 40) g; +}); + my $h = $node->background_psql('postgres'); + foreach my $k (1, 8, 9, 12) + { + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id <= $k ORDER BY id FOR KEY SHARE) s" + ); + is( probe_rows($am, 1, 20, 'UPDATE'), + "$k " . (20 - $k), + "$am: $k rows locked, all found, no false lock"); + is(probe_rows($am, 1, 20, 'NO KEY UPDATE'), + "0 20", "$am: $k KEY SHARE rows do not block NO KEY UPDATE"); + $h->query_safe('COMMIT'); + is(probe_rows($am, 1, 20, 'UPDATE'), + "0 20", "$am: $k rows free after commit"); + } + + # A savepoint level on an inline row and on a row in a tree: after + # ROLLBACK TO only the outer KEY SHARE is left. + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id <= 10 ORDER BY id FOR KEY SHARE) s" + ); + $h->query_safe('SAVEPOINT s'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id IN (2, 10) FOR UPDATE) s" + ); + is(probe_rows($am, 1, 10, 'NO KEY UPDATE'), + "2 8", + "$am: savepoint FOR UPDATE found on an inline row and a tree row"); + $h->query_safe('ROLLBACK TO s'); + is(probe_rows($am, 1, 10, 'NO KEY UPDATE'), + "0 10", "$am: savepoint levels dead after ROLLBACK TO"); + is(probe_rows($am, 1, 10, 'UPDATE'), + "10 0", "$am: outer KEY SHARE kept after ROLLBACK TO"); + $h->query_safe('ROLLBACK'); + + # Inline-only and mixed prepared transactions, across a restart. + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id <= 3 FOR UPDATE) s" + ); + $h->query_safe("PREPARE TRANSACTION 'q1_$am'"); + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id BETWEEN 11 AND 30 FOR KEY SHARE) s" + ); + $h->query_safe("PREPARE TRANSACTION 'q2_$am'"); + $h->quit; + is(probe_rows($am, 1, 40, 'UPDATE'), + "23 17", "$am: prepared locks found (inline only, and mixed)"); + $node->restart; + is(probe_rows($am, 1, 40, 'UPDATE'), + "23 17", "$am: prepared locks found after restart"); + is(probe_rows($am, 11, 30, 'NO KEY UPDATE'), + "0 20", "$am: restored KEY SHARE keeps its strength"); + $node->safe_psql('postgres', "COMMIT PREPARED 'q1_$am'"); + is(probe_rows($am, 1, 40, 'UPDATE'), + "20 20", "$am: one prepared transaction's locks gone"); + $node->safe_psql('postgres', "ROLLBACK PREPARED 'q2_$am'"); + is(probe_rows($am, 1, 40, 'UPDATE'), + "0 40", "$am: all prepared locks gone"); +} + +# A backend may keep its emptied trees for its next transactions; a tree +# that went to a prepared transaction must not be reused. One session locks +# 10 rows (two of them in a tree) and commits, so that it may keep that tree; +# its next transaction locks 10 rows, the last two in a tree again, and +# prepares; its third locks 10 other rows and commits. The prepared rows +# must stay locked throughout and until ROLLBACK PREPARED. +foreach my $am ('heap', 'flux') +{ + my $h = $node->background_psql('postgres'); + my $lock = sub { + my ($lo, $hi) = @_; + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id BETWEEN $lo AND $hi ORDER BY id FOR UPDATE) s" + ); + }; + $lock->(1, 10); + $h->query_safe('COMMIT'); + $lock->(11, 20); + $h->query_safe("PREPARE TRANSACTION 'r_$am'"); + is(probe_rows($am, 11, 20, 'UPDATE'), + "10 0", "$am: prepared rows locked"); + $lock->(21, 30); + is(probe_rows($am, 11, 30, 'UPDATE'), + "20 0", "$am: prepared rows and the next transaction's rows locked"); + $h->query_safe('COMMIT'); + is(probe_rows($am, 11, 30, 'UPDATE'), + "10 10", "$am: prepared rows still locked after the next commit"); + $lock->(31, 40); + $h->query_safe('ROLLBACK'); + is(probe_rows($am, 11, 40, 'UPDATE'), + "10 20", "$am: prepared rows still locked after a rollback"); + $h->quit; + $node->safe_psql('postgres', "ROLLBACK PREPARED 'r_$am'"); + is(probe_rows($am, 1, 40, 'UPDATE'), "0 40", "$am: all free at the end"); +} + +# Many holders. Five sessions lock the same 20 rows (8 in records, 12 in a +# table each); they stay locked until the last one commits. Then five +# sessions lock 12 rows each on blocks of their own; committing one frees +# exactly its rows. +foreach my $am ('heap', 'flux') +{ + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE d_$am (id int PRIMARY KEY, v int) USING $am; +INSERT INTO d_$am SELECT g, g FROM generate_series(1, 3000) g; +}); + my $nblk = $node->safe_psql('postgres', + "SELECT count(DISTINCT (ctid::text::point)[0]) FROM d_$am WHERE id IN (1, 501, 1001, 1501, 2001)" + ); + is($nblk, '5', "$am: the five ranges are on five blocks"); + + my @h = map { $node->background_psql('postgres') } 1 .. 5; + foreach my $s (@h) + { + $s->query_safe('BEGIN'); + $s->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM d_$am WHERE id <= 20 ORDER BY id FOR KEY SHARE) s" + ); + } + is(probe_rows($am, 1, 20, 'UPDATE', "d_$am"), + "20 0", "$am: five holders of the same rows: all found"); + is(probe_rows($am, 1, 20, 'NO KEY UPDATE', "d_$am"), + "0 20", "$am: five KEY SHARE holders do not block NO KEY UPDATE"); + foreach my $k (0 .. 3) + { + $h[$k]->query_safe('COMMIT'); + is( probe_rows($am, 1, 20, 'UPDATE', "d_$am"), + "20 0", + "$am: rows still locked after " + . ($k + 1) + . " of 5 holders commit"); + } + $h[4]->query_safe('COMMIT'); + is(probe_rows($am, 1, 20, 'UPDATE', "d_$am"), + "0 20", "$am: rows free after the last holder commits"); + + foreach my $k (0 .. 4) + { + my $lo = $k * 500 + 1; + my $hi = $lo + 11; + $h[$k]->query_safe('BEGIN'); + $h[$k]->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM d_$am WHERE id BETWEEN $lo AND $hi ORDER BY id FOR UPDATE) s" + ); + } + $h[2]->query_safe('COMMIT'); + my @got = + map { probe_rows($am, $_ * 500 + 1, $_ * 500 + 12, 'UPDATE', "d_$am") } + 0 .. 4; + is( "@got", + "12 0 12 0 0 12 12 0 12 0", + "$am: holders on different blocks: one commit frees exactly its rows" + ); + $h[$_]->query_safe('ROLLBACK') foreach (0, 1, 3, 4); + $_->quit foreach @h; + is(probe_rows($am, 1, 2012, 'UPDATE', "d_$am"), + "0 2012", "$am: all free after the holders end"); +} + +# More than a thousand rows, a savepoint that upgrades some and rolls back, +# then more rows, prepared and restored across a restart. +foreach my $am ('heap', 'flux') +{ + my $h = $node->background_psql('postgres'); + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM d_$am WHERE id <= 1500 FOR KEY SHARE) s" + ); + is(probe_rows($am, 1, 1500, 'UPDATE', "d_$am"), + "1500 0", "$am: 1500 rows locked, all found"); + is(probe_rows($am, 1, 1500, 'NO KEY UPDATE', "d_$am"), + "0 1500", "$am: 1500 KEY SHARE rows do not block NO KEY UPDATE"); + $h->query_safe('SAVEPOINT s'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM d_$am WHERE id > 1000 AND id <= 1500 FOR UPDATE) s" + ); + is(probe_rows($am, 1, 1500, 'NO KEY UPDATE', "d_$am"), + "500 1000", "$am: 500 rows upgraded in a savepoint"); + $h->query_safe('ROLLBACK TO s'); + is(probe_rows($am, 1, 1500, 'NO KEY UPDATE', "d_$am"), + "0 1500", "$am: upgrades dead after ROLLBACK TO"); + is(probe_rows($am, 1, 1500, 'UPDATE', "d_$am"), + "1500 0", "$am: 1500 outer locks kept after ROLLBACK TO"); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM d_$am WHERE id BETWEEN 1501 AND 1600 FOR KEY SHARE) s" + ); + $h->query_safe("PREPARE TRANSACTION 'big_$am'"); + $h->quit; + is(probe_rows($am, 1, 1700, 'UPDATE', "d_$am"), + "1600 100", "$am: 1600 prepared locks found"); + $node->restart; + is(probe_rows($am, 1, 1700, 'UPDATE', "d_$am"), + "1600 100", "$am: 1600 prepared locks found after restart"); + is(probe_rows($am, 1, 1700, 'NO KEY UPDATE', "d_$am"), + "0 1700", "$am: 1600 restored locks keep their strength"); + $node->safe_psql('postgres', "COMMIT PREPARED 'big_$am'"); + is(probe_rows($am, 1, 1700, 'UPDATE', "d_$am"), + "0 1700", "$am: 1600 locks gone after COMMIT PREPARED"); +} + +# A savepoint across the boundary between records and table: 6 rows, then +# in a savepoint 6 more (2 in records, 4 in the table) and an upgrade of an +# outer one; after ROLLBACK TO, the same rows locked again. +foreach my $am ('heap', 'flux') +{ + my $h = $node->background_psql('postgres'); + $h->query_safe('BEGIN'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id <= 6 ORDER BY id FOR KEY SHARE) s" + ); + $h->query_safe('SAVEPOINT s'); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id BETWEEN 7 AND 12 OR id = 3 ORDER BY id FOR UPDATE) s" + ); + is(probe_rows($am, 1, 12, 'UPDATE'), + "12 0", "$am: savepoint across the boundary: all 12 locked"); + is(probe_rows($am, 1, 12, 'NO KEY UPDATE'), + "7 5", "$am: savepoint across the boundary: its 7 FOR UPDATE found"); + $h->query_safe('ROLLBACK TO s'); + is(probe_rows($am, 1, 12, 'NO KEY UPDATE'), + "0 12", "$am: savepoint across the boundary: dead after ROLLBACK TO"); + is(probe_rows($am, 1, 12, 'UPDATE'), + "6 6", "$am: savepoint across the boundary: outer 6 kept"); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id BETWEEN 7 AND 12 ORDER BY id FOR KEY SHARE) s" + ); + is(probe_rows($am, 1, 12, 'UPDATE'), + "12 0", "$am: rows locked again after ROLLBACK TO"); + $h->query_safe('COMMIT'); + is(probe_rows($am, 1, 12, 'UPDATE'), "0 12", + "$am: all free after commit"); + $h->quit; +} + +# A relation truncated while the transaction holds locks in it (records and +# table) and in another (records and table): the other's locks stay, before +# and after PREPARE and a restart. +foreach my $am ('heap', 'flux') +{ + my $h = $node->background_psql('postgres'); + $h->query_safe('BEGIN'); + $h->query_safe( + "CREATE TABLE f_$am (id int PRIMARY KEY) USING $am; INSERT INTO f_$am SELECT generate_series(1, 40)" + ); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id <= 4 ORDER BY id FOR UPDATE) s" + ); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM f_$am WHERE id <= 20 ORDER BY id FOR UPDATE) s" + ); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM c_$am WHERE id BETWEEN 5 AND 20 ORDER BY id FOR UPDATE) s" + ); + $h->query_safe("TRUNCATE f_$am"); + $h->query_safe("INSERT INTO f_$am SELECT generate_series(1, 40)"); + $h->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM f_$am WHERE id <= 10 ORDER BY id FOR KEY SHARE) s" + ); + is(probe_rows($am, 1, 40, 'UPDATE'), + "20 20", "$am: locks in another relation kept across TRUNCATE"); + $h->query_safe("PREPARE TRANSACTION 'tr_$am'"); + $h->quit; + is(probe_rows($am, 1, 40, 'UPDATE'), "20 20", "$am: they are prepared"); + $node->restart; + is(probe_rows($am, 1, 40, 'UPDATE'), "20 20", "$am: they are restored"); + $node->safe_psql('postgres', "COMMIT PREPARED 'tr_$am'"); + is(probe_rows($am, 1, 40, 'UPDATE'), "0 40", "$am: all free at the end"); + is(probe_rows($am, 1, 40, 'UPDATE', "f_$am"), + "0 40", "$am: truncated relation's rows free at the end"); +} + + +# Concurrency: FK inserts take KEY SHARE on the parent while other sessions +# delete parents, cascading. A parent DELETE that missed a KEY SHARE lock +# would let the child commit without its parent. +foreach my $am ('heap', 'flux') +{ + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE p_$am (id int PRIMARY KEY) USING $am; +INSERT INTO p_$am SELECT generate_series(1, 3000); +CREATE TABLE k_$am (pid int REFERENCES p_$am ON DELETE CASCADE) USING $am; +CREATE FUNCTION ins_$am(int) RETURNS void LANGUAGE plpgsql AS \$\$ +BEGIN INSERT INTO k_$am VALUES (\$1); +EXCEPTION WHEN foreign_key_violation THEN NULL; END \$\$; +}); + $node->pgbench( + '--no-vacuum --client=8 --jobs=8 --transactions=1500', + 0, + [qr{processed: 12000/12000}], + [qr{^$}], + "$am: concurrent FK inserts and cascading parent deletes", + { + "135_ins_$am" => + "\\set id random(1, 3000)\nSELECT ins_$am(:id);\n", + "135_del_$am\@1" => + "\\set id random(1, 3000)\nDELETE FROM p_$am WHERE id = :id;\n", + "135_ins2_$am\@3" => + "\\set id random(1, 3000)\nBEGIN;\nSELECT ins_$am(:id);\nSELECT ins_$am(:id + 1);\nSELECT ins_$am(:id + 2);\nEND;\n", + "135_insm_$am\@2" => + "\\set id random(1, 2976)\nSELECT count(ins_$am(g)) FROM generate_series(:id, :id + 24) g;\n" + }); + is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM k_$am k WHERE NOT EXISTS (SELECT 1 FROM p_$am p WHERE p.id = k.pid)" + ), + '0', + "$am: no orphaned child row"); + cmp_ok($node->safe_psql('postgres', "SELECT count(*) FROM p_$am"), + '<', 3000, "$am: some parents were deleted"); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/136_flux_rowlock_oom.pl b/src/test/recovery/t/136_flux_rowlock_oom.pl new file mode 100644 index 0000000000000..f5df19b4cad39 --- /dev/null +++ b/src/test/recovery/t/136_flux_rowlock_oom.pl @@ -0,0 +1,318 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Running the row-lock store out of shared memory fails the statement before +# anything changes, and the cluster carries on. +# +# A FLUX row lock records its holder in the tuple sLog under the tuple's +# buffer lock before it marks the page. An injection point fails the +# allocation of the shared tree that a transaction's further locks go to: +# the failing lock must leave no FLUX_TUPLE_LOCKED bit and no WAL behind, the +# locks the transaction already held must be released by the abort, and a +# later transaction must lock the same rows normally. The first few locks of +# a transaction need no allocation, so they succeed with the injection point +# attached; how many is not part of the contract, so the test finds the first +# failing row rather than assuming it. +# +# Then the store's allocations themselves fail, part way through a lock that +# needs two (a bigger relation array, then a table), and part way through a +# transaction whose table must grow, at a new key and at a stronger lock on +# a key it holds. A savepoint catches each failure; the transaction's +# earlier locks must all still be found, the failed ones must not, and the +# transaction must go on locking, then end with nothing left. Repeated +# failures must not use up shared memory. (Heap has no such store: these +# parts have no heap control.) +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('main'); +$node->init; +$node->append_conf('postgresql.conf', "autovacuum = off\n"); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('pageinspect') + || !$node->check_extension('pg_walinspect')) +{ + plan skip_all => + 'injection_points, pageinspect or pg_walinspect not installed'; +} +$node->safe_psql('postgres', + 'CREATE EXTENSION injection_points; CREATE EXTENSION pageinspect; CREATE EXTENSION pg_walinspect;' +); +$node->safe_psql( + 'postgres', q{ +CREATE TABLE t (id int PRIMARY KEY, v int) USING flux; +INSERT INTO t SELECT g, g FROM generate_series(1, 40) g; +CHECKPOINT; +}); + +# FLUX_TUPLE_LOCKED is 0x0010 of t_flags, whose low byte is at byte 32 of +# the tuple. +my $locked_expr = "get_byte(get_raw_page('t', 0), lp_off + 32) & 16 <> 0"; + +# Number of tuples on block 0 marked locked +sub locked_bits +{ + return $node->safe_psql('postgres', + "SELECT count(*) FROM heap_page_items(get_raw_page('t', 0)) WHERE lp_flags = 1 AND $locked_expr" + ); +} +my $bits_before = locked_bits(); + +# (a record written now is the start of the range read below) +$node->safe_psql('postgres', 'SELECT pg_switch_wal(); CHECKPOINT;'); +my $lsn_before = + $node->safe_psql('postgres', "SELECT pg_current_wal_insert_lsn()"); + +# Lock rows one by one in one transaction until a lock fails. +my $h = $node->background_psql('postgres', on_error_stop => 0); +$h->query_safe( + "SELECT injection_points_attach('slog-tuple-lock-oom', 'notice')"); +$h->query_safe('BEGIN'); +my $ok = 0; +my $err = ''; +foreach my $i (1 .. 40) +{ + my ($out, $failed) = + $h->query("SELECT id FROM t WHERE id = $i FOR UPDATE"); + if ($failed && $h->{stderr} =~ /out of memory/) + { + $err = $h->{stderr}; + last; + } + $h->{stderr} = ''; + $ok++; +} +like( + $err, + qr/out of memory/, + 'a row lock fails once the store is out of memory'); +note "$ok row locks succeeded before the failure"; +$h->{stderr} = ''; +$h->query_safe('ROLLBACK'); +$h->query_safe("SELECT injection_points_detach('slog-tuple-lock-oom')"); + +# The failed lock changed nothing on the page: the only LOCKED bits are those +# of the locks that succeeded (they stay until the page is next written). +my $failed_id = $ok + 1; +is( $node->safe_psql( + 'postgres', + "SELECT $locked_expr FROM heap_page_items(get_raw_page('t', 0)) WHERE lp = (SELECT (ctid::text::point)[1]::int FROM t WHERE id = $failed_id)" + ), + 'f', + 'the row whose lock failed was not marked locked'); +cmp_ok( + locked_bits(), '<=', + $bits_before + $ok, + 'no row beyond those locked was marked'); + +# No lock record was logged for the failed row. (The transaction rolled +# back, and an abort does not flush WAL: flush it before reading it.) +$node->safe_psql('postgres', 'CHECKPOINT'); +my $nlock = $node->safe_psql( + 'postgres', qq{ +SELECT count(*) FROM pg_get_wal_records_info('$lsn_before', pg_current_wal_flush_lsn()) +WHERE resource_manager = 'FLUX' AND record_type = 'LOCK'}); +is($nlock, $ok, + 'one lock record per successful lock, none for the failed one'); + +# The aborted transaction holds nothing; a new one locks every row. +my ($ret, $out, $stderr) = $node->psql('postgres', + "SET lock_timeout = '1s'; BEGIN; SELECT count(*) FROM (SELECT 1 FROM t FOR UPDATE) s; COMMIT;" +); +is($ret, 0, 'after the failure every row can be locked again'); +like($out, qr/^40$/m, 'all 40 rows locked by a later transaction'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM t'), + '40', 'table intact'); + +$h->quit; + +# Count rows lo..hi of rel that another session cannot lock in mode. +sub blocked +{ + my ($rel, $lo, $hi, $mode) = @_; + my ($stdout, $stderr); + $node->psql( + 'postgres', qq{ +SET client_min_messages = notice; +DO \$\$ +DECLARE i int; n int := 0; +BEGIN + FOR i IN $lo..$hi LOOP + BEGIN + PERFORM 1 FROM $rel WHERE id = i FOR $mode NOWAIT; + EXCEPTION WHEN lock_not_available THEN n := n + 1; + END; + END LOOP; + RAISE NOTICE 'blocked %', n; +END \$\$; +}, + stdout => \$stdout, + stderr => \$stderr); + $stderr =~ /blocked (\d+)/ or die "no count for $rel: $stderr"; + return $1; +} + +sub dsa_size +{ + return $node->safe_psql('postgres', + "SELECT coalesce(sum(size), 0) FROM pg_dsm_registry_allocations WHERE name = 'slog_tuple_lock_tables'" + ); +} + +$node->safe_psql( + 'postgres', q{ +CREATE TABLE u (id int PRIMARY KEY, v int) USING flux; +INSERT INTO u SELECT g, g FROM generate_series(1, 300) g; +CREATE TABLE w1 (id int PRIMARY KEY) USING flux; +CREATE TABLE w2 (id int PRIMARY KEY) USING flux; +CREATE TABLE w3 (id int PRIMARY KEY) USING flux; +CREATE TABLE w4 (id int PRIMARY KEY) USING flux; +CREATE TABLE w5 (id int PRIMARY KEY) USING flux; +INSERT INTO w1 SELECT generate_series(1, 10); +INSERT INTO w2 SELECT generate_series(1, 10); +INSERT INTO w3 SELECT generate_series(1, 10); +INSERT INTO w4 SELECT generate_series(1, 10); +INSERT INTO w5 SELECT generate_series(1, 10); +}); + +# Run sql in session h with the injection point attached; return its error. +sub fails +{ + my ($h, $point, $sql) = @_; + $h->query_safe("SELECT injection_points_attach('$point', 'notice')"); + $h->query_safe('SAVEPOINT f'); + $h->{stderr} = ''; + $h->query($sql); + my $err = $h->{stderr}; + $h->{stderr} = ''; + $h->query_safe('ROLLBACK TO f'); + $h->query_safe("SELECT injection_points_detach('$point')"); + return $err; +} + +# A table must grow: 8 rows in records and 192 in the table, which is then +# a 256-entry table 3/4 full (a slot may keep an emptied table of up to 256 +# entries from an earlier transaction, so fewer rows might need no growth). +# Growing fails at a new row and at an upgrade. +foreach my $end ('ROLLBACK', 'COMMIT') +{ + my $g = $node->background_psql('postgres', on_error_stop => 0); + $g->query_safe('BEGIN'); + $g->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM u WHERE id <= 200 ORDER BY id FOR KEY SHARE) s" + ); + like( + fails( + $g, 'slog-tuple-lock-table-oom', + 'SELECT 1 FROM u WHERE id = 201 FOR UPDATE'), + qr/out of memory/, + "$end: growing the table for a new row fails"); + like( + fails( + $g, 'slog-tuple-lock-table-oom', + 'SELECT 1 FROM u WHERE id = 150 FOR UPDATE'), + qr/out of memory/, + "$end: growing the table for an upgrade fails"); + is(blocked('u', 1, 300, 'UPDATE'), + 200, "$end: after the failures the 200 earlier locks are all found"); + is(blocked('u', 1, 300, 'NO KEY UPDATE'), + 0, "$end: and the failed FOR UPDATE locks are not"); + $g->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM u WHERE id > 200 ORDER BY id FOR UPDATE) s" + ); + $g->query_safe('SELECT 1 FROM u WHERE id = 150 FOR UPDATE'); + is(blocked('u', 1, 300, 'UPDATE'), + 300, "$end: the transaction goes on locking after the failures"); + is(blocked('u', 1, 300, 'NO KEY UPDATE'), + 101, "$end: its FOR UPDATE locks are found"); + $g->query_safe($end); + is(blocked('u', 1, 300, 'UPDATE'), 0, "$end: nothing left after $end"); + + # the next transaction of this backend reuses the emptied table + $g->query_safe('BEGIN'); + $g->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM u WHERE id <= 30 ORDER BY id FOR KEY SHARE) s" + ); + is(blocked('u', 1, 300, 'UPDATE'), + 30, "$end: the next transaction's locks are found"); + is(blocked('u', 1, 300, 'NO KEY UPDATE'), + 0, "$end: and only those, at their strength"); + $g->query_safe('COMMIT'); + is(blocked('u', 1, 300, 'UPDATE'), 0, "$end: and released"); + $g->quit; +} + +# A lock that needs a new relation array and a table, whose table fails, and +# a lock whose relation array fails. Five relations need a second array +# (the first has four entries). +my $g = $node->background_psql('postgres', on_error_stop => 0); +$g->query_safe('BEGIN'); +$g->query_safe( + "SELECT count(*) FROM (SELECT 1 FROM u WHERE id <= 8 ORDER BY id FOR UPDATE) s" +); +$g->query_safe('SELECT 1 FROM u WHERE id = 9 FOR UPDATE'); +$g->query_safe("SELECT 1 FROM $_ WHERE id = 1 FOR UPDATE") + foreach ('w1', 'w2', 'w3'); +like( + fails( + $g, 'slog-tuple-lock-oom', + 'SELECT 1 FROM w4 WHERE id = 1 FOR UPDATE'), + qr/out of memory/, + 'a lock whose relation array cannot grow fails'); +like( + fails( + $g, 'slog-tuple-lock-table-oom', + 'SELECT 1 FROM w4 WHERE id = 1 FOR UPDATE'), + qr/out of memory/, + 'a lock whose new table cannot be allocated fails'); +is(blocked('w4', 1, 10, 'UPDATE'), 0, 'the failed lock is not found'); +is( blocked('u', 1, 60, 'UPDATE') . ' ' + . join(' ', map { blocked($_, 1, 10, 'UPDATE') } ('w1', 'w2', 'w3')), + '9 1 1 1', + 'the earlier locks in five relations are all found'); + +# The same failure again and again: shared memory must not grow. +my $before = dsa_size(); +cmp_ok($before, '>', 0, 'the lock tables exist'); +$g->query_safe( + "SELECT injection_points_attach('slog-tuple-lock-table-oom', 'notice')"); +$g->query_safe( + q{ +DO $$ +DECLARE n int := 0; +BEGIN + FOR i IN 1..10000 LOOP + BEGIN + PERFORM 1 FROM w4 WHERE id = 1 FOR UPDATE; + EXCEPTION WHEN out_of_memory THEN n := n + 1; + END; + END LOOP; + IF n <> 10000 THEN RAISE 'only % failures', n; END IF; +END $$}); +$g->query_safe("SELECT injection_points_detach('slog-tuple-lock-table-oom')"); +is(dsa_size(), $before, '10000 failed allocations use no shared memory'); +$g->query_safe("SELECT 1 FROM $_ WHERE id = 1 FOR UPDATE") + foreach ('w4', 'w5'); +is( join(' ', + map { blocked($_, 1, 10, 'UPDATE') } ('w1', 'w2', 'w3', 'w4', 'w5')), + '1 1 1 1 1', + 'then the transaction locks rows of five relations past its records'); +$g->query_safe('ROLLBACK'); +is( blocked('u', 1, 60, 'UPDATE') . ' ' + . join(' ', + map { blocked($_, 1, 10, 'UPDATE') } ('w1', 'w2', 'w3', 'w4', 'w5')), + '0 0 0 0 0 0', + 'and nothing is left after its rollback'); +$g->quit; + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/137_flux_vacuum_parallel.pl b/src/test/recovery/t/137_flux_vacuum_parallel.pl new file mode 100644 index 0000000000000..d0aeb94c2717e --- /dev/null +++ b/src/test/recovery/t/137_flux_vacuum_parallel.pl @@ -0,0 +1,275 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX VACUUM vacuums its indexes with parallel workers, as heap's does. +# Parallel and serial VACUUM of the same contents must leave the same +# result: rows, index contents (amcheck heapallindexed, index-only counts +# per index), pg_stat and pg_class counters. The index work includes what +# only FLUX has: delete-marked entries of in-place key UPDATEs, removed in a +# worker through table_index_tombstone_removable, and rows of an aborted +# key UPDATE whose revert is still pending. Also: several rounds under a +# small maintenance_work_mem, a crash while the workers run, and a standby. +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('flux_vacuum_parallel'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq{ +autovacuum = off +shared_preload_libraries = 'injection_points' +max_worker_processes = 16 +max_parallel_workers = 8 +max_parallel_maintenance_workers = 4 +min_parallel_index_scan_size = 0 +}); +$node->start; +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION amcheck; +CREATE EXTENSION injection_points; +}); + +# A slot keeps the WAL the standby has not received yet: with +# wal_consistency_checking the tables below write far more than max_wal_size. +$node->safe_psql('postgres', + "SELECT pg_create_physical_replication_slot('standby_slot')"); +$node->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('flux_vacuum_parallel_standby'); +$standby->init_from_backup($node, 'bkp', has_streaming => 1); +$standby->append_conf('postgresql.conf', + "primary_slot_name = 'standby_slot'"); +$standby->start; + +# Two tables of identical contents: four btree indexes, a hash, a brin and a +# partial btree; brin's cleanup, run by a worker, scans the table. In-place key UPDATEs of a and b leave delete-marked +# entries; DELETEs leave dead TIDs; a rolled-back key UPDATE leaves rows +# whose versions are an aborted writer's. +sub make_table +{ + my ($t, $am) = @_; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, a int, b int, c text, d int) USING $am + WITH (autovacuum_enabled = off); +INSERT INTO $t SELECT g, g % 997, g % 1009, 'c' || g, -g + FROM generate_series(1, 40000) g; +CREATE INDEX ${t}_id ON $t (id); +CREATE INDEX ${t}_a ON $t (a); +CREATE INDEX ${t}_b ON $t (b); +CREATE INDEX ${t}_ab ON $t (a, b); +CREATE INDEX ${t}_c ON $t USING hash (c); +CREATE INDEX ${t}_d ON $t USING brin (d) WITH (pages_per_range = 4); +CREATE INDEX ${t}_p ON $t (id) WHERE id % 10 = 3; +-- block ranges brin has not summarized, for its cleanup to summarize +INSERT INTO $t SELECT g, g % 997, g % 1009, 'c' || g, -g + FROM generate_series(40001, 44000) g; +UPDATE $t SET a = a + 5000 WHERE id % 7 = 0; +UPDATE $t SET b = b + 7000 WHERE id % 11 = 0; +DELETE FROM $t WHERE id % 5 = 0; +BEGIN; +UPDATE $t SET a = a + 9000 WHERE id % 13 = 0; +ROLLBACK; +}); +} + +my @idx = qw(id a b ab p); + +# Everything parallel and serial VACUUM must agree on. +sub state_of +{ + my ($t) = @_; + my $s = $node->safe_psql( + 'postgres', qq{ +SELECT count(*), sum(id), sum(a), sum(b), sum(length(c)), sum(d) FROM $t}); + foreach my $i (@idx) + { + $s .= "\n$i: " + . $node->safe_psql('postgres', + "SELECT bt_index_check('${t}_$i', true)"); + } + # Every index reaches every row, by its own key. + $s .= "\n" . $node->safe_psql( + 'postgres', qq{ +SET enable_seqscan = off; SET enable_bitmapscan = on; +SET enable_indexscan = off; SET enable_indexonlyscan = off; +SELECT (SELECT count(*) FROM $t WHERE id > 0), + (SELECT count(*) FROM $t WHERE a >= 0), + (SELECT count(*) FROM $t WHERE b >= 0), + (SELECT count(*) FROM $t WHERE a >= 0 AND b >= 0), + (SELECT count(*) FROM $t WHERE c = 'c4001'), + (SELECT count(*) FROM $t WHERE d < 0), + (SELECT count(*) FROM $t WHERE id % 10 = 3 AND id > 0)}); + $s .= "\n" . $node->safe_psql( + 'postgres', qq{ +SELECT n_live_tup, n_dead_tup, vacuum_count FROM pg_stat_user_tables +WHERE relid = '$t'::regclass}); + $s .= "\n" . $node->safe_psql( + 'postgres', qq{ +SELECT string_agg(substr(c.relname, length('$t') + 2) || '=' || + c.relpages || '/' || c.reltuples, ' ' ORDER BY c.relname) +FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid +WHERE i.indrelid = '$t'::regclass}); + $s .= "\n" + . $node->safe_psql('postgres', + "SELECT relpages, reltuples FROM pg_class WHERE oid = '$t'::regclass" + ); + return $s; +} + +# VACUUM VERBOSE; return the number of workers it launched in total. +sub vacuum_launched +{ + my ($opts, $t, $mwm) = @_; + my ($stdout, $stderr); + $node->psql( + 'postgres', "SET maintenance_work_mem = '$mwm'; + SET client_min_messages = info; VACUUM (VERBOSE, $opts) $t; + SELECT pg_stat_force_next_flush();", + stdout => \$stdout, + stderr => \$stderr, + on_error_die => 1); + my $n = 0; + $n += $1 while $stderr =~ /launched (\d+) parallel vacuum worker/g; + return $n; +} + +foreach my $am ('heap', 'flux') +{ + foreach my $mwm ('64MB', '64kB') + { + make_table("vs_$am", $am); + make_table("vp_$am", $am); + my $before = state_of("vs_$am"); + is(state_of("vp_$am"), $before, "$am $mwm: twins start equal"); + + is(vacuum_launched('PARALLEL 0', "vs_$am", $mwm), + 0, "$am $mwm: PARALLEL 0 launches no worker"); + cmp_ok(vacuum_launched('PARALLEL 4', "vp_$am", $mwm), + '>', 0, "$am $mwm: PARALLEL 4 launches workers"); + + my $serial = state_of("vs_$am"); + is(state_of("vp_$am"), $serial, + "$am $mwm: parallel and serial VACUUM leave the same state"); + note "$am $mwm serial state:\n$serial"; + + # Delete-marked entries left by the UPDATEs are gone in both. + my $tomb = sub { + my ($t) = @_; + return $node->safe_psql( + 'postgres', qq{ +SET enable_seqscan = off; SET enable_bitmapscan = off; +SET enable_indexonlyscan = on; +SELECT count(*) FROM $t WHERE a >= 5000 AND a < 9000}); + }; + is($tomb->("vp_$am"), $tomb->("vs_$am"), + "$am $mwm: same index-only count on the updated key range"); + + # A second VACUUM of each finds nothing more to do, in the same way. + vacuum_launched('PARALLEL 0', "vs_$am", $mwm); + vacuum_launched('PARALLEL 4', "vp_$am", $mwm); + is(state_of("vp_$am"), state_of("vs_$am"), + "$am $mwm: and after a second VACUUM"); + + $node->safe_psql('postgres', "DROP TABLE vs_$am, vp_$am"); + } +} + +# VACUUM (PARALLEL n) of a temporary FLUX table: as heap, a WARNING. +{ + my ($stdout, $stderr); + $node->psql( + 'postgres', q{ +CREATE TEMP TABLE tf (a int) USING flux; +CREATE INDEX ON tf (a); CREATE INDEX ON tf ((a + 1)); +INSERT INTO tf SELECT generate_series(1, 100); +VACUUM (PARALLEL 2) tf;}, + stdout => \$stdout, + stderr => \$stderr); + like( + $stderr, + qr/disabling parallel option of vacuum on "tf" --- cannot vacuum temporary tables in parallel/, + 'temporary FLUX table: parallel VACUUM disabled with a WARNING'); +} + +# Crash while parallel workers vacuum the indexes: the next VACUUM finishes +# the job and the indexes are sound. +make_table('vc', 'flux'); +my $expect = $node->safe_psql('postgres', + 'SELECT count(*), sum(id), sum(a), sum(b) FROM vc'); +$node->safe_psql('postgres', + "SELECT injection_points_attach('vacuum-bulkdel-one-index', 'wait')"); +my $vac = $node->background_psql('postgres', on_error_stop => 0); +$vac->query_until(qr/start/, "\\echo start\nVACUUM (PARALLEL 4) vc;\n"); +$node->poll_query_until( + 'postgres', q{ +SELECT count(*) >= 2 FROM pg_stat_activity +WHERE wait_event = 'vacuum-bulkdel-one-index'} +) or die 'parallel VACUUM did not reach the index phase'; +is( $node->safe_psql( + 'postgres', q{ +SELECT count(*) > 0 FROM pg_stat_activity +WHERE backend_type = 'parallel worker' + AND wait_event = 'vacuum-bulkdel-one-index'}), + 't', + 'a parallel worker is vacuuming an index'); +$node->stop('immediate'); +eval { $vac->quit }; +$node->start; +is( $node->safe_psql( + 'postgres', 'SELECT count(*), sum(id), sum(a), sum(b) FROM vc'), + $expect, + 'rows survive a crash during parallel index vacuum'); +foreach my $i (@idx) +{ + is( $node->safe_psql('postgres', "SELECT bt_index_check('vc_$i', true)"), + '', + "after crash: bt_index_check heapallindexed on vc_$i"); +} +cmp_ok(vacuum_launched('PARALLEL 4', 'vc', '64MB'), + '>', 0, 'parallel VACUUM after the crash'); +foreach my $i (@idx) +{ + is( $node->safe_psql('postgres', "SELECT bt_index_check('vc_$i', true)"), + '', + "after VACUUM: bt_index_check heapallindexed on vc_$i"); +} + +# The standby replays all of it. +$node->wait_for_catchup($standby); +is( $standby->safe_psql( + 'postgres', 'SELECT count(*), sum(id), sum(a), sum(b) FROM vc'), + $expect, + 'standby: same rows'); +is( $standby->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM vc WHERE a >= 0}), + $node->safe_psql( + 'postgres', q{ +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT count(*) FROM vc WHERE a >= 0}), + 'standby: index scan agrees with the primary'); +foreach my $i (@idx) +{ + is( $standby->safe_psql( + 'postgres', "SELECT bt_index_check('vc_$i', true)"), + '', + "standby: bt_index_check heapallindexed on vc_$i"); +} + +$standby->stop; +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/138_flux_vacuum_toast.pl b/src/test/recovery/t/138_flux_vacuum_toast.pl new file mode 100644 index 0000000000000..daf25cfa5a88c --- /dev/null +++ b/src/test/recovery/t/138_flux_vacuum_toast.pl @@ -0,0 +1,310 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX and heap delete a row's external TOAST values in the deleting +# transaction. TOAST VACUUM reclaims those values and values whose inserter +# aborted. Both AMs must end with the same rows and with no TOAST chunk +# that no row references. +# +# Each case runs on a heap and a FLUX twin and checks that VACUUM succeeds +# (twice), the rows and their values, and, after VACUUM and a VACUUM of the +# TOAST table, that every TOAST chunk left belongs to a live row. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_vacuum_toast'); +$node->init; +my $injection = ($ENV{enable_injection_points} // '') eq 'yes'; +$node->append_conf('postgresql.conf', + "autovacuum = off\n" + . ($injection ? "shared_preload_libraries = 'injection_points'\n" : '') +); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION pageinspect'); +$injection = 0 unless $node->check_extension('injection_points'); +$node->safe_psql('postgres', 'CREATE EXTENSION injection_points') + if $injection; + +# Row id's value: about 10 kB, stored uncompressed out of line. +my $val = sub { my ($id) = @_; return "($id || ':' || repeat('x', 10000))" }; + +sub make +{ + my ($t, $am, $ids) = @_; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, k int, big text) USING $am + WITH (autovacuum_enabled = off); +ALTER TABLE $t ALTER big SET STORAGE EXTERNAL; +CREATE UNIQUE INDEX ${t}_id ON $t (id); +CREATE INDEX ${t}_k ON $t (k); +INSERT INTO $t SELECT g, g, @{[$val->('g')]} FROM unnest('{$ids}'::int[]) g; +}); +} + +sub toast_of +{ + my ($t) = @_; + return $node->safe_psql('postgres', + "SELECT reltoastrelid::regclass FROM pg_class WHERE oid = '$t'::regclass" + ); +} + +# VACUUM; returns '' on success, else the error. +sub vacuum +{ + my ($what) = @_; + my ($rc, $out, $err) = $node->psql('postgres', "VACUUM $what"); + return $rc == 0 ? '' : $err; +} + +# Rows, each with its value intact, and TOAST state: values an MVCC +# snapshot sees, and whether every chunk on the TOAST pages is one of them. +sub state_of +{ + my ($t) = @_; + my $toast = toast_of($t); + return $node->safe_psql( + 'postgres', qq{ +SELECT string_agg(id || '/' || k || '/' || + (big = @{[$val->('id')]})::text, ' ' ORDER BY id) +FROM $t; +SELECT count(DISTINCT chunk_id) FROM $toast; +SELECT (SELECT count(*) + FROM generate_series(0, pg_relation_size('$toast') / + current_setting('block_size')::int - 1) b, + heap_page_items(get_raw_page('$toast', b::int)) + WHERE lp_flags <> 0) = (SELECT count(*) FROM $toast)}); +} + +# name, the change to make on table T (rows 1-4 exist), the rows expected. +my @cases = ( + [ + 'committed DELETE', + 'DELETE FROM T WHERE id = 4', + '1/1/true 2/2/true 3/3/true' + ], + [ + 'aborted INSERT', + "BEGIN; INSERT INTO T SELECT g, g, @{[$val->('g')]} + FROM generate_series(11, 13) g; ROLLBACK", + '1/1/true 2/2/true 3/3/true 4/4/true' + ], + [ + 'INSERT rolled back to a savepoint in a committed transaction', + "BEGIN; INSERT INTO T VALUES (21, 21, @{[$val->(21)]}); + SAVEPOINT s; + INSERT INTO T SELECT g, g, @{[$val->('g')]} + FROM generate_series(22, 23) g; + ROLLBACK TO s; COMMIT", + '1/1/true 2/2/true 3/3/true 4/4/true 21/21/true' + ], + [ + 'aborted in-place UPDATE of the TOASTed column', + "BEGIN; UPDATE T SET big = 'new' || repeat('y', 10000) WHERE id = 1; + ROLLBACK", + '1/1/true 2/2/true 3/3/true 4/4/true' + ], + # A hash index has no delete-marking, so FLUX moves a row whose key + # changes to a new TID (delete + insert), as heap does. + [ + 'aborted out-of-place UPDATE (key change, new value)', + "CREATE INDEX ON T USING hash (k); + BEGIN; UPDATE T SET k = 102, big = 'new' || repeat('y', 10000) + WHERE id = 2; ROLLBACK", + '1/1/true 2/2/true 3/3/true 4/4/true' + ], + [ + 'aborted out-of-place UPDATE (key change, value kept)', + 'CREATE INDEX ON T USING hash (k); + BEGIN; UPDATE T SET k = 103 WHERE id = 3; ROLLBACK', + '1/1/true 2/2/true 3/3/true 4/4/true' + ], + [ + 'committed out-of-place UPDATE (key change, value kept)', + "CREATE INDEX ON T USING hash (k); + UPDATE T SET k = 103 WHERE id = 3; + DO \$\$ BEGIN IF (SELECT ctid FROM T WHERE id = 3) = '(0,3)' + THEN RAISE 'row did not move'; END IF; END \$\$", + '1/1/true 2/2/true 3/103/true 4/4/true' + ], + [ + 'DELETE rolled back', + 'BEGIN; DELETE FROM T WHERE id IN (1, 2); ROLLBACK', + '1/1/true 2/2/true 3/3/true 4/4/true' + ], + [ + 'INSERT and DELETE in one committed transaction', + "BEGIN; INSERT INTO T VALUES (31, 31, @{[$val->(31)]}); + DELETE FROM T WHERE id = 31; COMMIT", + '1/1/true 2/2/true 3/3/true 4/4/true' + ],); + +my $n = 0; +foreach my $case (@cases) +{ + my ($name, $sql, $rows) = @$case; + $n++; + foreach my $am ('heap', 'flux') + { + my $t = "c${n}_$am"; + make($t, $am, '1,2,3,4'); + (my $s = $sql) =~ s/\bT\b/$t/g; + $node->safe_psql('postgres', $s); + + is(vacuum($t), '', "$am, $name: VACUUM succeeds"); + is(vacuum(toast_of($t)), '', "$am, $name: VACUUM of the TOAST table"); + my $nrows = () = $rows =~ /\//g; + is( state_of($t), + "$rows\n" . ($nrows / 2) . "\nt", + "$am, $name: rows and TOAST after VACUUM"); + is(vacuum($t), '', "$am, $name: a second VACUUM succeeds"); + } +} + +# A VACUUM that cannot compact a page (a cursor pins it, and a +# non-aggressive VACUUM does not wait) removes none of its rows, so it must +# not delete their TOAST values either: the next VACUUM would find them +# deleted but, while the cursor's transaction holds the horizon, not yet +# removed. +foreach my $am ('heap', 'flux') +{ + my $t = "pin_$am"; + make($t, $am, '1,2,3,4'); + $node->safe_psql('postgres', "DELETE FROM $t WHERE id = 2"); + my $cur = $node->background_psql('postgres'); + $cur->query_safe("BEGIN; DECLARE c CURSOR FOR SELECT id FROM $t"); + $cur->query_safe('FETCH 1 FROM c'); + is(vacuum($t), '', "$am, page pinned: VACUUM succeeds"); + is(vacuum($t), '', "$am, page pinned: a second VACUUM succeeds"); + $cur->query_safe('COMMIT'); + $cur->quit; + is(vacuum($t), '', "$am, unpinned: VACUUM succeeds"); + is(vacuum(toast_of($t)), '', "$am, unpinned: VACUUM of the TOAST table"); + is( state_of($t), + "1/1/true 3/3/true 4/4/true\n3\nt", + "$am, unpinned: rows and TOAST after VACUUM"); +} + +# Opportunistic pruning reclaims a committed-deleted row's storage before +# VACUUM sees it. Its TOAST values must not outlive it. +foreach my $am ('heap', 'flux') +{ + my $t = "prune_$am"; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, big text, pad text) USING $am + WITH (autovacuum_enabled = off); +ALTER TABLE $t ALTER big SET STORAGE EXTERNAL; +INSERT INTO $t SELECT g, @{[$val->('g')]}, repeat('p', 200) + FROM generate_series(1, 40) g; +DELETE FROM $t WHERE id % 2 = 0; +}); + # The page is nearly full, so a scan prunes it. + $node->safe_psql('postgres', "SELECT count(*) FROM $t"); + $node->safe_psql('postgres', "SELECT count(*) FROM $t"); + is(vacuum($t), '', "$am, pruned: VACUUM succeeds"); + is(vacuum(toast_of($t)), '', "$am, pruned: VACUUM of the TOAST table"); + is( $node->safe_psql( + 'postgres', qq{ +SELECT (SELECT count(*) FROM $t), + (SELECT count(DISTINCT chunk_id) FROM @{[toast_of($t)]})}), + '20|20', + "$am, pruned: one TOAST value per live row"); +} + +# A crash after VACUUM removed rows, before it committed. The page changes +# are not transactional, so the rows are gone for good; their TOAST values +# must not stay behind. The FLUX VACUUM stops just before truncating the +# table, after it has compacted every page. Heap has no such stop; its twin +# crashes right after the DELETE. +SKIP: +{ + skip 'injection points not supported by this build', 6 + unless $injection; + foreach my $am ('heap', 'flux') + { + my $t = "crash_$am"; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, big text) USING $am WITH (autovacuum_enabled = off); +ALTER TABLE $t ALTER big SET STORAGE EXTERNAL; +INSERT INTO $t SELECT g, g || repeat('v', 2100) FROM generate_series(1, 8000) g; +CREATE INDEX ${t}_id ON $t (id); +DELETE FROM $t WHERE id > 100; +}); + my $vac; + if ($am eq 'flux') + { + $vac = $node->background_psql('postgres', on_error_stop => 0); + $vac->query_safe( + "SELECT injection_points_set_local(); + SELECT injection_points_attach('flux-vacuum-before-truncate', 'wait')" + ); + $vac->query_until(qr/start/, "\\echo start\nVACUUM $t;\n"); + $node->wait_for_event('client backend', + 'flux-vacuum-before-truncate'); + } + $node->stop('immediate'); + eval { $vac->quit } if $vac; + $node->start; + is(vacuum($t), '', "$am, after a crash: VACUUM succeeds"); + is(vacuum(toast_of($t)), + '', "$am, after a crash: VACUUM of the TOAST table"); + is( $node->safe_psql( + 'postgres', qq{ +SELECT (SELECT count(*) FROM $t), + (SELECT count(DISTINCT chunk_id) FROM @{[toast_of($t)]})}), + '100|100', + "$am, after a crash: one TOAST value per live row"); + } +} + +# A speculative insertion that loses to a concurrent INSERT is killed with +# its TOAST values (heap_abort_speculative); its transaction commits. +SKIP: +{ + skip 'injection points not supported by this build', 8 + unless $injection; + foreach my $am ('heap', 'flux') + { + my $t = "spec_$am"; + make($t, $am, '1'); + my $s1 = $node->background_psql('postgres'); + $s1->query_safe( + "SELECT injection_points_set_local(); + SELECT injection_points_attach('exec-insert-before-insert-speculative', 'wait')" + ); + $s1->query_until( + qr/start/, qq{\\echo start +INSERT INTO $t VALUES (5, 5, @{[$val->(5)]}) ON CONFLICT (id) DO NOTHING; +}); + $node->wait_for_event('client backend', + 'exec-insert-before-insert-speculative'); + $node->safe_psql('postgres', + "INSERT INTO $t VALUES (5, 5, @{[$val->(5)]})"); + $node->safe_psql('postgres', + "SELECT injection_points_wakeup('exec-insert-before-insert-speculative')" + ); + $s1->query_safe( + "SELECT injection_points_detach('exec-insert-before-insert-speculative')" + ); + $s1->quit; + + is(vacuum($t), '', "$am, aborted speculative insertion: VACUUM"); + is(vacuum(toast_of($t)), + '', + "$am, aborted speculative insertion: VACUUM of the TOAST table"); + is( state_of($t), + "1/1/true 5/5/true\n2\nt", + "$am, aborted speculative insertion: rows and TOAST"); + is(vacuum($t), '', + "$am, aborted speculative insertion: a second VACUUM"); + } +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/139_flux_toast_delete.pl b/src/test/recovery/t/139_flux_toast_delete.pl new file mode 100644 index 0000000000000..0c865d5d5313a --- /dev/null +++ b/src/test/recovery/t/139_flux_toast_delete.pl @@ -0,0 +1,304 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# FLUX deletes a row version's external TOAST values when the version is +# deleted, as heap_delete does: an MVCC delete in the TOAST table by the +# deleting transaction. Every path that retires a version must do so once, +# the values must come back with a rolled-back DELETE, a snapshot that still +# reads the deleted version must still detoast it across VACUUM of the table +# and of its TOAST table, and logical decoding must see what it sees for heap. +# Every case runs on a heap twin with the same expectations. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_toast_delete'); +$node->init(allows_streaming => 'logical'); +$node->append_conf('postgresql.conf', + "autovacuum = off\nmax_prepared_transactions = 5\n"); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION pageinspect'); + +# Row id's value: about 10 kB, stored uncompressed out of line. +sub val { my ($id) = @_; return "($id || ':' || repeat('x', 10000))" } + +sub make +{ + my ($t, $am, $extra) = @_; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, k int, big text) USING $am + WITH (autovacuum_enabled = off); +ALTER TABLE $t ALTER big SET STORAGE EXTERNAL; +CREATE UNIQUE INDEX ${t}_id ON $t (id); +@{[$extra // '']} +INSERT INTO $t SELECT g, g, @{[val('g')]} FROM generate_series(1, 4) g; +}); +} + +sub toast_of +{ + my ($t) = @_; + return $node->safe_psql('postgres', + "SELECT reltoastrelid::regclass FROM pg_class WHERE oid = '$t'::regclass" + ); +} + +# VACUUM the table and its TOAST table. +sub vacuum_both +{ + my ($t) = @_; + $node->safe_psql('postgres', "VACUUM $t"); + $node->safe_psql('postgres', 'VACUUM ' . toast_of($t)); +} + +# Rows with intact values; TOAST values an MVCC snapshot sees; and whether +# the TOAST pages hold nothing else (every deleted chunk was removed). +sub state_of +{ + my ($t) = @_; + my $toast = toast_of($t); + return $node->safe_psql( + 'postgres', qq{ +SELECT string_agg(id || '/' || k || '/' || (big = @{[val('id')]})::text, ' ' + ORDER BY id) FROM $t; +SELECT count(DISTINCT chunk_id) FROM $toast; +SELECT (SELECT count(*) + FROM generate_series(0, pg_relation_size('$toast') / + current_setting('block_size')::int - 1) b, + heap_page_items(get_raw_page('$toast', b::int)) + WHERE lp_flags <> 0) = (SELECT count(*) FROM $toast)}); +} + +my $all4 = '1/1/true 2/2/true 3/3/true 4/4/true'; + +foreach my $am ('heap', 'flux') +{ + # --- A snapshot that reads deleted and superseded versions. --- + # Committed DELETE, in-place UPDATE of the TOASTed column, and a + # key-changing UPDATE that moves the row (a hash index cannot be + # delete-marked, so FLUX moves it as heap does), each with a snapshot + # taken before it that reads the old version across VACUUM of the table + # and of the TOAST table. + my $t = "old_$am"; + make($t, $am, "CREATE INDEX ${t}_k ON $t USING hash (k);"); + my $rr = $node->background_psql('postgres'); + $rr->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + $rr->query_safe("SELECT count(*) FROM $t"); + $node->safe_psql( + 'postgres', qq{ +DELETE FROM $t WHERE id = 1; +UPDATE $t SET big = 'new' || repeat('y', 10000) WHERE id = 2; +UPDATE $t SET k = 103 WHERE id = 3; +UPDATE $t SET k = 104, big = 'new' || repeat('z', 10000) WHERE id = 4; +}); + my $read_old = qq{SELECT string_agg(id || '/' || k || '/' || + (big = @{[val('id')]})::text, ' ' ORDER BY id) FROM $t}; + is($rr->query_safe($read_old), + $all4, "$am: old snapshot reads the old versions before VACUUM"); + vacuum_both($t); + is($rr->query_safe($read_old), + $all4, "$am: and after VACUUM of the table and its TOAST table"); + $rr->query_safe('COMMIT'); + $rr->quit; + vacuum_both($t); + is( state_of($t), + "2/2/false 3/103/true 4/104/false\n3\nt", + "$am: once the snapshot is gone the old values are reclaimed"); + + # --- Partition move and MERGE DELETE. --- + my $p = "part_$am"; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $p (id int, k int, big text) PARTITION BY RANGE (k); +CREATE TABLE ${p}_1 PARTITION OF $p FOR VALUES FROM (0) TO (100) USING $am; +CREATE TABLE ${p}_2 PARTITION OF $p FOR VALUES FROM (100) TO (1000) USING $am; +ALTER TABLE $p ALTER big SET STORAGE EXTERNAL; +INSERT INTO $p SELECT g, g, @{[val('g')]} FROM generate_series(1, 4) g; +UPDATE $p SET k = 500 WHERE id = 1; +MERGE INTO $p USING (VALUES (2)) s(id) ON $p.id = s.id + WHEN MATCHED THEN DELETE; +}); + vacuum_both("${p}_1"); + vacuum_both("${p}_2"); + is( state_of("${p}_1"), + "3/3/true 4/4/true\n2\nt", + "$am: partition moved from and MERGE DELETE: two values left"); + is(state_of("${p}_2"), "1/500/true\n1\nt", + "$am: partition moved to: one value"); + + # --- DELETE that waits and retries. --- + # The first deleter rolls back: the waiter deletes the row, once. An + # in-place UPDATE of another column commits: the waiter deletes the + # updated version, once. + $t = "wait_$am"; + make($t, $am); + my $s1 = $node->background_psql('postgres'); + my $s2 = $node->background_psql('postgres'); + foreach my $c ( + [ 'BEGIN; DELETE FROM T WHERE id = 1', 'ROLLBACK', 1 ], + [ 'BEGIN; UPDATE T SET k = 20 WHERE id = 2', 'COMMIT', 2 ]) + { + my ($first, $end, $id) = @$c; + (my $f = $first) =~ s/\bT\b/$t/g; + $s1->query_safe($f); + $s2->query_until(qr/start/, + "\\echo start\nDELETE FROM $t WHERE id = $id;\n"); + $node->poll_query_until( + 'postgres', qq{ +SELECT count(*) > 0 FROM pg_stat_activity +WHERE wait_event_type = 'Lock' AND query LIKE 'DELETE FROM $t%'} + ) or die 'DELETE did not wait'; + $s1->query_safe($end); + is($s2->query_safe("SELECT count(*) FROM $t WHERE id = $id"), + '0', "$am: waiting DELETE of row $id went ahead after $end"); + } + $s1->quit; + $s2->quit; + vacuum_both($t); + is( state_of($t), + "3/3/true 4/4/true\n2\nt", + "$am: each waited DELETE deleted its values once"); + + # --- Rollbacks: the values must be live again. --- + $t = "rb_$am"; + make($t, $am); + $node->safe_psql( + 'postgres', qq{ +BEGIN; DELETE FROM $t WHERE id = 1; ROLLBACK; +BEGIN; SAVEPOINT s; DELETE FROM $t WHERE id = 2; ROLLBACK TO s; COMMIT; +BEGIN; DELETE FROM $t WHERE id = 3; PREPARE TRANSACTION 'rb_$am'; +ROLLBACK PREPARED 'rb_$am'; +BEGIN; DELETE FROM $t WHERE id = 4; PREPARE TRANSACTION 'cp_$am'; +COMMIT PREPARED 'cp_$am'; +}); + vacuum_both($t); + is( state_of($t), + "1/1/true 2/2/true 3/3/true\n3\nt", + "$am: aborted, savepoint, prepared DELETEs keep their values; a committed prepared one does not" + ); + + # --- CLUSTER copies a recently dead row. --- + $t = "cl_$am"; + make($t, $am); + $rr = $node->background_psql('postgres'); + $rr->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + $rr->query_safe('SELECT 1 FROM pg_class LIMIT 1'); + $node->safe_psql('postgres', "DELETE FROM $t WHERE id = 2"); + $node->safe_psql('postgres', "CLUSTER $t USING ${t}_id"); + is($rr->query_safe("SELECT big = @{[val(2)]} FROM $t WHERE id = 2"), + 't', "$am: old snapshot reads the recently dead row after CLUSTER"); + $rr->query_safe('COMMIT'); + $rr->quit; + vacuum_both($t); + my $cl = state_of($t); + note "$am CLUSTER state: $cl"; + like( + $cl, + qr/^1\/1\/true 3\/3\/true 4\/4\/true\n/, + "$am: rows after CLUSTER and VACUUM"); + $node->safe_psql('postgres', "SELECT 1"); +} + +# CLUSTER: FLUX keeps exactly what heap keeps. +is(state_of('cl_flux'), state_of('cl_heap'), + 'CLUSTER of a recently dead row: FLUX TOAST state equals heap'); + +# --- Logical decoding of a DELETE, REPLICA IDENTITY FULL. --- +my %out; +foreach my $am ('heap', 'flux') +{ + my $t = "ld_$am"; + make($t, $am); + $node->safe_psql('postgres', "ALTER TABLE $t REPLICA IDENTITY FULL"); + $node->safe_psql('postgres', + "SELECT pg_create_logical_replication_slot('s_$am', 'test_decoding')" + ); + $node->safe_psql('postgres', "DELETE FROM $t WHERE id IN (2, 3)"); + $out{$am} = $node->safe_psql( + 'postgres', qq{ +SELECT string_agg(data, E'\\n') FROM pg_logical_slot_get_changes('s_$am', + NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1')}); + $node->safe_psql('postgres', "SELECT pg_drop_replication_slot('s_$am')"); + $out{$am} =~ s/ld_$am/ld_AM/g; + vacuum_both($t); + is( state_of($t), + "1/1/true 4/4/true\n2\nt", + "$am: decoded DELETE left two values"); +} +like($out{heap}, qr/DELETE: id\[integer\]:2/, 'heap DELETE decoded'); +is($out{flux}, $out{heap}, + 'logical decoding of a DELETE with TOAST values: FLUX output equals heap' +); + +$node->stop; + +# --- A DELETE rolled back while its revert is pending, then helped. --- +# The rollback goes to an undo apply worker (undo_rollback_overflow_size = +# 0), held at an injection point; an UPDATE of the row reverts the aborted +# DELETE itself first. The DELETE's TOAST deletes rolled back with it. +SKIP: +{ + skip 'injection points not supported by this build', 3 + unless ($ENV{enable_injection_points} // '') eq 'yes'; + my $h = PostgreSQL::Test::Cluster->new('flux_toast_helped'); + $h->init; + $h->append_conf( + 'postgresql.conf', qq{ +autovacuum = off +undo_rollback_overflow_size = 0 +shared_preload_libraries = 'injection_points' +}); + $h->start; + skip 'injection_points not installed', 3 + unless $h->check_extension('injection_points'); + $node = $h; + $h->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +CREATE EXTENSION injection_points; +CREATE FUNCTION release_one() RETURNS bool LANGUAGE plpgsql AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE wait_event = 'pbu-undo-worker-before-apply') THEN + RETURN true; + END IF; + BEGIN + PERFORM injection_points_wakeup('pbu-undo-worker-before-apply'); + EXCEPTION WHEN OTHERS THEN + NULL; + END; + RETURN false; +END $$; +}); + make('hp', 'flux'); + $h->safe_psql('postgres', + "SELECT injection_points_attach('pbu-undo-worker-before-apply', 'wait')" + ); + $h->safe_psql('postgres', 'BEGIN; DELETE FROM hp WHERE id = 1; ROLLBACK'); + $h->poll_query_until( + 'postgres', q{ +SELECT count(*) > 0 FROM pg_stat_activity +WHERE wait_event = 'pbu-undo-worker-before-apply'} + ) or die 'the rollback did not go to a worker'; + $h->safe_psql('postgres', 'UPDATE hp SET k = 10 WHERE id = 1'); + is( $h->safe_psql( + 'postgres', "SELECT k, big = @{[val(1)]} FROM hp WHERE id = 1"), + '10|t', + 'flux: the helped row has its value'); + $h->safe_psql('postgres', + "SELECT injection_points_detach('pbu-undo-worker-before-apply')"); + $h->poll_query_until('postgres', 'SELECT release_one()') + or die 'worker not released'; + vacuum_both('hp'); + is( state_of('hp'), + "1/10/true 2/2/true 3/3/true 4/4/true\n4\nt", + 'flux: helped revert of a DELETE keeps every value'); + is($h->safe_psql('postgres', 'SELECT count(*) FROM hp'), + '4', 'flux: no row lost'); + $h->stop; +} + +done_testing(); diff --git a/src/test/recovery/t/140_flux_spec_insert_vm.pl b/src/test/recovery/t/140_flux_spec_insert_vm.pl new file mode 100644 index 0000000000000..2d5f668a38fff --- /dev/null +++ b/src/test/recovery/t/140_flux_spec_insert_vm.pl @@ -0,0 +1,102 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A speculative insertion (INSERT ... ON CONFLICT) adds a tuple to a page, +# so it must clear the page's visibility map bits, as every other insert +# does. A page left all-visible would let an index-only scan count a row +# that no snapshot sees (an uncommitted or aborted insertion), and a page +# left all-frozen is skipped by VACUUM's first pass, which then leaves the +# aborted tuple's index entry behind. heap is the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_spec_insert_vm'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +foreach my $ext ('pg_visibility', 'pageinspect') +{ + plan skip_all => "Extension $ext not installed" + unless $node->check_extension($ext); + $node->safe_psql('postgres', "CREATE EXTENSION $ext"); +} + +foreach my $am ('heap', 'flux') +{ + my $vm = qq{SELECT all_visible || ',' || all_frozen + FROM pg_visibility_map('sp_$am') WHERE blkno = 0}; + + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE sp_$am (id int PRIMARY KEY, v int) USING $am; +INSERT INTO sp_$am SELECT g, 0 FROM generate_series(1, 10) g; +VACUUM (FREEZE) sp_$am; +}); + is($node->safe_psql('postgres', $vm), + 'true,true', "$am: page is all-visible and all-frozen"); + + # An insertion still in progress. + my $bg = $node->background_psql('postgres'); + $bg->query_safe('BEGIN'); + $bg->query_safe( + "INSERT INTO sp_$am VALUES (11, 1) ON CONFLICT DO NOTHING"); + my $xid = $bg->query_safe('SELECT pg_current_xact_id()::xid'); + chomp $xid; + is($node->safe_psql('postgres', $vm), + 'false,false', "$am: in-progress speculative insert clears the VM"); + $bg->query_safe('ROLLBACK'); + $bg->quit; + is($node->safe_psql('postgres', $vm), + 'false,false', "$am: VM stays clear after the insert aborts"); + + # One aggressive VACUUM must remove the aborted tuple and its index + # entry; skipping the page in the first pass leaves both behind. + $node->safe_psql('postgres', "VACUUM (FREEZE) sp_$am"); + is( $node->safe_psql( + 'postgres', qq{ +SELECT string_agg(lp_flags::text || ':' || n, ',' ORDER BY lp_flags) +FROM (SELECT lp_flags, count(*) n + FROM heap_page_items(get_raw_page('sp_$am', 0)) GROUP BY lp_flags) s}), + '1:10', + "$am: VACUUM removed the aborted tuple"); + is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM bt_page_items('sp_${am}_pkey', 1)"), + '10', + "$am: VACUUM removed its index entry"); + # relfrozenxid may pass the aborted xid only once its tuple is gone: + # otherwise CLOG for it can be truncated while the tuple is still there. + is( $node->safe_psql( + 'postgres', qq{ +SELECT age(relfrozenxid) >= age('$xid'::xid) OR + (SELECT count(*) FROM heap_page_items(get_raw_page('sp_$am', 0)) + WHERE lp_flags <> 0) = 10 +FROM pg_class WHERE relname = 'sp_$am'}), + 't', + "$am: relfrozenxid does not pass the xid of a tuple still on the page" + ); + is($node->safe_psql('postgres', $vm), + 'true,true', "$am: VACUUM sets the bits again"); + + # COPY (multi_insert) onto the all-frozen page. + $node->safe_psql('postgres', + "COPY sp_$am FROM PROGRAM 'echo 13,1' (FORMAT csv)"); + is($node->safe_psql('postgres', $vm), + 'false,false', "$am: COPY clears the VM"); + $node->safe_psql('postgres', "VACUUM (FREEZE) sp_$am"); + + # A committed one. + $node->safe_psql('postgres', + "INSERT INTO sp_$am VALUES (12, 1) ON CONFLICT (id) DO UPDATE SET v = 2" + ); + is($node->safe_psql('postgres', $vm), + 'false,false', "$am: committed speculative insert clears the VM"); + is($node->safe_psql('postgres', "SELECT count(*) FROM sp_$am"), + '12', "$am: row count"); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/141_flux_index_exact_keys.pl b/src/test/recovery/t/141_flux_index_exact_keys.pl new file mode 100644 index 0000000000000..4bdf975fd620a --- /dev/null +++ b/src/test/recovery/t/141_flux_index_exact_keys.pl @@ -0,0 +1,276 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A FLUX UPDATE that changes an indexed column in place inserts (k_new, TID) +# and delete-marks (k_old, TID). That only describes the change when the two +# entries are different keys. Two changes it cannot describe: +# +# - a change of an INCLUDE column only: the key, and so the entry, stays the +# same, and the entry kept the old INCLUDE value; +# - a key change in an index whose opclass can call two different images +# equal (numeric 3 and 3.00, float8 0 and -0): the new entry duplicated +# the old one's (key, TID), or a tombstone revived under the other image +# hid the row from a snapshot that saw the old one. +# +# Such an UPDATE must move the row to a new TID, as heap's non-HOT UPDATE +# does, so that every entry describes exactly one version. The same holds +# when a row re-enters a partial index: the entry it gets back may be its own +# tombstone from before it left, re-formed from the new version. amcheck +# (heapallindexed, and rootdescend + checkunique) runs after every step; heap +# is the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_index_exact_keys'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; +$node->safe_psql( + 'postgres', q{ +CREATE EXTENSION amcheck; +-- bt_index_check's error as text, '' if none: a background session can call +-- it in the snapshot it holds. +CREATE FUNCTION amcheck_err(idx regclass) RETURNS text LANGUAGE plpgsql AS $$ +BEGIN + PERFORM bt_index_check(idx, true, false); + RETURN ''; +EXCEPTION WHEN OTHERS THEN + RETURN SQLERRM; +END $$; +}); + +# All checks of one index; '' when amcheck finds nothing. With a writer in +# progress only bt_index_check, as bt_index_parent_check waits for its lock. +sub check_index +{ + my ($idx, $unique, $busy) = @_; + my $sql = "SELECT bt_index_check('$idx', true, $unique)"; + $sql .= "; SELECT bt_index_parent_check('$idx', true, true, $unique)" + unless $busy; + my ($rc, $out, $err) = $node->psql('postgres', $sql); + return $err; +} + +# Rows the index finds for a qual, by an index scan. +sub idx_rows +{ + my ($sql) = @_; + return $node->safe_psql( + 'postgres', qq{ +SET enable_seqscan = off; SET enable_bitmapscan = off; +SET enable_indexonlyscan = off; +$sql}); +} + +foreach my $am ('heap', 'flux') +{ + # INCLUDE column changed alone. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE ti_$am (k int, inc int) USING $am; +CREATE INDEX ti_${am}_k ON ti_$am (k) INCLUDE (inc); +INSERT INTO ti_$am SELECT g, g FROM generate_series(1, 5) g; +UPDATE ti_$am SET inc = 30 WHERE k = 3; +}); + is(check_index("ti_${am}_k", 'false'), '', "$am: INCLUDE-only UPDATE"); + $node->safe_psql('postgres', "UPDATE ti_$am SET inc = 3 WHERE k = 3"); + is(check_index("ti_${am}_k", 'false'), + '', "$am: INCLUDE value changed back (A->B->A)"); + + # The same on a unique index, with a rollback and a real duplicate. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE tu_$am (k int, inc int) USING $am; +CREATE UNIQUE INDEX tu_${am}_k ON tu_$am (k) INCLUDE (inc); +INSERT INTO tu_$am SELECT g, g FROM generate_series(1, 5) g; +}); + my ($rc, $out, $err) = + $node->psql('postgres', "UPDATE tu_$am SET inc = 30 WHERE k = 3"); + is($rc . $err, '0', "$am: unique INCLUDE-only UPDATE raises no conflict"); + is(check_index("tu_${am}_k", 'true'), + '', "$am: unique INCLUDE-only UPDATE"); + + my $bg = $node->background_psql('postgres'); + $bg->query_safe('BEGIN'); + $bg->query_safe("UPDATE tu_$am SET inc = 300 WHERE k = 3"); + is(check_index("tu_${am}_k", 'true', 1), + '', "$am: unique INCLUDE-only UPDATE in progress"); + $bg->query_safe('ROLLBACK'); + $bg->quit; + is(check_index("tu_${am}_k", 'true'), + '', "$am: unique INCLUDE-only UPDATE rolled back"); + is($node->safe_psql('postgres', "SELECT inc FROM tu_$am WHERE k = 3"), + '30', "$am: rollback restored the INCLUDE value"); + + $node->safe_psql('postgres', "UPDATE tu_$am SET inc = 3 WHERE k = 3"); + is(check_index("tu_${am}_k", 'true'), '', "$am: unique INCLUDE A->B->A"); + ($rc, $out, $err) = + $node->psql('postgres', "INSERT INTO tu_$am VALUES (3, 99)"); + like( + $err, + qr/duplicate key value/, + "$am: a real duplicate is still refused"); + + # A key change keeps an int-keyed row at its TID; a change of an INCLUDE + # column moves it, with or without a key change (a later change back to + # the old key would revive that key's entry with the new INCLUDE value). + my $ctid = + $node->safe_psql('postgres', "SELECT ctid FROM tu_$am WHERE k = 3"); + $node->safe_psql('postgres', "UPDATE tu_$am SET k = 13 WHERE k = 3"); + is( $node->safe_psql('postgres', "SELECT ctid FROM tu_$am WHERE k = 13") + eq $ctid ? 'same' : 'moved', + $am eq 'flux' ? 'same' : 'moved', + "$am: key change in an int index with INCLUDE columns"); + $ctid = + $node->safe_psql('postgres', "SELECT ctid FROM tu_$am WHERE k = 13"); + $node->safe_psql('postgres', + "UPDATE tu_$am SET k = 103, inc = 103 WHERE k = 13"); + isnt( + $node->safe_psql('postgres', "SELECT ctid FROM tu_$am WHERE k = 103"), + $ctid, + "$am: a key and INCLUDE change moves the row"); + $ctid = + $node->safe_psql('postgres', "SELECT ctid FROM tu_$am WHERE k = 4"); + $node->safe_psql('postgres', "UPDATE tu_$am SET inc = 40 WHERE k = 4"); + isnt($node->safe_psql('postgres', "SELECT ctid FROM tu_$am WHERE k = 4"), + $ctid, "$am: an INCLUDE-only change moves the row"); + is(check_index("tu_${am}_k", 'true'), '', "$am: key and INCLUDE change"); + is(idx_rows("SELECT k, inc FROM tu_$am WHERE k IN (3, 103)"), + '103|103', "$am: index finds the row under its new key only"); + + # numeric: 3 and 3.00 are equal keys with different images. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE tn_$am (id int, n numeric) USING $am; +CREATE INDEX tn_${am}_n ON tn_$am (n); +INSERT INTO tn_$am SELECT g, g FROM generate_series(1, 5) g; +UPDATE tn_$am SET n = 3.00 WHERE id = 3; +}); + is(check_index("tn_${am}_n", 'false'), '', "$am: numeric 3 -> 3.00"); + is(idx_rows("SELECT id, n FROM tn_$am WHERE n = 3"), + '3|3.00', "$am: index scan sees 3.00"); + + # 3 -> 4 -> 3.0 while a snapshot from before both UPDATEs is open. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE tr_$am (id int, n numeric) USING $am; +CREATE INDEX tr_${am}_n ON tr_$am (n); +INSERT INTO tr_$am SELECT g, g FROM generate_series(1, 5) g; +}); + my $rr = $node->background_psql('postgres'); + $rr->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + $rr->query_safe("SELECT count(*) FROM tr_$am"); + $node->safe_psql( + 'postgres', qq{ +UPDATE tr_$am SET n = 4 WHERE id = 3; +UPDATE tr_$am SET n = 3.0 WHERE id = 3; +}); + is( $rr->query_safe( + qq{SET enable_seqscan = off; SET enable_bitmapscan = off; +SET enable_indexonlyscan = off; +SELECT id, n FROM tr_$am WHERE n = 3}), + '3|3', + "$am: old snapshot finds the old version through the index"); + $rr->query_safe('COMMIT'); + $rr->quit; + is(idx_rows("SELECT id, n FROM tr_$am WHERE n = 3"), + '3|3.0', "$am: new snapshot finds the new version"); + is(check_index("tr_${am}_n", 'false'), '', "$am: numeric 3 -> 4 -> 3.0"); + + # float8: 0 and -0 are equal keys with different images. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE tf_$am (id int, f float8) USING $am; +CREATE UNIQUE INDEX tf_${am}_f ON tf_$am (f); +INSERT INTO tf_$am SELECT g, g FROM generate_series(0, 5) g; +UPDATE tf_$am SET f = '-0' WHERE id = 0; +}); + is(check_index("tf_${am}_f", 'true'), '', "$am: float8 0 -> -0"); + is(idx_rows("SELECT id, f FROM tf_$am WHERE f = 0"), + '0|-0', "$am: index scan sees -0"); + + # A row leaves a partial index and comes back with an equal key of + # another image, or another INCLUDE value, while a snapshot from before + # it left is open. Leaving delete-marks its entry; coming back finds + # that tombstone for the same (key, TID) and revives it re-formed from + # the new version, so the old snapshot loses the image it sees. Each + # index is checked through the old snapshot (index scan against seqscan, + # and amcheck's heapallindexed from that snapshot) and afterwards. + # A key that changes and comes back with another INCLUDE value revives + # its tombstone the same way, and so does one in an index with no + # predicate. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE kc_$am (id int, n int, inc int) USING $am; +CREATE INDEX kc_${am}_i ON kc_$am (n) INCLUDE (inc); +INSERT INTO kc_$am SELECT g, g, g FROM generate_series(1, 5) g; +}); + $rr = $node->background_psql('postgres'); + $rr->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + $rr->query_safe("SELECT count(*) FROM kc_$am"); + $node->safe_psql( + 'postgres', qq{ +UPDATE kc_$am SET n = 4 WHERE id = 3; +UPDATE kc_$am SET n = 3, inc = 30 WHERE id = 3; +}); + is($rr->query_safe("SELECT amcheck_err('kc_${am}_i')"), + '', "$am: key cycle with an INCLUDE change, from the old snapshot"); + $rr->query_safe('COMMIT'); + $rr->quit; + is(check_index("kc_${am}_i", 'false'), + '', "$am: key cycle with an INCLUDE change"); + + foreach my $case ( + [ 'pn', 'n numeric', '(n)', 'n = 3', 'n = 3.0', '3' ], + [ 'pe', 'n numeric', '((n + 0))', 'n + 0 = 3', 'n = 3.0', '3' ], + [ + 'pi', + 'n int, inc int', + '(n) INCLUDE (inc)', + 'n = 3', 'inc = 30', '3|3' + ]) + { + my ($t, $cols, $def, $qual, $set, $old) = @$case; + my $tab = "${t}_$am"; + my $out = $cols =~ /inc/ ? 'n, inc' : 'n'; + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE $tab (id int, $cols, active bool) USING $am; +CREATE INDEX ${tab}_i ON $tab $def WHERE active; +INSERT INTO $tab (id, n, active) SELECT g, g, true FROM generate_series(1, 5) g; +}); + $node->safe_psql('postgres', "UPDATE $tab SET inc = n") + if $cols =~ /inc/; + my $rr = $node->background_psql('postgres'); + $rr->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + $rr->query_safe("SELECT count(*) FROM $tab"); + $node->safe_psql( + 'postgres', qq{ +UPDATE $tab SET active = false WHERE id = 3; +UPDATE $tab SET active = true, $set WHERE id = 3; +}); + my $q = "SELECT $out FROM $tab WHERE $qual AND active"; + is( $rr->query_safe( + qq{SET enable_seqscan = off; SET enable_bitmapscan = off; +SET enable_indexonlyscan = off; $q}), + $old, + "$am $t: old snapshot finds the row it sees through the index"); + is( $rr->query_safe( + qq{SET enable_seqscan = on; SET enable_indexscan = off; +SET enable_bitmapscan = off; $q}), + $old, + "$am $t: old snapshot seqscan"); + is($rr->query_safe("SELECT amcheck_err('${tab}_i')"), + '', "$am $t: heapallindexed from the old snapshot"); + $rr->query_safe('COMMIT'); + $rr->quit; + is(check_index("${tab}_i", 'false'), '', "$am $t: amcheck after"); + is(idx_rows("SELECT count(*) FROM $tab WHERE $qual AND active"), + '1', "$am $t: new snapshot finds the row once"); + } +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/142_flux_vm_clear_redo.pl b/src/test/recovery/t/142_flux_vm_clear_redo.pl new file mode 100644 index 0000000000000..56c83d26af2ed --- /dev/null +++ b/src/test/recovery/t/142_flux_vm_clear_redo.pl @@ -0,0 +1,147 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A change to a page clears the page's visibility map bits and its +# PD_ALL_VISIBLE flag in the WAL record of the change, and replay of that +# record clears them too: heap flags the record XLH_*_ALL_VISIBLE_CLEARED. +# Otherwise a standby that has replayed the change, but not yet a later +# record that clears the bits, sees an all-visible page holding a version +# not every snapshot sees, and a page restored by crash recovery or replayed +# on a standby keeps PD_ALL_VISIBLE. An index-only scan trusts the bit. +# +# For each kind of change, on heap (the control) and FLUX: the primary after +# the change, a standby stopped right after the change record (which is also +# what a crash right after that record leaves behind), the standby once caught +# up, and the primary after crash recovery. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf('postgresql.conf', "autovacuum = off\n"); +$primary->start; + +foreach my $ext ('pg_visibility', 'pg_walinspect') +{ + plan skip_all => "Extension $ext not installed" + unless $primary->check_extension($ext); + $primary->safe_psql('postgres', "CREATE EXTENSION $ext"); +} + +my $copyfile = $primary->basedir . '/rows.tsv'; +open(my $fh, '>', $copyfile) or die "could not write $copyfile: $!"; +print $fh "11\t0\tx\n12\t0\tx\n"; +close($fh); + +# Each change touches block 0 of its own table and commits; %t is the table. +my @kinds = ( + [ 'insert', "INSERT INTO %t VALUES (11, 0, 'x')" ], + [ 'copy', "COPY %t FROM '$copyfile'" ], + [ 'spec', "INSERT INTO %t VALUES (11, 0, 'x') ON CONFLICT DO NOTHING" ], + [ 'delete', "DELETE FROM %t WHERE id = 3" ], + [ 'update', "UPDATE %t SET v = 1 WHERE id = 3" ], + [ 'grow', "UPDATE %t SET pad = repeat('y', 200) WHERE id = 3" ], + [ 'key', "UPDATE %t SET id = 100 WHERE id = 3" ],); + +my @cases; +foreach my $am ('heap', 'flux') +{ + foreach my $k (@kinds) + { + my $t = "vm_$k->[0]_$am"; + $primary->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int PRIMARY KEY, v int, pad text) USING $am; +INSERT INTO $t SELECT g, 0, 'x' FROM generate_series(1, 10) g; +VACUUM (FREEZE) $t; +}); + is( $primary->safe_psql( + 'postgres', + "SELECT all_visible, pd_all_visible FROM pg_visibility('$t', 0)" + ), + 't|t', + "$t: page is all-visible before the change"); + push @cases, { t => $t, sql => ($k->[1] =~ s/%t/$t/gr) }; + } +} + +$primary->backup('bk'); + +# Make the changes, and find the record of each. +foreach my $c (@cases) +{ + my $t = $c->{t}; + my $before = + $primary->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + $primary->safe_psql('postgres', $c->{sql}); + my $after = + $primary->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + $c->{lsn} = $primary->safe_psql( + 'postgres', qq{ +SELECT start_lsn FROM pg_get_wal_block_info('$before', '$after', false) +WHERE relfilenode = pg_relation_filenode('$t') AND relforknumber = 0 + AND relblocknumber = 0 + AND record_type NOT IN ('VM_SET', 'VM_CLEAR', 'FPI_FOR_HINT') +ORDER BY start_lsn LIMIT 1}); + ok($c->{lsn} ne '', "$t: found the change record"); + is( $primary->safe_psql( + 'postgres', + "SELECT all_visible, pd_all_visible FROM pg_visibility('$t', 0)"), + 'f|f', + "$t: primary: page not all-visible after the change"); +} + +# A standby that stops right after each change record. The changes were +# made in order, so each restart moves the target forward. +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->append_conf('postgresql.conf', + "recovery_target_inclusive = on\nrecovery_target_action = 'pause'\n"); +foreach my $c (@cases) +{ + my $t = $c->{t}; + $standby->append_conf('postgresql.conf', + "recovery_target_lsn = '$c->{lsn}'\n"); + $standby->start; + $standby->poll_query_until('postgres', + "SELECT pg_get_wal_replay_pause_state() = 'paused'") + or die "standby did not pause at $c->{lsn}"; + is( $standby->safe_psql( + 'postgres', + "SELECT all_visible, pd_all_visible FROM pg_visibility('$t', 0)"), + 'f|f', + "$t: standby stopped after the change record: page not all-visible"); + $standby->stop; +} + +# The standby caught up. +$standby->append_conf('postgresql.conf', "recovery_target_lsn = ''\n"); +$standby->start; +$primary->wait_for_catchup($standby); +foreach my $c (@cases) +{ + my $t = $c->{t}; + is( $standby->safe_psql( + 'postgres', + "SELECT all_visible, pd_all_visible FROM pg_visibility('$t', 0)"), + 'f|f', + "$t: standby caught up: page not all-visible"); +} +$standby->stop; + +# Crash recovery replays the changes from the full-page images they took. +$primary->stop('immediate'); +$primary->start; +foreach my $c (@cases) +{ + my $t = $c->{t}; + is( $primary->safe_psql( + 'postgres', + "SELECT all_visible, pd_all_visible FROM pg_visibility('$t', 0)"), + 'f|f', + "$t: after crash recovery: page not all-visible"); +} + +done_testing(); diff --git a/src/test/recovery/t/143_flux_update_page_lsn.pl b/src/test/recovery/t/143_flux_update_page_lsn.pl new file mode 100644 index 0000000000000..35a80aef90c4b --- /dev/null +++ b/src/test/recovery/t/143_flux_update_page_lsn.pl @@ -0,0 +1,315 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A change WAL-logged for a page sets the page's LSN to its record's, on the +# primary and in replay, so that the buffer manager writes the page only +# after its WAL is flushed and a later change after a checkpoint knows the +# page is logged. A FLUX UPDATE that rewrites the row in place under the +# exclusive lock (it grows or shrinks the row, changes a key, or the +# relation is logically logged) left the page with the LSN of an earlier +# change. heap is the control throughout. +# +# 1. For each kind of change: the page LSN after the change is at least the +# end of its record, on the primary and on a standby that replayed it. +# 2. The consequence: an open transaction rewrites a row in place, the page +# is written out (pg_buffercache_evict) while the UPDATE's WAL is not +# flushed, and the server crashes. The committed row must come back. +# The WAL writer is held at an injection point, and the test checks that +# the UPDATE's record is not flushed before the page is written. +# 3. A checkpoint between UPDATEs of a page makes only the first UPDATE after +# it log a full-page image. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', qq{ +autovacuum = off +shared_buffers = 32MB +bgwriter_lru_maxpages = 0 +wal_writer_delay = 10000ms +}); +$primary->start; + +plan skip_all => 'Injection points not supported by this build' + unless $ENV{enable_injection_points} eq 'yes'; +foreach my $ext ( + 'pageinspect', 'pg_walinspect', + 'pg_buffercache', 'amcheck', + 'injection_points') +{ + plan skip_all => "Extension $ext not installed" + unless $primary->check_extension($ext); + $primary->safe_psql('postgres', "CREATE EXTENSION $ext"); +} + +my $copyfile = $primary->basedir . '/rows.tsv'; +open(my $fh, '>', $copyfile) or die "could not write $copyfile: $!"; +print $fh "11\t0\tx\n12\t0\tx\n"; +close($fh); + +# Each change touches block 0 of its own table; %t is the table. An +# INCLUDE-only change moves a FLUX row to a new TID (out of place). A +# speculative abort is set up below. The relation is logically logged in the +# last case only, while a logical slot exists. +my @kinds = ( + [ 'insert', "INSERT INTO %t VALUES (11, 0, 'x')" ], + [ 'copy', "COPY %t FROM '$copyfile'" ], + [ + 'spec confirm', + "INSERT INTO %t VALUES (11, 0, 'x') ON CONFLICT DO NOTHING" + ], + [ 'spec abort', undef ], + [ 'delete', "DELETE FROM %t WHERE id = 3" ], + [ 'same-size update', "UPDATE %t SET v = 1 WHERE id = 3" ], + [ 'growing update', "UPDATE %t SET pad = repeat('y', 200) WHERE id = 3" ], + [ 'shrinking update', "UPDATE %t SET pad = '' WHERE id = 3" ], + [ 'key update', "UPDATE %t SET id = 100 WHERE id = 3" ], + [ + 'out-of-place update', + "UPDATE %t SET pad = 'q' WHERE id = 3", + "CREATE INDEX ON %t (v) INCLUDE (pad)" + ], + [ 'row lock', "SELECT * FROM %t WHERE id = 3 FOR UPDATE" ], + [ 'logical same-size update', "UPDATE %t SET v = 1 WHERE id = 3" ],); + +# A speculative insertion that finds, after inserting its tuple, that another +# transaction committed the same key in the meantime, and backs out. +sub spec_abort +{ + my ($t) = @_; + $primary->safe_psql('postgres', + "SELECT injection_points_attach('exec-insert-before-insert-speculative', 'wait')" + ); + my $bg = $primary->background_psql('postgres'); + $bg->query_until( + qr/started/, qq{ +\\echo started +INSERT INTO $t VALUES (11, 0, 'x') ON CONFLICT DO NOTHING; +}); + $primary->wait_for_event('client backend', + 'exec-insert-before-insert-speculative'); + $primary->safe_psql('postgres', "INSERT INTO $t VALUES (11, 1, 'y')"); + $primary->safe_psql( + 'postgres', qq{ +SELECT injection_points_detach('exec-insert-before-insert-speculative'); +SELECT injection_points_wakeup('exec-insert-before-insert-speculative'); +}); + $bg->quit; +} + +$primary->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->start; +$standby->safe_psql('postgres', 'SELECT 1'); + +my @cases; +foreach my $am ('heap', 'flux') +{ + my $n = 0; + foreach my $k (@kinds) + { + my $t = "ln_${n}_$am"; + $n++; + $primary->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int PRIMARY KEY, v int, pad text) USING $am; +INSERT INTO $t SELECT g, 0, 'x' FROM generate_series(1, 10) g; +}); + $primary->safe_psql('postgres', $k->[2] =~ s/%t/$t/gr) + if defined $k->[2]; + push @cases, + { + t => $t, + name => "$am $k->[0]", + sql => (defined $k->[1] ? $k->[1] =~ s/%t/$t/gr : undef), + logical => ($k->[0] =~ /^logical/ ? 1 : 0) + }; + } +} + +$primary->safe_psql('postgres', 'CHECKPOINT'); +foreach my $c (@cases) +{ + my $t = $c->{t}; + if ($c->{logical}) + { + $primary->safe_psql('postgres', + "SELECT pg_create_logical_replication_slot('ln_slot', 'test_decoding')" + ); + $primary->poll_query_until('postgres', + "SELECT current_setting('effective_wal_level') = 'logical'") + or die "logical decoding did not turn on"; + } + my $before = + $primary->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + if (defined $c->{sql}) + { + $primary->safe_psql('postgres', $c->{sql}); + } + else + { + spec_abort($t); + } + $c->{end} = $primary->safe_psql( + 'postgres', qq{ +SELECT max(end_lsn) FROM pg_get_wal_block_info('$before', + pg_current_wal_insert_lsn(), false) +WHERE relfilenode = pg_relation_filenode('$t') AND relforknumber = 0 + AND relblocknumber = 0 + AND record_type NOT IN ('VM_SET', 'VM_CLEAR', 'VISIBLE')}); + $primary->safe_psql('postgres', + "SELECT pg_drop_replication_slot('ln_slot')") + if $c->{logical}; + ok($c->{end} ne '', "$c->{name}: found a record for the page"); + is( $primary->safe_psql( + 'postgres', qq{ +SELECT lsn >= '$c->{end}'::pg_lsn FROM page_header(get_raw_page('$t', 0))}), + 't', + "$c->{name}: primary: page LSN covers the record") + or diag $primary->safe_psql( + 'postgres', qq{ +SELECT (SELECT lsn FROM page_header(get_raw_page('$t', 0))) AS page_lsn, + string_agg(start_lsn || '-' || end_lsn || ' ' || record_type, ', ' + ORDER BY start_lsn) +FROM pg_get_wal_block_info('$before', pg_current_wal_insert_lsn(), false) +WHERE relfilenode = pg_relation_filenode('$t') AND relforknumber = 0 + AND relblocknumber = 0}); +} + +$primary->wait_for_replay_catchup($standby); +foreach my $c (@cases) +{ + is( $standby->safe_psql( + 'postgres', qq{ +SELECT lsn >= '$c->{end}'::pg_lsn FROM page_header(get_raw_page('$c->{t}', 0))} + ), + 't', + "$c->{name}: standby: page LSN covers the record"); +} +$standby->stop; + +# 2. A page written out before the WAL of its uncommitted in-place UPDATE. +# The table is vacuumed and the pages the queries below read are hinted +# before the checkpoint, so nothing logs a hint-bit image or flushes WAL +# between the UPDATE and the write, and the WAL writer is held at +# walwriter-before-background-flush: the page is written with the UPDATE's +# WAL still unflushed unless the page LSN names the UPDATE's record. The +# server crashes. The row is read right after the restart, and again after +# new write transactions, which take the xids the crash forgot. +$primary->safe_psql('postgres', 'CREATE TABLE evflush (i int)'); +my $evict = q{ +SELECT buffer_evicted FROM pg_buffercache_evict( + (SELECT bufferid FROM pg_buffercache + WHERE relfilenode = pg_relation_filenode('%s') AND relblocknumber = 0 + AND relforknumber = 0))}; +foreach my $am ('heap', 'flux') +{ + my $t = "ev_$am"; + $primary->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int PRIMARY KEY, v int, pad text) USING $am; +INSERT INTO $t SELECT g, g, 'x' FROM generate_series(1, 10) g; +INSERT INTO evflush VALUES (0); +SELECT count(*) FROM $t; +SELECT count(*) FROM evflush; +VACUUM $t; +CHECKPOINT; +}); + foreach my $i (1 .. 2) + { + $primary->safe_psql('postgres', sprintf($evict, 'evflush')); + $primary->safe_psql('postgres', + "SELECT lsn FROM page_header(get_raw_page('$t', 0))"); + } + $primary->safe_psql('postgres', 'INSERT INTO evflush VALUES (1)'); + $primary->safe_psql('postgres', + "SELECT injection_points_attach('walwriter-before-background-flush', 'wait')" + ); + $primary->wait_for_event('walwriter', + 'walwriter-before-background-flush'); + + my $bg = $primary->background_psql('postgres'); + $bg->query_safe('BEGIN'); + $bg->query_safe( + "UPDATE $t SET v = -1, pad = repeat('u', 300) WHERE id = 3"); + my $upd_end = + $primary->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + is( $primary->safe_psql( + 'postgres', + "SELECT pg_current_wal_flush_lsn() < '$upd_end'::pg_lsn"), + 't', + "$am: the UPDATE's WAL is not flushed yet"); + is($primary->safe_psql('postgres', sprintf($evict, $t)), + 't', "$am: the page was written out"); + note "$am: page written, WAL flushed to " + . $primary->safe_psql('postgres', 'SELECT pg_current_wal_flush_lsn()') + . ", UPDATE ends at $upd_end"; + $primary->stop('immediate'); + $bg->{run}->kill_kill; + $primary->start; + + my $check = sub { + my ($when) = @_; + my ($rc, $out, $err) = $primary->psql('postgres', + "SELECT id, v, pad FROM $t WHERE id = 3"); + is("$rc|$out", '0|3|3|x', "$am: $when: the committed row is back") + or diag "rc $rc, out '" . substr($out, 0, 30) . "', err $err"; + ($rc, $out, $err) = $primary->psql('postgres', + "SET enable_indexscan = off; SET enable_bitmapscan = off;" + . " SELECT count(*), sum(v) FROM $t"); + is("$rc|$out", '0|10|55', "$am: $when: every row is unchanged") + or diag "rc $rc, out '$out', err $err"; + }; + $check->('after the crash'); + foreach my $i (1 .. 3) + { + $primary->safe_psql('postgres', "INSERT INTO evflush VALUES ($i)"); + } + $check->('after new transactions'); + my ($rc, $out, $err) = + $primary->psql('postgres', "SELECT bt_index_check('${t}_pkey', true)"); + is($rc, 0, "$am: amcheck after recovery") or diag $err; +} + +# 3. Full-page images: one per page per checkpoint. The rows are hinted +# before the checkpoint and found through the index, so no hint-bit image +# sets the page LSN in between. Only images that replay applies count: +# wal_consistency_checking adds one to every record. +foreach my $am ('heap', 'flux') +{ + my $t = "fpi_$am"; + $primary->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int PRIMARY KEY, v int, pad text) USING $am; +INSERT INTO $t SELECT g, 0, 'x' FROM generate_series(1, 10) g; +SELECT count(*) FROM $t; +CHECKPOINT; +}); + my $before = + $primary->safe_psql('postgres', 'SELECT pg_current_wal_insert_lsn()'); + foreach my $i (1 .. 5) + { + $primary->safe_psql('postgres', + "SET enable_seqscan = off; SET enable_bitmapscan = off;" + . " UPDATE $t SET pad = repeat('g', 20 * $i) WHERE id = $i"); + } + my $fpis = $primary->safe_psql( + 'postgres', qq{ +SELECT count(*), coalesce(sum(block_fpi_length), 0), + pg_current_wal_insert_lsn() - '$before'::pg_lsn +FROM pg_get_wal_block_info('$before', pg_current_wal_insert_lsn(), false) +WHERE relfilenode = pg_relation_filenode('$t') AND relforknumber = 0 + AND 'APPLY' = ANY (block_fpi_info)}); + note "$am: full-page images | their bytes | WAL bytes: $fpis"; + is( (split /\|/, $fpis)[0], + '1', + "$am: five growing UPDATEs after a checkpoint log one full-page image" + ); +} + +done_testing(); diff --git a/src/test/recovery/t/145_flux_slog_storage.pl b/src/test/recovery/t/145_flux_slog_storage.pl new file mode 100644 index 0000000000000..5adf100e6dae6 --- /dev/null +++ b/src/test/recovery/t/145_flux_slog_storage.pl @@ -0,0 +1,57 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# Storage replacement must not alias savepoint-aborted tuple markers. +use strict; +use warnings FATAL => "all"; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; +my $node = PostgreSQL::Test::Cluster->new("slog"); +$node->init; +$node->append_conf("postgresql.conf", "autovacuum = off"); +$node->start; + +for my $inplace (0, 1) +{ + for my $ddl ( + "TRUNCATE t", + "ALTER TABLE t ALTER COLUMN id TYPE bigint", + "ALTER TABLE t SET ACCESS METHOD heap; ALTER TABLE t SET ACCESS METHOD AM" + ) + { + my @result; + for my $am ("heap", "flux") + { + my $change = $ddl; + $change =~ s/AM/$am/g; + my $create = "CREATE TABLE t(id int, w text) USING $am;"; + my $sql = ($inplace ? "BEGIN; $create" : "$create BEGIN;") . qq{ +SAVEPOINT a; +INSERT INTO t SELECT -g, repeat('x',100) FROM generate_series(1,20) g; +ROLLBACK TO a; +RELEASE a; +SELECT set_config('v3h.oldfile', pg_relation_filenode('t')::text, true) IS NOT NULL; +$change; +INSERT INTO t SELECT g, repeat('x',100) FROM generate_series(1,20) g; +SELECT pg_relation_filenode('t') = current_setting('v3h.oldfile')::oid; +SELECT count(*), sum(id) FROM t; +SELECT count(*) FROM (SELECT * FROM t FOR UPDATE) s; +WITH s AS (UPDATE t SET w = 'updated' RETURNING *) SELECT count(*) FROM s; +WITH s AS (DELETE FROM t RETURNING *) SELECT count(*) FROM s; +COMMIT; +DROP TABLE t; +}; + push @result, $node->safe_psql("postgres", $sql); + } + my $samefile = $inplace && $ddl eq "TRUNCATE t" ? 't' : 'f'; + # SET AM heap on heap is a no-op, unlike flux -> heap -> flux. + $result[0] =~ s/^t\n[tf]\n//; + like($result[1], qr/^t\n$samefile\n/, + "physical incarnation inplace=$inplace $ddl"); + $result[1] =~ s/^t\n[tf]\n//; + is($result[0], "20|210\n20\n20\n20", + "heap control inplace=$inplace $ddl"); + is($result[1], $result[0], "flux matches heap inplace=$inplace $ddl"); + } +} +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/115_flux_subxact_abort_dropped_storage.pl b/src/test/recovery/t/146_flux_subxact_abort_dropped_storage.pl similarity index 86% rename from src/test/recovery/t/115_flux_subxact_abort_dropped_storage.pl rename to src/test/recovery/t/146_flux_subxact_abort_dropped_storage.pl index f1c7c6cc1de13..6e53b0efb3816 100644 --- a/src/test/recovery/t/115_flux_subxact_abort_dropped_storage.pl +++ b/src/test/recovery/t/146_flux_subxact_abort_dropped_storage.pl @@ -114,6 +114,27 @@ "$am: $scope rollback restores the old index file"); } + # DROP INDEX: the index UNDO names an index the subtransaction dropped, + # which the relcache cannot open until the subtransaction's catalog + # changes are rolled back. Left unreversed, the index keeps the aborted + # (k_new, TID) entry live, and moving the row there again inserts a + # second entry with the same key and TID. + $node->safe_psql( + 'postgres', qq( +BEGIN; +SAVEPOINT s; +UPDATE ${am}_keep SET v = v + 1000 WHERE id BETWEEN 1 AND 5; +DROP INDEX ${am}_keep_v; +ROLLBACK TO s; +COMMIT; +UPDATE ${am}_keep SET v = v + 1000 WHERE id BETWEEN 1 AND 5; +UPDATE ${am}_keep SET v = v - 1000 WHERE id BETWEEN 1 AND 5; +)); + is( $node->psql( + 'postgres', "SELECT bt_index_check('${am}_keep_v', true)"), + 0, + "$am: savepoint rollback of DROP INDEX restores the index"); + is( $node->safe_psql( 'postgres', "SELECT count(*), sum(v) FROM ${am}_keep"), '13|0', diff --git a/src/test/recovery/t/147_flux_repack_concurrently.pl b/src/test/recovery/t/147_flux_repack_concurrently.pl new file mode 100644 index 0000000000000..c9d45cc5cc064 --- /dev/null +++ b/src/test/recovery/t/147_flux_repack_concurrently.pl @@ -0,0 +1,517 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# REPACK (CONCURRENTLY) of a FLUX table across a crash, on a standby, and at +# wal_level = replica, each next to a heap twin that runs the same statements. +# +# Crash: the server is stopped in immediate mode while REPACK +# - slot: has its decoding worker create the replication slot +# (replication-slot-create-begin), before the initial snapshot; +# - copied: has copied the table and built the new indexes, and waits at +# repack-concurrently-before-lock while other sessions change the table; +# - caught_up: has replayed those changes onto the new table (FLUX changes +# the new rows in place, with undo, as REPACK's transaction) and waits for +# its AccessExclusiveLock; +# - commit: has flushed its commit record (commit-before-clog-update); +# - committed: has returned. +# After recovery the table and its indexes must hold exactly the rows that +# were committed before the crash, in the new file only if REPACK's commit +# record was written, and nothing of the transient table may be left: no +# catalog entry, no file, no slot, no undo holding WAL back. +# +# Standby: a FLUX REPACK (CONCURRENTLY) is replayed. A repeatable read +# transaction on the standby that read the table before the swap holds the +# replay back and keeps reading the old rows; once it ends, the standby has +# the primary's new file, rows and intact indexes. +# +# wal_level = replica: REPACK enables logical decoding itself while a +# transaction that wrote the table's FLUX records without logical images is +# still open. The snapshot builder waits for it; its changes must reach the +# copy and never the decoding, both when it commits and when it aborts. +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'; +} + +# Replace the table name T in $sql. +sub sub_t +{ + my ($sql, $t) = @_; + (my $x = $sql) =~ s/\bT(?=\b|_)/$t/g; + return $x; +} + +my $setup = q( +CREATE TABLE T (id int PRIMARY KEY, k int, v text) USING AM; +ALTER TABLE T ALTER COLUMN v SET STORAGE EXTERNAL; +CREATE INDEX T_k_idx ON T (k); +INSERT INTO T SELECT g, g, CASE WHEN g % 10 = 0 THEN repeat(md5(g::text), 100) + ELSE 'v' || g END FROM generate_series(1, 200) g; +DELETE FROM T WHERE id % 7 = 0; +UPDATE T SET v = v || '+' WHERE id % 5 = 0; +UPDATE T SET k = k + 1000 WHERE id % 11 = 0; +); + +# Committed while REPACK runs: INSERT; UPDATE in place, of an indexed +# column, of the key, growing, of a TOAST value; DELETE; and an INSERT that +# is updated and deleted again. +my $dml = q( +INSERT INTO T SELECT g, g, 'n' || g FROM generate_series(301, 320) g; +UPDATE T SET v = 'u' || id WHERE id BETWEEN 1 AND 6; +UPDATE T SET k = k + 2000 WHERE id BETWEEN 8 AND 12; +UPDATE T SET id = id + 1000 WHERE id BETWEEN 15 AND 18; +UPDATE T SET v = repeat('g', 300) WHERE id = 19; +UPDATE T SET v = repeat(md5('t'), 100) WHERE id = 20; +DELETE FROM T WHERE id BETWEEN 22 AND 26; +INSERT INTO T VALUES (400, 400, 'x'); +UPDATE T SET v = 'y' WHERE id = 400; +DELETE FROM T WHERE id = 400; +); + +# More DML once the table has been repacked. +my $after = q( +UPDATE T SET v = v || 'a'; +DELETE FROM T WHERE id % 3 = 0; +INSERT INTO T SELECT g, g, 'a' FROM generate_series(501, 510) g; +UPDATE T SET k = k + 1 WHERE id IN (1, 2, 501); +); + +sub create_twins +{ + my ($node, $prefix) = @_; + foreach my $am ('flux', 'heap') + { + my $t = $prefix . '_' . substr($am, 0, 1); + (my $s = $setup) =~ s/\bAM\b/$am/; + $node->safe_psql('postgres', sub_t($s, $t)); + } +} + +# Rows of a table as count, sum and md5, through a seqscan and through the +# primary key. +sub content_sql +{ + my ($t) = @_; + my $q = + "SELECT count(*) || ':' || coalesce(sum(k), 0) || ':' || md5(coalesce(string_agg(x::text, ',' ORDER BY id), '')) FROM $t x"; + return + "SET enable_indexscan = off; SET enable_bitmapscan = off; $q;\n" + . "SET enable_seqscan = off; SET enable_indexscan = on; $q;\n" + . "RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan;"; +} + +sub content +{ + my ($node, $t) = @_; + return $node->safe_psql('postgres', content_sql($t)); +} + +# bt_index_check (heapallindexed) reports corruption with an ERROR: returns +# psql's stderr, empty on success. +sub bt_check +{ + my ($node, $rel) = @_; + my ($out, $err); + my $rc = $node->psql( + 'postgres', + "SELECT bt_index_check('$rel'::regclass, heapallindexed => true)", + stdout => \$out, + stderr => \$err, + on_error_stop => 1); + return $rc == 0 && $err eq '' ? '' : "rc=$rc: $err"; +} + +# Indexes intact, every key found by a unique check (primary only), and the +# heap twin passes verify_heapam. +sub check_table +{ + my ($node, $t, $what) = @_; + my @idx = split /\n/, + $node->safe_psql('postgres', + "SELECT c.relname FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid WHERE i.indrelid = '$t'::regclass ORDER BY 1" + ); + is(join(',', @idx), "${t}_k_idx,${t}_pkey", "$t: both indexes, $what"); + is(bt_check($node, $_), '', "$_: bt_index_check, $what") foreach @idx; + is( $node->safe_psql( + 'postgres', + "WITH i AS (INSERT INTO $t SELECT * FROM $t ON CONFLICT DO NOTHING RETURNING 1) SELECT count(*) FROM i" + ), + '0', + "$t: unique checks see every row, $what") + unless $node->safe_psql('postgres', 'SELECT pg_is_in_recovery()') eq + 't'; + is( $node->safe_psql( + 'postgres', "SELECT count(*) FROM verify_heapam('$t')"), + '0', + "$t: verify_heapam, $what") if $t =~ /_h$/; +} + +sub filenode +{ + my ($node, $t) = @_; + return $node->safe_psql('postgres', "SELECT pg_relation_filenode('$t')"); +} + +# Files of the database that no relation owns, after a checkpoint has +# removed the files of dropped relations. +sub orphans +{ + my ($node) = @_; + $node->safe_psql('postgres', 'CHECKPOINT'); + return $node->safe_psql( + 'postgres', q( +SELECT count(*) FROM pg_ls_dir('base/' || (SELECT oid FROM pg_database + WHERE datname = current_database())) f +WHERE f ~ '^[0-9]+$' AND pg_filenode_relation(0, f::oid) IS NULL)); +} + +sub wait_pid +{ + my ($node, $pid, $cond, $what) = @_; + ok( $node->poll_query_until( + 'postgres', + "SELECT count(*) = 1 FROM pg_stat_activity WHERE pid = $pid AND $cond" + ), + $what); +} + +# Start REPACK (CONCURRENTLY) of $t in a background session that stops at +# the injection points in @points (attached for that session only). +sub start_repack +{ + my ($node, $t, @points) = @_; + my $rp = $node->background_psql('postgres'); + my $pid = $rp->query_safe('SELECT pg_backend_pid()'); + $rp->query_safe('SELECT injection_points_set_local()') if @points; + $rp->query_safe("SELECT injection_points_attach('$_', 'wait')") + foreach @points; + $rp->query_until(qr/started/, + "\\echo started\nREPACK (CONCURRENTLY) $t;\n\\echo repacked\n"); + return ($rp, $pid); +} + +# Wait for the background REPACK to return; true if it succeeded. +sub finish_repack +{ + my ($rp) = @_; + $rp->query_until(qr/repacked/, ''); + my $err = $rp->{stderr}; + $rp->quit; + return $err; +} + +# Is any undo, or any ATM entry, still holding WAL back? +my $retained = q( +SELECT (SELECT count(*) FROM pg_stat_get_undo_retention()) = 0 + AND (SELECT bool_and(discard = insert) FROM pg_stat_get_undo_logs() + WHERE persistence = 'permanent')); + +########################################################################### +# Crash during REPACK (CONCURRENTLY) +########################################################################### +my $node = PostgreSQL::Test::Cluster->new('crash'); +$node->init(allows_streaming => 1); +$node->append_conf( + 'postgresql.conf', qq( +autovacuum = off +shared_preload_libraries = 'injection_points' +)); +$node->start; +if ( !$node->check_extension('injection_points') + || !$node->check_extension('amcheck')) +{ + plan skip_all => 'injection_points or amcheck not installed'; +} +$node->safe_psql('postgres', + 'CREATE EXTENSION injection_points; CREATE EXTENSION amcheck;'); + +like( + bt_check($node, 'pg_class'), + qr/ERROR: "pg_class" is not an index/, + 'control: bt_index_check of a table fails'); + +my @stages = qw(slot copied caught_up commit committed); +create_twins($node, "c_$_") foreach @stages; + +sub crash_during_repack +{ + my ($stage, $t) = @_; + my $oid = $node->safe_psql('postgres', "SELECT '$t'::regclass::oid"); + my $fn0 = filenode($node, $t); + my $orph0 = orphans($node); + my ($rp, $pid, $lk); + + if ($stage eq 'slot') + { + # The decoding worker creates the slot: attach for every process. + $node->safe_psql('postgres', + "SELECT injection_points_attach('replication-slot-create-begin', 'wait')" + ); + ($rp, $pid) = start_repack($node, $t); + $node->wait_for_event('REPACK decoding worker', + 'replication-slot-create-begin'); + } + else + { + ($rp, $pid) = start_repack( + $node, $t, + 'repack-concurrently-before-lock', + $stage eq 'commit' ? ('commit-before-clog-update') : ()); + wait_pid( + $node, $pid, + "wait_event = 'repack-concurrently-before-lock'", + "$t: REPACK copied the table"); + } + # Control for the slot check below. + is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_replication_slots WHERE slot_name LIKE 'pg_repack_%'" + ), + '1', + "$t: control: REPACK's slot exists before the crash" + ) unless $stage eq 'slot'; + $node->safe_psql('postgres', sub_t($dml, $t)); + my $expect = content($node, $t); + + if ($stage eq 'caught_up') + { + $lk = $node->background_psql('postgres'); + $lk->query_safe("BEGIN; LOCK TABLE $t IN ACCESS SHARE MODE"); + } + $node->safe_psql('postgres', + "SELECT injection_points_wakeup('repack-concurrently-before-lock')") + unless $stage eq 'slot'; + if ($stage eq 'caught_up') + { + wait_pid( + $node, $pid, + "wait_event_type = 'Lock' AND wait_event = 'relation'", + "$t: REPACK replayed the changes and waits for its lock"); + is( $node->safe_psql( + 'postgres', + "SELECT phase, heap_tuples_inserted > 0, heap_tuples_updated > 0, heap_tuples_deleted > 0 FROM pg_stat_progress_repack WHERE pid = $pid" + ), + 'catch-up|t|t|t', + "$t: control: REPACK replayed inserts, updates and deletes"); + } + elsif ($stage eq 'commit') + { + wait_pid( + $node, $pid, + "wait_event = 'commit-before-clog-update'", + "$t: REPACK flushed its commit record"); + } + elsif ($stage eq 'committed') + { + is(finish_repack($rp), '', "$t: REPACK succeeded"); + } + + $node->stop('immediate'); + $rp->{run}->kill_kill if $stage ne 'committed'; + $lk->{run}->kill_kill if $lk; + $node->start; + + my $what = "after a crash at $stage"; + if ($stage eq 'commit' || $stage eq 'committed') + { + isnt(filenode($node, $t), $fn0, "$t: REPACK committed, $what"); + } + else + { + is(filenode($node, $t), $fn0, "$t: REPACK rolled back, $what"); + } + is(content($node, $t), $expect, "$t: the committed rows, $what"); + check_table($node, $t, $what); + is( $node->safe_psql( + 'postgres', + "SELECT count(*) FROM pg_class WHERE relname = 'pg_temp_$oid'"), + '0', + "$t: no transient relation left, $what"); + is( $node->safe_psql( + 'postgres', 'SELECT count(*) FROM pg_replication_slots'), + '0', + "$t: no slot left, $what"); + ok( $node->poll_query_until('postgres', $retained), + "$t: no undo holding WAL back, $what"); + return orphans($node) - $orph0; +} + +# A crash leaves the files of a relation that an uncommitted transaction +# created behind (for heap as well; the same holds for a plain CREATE TABLE), +# so only the committed stages must leave nothing. +foreach my $stage (@stages) +{ + my %left = + map { $_ => crash_during_repack($stage, "c_${stage}_$_") } ('f', 'h'); + if ($stage eq 'commit' || $stage eq 'committed') + { + is($left{$_}, 0, "c_${stage}_$_: no file left behind after a crash") + foreach ('f', 'h'); + } + else + { + is($left{f}, $left{h}, + "c_${stage}_f: leaves as many files behind as its heap twin"); + # Control: the transient table's files are counted. + cmp_ok($left{h}, '>', 0, + "c_${stage}_h: control: the transient table's files are left") + unless $stage eq 'slot'; + } + is( content($node, "c_${stage}_f"), + content($node, "c_${stage}_h"), + "c_${stage}_f: same rows as its heap twin after a crash at $stage"); +} + +# Both tables can be repacked again and changed afterwards. +foreach my $stage (@stages) +{ + foreach my $am ('f', 'h') + { + my $t = "c_${stage}_$am"; + $node->safe_psql('postgres', "REPACK (CONCURRENTLY) $t"); + $node->safe_psql('postgres', sub_t($after, $t)); + check_table($node, $t, 'repacked again after the crash'); + } + is( content($node, "c_${stage}_f"), + content($node, "c_${stage}_h"), + "c_${stage}_f: same rows as its heap twin, repacked again"); +} + +########################################################################### +# wal_level = replica: an open transaction wrote the table without logical +# images when REPACK enables logical decoding. +########################################################################### +# Before logical decoding is enabled: a same-size UPDATE (the byte-diff +# path), an UPDATE of an indexed column, an INSERT and a DELETE. +my $xbefore = q( +UPDATE T SET v = 'w' || substr(v, 2) WHERE id BETWEEN 1 AND 4; +UPDATE T SET k = k + 5000 WHERE id = 5; +INSERT INTO T VALUES (601, 601, 'x601'); +DELETE FROM T WHERE id = 6; +); +# The same transaction, after REPACK enabled it. +my $xafter = q( +UPDATE T SET v = 'z' || substr(v, 2) WHERE id BETWEEN 1 AND 2; +INSERT INTO T VALUES (602, 602, 'x602'); +DELETE FROM T WHERE id = 8; +); +foreach my $end ('commit', 'rollback') +{ + create_twins($node, "w_$end"); + foreach my $am ('f', 'h') + { + my $t = "w_${end}_$am"; + ok( $node->poll_query_until( + 'postgres', + "SELECT current_setting('effective_wal_level') = 'replica'"), + "$t: logical decoding is off"); + my $fn0 = filenode($node, $t); + my $x = $node->background_psql('postgres'); + $x->query_safe("BEGIN;\n" . sub_t($xbefore, $t)); + my ($rp, $pid) = start_repack($node, $t); + $node->wait_for_event('REPACK decoding worker', 'transactionid'); + is( $node->safe_psql( + 'postgres', "SELECT current_setting('effective_wal_level')"), + 'logical', + "$t: REPACK enabled logical decoding, waits for the open transaction" + ); + $x->query_safe(sub_t($xafter, $t) . "\n$end"); + $x->quit; + is(finish_repack($rp), '', "$t: REPACK succeeded"); + isnt(filenode($node, $t), $fn0, "$t: repacked"); + check_table($node, $t, + "transaction open across REPACK's start, $end"); + } + is( content($node, "w_${end}_f"), + content($node, "w_${end}_h"), + "w_${end}_f: same rows as its heap twin"); + ok($node->poll_query_until('postgres', $retained), + "w_$end: no undo holding WAL back"); +} + +########################################################################### +# Standby +########################################################################### +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', qq( +autovacuum = off +shared_preload_libraries = 'injection_points' +)); +$primary->start; +$primary->safe_psql('postgres', + 'CREATE EXTENSION injection_points; CREATE EXTENSION amcheck;'); +$primary->backup('bk'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bk', has_streaming => 1); +$standby->append_conf('postgresql.conf', 'max_standby_streaming_delay = -1'); +$standby->start; + +create_twins($primary, 's'); +$primary->wait_for_replay_catchup($standby); +foreach my $am ('f', 'h') +{ + my $t = "s_$am"; + my $sb = $standby->background_psql('postgres'); + $sb->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + my $old = $sb->query_safe(content_sql($t)); + my $fn0 = filenode($primary, $t); + is($old, content($primary, $t), "$t: standby transaction reads the rows"); + + my ($rp, $pid) = + start_repack($primary, $t, 'repack-concurrently-before-lock'); + wait_pid( + $primary, $pid, + "wait_event = 'repack-concurrently-before-lock'", + "$t: REPACK copied the table"); + $primary->safe_psql('postgres', sub_t($dml, $t)); + $primary->safe_psql('postgres', + "SELECT injection_points_wakeup('repack-concurrently-before-lock')"); + is(finish_repack($rp), '', "$t: REPACK succeeded"); + isnt(filenode($primary, $t), $fn0, "$t: repacked"); + + ok( $standby->poll_query_until( + 'postgres', + "SELECT count(*) = 1 FROM pg_stat_activity WHERE backend_type = 'startup' AND wait_event_type = 'Lock' AND wait_event = 'relation'" + ), + "$t: replay of the swap waits for the standby transaction"); + is($sb->query_safe(content_sql($t)), + $old, "$t: the standby transaction still reads the old rows"); + $sb->query_safe('COMMIT'); + $sb->quit; + + $primary->wait_for_replay_catchup($standby); + is( filenode($standby, $t), + filenode($primary, $t), + "$t: standby has the new file"); + is( content($standby, $t), + content($primary, $t), + "$t: standby has the primary's rows"); + check_table($standby, $t, 'on the standby'); + check_table($primary, $t, 'on the primary'); +} +is( content($primary, 's_f'), + content($primary, 's_h'), + 's_f: same rows as its heap twin'); +is( content($standby, 's_f'), + content($standby, 's_h'), + 's_f: same rows as its heap twin on the standby'); + +foreach my $n ($node, $primary, $standby) +{ + unlike( + slurp_file($n->logfile), + qr/TRAP|PANIC|does not match aborted version|giving up on rolling back/, + $n->name . ': no failed assertion, mismatched or abandoned undo'); +} +$standby->stop; +$primary->stop; +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/148_flux_pg_visibility.pl b/src/test/recovery/t/148_flux_pg_visibility.pl new file mode 100644 index 0000000000000..a67ff3fed5bff --- /dev/null +++ b/src/test/recovery/t/148_flux_pg_visibility.pl @@ -0,0 +1,87 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# pg_visibility's pg_check_visible and pg_check_frozen decode each item of a +# page marked all-visible or all-frozen as a heap tuple header. On a FLUX +# table they must refuse, as pgstattuple, pgrowlocks, pg_surgery and +# amcheck's verify_heapam do. They used to read the FLUX items as heap +# tuples: every row of every all-visible page was reported corrupt, and with +# an xid open in another database the backend failed an assertion in +# HeapTupleSatisfiesVacuum (pg_visibility's own 001_concurrent_transaction +# setup). Heap is the control. The functions that read only the visibility +# map and the page header keep working on a FLUX table. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; + +$node->safe_psql('postgres', 'CREATE DATABASE other_database'); +my $bsession = $node->background_psql('other_database'); +$bsession->query_safe('BEGIN; SELECT txid_current();'); + +$node->safe_psql('postgres', 'CREATE EXTENSION pg_visibility'); +foreach my $am ('heap', 'flux') +{ + $node->safe_psql( + 'postgres', qq( +CREATE TABLE vt_$am USING $am AS SELECT 42 i; +CREATE TABLE vf_$am USING $am AS SELECT g FROM generate_series(1, 2000) g; +VACUUM (disable_page_skipping) vt_$am; +VACUUM (FREEZE, disable_page_skipping) vf_$am; +)); +} + +# Heap: nothing to report. +foreach my $t ('vt_heap', 'vf_heap') +{ + foreach my $f ('pg_check_visible', 'pg_check_frozen') + { + is($node->safe_psql('postgres', "SELECT * FROM $f('$t')"), + '', "$f on heap table $t reports nothing"); + } +} + +# FLUX: a clean error, and the server stays up. +foreach my $t ('vt_flux', 'vf_flux') +{ + foreach my $f ('pg_check_visible', 'pg_check_frozen') + { + my ($ret, $out, $err) = + $node->psql('postgres', "SELECT * FROM $f('$t')"); + like( + $err, + qr/only heap AM is supported/, + "$f on FLUX table $t is refused"); + is($out, '', "$f on FLUX table $t returns no TIDs"); + } +} +is($node->safe_psql('postgres', 'SELECT 1'), '1', 'server still up'); +my $log = slurp_file($node->logfile); +unlike($log, qr/TRAP:|terminated by signal/, 'no backend crashed'); + +# The visibility map functions read no tuples: they work for FLUX too. +is( $node->safe_psql( + 'postgres', + "SELECT count(*) = pg_relation_size('vf_flux') / current_setting('block_size')::int + FROM pg_visibility('vf_flux')"), + 't', + 'pg_visibility covers every block of a FLUX table'); +is( $node->safe_psql( + 'postgres', + "SELECT all_visible > 0 AND all_frozen > 0 FROM pg_visibility_map_summary('vf_flux')" + ), + 't', + 'pg_visibility_map_summary sees the FLUX table frozen by VACUUM (FREEZE)' +); + +$bsession->query_safe('COMMIT'); +$bsession->quit; +$node->stop; + +done_testing(); diff --git a/src/test/recovery/t/149_flux_partial_index_member.pl b/src/test/recovery/t/149_flux_partial_index_member.pl new file mode 100644 index 0000000000000..db920b1bdca33 --- /dev/null +++ b/src/test/recovery/t/149_flux_partial_index_member.pl @@ -0,0 +1,196 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A partial index answers a query only for rows that satisfy its predicate: +# the planner drops a qual the predicate implies, and a unique or exclusion +# check counts only member rows. A FLUX in-place UPDATE keeps the row at +# its TID, so a row whose membership changes keeps the entry its earlier +# version had, delete-marked, and the reader's recheck compares only the +# entry's key with the visible version's. Every query here has a heap +# control; amcheck runs on every index afterwards. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_partial_index_member'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION amcheck'); + +# Run a query with index scans forced (no seqscan, no bitmap scan). +sub idx +{ + my ($sql) = @_; + return $node->safe_psql( + 'postgres', qq{ +SET enable_seqscan = off; SET enable_bitmapscan = off; +SET enable_indexonlyscan = off; SET enable_sort = off; +$sql}); +} + +# The same through a bitmap heap scan. +sub bitmap +{ + my ($sql, $work_mem) = @_; + return $node->safe_psql( + 'postgres', qq{ +SET enable_seqscan = off; SET enable_indexscan = off; +SET enable_indexonlyscan = off; SET work_mem = '$work_mem'; +$sql}); +} + +sub check_indexes +{ + my ($tab) = @_; + my $res = $node->safe_psql( + 'postgres', qq{ +SELECT string_agg(c.relname, ',') FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am a ON a.oid = c.relam + WHERE i.indrelid = '$tab'::regclass AND a.amname = 'btree' + AND NOT i.indisexclusion}); + my $err = ''; + foreach my $idx (split /,/, $res) + { + my ($rc, $out, $e) = $node->psql('postgres', + "SELECT bt_index_parent_check('$idx', true, true, true)"); + $err .= $e; + } + return $err; +} + +foreach my $am ('heap', 'flux') +{ + # A row leaves the index's predicate, its key unchanged. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE l_$am (id int, n int, active bool) USING $am; +CREATE INDEX l_${am}_i ON l_$am (n) WHERE active; +INSERT INTO l_$am SELECT g, g, true FROM generate_series(1, 5) g; +UPDATE l_$am SET active = false WHERE id IN (1, 3); +}); + is(idx("SELECT id, active FROM l_$am WHERE n = 3 AND active"), + '', "$am: a row that left the predicate is not found by its key"); + is( idx("SELECT count(*) FROM l_$am WHERE n BETWEEN 1 AND 5 AND active"), + '3', "$am: nor by a range scan"); + is(idx("SELECT min(n) FROM l_$am WHERE active"), + '2', "$am: min() through the partial index"); + is(idx("SELECT id FROM l_$am WHERE active ORDER BY n LIMIT 1"), + '2', "$am: ORDER BY through the partial index"); + is( idx(qq{SET enable_hashjoin = off; SET enable_nestloop = off; +SELECT count(*) FROM l_$am a JOIN generate_series(1, 5) g ON a.n = g + WHERE a.active}), + '3', + "$am: merge join through the partial index"); + is(bitmap("SELECT count(*) FROM l_$am WHERE n <= 5 AND active", '4MB'), + '3', "$am: exact bitmap scan"); + is(check_indexes("l_$am"), '', "$am: amcheck after leaving"); + + # Lossy bitmap pages: enough rows that 64kB cannot keep every TID. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE lb_$am (id int, n int, active bool) USING $am; +CREATE INDEX lb_${am}_i ON lb_$am (n) WHERE active; +INSERT INTO lb_$am SELECT g, g % 10, true FROM generate_series(1, 100000) g; +UPDATE lb_$am SET active = false WHERE id % 10 = 3; +}); + is( bitmap( + "SELECT count(*), count(*) FILTER (WHERE NOT active) FROM lb_$am WHERE n = 3 AND active", + '64kB'), + '0|0', + "$am: lossy bitmap scan"); + + # A row leaves the predicate and changes its key. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE lk_$am (id int, n int, active bool) USING $am; +CREATE INDEX lk_${am}_i ON lk_$am (n) WHERE active; +INSERT INTO lk_$am SELECT g, g, true FROM generate_series(1, 5) g; +UPDATE lk_$am SET active = false, n = 30 WHERE id = 3; +}); + is(idx("SELECT count(*) FROM lk_$am WHERE n IN (3, 30) AND active"), + '0', "$am: left the predicate with another key"); + is(check_indexes("lk_$am"), '', "$am: amcheck after leaving with a key"); + + # A predicate on another column, through an expression. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE le_$am (id int, n int) USING $am; +CREATE INDEX le_${am}_i ON le_$am (id) WHERE n % 2 = 1; +INSERT INTO le_$am SELECT g, g FROM generate_series(1, 5) g; +UPDATE le_$am SET n = 4 WHERE id = 3; +}); + is(idx("SELECT id, n FROM le_$am WHERE id = 3 AND n % 2 = 1"), + '', "$am: left an expression predicate"); + is(check_indexes("le_$am"), '', "$am: amcheck after an expression left"); + + # A snapshot that sees the row outside the predicate, after it came + # back with the same key. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE r_$am (id int, n int, active bool) USING $am; +CREATE INDEX r_${am}_i ON r_$am (n) WHERE active; +INSERT INTO r_$am SELECT g, g, true FROM generate_series(1, 5) g; +UPDATE r_$am SET active = false WHERE id = 3; +}); + my $rr = $node->background_psql('postgres'); + $rr->query_safe('BEGIN ISOLATION LEVEL REPEATABLE READ'); + $rr->query_safe("SELECT count(*) FROM r_$am"); + $node->safe_psql('postgres', + "UPDATE r_$am SET active = true WHERE id = 3"); + is( $rr->query_safe( + qq{SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT id, active FROM r_$am WHERE n = 3 AND active}), + '', + "$am: snapshot from outside the predicate after re-entry"); + $rr->query_safe('COMMIT'); + $rr->quit; + is(idx("SELECT id, active FROM r_$am WHERE n = 3 AND active"), + '3|t', "$am: re-entered row is found"); + is(check_indexes("r_$am"), '', "$am: amcheck after re-entry"); + + # A partial unique index counts member rows only. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE u_$am (id int, n int, active bool) USING $am; +CREATE UNIQUE INDEX u_${am}_i ON u_$am (n) WHERE active; +INSERT INTO u_$am SELECT g, g, true FROM generate_series(1, 5) g; +UPDATE u_$am SET active = false WHERE id IN (2, 3); +}); + my ($rc, $out, $err) = + $node->psql('postgres', "INSERT INTO u_$am VALUES (13, 3, true)"); + is($rc . $err, '0', "$am: unique key free after its row left"); + is( $node->safe_psql( + 'postgres', qq{ +INSERT INTO u_$am VALUES (12, 2, true) ON CONFLICT (n) WHERE active DO NOTHING +RETURNING id}), + '12', + "$am: ON CONFLICT finds no conflict for a row that left"); + ($rc, $out, $err) = + $node->psql('postgres', "UPDATE u_$am SET active = true WHERE id = 3"); + like( + $err, + qr/duplicate key value/, + "$am: a row coming back to a taken key conflicts"); + is(check_indexes("u_$am"), '', "$am: amcheck after unique membership"); + + # An exclusion constraint with a predicate, and a hash index. + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE x_$am (id int, n int, active bool, + EXCLUDE USING btree (n WITH =) WHERE (active)) USING $am; +CREATE INDEX x_${am}_h ON x_$am USING hash (n) WHERE active; +INSERT INTO x_$am SELECT g, g, true FROM generate_series(1, 5) g; +UPDATE x_$am SET active = false WHERE id = 3; +}); + ($rc, $out, $err) = + $node->psql('postgres', "INSERT INTO x_$am VALUES (13, 3, true)"); + is($rc . $err, '0', "$am: exclusion constraint free after its row left"); + is(idx("SELECT id FROM x_$am WHERE n = 3 AND active"), + '13', "$am: hash partial index finds the member only"); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/150_flux_vm_stale_size.pl b/src/test/recovery/t/150_flux_vm_stale_size.pl new file mode 100644 index 0000000000000..bd6cece89123a --- /dev/null +++ b/src/test/recovery/t/150_flux_vm_stale_size.pl @@ -0,0 +1,121 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# A change to an all-visible page must clear the page's visibility map bits +# even if the map was created after the changing backend last looked at its +# size. A backend processes the invalidation that announces the new map +# only when it next takes a new lock, so one statement can look at the map +# (it has none), wait while VACUUM creates it and makes pages all-visible, +# and then change those pages. The statement is a plpgsql function whose +# plans are already cached, and it waits on an advisory lock, so it takes +# no relation lock in between (a subtransaction that ends would release +# its lock, and the next statement's would process the invalidation). heap is the control; it pins the map with +# extension, which reads the size again. +# +# A bit left set wrongly would let a VACUUM that skips all-frozen pages +# leave the change's xid behind relfrozenxid; that check is a guard. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('flux_vm_stale_size'); +$node->init; +$node->append_conf('postgresql.conf', 'autovacuum = off'); +$node->start; +$node->safe_psql('postgres', 'CREATE EXTENSION pg_visibility'); + +foreach my $am ('heap', 'flux') +{ + $node->safe_psql( + 'postgres', qq{ +CREATE TABLE t_$am (id int, v int) USING $am; +INSERT INTO t_$am SELECT g, g FROM generate_series(1, 2000) g; +CREATE FUNCTION change_$am(t1 tid, t2 tid) RETURNS void LANGUAGE plpgsql AS \$\$ +BEGIN + UPDATE t_$am SET v = v + 1 WHERE ctid = '(0,1)'; + PERFORM pg_advisory_lock(1); + UPDATE t_$am SET v = -1 WHERE ctid = t1; + DELETE FROM t_$am WHERE ctid = t2; + PERFORM pg_advisory_unlock(1); +END \$\$; +}); + my %id; + foreach my $blk (1, 2) + { + $id{$blk} = $node->safe_psql('postgres', + "SELECT id FROM t_$am WHERE ctid = '($blk,1)'"); + } + is( $node->safe_psql( + 'postgres', "SELECT pg_relation_size('t_$am', 'vm')"), + '0', + "$am: no visibility map yet"); + + # Cache the function's plans first. + my $w = $node->background_psql('postgres'); + $w->query_safe("BEGIN; SELECT change_$am('(1,2)', '(2,2)'); ROLLBACK"); + + my $holder = $node->background_psql('postgres'); + $holder->query_safe('SELECT pg_advisory_lock(1)'); + $w->query_safe('BEGIN'); + $w->query_until(qr/started/, + "\\echo started\nSELECT change_$am('(1,1)', '(2,1)');\n"); + $node->poll_query_until('postgres', + "SELECT count(*) > 0 FROM pg_locks WHERE locktype = 'advisory' AND NOT granted" + ) or die "the change did not wait"; + $node->safe_psql('postgres', "VACUUM (FREEZE) t_$am"); + is( $node->safe_psql( + 'postgres', + "SELECT all_visible, all_frozen, pd_all_visible FROM pg_visibility('t_$am', 1)" + ), + 't|t|t', + "$am: VACUUM made block 1 all-frozen"); + $holder->query_safe('SELECT pg_advisory_unlock(1)'); + $holder->quit; + $w->query_safe('SELECT 1'); + + foreach my $blk (1, 2) + { + is( $node->safe_psql( + 'postgres', + "SELECT all_visible, all_frozen, pd_all_visible FROM pg_visibility('t_$am', $blk)" + ), + 'f|f|f', + "$am: change in progress cleared block $blk"); + } + is( $node->safe_psql( + 'postgres', + "SELECT count(*), sum(v) FROM t_$am WHERE id IN ($id{1}, $id{2})" + ), + "2|" . ($id{1} + $id{2}), + "$am: a seqscan does not see the changes in progress"); + $w->query_safe('COMMIT'); + $w->quit; + is( $node->safe_psql( + 'postgres', + "SELECT count(*), sum(v) FROM t_$am WHERE id IN ($id{1}, $id{2})" + ), + '1|-1', + "$am: a seqscan sees the committed changes"); + + # A VACUUM that skips all-frozen pages must not skip the page with the + # UPDATE, or relfrozenxid passes its xid. + my ($xmin) = $node->safe_psql('postgres', + "SELECT xmin FROM t_$am WHERE id = $id{1}"); + $node->safe_psql('postgres', "VACUUM t_$am"); + is( $node->safe_psql( + 'postgres', qq{ +SELECT age('$xmin'::xid) <= age(relfrozenxid) FROM pg_class + WHERE relname = 't_$am'}), + 't', + "$am: relfrozenxid does not pass the xid of the UPDATE"); + $node->safe_psql('postgres', "VACUUM (FREEZE) t_$am"); + is( $node->safe_psql( + 'postgres', + "SELECT all_visible, all_frozen FROM pg_visibility('t_$am', 1)"), + 't|t', + "$am: VACUUM froze block 1 again"); +} + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/151_flux_vm_set_fpi.pl b/src/test/recovery/t/151_flux_vm_set_fpi.pl new file mode 100644 index 0000000000000..67170bdeee8f9 --- /dev/null +++ b/src/test/recovery/t/151_flux_vm_set_fpi.pl @@ -0,0 +1,91 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# VACUUM's visibility map changes must survive crash recovery and replay. +# The first change to a map page after a checkpoint is logged with a +# full-page image. The map lies between pd_lower and pd_upper, the hole a +# standard page image leaves out, so a map page registered as standard is +# restored without its bits. Heap registers map pages as non-standard. +# +# For each AM: a checkpoint, then VACUUM (FREEZE) sets the bits of a new +# map (its first change since the checkpoint), compared on a standby; then +# a change clears one page's bits, another checkpoint, VACUUM (FREEZE) sets +# them again, compared on the standby and after crash recovery. heap is +# the control. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf('postgresql.conf', "autovacuum = off\n"); +$primary->start; +$primary->safe_psql('postgres', + 'CREATE EXTENSION pg_visibility; CREATE TABLE flushed (i int)'); +$primary->backup('bkp'); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'bkp', has_streaming => 1); +$standby->start; + +sub bits +{ + my ($node, $t) = @_; + return $node->safe_psql( + 'postgres', qq{ +SELECT count(*) || ' pages, ' || count(*) FILTER (WHERE all_visible) + || ' all-visible, ' || count(*) FILTER (WHERE all_frozen) || ' all-frozen' + FROM pg_visibility_map('$t')}); +} + +# A VACUUM's WAL is not flushed at its end; a committed write flushes it. +sub flush_wal +{ + $primary->safe_psql('postgres', 'INSERT INTO flushed VALUES (1)'); +} + +my %all; +foreach my $am ('heap', 'flux') +{ + my $t = "vs_$am"; + $primary->safe_psql( + 'postgres', qq{ +CREATE TABLE $t (id int, pad text) USING $am; +INSERT INTO $t SELECT g, repeat('x', 100) FROM generate_series(1, 150) g; +}); + $primary->safe_psql('postgres', 'CHECKPOINT'); + $primary->safe_psql('postgres', "VACUUM (FREEZE) $t"); + $all{$am} = bits($primary, $t); + like( + $all{$am}, + qr/^(\d+) pages, \1 all-visible, \1 all-frozen$/, + "$am: VACUUM set the bits: $all{$am}"); + flush_wal(); + $primary->wait_for_replay_catchup($standby); + is(bits($standby, $t), $all{$am}, "$am: standby replayed the new map"); + + # A change clears block 1; after a checkpoint, VACUUM sets it again. + $primary->safe_psql('postgres', "DELETE FROM $t WHERE id = 80"); + $primary->safe_psql('postgres', 'CHECKPOINT'); + $primary->safe_psql('postgres', "VACUUM (FREEZE) $t"); + is(bits($primary, $t), $all{$am}, "$am: VACUUM set the bits again"); +} +flush_wal(); +$primary->wait_for_replay_catchup($standby); +foreach my $am ('heap', 'flux') +{ + is(bits($standby, "vs_$am"), + $all{$am}, "$am: standby replayed the map after a checkpoint"); +} + +$primary->stop('immediate'); +$primary->start; +foreach my $am ('heap', 'flux') +{ + is(bits($primary, "vs_$am"), + $all{$am}, "$am: crash recovery keeps the bits"); +} + +$standby->stop; +$primary->stop; +done_testing(); diff --git a/src/test/regress/expected/create_am.out b/src/test/regress/expected/create_am.out index c1a951572512c..4018dc0bab665 100644 --- a/src/test/regress/expected/create_am.out +++ b/src/test/regress/expected/create_am.out @@ -131,9 +131,10 @@ ERROR: function bthandler must return type table_am_handler SELECT amname, amhandler, amtype FROM pg_am where amtype = 't' ORDER BY 1, 2; amname | amhandler | amtype --------+----------------------+-------- + flux | flux_tableam_handler | t heap | heap_tableam_handler | t heap2 | heap_tableam_handler | t -(2 rows) +(3 rows) -- First create tables employing the new AM using USING -- plain CREATE TABLE diff --git a/src/test/regress/expected/flux.out b/src/test/regress/expected/flux.out new file mode 100644 index 0000000000000..d6796ab3bdf36 --- /dev/null +++ b/src/test/regress/expected/flux.out @@ -0,0 +1,1436 @@ +-- +-- FLUX table access method: core functional + index-integrity tests. +-- +-- The critical property is that +-- index scans, sequential scans, and bitmap scans agree after key-changing +-- UPDATEs, including A -> B -> A recurrences. FLUX achieves this by doing a +-- non-in-place (new-TID) UPDATE whenever an indexed column changes, so +-- secondary indexes are maintained by the standard heap-TID path. +-- +-- +-- amcheck (bt_index_check) is a contrib extension, so it may be absent when +-- the core regression suite runs without EXTRA_INSTALL=contrib/amcheck (for +-- example when another test module re-runs the schedule, or on a packager's +-- "make check"). Verify indexes with it when present; otherwise skip, so the +-- expected output is identical either way. The idxscan-vs-seqscan checks +-- below are the primary consistency assertion; amcheck is a stronger backstop. +-- +CREATE FUNCTION flux_check_index(regclass) RETURNS text +LANGUAGE plpgsql AS $$ +BEGIN + IF to_regprocedure('bt_index_check(regclass,boolean)') IS NOT NULL THEN + PERFORM bt_index_check($1, true); + END IF; + RETURN 'ok'; +END; +$$; +CREATE TABLE flux_basic (id int, k int, v text) USING flux; +INSERT INTO flux_basic SELECT g, g, 'v' || g FROM generate_series(1, 20) g; +CREATE INDEX flux_basic_k_idx ON flux_basic (k); +-- non-key UPDATE (in place): TID and index unchanged +UPDATE flux_basic SET v = 'updated' WHERE id = 5; +SELECT id, k, v FROM flux_basic WHERE id = 5; + id | k | v +----+---+--------- + 5 | 5 | updated +(1 row) + +-- key-changing UPDATEs (out of place): old index entry dies, new one inserted +UPDATE flux_basic SET k = 105 WHERE id = 5; +UPDATE flux_basic SET k = 106 WHERE id = 6; +-- A -> B -> A recurrence on an indexed key +UPDATE flux_basic SET k = 999 WHERE id = 7; +UPDATE flux_basic SET k = 7 WHERE id = 7; +DELETE FROM flux_basic WHERE id = 20; +-- The gate: idxscan == seqscan == bitmapscan. +SET enable_seqscan = on; SET enable_indexscan = off; SET enable_bitmapscan = off; +SELECT count(*) AS seq_count, sum(k) AS seq_sumk FROM flux_basic; + seq_count | seq_sumk +-----------+---------- + 19 | 390 +(1 row) + +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_count, sum(k) AS idx_sumk FROM flux_basic WHERE k > -2147483648; + idx_count | idx_sumk +-----------+---------- + 19 | 390 +(1 row) + +SELECT count(*) AS idx_k_eq7 FROM flux_basic WHERE k = 7; -- 1, not 2 + idx_k_eq7 +----------- + 1 +(1 row) + +SELECT count(*) AS idx_k_eq5 FROM flux_basic WHERE k = 5; -- 0 (moved to 105) + idx_k_eq5 +----------- + 0 +(1 row) + +SELECT count(*) AS idx_k_eq105 FROM flux_basic WHERE k = 105; -- 1 + idx_k_eq105 +------------- + 1 +(1 row) + +SET enable_seqscan = off; SET enable_indexscan = off; SET enable_bitmapscan = on; +SELECT count(*) AS bmp_count, sum(k) AS bmp_sumk FROM flux_basic WHERE k > -2147483648; + bmp_count | bmp_sumk +-----------+---------- + 19 | 390 +(1 row) + +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +-- VACUUM then amcheck (heapallindexed): no error means the index is consistent +-- with the heap after key churn. +VACUUM flux_basic; +SELECT flux_check_index('flux_basic_k_idx'::regclass); + flux_check_index +------------------ + ok +(1 row) + +-- ROLLBACK restores the old value (non-key) and old key + index entry (key). +BEGIN; +UPDATE flux_basic SET v = 'should_not_persist' WHERE id = 10; +ROLLBACK; +SELECT v FROM flux_basic WHERE id = 10; + v +----- + v10 +(1 row) + +BEGIN; +UPDATE flux_basic SET k = 5000 WHERE id = 11; +ROLLBACK; +SELECT k FROM flux_basic WHERE id = 11; + k +---- + 11 +(1 row) + +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_k_eq11 FROM flux_basic WHERE k = 11; -- 1 + idx_k_eq11 +------------ + 1 +(1 row) + +SELECT count(*) AS idx_k_eq5000 FROM flux_basic WHERE k = 5000; -- 0 + idx_k_eq5000 +-------------- + 0 +(1 row) + +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +-- TOAST: a >8KB value round-trips exactly and the relation gets a TOAST table. +CREATE TABLE flux_toast (id int, big text) USING flux; +INSERT INTO flux_toast VALUES (1, repeat('X', 100000)); +SELECT id, length(big) AS len, (big = repeat('X', 100000)) AS exact FROM flux_toast; + id | len | exact +----+--------+------- + 1 | 100000 | t +(1 row) + +SELECT reltoastrelid <> 0 AS has_toast_table FROM pg_class WHERE relname = 'flux_toast'; + has_toast_table +----------------- + t +(1 row) + +DROP TABLE flux_basic; +DROP TABLE flux_toast; +-- +-- BUG 2 regression: an in-place UPDATE of a NON-indexed column that GROWS the +-- row on a full page must NOT error with "does not fit on page". FLUX falls +-- back to an out-of-place (new-TID) update and rebuilds all index entries, so +-- the row survives and every index stays consistent with the heap. +-- +CREATE TABLE flux_grow (id int, k int, pad text, v text) USING flux + WITH (fillfactor = 100); +CREATE INDEX flux_grow_k_idx ON flux_grow (k); +-- Pack pages tight: many rows with a NULL v (narrowest) so the page is full +-- of committed tuples with no free space and no slack for in-place growth. +INSERT INTO flux_grow SELECT g, g, repeat('p', 200), NULL + FROM generate_series(1, 2000) g; +VACUUM flux_grow; +-- Grow a NON-indexed column (v: NULL -> long string) on rows across full +-- pages. Pre-fix this raised: ERROR updated flux tuple does not fit on page. +UPDATE flux_grow SET v = repeat('w', 400) WHERE id <= 2000; +-- All rows survived with the grown value. +SELECT count(*) AS grown, count(*) FILTER (WHERE v = repeat('w', 400)) AS correct + FROM flux_grow; + grown | correct +-------+--------- + 2000 | 2000 +(1 row) + +-- The non-indexed grow may have moved TIDs; every index must still agree with +-- the heap (idxscan == seqscan) and amcheck must pass. +VACUUM flux_grow; +SELECT flux_check_index('flux_grow_k_idx'::regclass); + flux_check_index +------------------ + ok +(1 row) + +SET enable_seqscan = on; SET enable_indexscan = off; SET enable_bitmapscan = off; +SELECT count(*) AS seq_count, sum(k) AS seq_sumk FROM flux_grow; + seq_count | seq_sumk +-----------+---------- + 2000 | 2001000 +(1 row) + +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_count, sum(k) AS idx_sumk FROM flux_grow; + idx_count | idx_sumk +-----------+---------- + 2000 | 2001000 +(1 row) + +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +-- A specific indexed lookup after the TID-moving grow returns exactly one row. +SELECT id, k, length(v) AS vlen FROM flux_grow WHERE k = 1000; + id | k | vlen +------+------+------ + 1000 | 1000 | 400 +(1 row) + +DROP TABLE flux_grow; +-- +-- BUG 7/8 regression: SAVEPOINT interaction with in-place UPDATE. +-- (7) Re-UPDATE inside a savepoint a row already updated by an earlier command +-- must NOT self-wait/assert. (8) ROLLBACK TO SAVEPOINT must RESTORE the +-- pre-savepoint value of in-place updates (and undo in-savepoint deletes), +-- not vanish the row or over-revert to the original committed value. +-- +CREATE TABLE flux_sp (id int PRIMARY KEY, v int) USING flux; +INSERT INTO flux_sp VALUES (1,100),(2,200),(3,300); +BEGIN; +UPDATE flux_sp SET v = 101 WHERE id = 1; -- before savepoint +SAVEPOINT s1; +UPDATE flux_sp SET v = 999 WHERE id = 1; -- re-update same row (bug 7) +DELETE FROM flux_sp WHERE id = 2; -- in-savepoint delete +UPDATE flux_sp SET v = 301 WHERE id = 3; -- in-savepoint update +ROLLBACK TO s1; -- must restore pre-savepoint state +-- Expect 1|101 (pre-savepoint update kept), 2|200 (delete undone), 3|300 (update undone) +SELECT id, v FROM flux_sp ORDER BY id; + id | v +----+----- + 1 | 101 + 2 | 200 + 3 | 300 +(3 rows) + +COMMIT; +SELECT id, v FROM flux_sp ORDER BY id; + id | v +----+----- + 1 | 101 + 2 | 200 + 3 | 300 +(3 rows) + +DROP TABLE flux_sp; +-- +-- ALWAYS-IN-PLACE indexed-column UPDATE via delete-marking. +-- An UPDATE of an indexed column keeps the STABLE TID (ctid unchanged), the +-- index sees the new key (old key delete-marked), unique is respected, and +-- ROLLBACK of an in-place key update restores the old key + removes the new +-- index entry. amcheck stays clean. +-- +CREATE TABLE flux_ip (id int PRIMARY KEY, k int, v text) USING flux; +INSERT INTO flux_ip SELECT g, g, 'v'||g FROM generate_series(1,10) g; +CREATE INDEX flux_ip_k ON flux_ip (k); +CREATE UNIQUE INDEX flux_ip_uk ON flux_ip (k); +-- (a) STABLE TID: ctid identical before and after an indexed-column UPDATE. +SELECT ctid AS ip_before FROM flux_ip WHERE id = 3; + ip_before +----------- + (0,3) +(1 row) + +UPDATE flux_ip SET k = 333 WHERE id = 3; +SELECT ctid AS ip_after, k FROM flux_ip WHERE id = 3; -- same ctid, k=333 + ip_after | k +----------+----- + (0,3) | 333 +(1 row) + +-- index scan sees new key, not old; idxscan == seqscan (no duplicate rows). +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS ip_k3 FROM flux_ip WHERE k = 3; -- 0 (moved) + ip_k3 +------- + 0 +(1 row) + +SELECT count(*) AS ip_k333 FROM flux_ip WHERE k = 333; -- 1 + ip_k333 +--------- + 1 +(1 row) + +SELECT count(*) AS ip_idx_total, sum(k) AS ip_idx_sumk FROM flux_ip WHERE k > -2147483648; + ip_idx_total | ip_idx_sumk +--------------+------------- + 10 | 385 +(1 row) + +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +SELECT count(*) AS ip_seq_total, sum(k) AS ip_seq_sumk FROM flux_ip; -- must match idx + ip_seq_total | ip_seq_sumk +--------------+------------- + 10 | 385 +(1 row) + +-- (d) UNIQUE respected: another row cannot take the new key. +INSERT INTO flux_ip VALUES (11, 11, 'x'); +UPDATE flux_ip SET k = 333 WHERE id = 11; -- ERROR: duplicate key +ERROR: duplicate key value violates unique constraint "flux_ip_uk" +DETAIL: Key (k)=(333) already exists. +-- (c) ROLLBACK of an in-place key UPDATE restores old key and removes new entry. +SELECT ctid AS ip_roll_before FROM flux_ip WHERE id = 4; + ip_roll_before +---------------- + (0,4) +(1 row) + +BEGIN; +UPDATE flux_ip SET k = 4444 WHERE id = 4; +SELECT ctid AS ip_roll_intxn, k FROM flux_ip WHERE id = 4; -- same ctid, k=4444 + ip_roll_intxn | k +---------------+------ + (0,4) | 4444 +(1 row) + +ROLLBACK; +SELECT ctid AS ip_roll_after, k FROM flux_ip WHERE id = 4; -- same ctid, k=4 restored + ip_roll_after | k +---------------+--- + (0,4) | 4 +(1 row) + +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS ip_roll_k4 FROM flux_ip WHERE k = 4; -- 1 (restored) + ip_roll_k4 +------------ + 1 +(1 row) + +SELECT count(*) AS ip_roll_k4444 FROM flux_ip WHERE k = 4444; -- 0 (new entry gone) + ip_roll_k4444 +--------------- + 0 +(1 row) + +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +-- Unique still works after rollback: k=4444 free to insert, k=4 taken. +INSERT INTO flux_ip VALUES (12, 4444, 'y'); -- ok +INSERT INTO flux_ip VALUES (13, 4, 'z'); -- ERROR: duplicate key (4 live) +ERROR: duplicate key value violates unique constraint "flux_ip_uk" +DETAIL: Key (k)=(4) already exists. +-- (e) amcheck clean on both indexes carrying delete-marks. +SELECT flux_check_index('flux_ip_k'::regclass); + flux_check_index +------------------ + ok +(1 row) + +SELECT flux_check_index('flux_ip_uk'::regclass); + flux_check_index +------------------ + ok +(1 row) + +DROP TABLE flux_ip; +-- +-- Consumer-reported regressions (undo-bug-report): the AFTER-trigger OLD image +-- and the same-transaction snapshot must match heap for an in-place AM. +-- +-- BUG 1: an AFTER ROW UPDATE trigger, and RI, must see the pre-update OLD row. +CREATE TABLE flux_bug1 (id int PRIMARY KEY, v int) USING flux; +INSERT INTO flux_bug1 VALUES (1, 10); +CREATE FUNCTION flux_bug1_log() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN RAISE NOTICE 'OLD.v=% NEW.v=%', OLD.v, NEW.v; RETURN NULL; END $$; +CREATE TRIGGER t AFTER UPDATE ON flux_bug1 FOR EACH ROW + EXECUTE FUNCTION flux_bug1_log(); +UPDATE flux_bug1 SET v = 20; -- expect NOTICE OLD.v=10 NEW.v=20 +NOTICE: OLD.v=10 NEW.v=20 +DROP TABLE flux_bug1; +DROP FUNCTION flux_bug1_log(); +-- BUG 1 (RI): ON UPDATE RESTRICT must refuse a parent-key change that a child +-- still references; the pre-fix in-place AM saw OLD.k==NEW.k and allowed it. +CREATE TABLE flux_pr (k int PRIMARY KEY) USING flux; +INSERT INTO flux_pr VALUES (10); +CREATE TABLE flux_fr (k int REFERENCES flux_pr ON UPDATE RESTRICT); +INSERT INTO flux_fr VALUES (10); +UPDATE flux_pr SET k = 20; -- expect ERROR: RESTRICT violation +ERROR: update or delete on table "flux_pr" violates RESTRICT setting of foreign key constraint "flux_fr_k_fkey" on table "flux_fr" +DETAIL: Key (k)=(10) is referenced from table "flux_fr". +SELECT count(*) AS flux_orphans FROM flux_fr f + WHERE NOT EXISTS (SELECT 1 FROM flux_pr p WHERE p.k = f.k); -- 0 + flux_orphans +-------------- + 0 +(1 row) + +DROP TABLE flux_fr; DROP TABLE flux_pr; +-- BUG 2: a snapshot taken between two in-place UPDATEs in one transaction must +-- see the first UPDATE's effect, not the last committed value. +CREATE TABLE flux_bug2 (id int PRIMARY KEY, v int) USING flux; +INSERT INTO flux_bug2 VALUES (1, 10); +BEGIN; +DECLARE c1 CURSOR FOR SELECT v FROM flux_bug2; -- before UPDATE 1 +UPDATE flux_bug2 SET v = 20; +DECLARE c2 CURSOR FOR SELECT v FROM flux_bug2; -- between the two UPDATEs +UPDATE flux_bug2 SET v = 30; +FETCH c1; -- 10 + v +---- + 10 +(1 row) + +FETCH c2; -- 20 + v +---- + 20 +(1 row) + +SELECT v FROM flux_bug2; -- 30 + v +---- + 30 +(1 row) + +COMMIT; +DROP TABLE flux_bug2; +-- FLUX: CREATE UNIQUE INDEX must ignore rows of aborted INSERTs. +CREATE TABLE fu (id int) USING flux; +INSERT INTO fu VALUES (1); +BEGIN; INSERT INTO fu VALUES (1); ROLLBACK; +BEGIN; INSERT INTO fu VALUES (2); SAVEPOINT s; INSERT INTO fu VALUES (2); +ROLLBACK TO s; COMMIT; +CREATE UNIQUE INDEX fu_id ON fu (id); +SET enable_seqscan = off; +SELECT id FROM fu WHERE id IN (1, 2) ORDER BY id; + id +---- + 1 + 2 +(2 rows) + +RESET enable_seqscan; +DROP INDEX fu_id; +-- a committed duplicate still fails +INSERT INTO fu VALUES (1); +CREATE UNIQUE INDEX fu_id ON fu (id); +ERROR: could not create unique index "fu_id" +DETAIL: Key (id)=(1) is duplicated. +DROP TABLE fu; +-- A varlena with double alignment followed by one with int alignment: the +-- index-fetch decode must not pad after the first by its own alignment. +CREATE TABLE fal (a int8range, b text) USING flux; +CREATE INDEX fal_b ON fal (b); +INSERT INTO fal VALUES ('[7,8)', 'foo'); +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT a, b FROM fal WHERE b = 'foo'; + a | b +-------+----- + [7,8) | foo +(1 row) + +RESET enable_seqscan; RESET enable_bitmapscan; +DROP TABLE fal; +DROP FUNCTION flux_check_index(regclass); +-- Ordinary VACUUM must not move a live row to another TID. Without an index +-- FLUX's former cross-page relocation moved tail rows forward and freed their +-- slots while their writer still referenced them. +CREATE TABLE flux_vmove (id int, v int, pad text) USING flux + WITH (autovacuum_enabled = off); +INSERT INTO flux_vmove SELECT g, 0, repeat('x', 200) FROM generate_series(1, 400) g; +DELETE FROM flux_vmove WHERE id <= 300 OR id % 3 = 0; +CREATE TEMP TABLE flux_vmove_before AS SELECT id, ctid AS c FROM flux_vmove; +VACUUM flux_vmove; +VACUUM flux_vmove; +SELECT count(*) AS moved FROM flux_vmove JOIN flux_vmove_before b USING (id) + WHERE flux_vmove.ctid <> b.c; -- 0 + moved +------- + 0 +(1 row) + +SELECT count(*) AS n FROM flux_vmove; -- 67 + n +---- + 67 +(1 row) + +DROP TABLE flux_vmove, flux_vmove_before; +-- VACUUM keeps an empty tail when told to (VACUUM (TRUNCATE false), the +-- vacuum_truncate reloption) and when it is too short to be worth the lock: +-- under 1000 pages and 1/16 of the table. Heap alongside as the control. +CREATE TEMP TABLE flux_tk_h (id int, pad text) USING heap; +CREATE TEMP TABLE flux_tk (id int, pad text) USING flux; +INSERT INTO flux_tk_h SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +INSERT INTO flux_tk SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +CREATE TEMP TABLE flux_tk_sz AS + SELECT pg_relation_size('flux_tk_h') AS h, pg_relation_size('flux_tk') AS r; +DELETE FROM flux_tk_h WHERE id > 19900; +DELETE FROM flux_tk WHERE id > 19900; +VACUUM flux_tk_h; +VACUUM flux_tk; +SELECT pg_relation_size('flux_tk_h') = h AS heap_kept, + pg_relation_size('flux_tk') = r AS flux_kept FROM flux_tk_sz; + heap_kept | flux_kept +-----------+----------- + t | t +(1 row) + +DELETE FROM flux_tk_h WHERE id > 1; +DELETE FROM flux_tk WHERE id > 1; +VACUUM (TRUNCATE false) flux_tk_h; +VACUUM (TRUNCATE false) flux_tk; +SELECT pg_relation_size('flux_tk_h') = h AS heap_kept, + pg_relation_size('flux_tk') = r AS flux_kept FROM flux_tk_sz; + heap_kept | flux_kept +-----------+----------- + t | t +(1 row) + +ALTER TABLE flux_tk_h SET (vacuum_truncate = false); +ALTER TABLE flux_tk SET (vacuum_truncate = false); +VACUUM flux_tk_h; +VACUUM flux_tk; +SELECT pg_relation_size('flux_tk_h') = h AS heap_kept, + pg_relation_size('flux_tk') = r AS flux_kept FROM flux_tk_sz; + heap_kept | flux_kept +-----------+----------- + t | t +(1 row) + +ALTER TABLE flux_tk_h RESET (vacuum_truncate); +ALTER TABLE flux_tk RESET (vacuum_truncate); +VACUUM flux_tk_h; +VACUUM flux_tk; +SELECT pg_relation_size('flux_tk_h') = 8192 AS heap_truncated, + pg_relation_size('flux_tk') = 8192 AS flux_truncated; + heap_truncated | flux_truncated +----------------+---------------- + t | t +(1 row) + +DROP TABLE flux_tk_h, flux_tk, flux_tk_sz; +-- VACUUM (DISABLE_PAGE_SKIPPING) still truncates an empty tail, as heap does. +CREATE TEMP TABLE flux_dps_h (id int, pad text) USING heap; +CREATE TEMP TABLE flux_dps (id int, pad text) USING flux; +INSERT INTO flux_dps_h SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +INSERT INTO flux_dps SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +DELETE FROM flux_dps_h WHERE id > 1; +DELETE FROM flux_dps WHERE id > 1; +VACUUM (DISABLE_PAGE_SKIPPING) flux_dps_h; +VACUUM (DISABLE_PAGE_SKIPPING) flux_dps; +SELECT pg_relation_size('flux_dps_h') = 8192 AS heap_truncated, + pg_relation_size('flux_dps') = 8192 AS flux_truncated; + heap_truncated | flux_truncated +----------------+---------------- + t | t +(1 row) + +DROP TABLE flux_dps_h, flux_dps; +-- Heap control, then the same operations with FLUX. +CREATE TABLE scan_semantics (a int) USING heap; +INSERT INTO scan_semantics SELECT generate_series(1,5); +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH 3 FROM c; + a +--- + 1 + 2 + 3 +(3 rows) + +FETCH BACKWARD 1 FROM c; + a +--- + 2 +(1 row) + +FETCH LAST FROM c; + a +--- + 5 +(1 row) + +FETCH PRIOR FROM c; + a +--- + 4 +(1 row) + +FETCH FIRST FROM c; + a +--- + 1 +(1 row) + +MOVE BACKWARD ALL FROM c; +FETCH NEXT FROM c; + a +--- + 1 +(1 row) + +COMMIT; +SET enable_seqscan = off; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics + WHERE ctid >= '(0,2)' AND ctid <= '(0,4)'; +FETCH LAST FROM c; + a +--- + 4 +(1 row) + +FETCH PRIOR FROM c; + a +--- + 3 +(1 row) + +FETCH FIRST FROM c; + a +--- + 2 +(1 row) + +FETCH NEXT FROM c; + a +--- + 3 +(1 row) + +COMMIT; +RESET enable_seqscan; +DROP TABLE scan_semantics; +CREATE TABLE scan_semantics (a int, pad text) USING heap; +INSERT INTO scan_semantics SELECT g, repeat('x', 100) FROM generate_series(1,1000) g; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics; + QUERY PLAN +---------------------------- + Seq Scan on scan_semantics +(1 row) + +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +MOVE 500 FROM c; +FETCH PRIOR FROM c; + a +----- + 499 +(1 row) + +FETCH NEXT FROM c; + a +----- + 500 +(1 row) + +FETCH LAST FROM c; + a +------ + 1000 +(1 row) + +FETCH NEXT FROM c; + a +--- +(0 rows) + +FETCH PRIOR FROM c; + a +------ + 1000 +(1 row) + +FETCH FIRST FROM c; + a +--- + 1 +(1 row) + +FETCH PRIOR FROM c; + a +--- +(0 rows) + +FETCH NEXT FROM c; + a +--- + 1 +(1 row) + +COMMIT; +SET enable_seqscan = off; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; + QUERY PLAN +---------------------------------------------------------------- + Tid Range Scan on scan_semantics + TID Cond: ((ctid >= '(1,1)'::tid) AND (ctid < '(2,1)'::tid)) +(2 rows) + +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT true AS found FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; +FETCH FIRST FROM c; + found +------- + t +(1 row) + +FETCH LAST FROM c; + found +------- + t +(1 row) + +FETCH NEXT FROM c; + found +------- +(0 rows) + +FETCH PRIOR FROM c; + found +------- + t +(1 row) + +FETCH FIRST FROM c; + found +------- + t +(1 row) + +FETCH PRIOR FROM c; + found +------- +(0 rows) + +FETCH NEXT FROM c; + found +------- + t +(1 row) + +COMMIT; +RESET enable_seqscan; +TRUNCATE scan_semantics; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH LAST FROM c; + a +--- +(0 rows) + +FETCH FIRST FROM c; + a +--- +(0 rows) + +COMMIT; +DROP TABLE scan_semantics; +CREATE TABLE scan_semantics (a int) USING flux; +INSERT INTO scan_semantics SELECT generate_series(1,5); +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH 3 FROM c; + a +--- + 1 + 2 + 3 +(3 rows) + +FETCH BACKWARD 1 FROM c; + a +--- + 2 +(1 row) + +FETCH LAST FROM c; + a +--- + 5 +(1 row) + +FETCH PRIOR FROM c; + a +--- + 4 +(1 row) + +FETCH FIRST FROM c; + a +--- + 1 +(1 row) + +MOVE BACKWARD ALL FROM c; +FETCH NEXT FROM c; + a +--- + 1 +(1 row) + +COMMIT; +SET enable_seqscan = off; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics + WHERE ctid >= '(0,2)' AND ctid <= '(0,4)'; +FETCH LAST FROM c; + a +--- + 4 +(1 row) + +FETCH PRIOR FROM c; + a +--- + 3 +(1 row) + +FETCH FIRST FROM c; + a +--- + 2 +(1 row) + +FETCH NEXT FROM c; + a +--- + 3 +(1 row) + +COMMIT; +RESET enable_seqscan; +DROP TABLE scan_semantics; +CREATE TABLE scan_semantics (a int, pad text) USING flux; +INSERT INTO scan_semantics SELECT g, repeat('x', 100) FROM generate_series(1,1000) g; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics; + QUERY PLAN +---------------------------- + Seq Scan on scan_semantics +(1 row) + +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +MOVE 500 FROM c; +FETCH PRIOR FROM c; + a +----- + 499 +(1 row) + +FETCH NEXT FROM c; + a +----- + 500 +(1 row) + +FETCH LAST FROM c; + a +------ + 1000 +(1 row) + +FETCH NEXT FROM c; + a +--- +(0 rows) + +FETCH PRIOR FROM c; + a +------ + 1000 +(1 row) + +FETCH FIRST FROM c; + a +--- + 1 +(1 row) + +FETCH PRIOR FROM c; + a +--- +(0 rows) + +FETCH NEXT FROM c; + a +--- + 1 +(1 row) + +COMMIT; +SET enable_seqscan = off; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; + QUERY PLAN +---------------------------------------------------------------- + Tid Range Scan on scan_semantics + TID Cond: ((ctid >= '(1,1)'::tid) AND (ctid < '(2,1)'::tid)) +(2 rows) + +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT true AS found FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; +FETCH FIRST FROM c; + found +------- + t +(1 row) + +FETCH LAST FROM c; + found +------- + t +(1 row) + +FETCH NEXT FROM c; + found +------- +(0 rows) + +FETCH PRIOR FROM c; + found +------- + t +(1 row) + +FETCH FIRST FROM c; + found +------- + t +(1 row) + +FETCH PRIOR FROM c; + found +------- +(0 rows) + +FETCH NEXT FROM c; + found +------- + t +(1 row) + +COMMIT; +RESET enable_seqscan; +TRUNCATE scan_semantics; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH LAST FROM c; + a +--- +(0 rows) + +FETCH FIRST FROM c; + a +--- +(0 rows) + +COMMIT; +DROP TABLE scan_semantics; +-- Heap control, then the same operations with FLUX. +CREATE TABLE scan_semantics () USING heap; +INSERT INTO scan_semantics DEFAULT VALUES; +ALTER TABLE scan_semantics ADD COLUMN a int DEFAULT 1; +CREATE INDEX ON scan_semantics(a); +UPDATE scan_semantics SET a = NULL; +SET enable_indexscan = off; +SET enable_bitmapscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; + count +------- + 1 +(1 row) + +RESET enable_indexscan; +SET enable_seqscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; + count +------- + 1 +(1 row) + +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; +CREATE TABLE scan_semantics () USING flux; +INSERT INTO scan_semantics DEFAULT VALUES; +ALTER TABLE scan_semantics ADD COLUMN a int DEFAULT 1; +CREATE INDEX ON scan_semantics(a); +UPDATE scan_semantics SET a = NULL; +SET enable_indexscan = off; +SET enable_bitmapscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; + count +------- + 1 +(1 row) + +RESET enable_indexscan; +SET enable_seqscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; + count +------- + 1 +(1 row) + +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; +-- Heap control, then the same operations with FLUX. +CREATE TABLE scan_semantics (pad text, a int) USING heap; +INSERT INTO scan_semantics SELECT repeat('x',100), generate_series(1,1000); +CREATE INDEX ON scan_semantics(a); +VACUUM ANALYZE scan_semantics; +SET enable_seqscan = off; +SET enable_bitmapscan = off; +CREATE AGGREGATE scan_count (*) (sfunc = int8inc, stype = int8, + initcond = '0', parallel = safe); +SELECT scan_count(*) FROM scan_semantics; + scan_count +------------ + 1000 +(1 row) + +SET debug_parallel_query = on; +SELECT scan_count(*) FROM scan_semantics; + scan_count +------------ + 1000 +(1 row) + +SELECT count(*) FROM scan_semantics; + count +------- + 1000 +(1 row) + +RESET debug_parallel_query; +DROP AGGREGATE scan_count(*); +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; +CREATE TABLE scan_semantics (pad text, a int) USING flux; +INSERT INTO scan_semantics SELECT repeat('x',100), generate_series(1,1000); +CREATE INDEX ON scan_semantics(a); +VACUUM ANALYZE scan_semantics; +SET enable_seqscan = off; +SET enable_bitmapscan = off; +CREATE AGGREGATE scan_count (*) (sfunc = int8inc, stype = int8, + initcond = '0', parallel = safe); +SELECT scan_count(*) FROM scan_semantics; + scan_count +------------ + 1000 +(1 row) + +SET debug_parallel_query = on; +SELECT scan_count(*) FROM scan_semantics; + scan_count +------------ + 1000 +(1 row) + +SELECT count(*) FROM scan_semantics; + count +------- + 1000 +(1 row) + +RESET debug_parallel_query; +DROP AGGREGATE scan_count(*); +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; +-- Heap control, then the same operations with FLUX. +CREATE TABLE self_direct (id int, v int) USING heap; +INSERT INTO self_direct VALUES (1,0); +CREATE FUNCTION self_direct_fn() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF pg_trigger_depth() = 1 THEN + UPDATE self_direct SET v = v + 1 WHERE id = OLD.id; + END IF; + RETURN OLD; +END $$; +CREATE TRIGGER self_direct_tr BEFORE DELETE OR UPDATE ON self_direct + FOR EACH ROW EXECUTE FUNCTION self_direct_fn(); +DELETE FROM self_direct; +ERROR: tuple to be deleted was already modified by an operation triggered by the current command +HINT: Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows. +SELECT * FROM self_direct; + id | v +----+--- + 1 | 0 +(1 row) + +UPDATE self_direct SET v = 10; +ERROR: tuple to be updated was already modified by an operation triggered by the current command +HINT: Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows. +SELECT * FROM self_direct; + id | v +----+--- + 1 | 0 +(1 row) + +DROP TABLE self_direct; +DROP FUNCTION self_direct_fn(); +-- Parent-first cascade succeeds; child-first cascade self-modifies. +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING heap; +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); +update self_ref_trigger set data = 'root!' where id = 1; +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+--------------+----------- + 1 | | root! | 2 + 2 | 1 | root child A | 1 + 3 | 1 | root child B | 1 + 4 | 2 | grandchild 1 | 0 + 5 | 3 | grandchild 2 | 0 +(5 rows) + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id ASC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; + id +---- + 1 + 2 + 3 + 4 + 5 +(5 rows) + +delete from self_ref_trigger; +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+------+----------- +(0 rows) + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING heap; +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); +update self_ref_trigger set data = 'root!' where id = 1; +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+--------------+----------- + 1 | | root! | 2 + 2 | 1 | root child A | 1 + 3 | 1 | root child B | 1 + 4 | 2 | grandchild 1 | 0 + 5 | 3 | grandchild 2 | 0 +(5 rows) + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id DESC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; + id +---- + 5 + 4 + 3 + 2 + 1 +(5 rows) + +delete from self_ref_trigger; +ERROR: tuple to be updated was already modified by an operation triggered by the current command +HINT: Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows. +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+--------------+----------- + 1 | | root! | 2 + 2 | 1 | root child A | 1 + 3 | 1 | root child B | 1 + 4 | 2 | grandchild 1 | 0 + 5 | 3 | grandchild 2 | 0 +(5 rows) + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); +CREATE TABLE self_direct (id int, v int) USING flux; +INSERT INTO self_direct VALUES (1,0); +CREATE FUNCTION self_direct_fn() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF pg_trigger_depth() = 1 THEN + UPDATE self_direct SET v = v + 1 WHERE id = OLD.id; + END IF; + RETURN OLD; +END $$; +CREATE TRIGGER self_direct_tr BEFORE DELETE OR UPDATE ON self_direct + FOR EACH ROW EXECUTE FUNCTION self_direct_fn(); +DELETE FROM self_direct; +ERROR: tuple to be deleted was already modified by an operation triggered by the current command +HINT: Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows. +SELECT * FROM self_direct; + id | v +----+--- + 1 | 0 +(1 row) + +UPDATE self_direct SET v = 10; +ERROR: tuple to be updated was already modified by an operation triggered by the current command +HINT: Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows. +SELECT * FROM self_direct; + id | v +----+--- + 1 | 0 +(1 row) + +DROP TABLE self_direct; +DROP FUNCTION self_direct_fn(); +-- Parent-first cascade succeeds; child-first cascade self-modifies. +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING flux; +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); +update self_ref_trigger set data = 'root!' where id = 1; +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+--------------+----------- + 1 | | root! | 2 + 2 | 1 | root child A | 1 + 3 | 1 | root child B | 1 + 4 | 2 | grandchild 1 | 0 + 5 | 3 | grandchild 2 | 0 +(5 rows) + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id ASC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; + id +---- + 1 + 2 + 3 + 4 + 5 +(5 rows) + +delete from self_ref_trigger; +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+------+----------- +(0 rows) + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING flux; +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); +update self_ref_trigger set data = 'root!' where id = 1; +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+--------------+----------- + 1 | | root! | 2 + 2 | 1 | root child A | 1 + 3 | 1 | root child B | 1 + 4 | 2 | grandchild 1 | 0 + 5 | 3 | grandchild 2 | 0 +(5 rows) + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id DESC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; + id +---- + 5 + 4 + 3 + 2 + 1 +(5 rows) + +delete from self_ref_trigger; +ERROR: tuple to be updated was already modified by an operation triggered by the current command +HINT: Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows. +select * from self_ref_trigger ORDER BY id; + id | parent | data | nchildren +----+--------+--------------+----------- + 1 | | root! | 2 + 2 | 1 | root child A | 1 + 3 | 1 | root child B | 1 + 4 | 2 | grandchild 1 | 0 + 5 | 3 | grandchild 2 | 0 +(5 rows) + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); diff --git a/src/test/regress/expected/flux_abort_ddl.out b/src/test/regress/expected/flux_abort_ddl.out new file mode 100644 index 0000000000000..0671967b01de9 --- /dev/null +++ b/src/test/regress/expected/flux_abort_ddl.out @@ -0,0 +1,409 @@ +-- +-- FLUX: a transaction updates rows in place, then replaces or drops storage +-- the update touched (DROP INDEX, REINDEX, ALTER COLUMN TYPE, TRUNCATE, +-- CLUSTER), then rolls back, entirely or to a savepoint. +-- +-- The update's undo names the files it changed. While the transaction is +-- aborting, its catalog changes are not rolled back yet, so the relcache +-- maps the new files (or no index at all). The rollback must still leave +-- the table and its indexes as they were, so that the same update applied +-- again gives the same rows and index entries as on heap. Each case runs on +-- a heap twin and on FLUX, permanent and temporary; the two columns of the +-- result must agree. +-- +CREATE SCHEMA ad_amcheck; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'amcheck') THEN + CREATE EXTENSION amcheck SCHEMA ad_amcheck; + END IF; +EXCEPTION WHEN OTHERS THEN + NULL; +END $$; +-- ad_h (heap) and ad_f (FLUX), 200 rows with k = id, an index on k. +CREATE PROCEDURE ad_setup(temp bool) LANGUAGE plpgsql AS $$ +DECLARE + t text; +BEGIN + DROP TABLE IF EXISTS pg_temp.ad_h, pg_temp.ad_f, public.ad_h, public.ad_f; + FOREACH t IN ARRAY ARRAY['ad_h', 'ad_f'] LOOP + EXECUTE format('CREATE %s TABLE %I (id int PRIMARY KEY, k int) USING %s', + CASE WHEN temp THEN 'TEMP' ELSE '' END, t, + CASE WHEN t = 'ad_h' THEN 'heap' ELSE 'flux' END); + EXECUTE format('CREATE INDEX %1$I_k ON %1$I (k)', t); + EXECUTE format('INSERT INTO %I SELECT g, g FROM generate_series(1, 200) g', t); + END LOOP; +END $$; +-- The update: move the first 50 rows' keys, in place on FLUX. +CREATE PROCEDURE ad_upd() LANGUAGE plpgsql AS $$ +BEGIN + UPDATE ad_h SET k = k + 1000 WHERE id <= 50; + UPDATE ad_f SET k = k + 1000 WHERE id <= 50; +END $$; +-- The DDL, on both tables; %1$I is the table. +CREATE PROCEDURE ad_ddl(ddl text) LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format(ddl, 'ad_h'); + EXECUTE format(ddl, 'ad_f'); +END $$; +-- Rows and sum of k, then index scans of the moved and the old keys, then +-- amcheck of every index. +CREATE FUNCTION ad_check1(t text) RETURNS text LANGUAGE plpgsql + SET enable_seqscan = off SET enable_bitmapscan = off AS $$ +DECLARE + chk text := (SELECT format('%I.bt_index_check', n.nspname) + FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'amcheck'); + n bigint; s bigint; moved bigint; old bigint; idx regclass; problems text := ''; +BEGIN + EXECUTE format('SELECT count(*), sum(k) FROM %I', t) INTO n, s; + EXECUTE format('SELECT count(*) FROM %I WHERE k > 1000', t) INTO moved; + EXECUTE format('SELECT count(*) FROM %I WHERE k BETWEEN 1 AND 50', t) INTO old; + IF chk IS NOT NULL THEN + FOR idx IN SELECT indexrelid FROM pg_index WHERE indrelid = t::regclass ORDER BY 1 LOOP + BEGIN + EXECUTE format('SELECT %s($1, true)', chk) USING idx; + EXCEPTION WHEN OTHERS THEN + problems := problems || ' ' || split_part(SQLERRM, ' for index', 1); + END; + END LOOP; + END IF; + RETURN format('%s|%s idx %s/%s%s', n, s, moved, old, + CASE WHEN problems = '' THEN '' ELSE ' amcheck:' || problems END); +END $$; +CREATE FUNCTION ad_check(OUT heap text, OUT flux text) LANGUAGE sql + RETURN (ad_check1('ad_h'), ad_check1('ad_f')); +-- Top-level ROLLBACK, then the update again in a new transaction. +CALL ad_setup(false); +NOTICE: schema "pg_temp" does not exist, skipping +NOTICE: schema "pg_temp" does not exist, skipping +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: schema "pg_temp" does not exist, skipping +NOTICE: schema "pg_temp" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: schema "pg_temp" does not exist, skipping +NOTICE: schema "pg_temp" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: schema "pg_temp" does not exist, skipping +NOTICE: schema "pg_temp" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: schema "pg_temp" does not exist, skipping +NOTICE: schema "pg_temp" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +-- The same with temporary tables. +CALL ad_setup(true); +NOTICE: schema "pg_temp" does not exist, skipping +NOTICE: schema "pg_temp" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +-- ROLLBACK TO SAVEPOINT, then the update again in the same transaction. +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +-- An error aborts the transaction the same way, in a transaction block or +-- in a single statement. +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); SELECT 1 / 0; ROLLBACK; +ERROR: division by zero +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); SELECT 1 / 0; ROLLBACK; +ERROR: division by zero +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +DO $$ BEGIN CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); PERFORM 1 / 0; END $$; +ERROR: division by zero +CONTEXT: SQL statement "SELECT 1 / 0" +PL/pgSQL function inline_code_block line 1 at PERFORM +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +-- ROLLBACK in a procedure, and ROLLBACK AND CHAIN: the next transaction +-- starts right away. +CREATE PROCEDURE ad_txn(ddl text) LANGUAGE plpgsql AS $$ +BEGIN + CALL ad_upd(); CALL ad_ddl(ddl); ROLLBACK; + CALL ad_upd(); +END $$; +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +CALL ad_txn('DROP INDEX %1$I_k'); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +CALL ad_txn('TRUNCATE %1$I'); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK AND CHAIN; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK AND CHAIN; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +-- Each row updated twice before the DDL: the rollback applies its two +-- records newest first. +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(false); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +CALL ad_setup(true); +NOTICE: table "ad_h" does not exist, skipping +NOTICE: table "ad_f" does not exist, skipping +BEGIN; CALL ad_upd(); SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK TO s; +ROLLBACK; CALL ad_upd(); SELECT * FROM ad_check(); + heap | flux +--------------------+-------------------- + 200|70100 idx 50/0 | 200|70100 idx 50/0 +(1 row) + +DROP TABLE ad_h, ad_f; +DROP FUNCTION ad_check, ad_check1; +DROP PROCEDURE ad_setup, ad_upd, ad_ddl, ad_txn; +DROP SCHEMA ad_amcheck CASCADE; +NOTICE: drop cascades to extension amcheck diff --git a/src/test/regress/expected/flux_cluster.out b/src/test/regress/expected/flux_cluster.out new file mode 100644 index 0000000000000..3c2bdec4c8719 --- /dev/null +++ b/src/test/regress/expected/flux_cluster.out @@ -0,0 +1,75 @@ +-- +-- CLUSTER orders a FLUX table by the index, whichever plan CLUSTER picks: +-- an index scan, or a sequential scan plus sort (an expression index, or a +-- big table with little maintenance_work_mem, makes it sort). Heap is the +-- control; both halves must give the same answer. +-- +CREATE FUNCTION flux_cluster_misordered(t regclass, key text) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM (SELECT %s AS k, lag(%s) OVER (ORDER BY ctid) AS lk FROM %s) s WHERE lk > k', + key, key, t) INTO n; + RETURN n; +END $$; +-- Expression index: CLUSTER always sorts. +CREATE TABLE heap_clx (id serial PRIMARY KEY, a int, b text COLLATE "C") USING heap; +CREATE TABLE flux_clx (id serial PRIMARY KEY, a int, b text COLLATE "C") USING flux; +INSERT INTO heap_clx (a, b) SELECT g % 42, 'prefix' || g FROM generate_series(1, 133) g; +INSERT INTO flux_clx (a, b) SELECT g % 42, 'prefix' || g FROM generate_series(1, 133) g; +CREATE INDEX heap_clx_minus_a ON heap_clx ((-a), b); +CREATE INDEX flux_clx_minus_a ON flux_clx ((-a), b); +CLUSTER heap_clx USING heap_clx_minus_a; +CLUSTER flux_clx USING flux_clx_minus_a; +SELECT flux_cluster_misordered('heap_clx', '-a') AS heap_misordered, + flux_cluster_misordered('flux_clx', '-a') AS flux_misordered; + heap_misordered | flux_misordered +-----------------+----------------- + 0 | 0 +(1 row) + +SELECT (SELECT count(*) FROM heap_clx) AS heap_rows, (SELECT count(*) FROM flux_clx) AS flux_rows; + heap_rows | flux_rows +-----------+----------- + 133 | 133 +(1 row) + +-- Plain index, sort forced by planner settings. +CREATE TABLE heap_cls (k int, pad text) USING heap; +CREATE TABLE flux_cls (k int, pad text) USING flux; +INSERT INTO heap_cls SELECT (g * 7919) % 5000, 'p' FROM generate_series(1, 5000) g; +INSERT INTO flux_cls SELECT (g * 7919) % 5000, 'p' FROM generate_series(1, 5000) g; +CREATE INDEX heap_cls_k ON heap_cls (k); +CREATE INDEX flux_cls_k ON flux_cls (k); +ANALYZE heap_cls, flux_cls; +SET enable_indexscan = off; +CLUSTER heap_cls USING heap_cls_k; +CLUSTER flux_cls USING flux_cls_k; +RESET enable_indexscan; +SELECT flux_cluster_misordered('heap_cls', 'k') AS heap_misordered, + flux_cluster_misordered('flux_cls', 'k') AS flux_misordered; + heap_misordered | flux_misordered +-----------------+----------------- + 0 | 0 +(1 row) + +-- Rows deleted but still visible to an open snapshot survive the sort. +CREATE TABLE flux_clr (k int) USING flux; +INSERT INTO flux_clr SELECT g FROM generate_series(1, 20) g; +CREATE INDEX flux_clr_e ON flux_clr ((-k)); +DELETE FROM flux_clr WHERE k > 10; +CLUSTER flux_clr USING flux_clr_e; +SELECT count(*), min(k), max(k) FROM flux_clr; + count | min | max +-------+-----+----- + 10 | 1 | 10 +(1 row) + +SELECT flux_cluster_misordered('flux_clr', '-k') AS flux_misordered; + flux_misordered +----------------- + 0 +(1 row) + +DROP TABLE heap_clx, flux_clx, heap_cls, flux_cls, flux_clr; +DROP FUNCTION flux_cluster_misordered(regclass, text); diff --git a/src/test/regress/expected/flux_deferred_trigger.out b/src/test/regress/expected/flux_deferred_trigger.out new file mode 100644 index 0000000000000..8a623b453c6cd --- /dev/null +++ b/src/test/regress/expected/flux_deferred_trigger.out @@ -0,0 +1,699 @@ +-- +-- AFTER-trigger row events on FLUX tables whose row was rewritten in place +-- before the event fired: deferred constraint triggers, SET CONSTRAINTS, +-- triggers firing after a nested statement, transition tables and deferred +-- foreign keys. Each case runs first on a heap twin (the control) and then +-- on FLUX; the two halves must print the same thing, and the final query +-- compares their table contents. +-- +-- Heap re-fetches an event's row version by TID when the trigger fires, and +-- RI's check skips a version that was since superseded. A FLUX row keeps its +-- TID across UPDATEs, so the event must carry its version. +-- +CREATE SCHEMA dt_heap; +CREATE SCHEMA dt_flux; +CREATE FUNCTION public.dt_show_row() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE '% % %: old % new %', TG_TABLE_NAME, TG_NAME, TG_OP, OLD, NEW; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_fix_pid() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.pid = 99 THEN + UPDATE nt SET pid = 1, v = v + 10 WHERE id = NEW.id; + END IF; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_show_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE o text; n text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO o FROM oldtab t; + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO n FROM newtab t; + RAISE NOTICE '% % % %: old % new %', TG_TABLE_NAME, TG_NAME, TG_OP, TG_LEVEL, o, n; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_show_new_table() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE n text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO n FROM newtab t; + RAISE NOTICE '% % %: new %', TG_TABLE_NAME, TG_NAME, TG_OP, n; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_show_old_table() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE o text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO o FROM oldtab t; + RAISE NOTICE '% % %: old %', TG_TABLE_NAME, TG_NAME, TG_OP, o; + RETURN NULL; +END $$; +-- +-- heap +-- +\set am heap +\set schema dt_heap +SET default_table_access_method = :am; +SET search_path = :schema, public; +-- +-- 1. A deferred constraint trigger sees each event's own OLD and NEW, even +-- after later statements rewrote the row: INSERT, UPDATE, UPDATE; then +-- UPDATE, DELETE. +-- +CREATE TABLE dt (id int PRIMARY KEY, v int); +CREATE CONSTRAINT TRIGGER dt_show AFTER INSERT OR UPDATE OR DELETE ON dt + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.dt_show_row(); +BEGIN; +INSERT INTO dt VALUES (1, 0); +UPDATE dt SET v = 1 WHERE id = 1; +UPDATE dt SET v = 2 WHERE id = 1; +COMMIT; +NOTICE: dt dt_show INSERT: old new (1,0) +NOTICE: dt dt_show UPDATE: old (1,0) new (1,1) +NOTICE: dt dt_show UPDATE: old (1,1) new (1,2) +BEGIN; +UPDATE dt SET v = 3 WHERE id = 1; +DELETE FROM dt WHERE id = 1; +COMMIT; +NOTICE: dt dt_show UPDATE: old (1,2) new (1,3) +NOTICE: dt dt_show DELETE: old (1,3) new +-- +-- 2. The same, with SET CONSTRAINTS IMMEDIATE midway (fires what is queued), +-- then DEFERRED again. +-- +BEGIN; +INSERT INTO dt VALUES (2, 0); +UPDATE dt SET v = 1 WHERE id = 2; +SET CONSTRAINTS dt_show IMMEDIATE; +NOTICE: dt dt_show INSERT: old new (2,0) +NOTICE: dt dt_show UPDATE: old (2,0) new (2,1) +UPDATE dt SET v = 2 WHERE id = 2; +NOTICE: dt dt_show UPDATE: old (2,1) new (2,2) +SET CONSTRAINTS dt_show DEFERRED; +UPDATE dt SET v = 3 WHERE id = 2; +UPDATE dt SET v = 4 WHERE id = 2; +COMMIT; +NOTICE: dt dt_show UPDATE: old (2,2) new (2,3) +NOTICE: dt dt_show UPDATE: old (2,3) new (2,4) +SELECT * FROM dt ORDER BY id; + id | v +----+--- + 2 | 4 +(1 row) + +-- +-- 3. Non-deferred AFTER triggers whose event fires after a later statement +-- rewrote the row: trigger "A_fix" (which fires first) re-updates the row in +-- a nested statement; "z_show" and the RI check on the outer event fire after +-- it. The outer event's NEW is superseded by then, so heap's RI check skips +-- it. +-- +CREATE TABLE np (id int PRIMARY KEY); +INSERT INTO np VALUES (1), (2); +CREATE TABLE nt (id int PRIMARY KEY, pid int REFERENCES np, v int); +CREATE TRIGGER "A_fix" AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_fix_pid(); +CREATE TRIGGER z_show AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_show_row(); +INSERT INTO nt VALUES (1, 1, 0), (2, 2, 0); +UPDATE nt SET pid = 99 WHERE id = 1; +NOTICE: nt z_show UPDATE: old (1,99,0) new (1,1,10) +NOTICE: nt z_show UPDATE: old (1,1,0) new (1,99,0) +-- a later statement's event on the twice-rewritten row +UPDATE nt SET v = v + 1 WHERE id = 1; +NOTICE: nt z_show UPDATE: old (1,1,10) new (1,1,11) +SELECT * FROM nt ORDER BY id; + id | pid | v +----+-----+---- + 1 | 1 | 11 + 2 | 2 | 0 +(2 rows) + +-- +-- 4. Transition tables, statement- and row-level, across statements of one +-- transaction. +-- +CREATE TABLE tt (id int PRIMARY KEY, v int); +CREATE TRIGGER tt_stmt_ins AFTER INSERT ON tt REFERENCING NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_new_table(); +CREATE TRIGGER tt_stmt_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_transition(); +CREATE TRIGGER tt_stmt_del AFTER DELETE ON tt REFERENCING OLD TABLE AS oldtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_old_table(); +CREATE TRIGGER tt_row_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH ROW EXECUTE FUNCTION public.dt_show_transition(); +BEGIN; +INSERT INTO tt VALUES (1, 0), (2, 0), (3, 0); +NOTICE: tt tt_stmt_ins INSERT: new (1,0) (2,0) (3,0) +UPDATE tt SET v = v + 1 WHERE id <= 2; +NOTICE: tt tt_row_upd UPDATE ROW: old (1,0) (2,0) new (1,1) (2,1) +NOTICE: tt tt_row_upd UPDATE ROW: old (1,0) (2,0) new (1,1) (2,1) +NOTICE: tt tt_stmt_upd UPDATE STATEMENT: old (1,0) (2,0) new (1,1) (2,1) +UPDATE tt SET v = v + 10; +NOTICE: tt tt_row_upd UPDATE ROW: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +NOTICE: tt tt_row_upd UPDATE ROW: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +NOTICE: tt tt_row_upd UPDATE ROW: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +NOTICE: tt tt_stmt_upd UPDATE STATEMENT: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +DELETE FROM tt WHERE id = 1; +NOTICE: tt tt_stmt_del DELETE: old (1,11) +COMMIT; +SELECT * FROM tt ORDER BY id; + id | v +----+---- + 2 | 11 + 3 | 10 +(2 rows) + +-- +-- 5. Deferred FK checks of a row the transaction rewrote. The check must +-- judge only the row's last version; each earlier one is superseded. +-- +CREATE TABLE fp (id int PRIMARY KEY, v int); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1, 0), (2, 0), (3, 0); +-- INSERT, UPDATE the FK column to a bad key, UPDATE it back: commits +BEGIN; +INSERT INTO fc VALUES (10, 1, 0); +UPDATE fc SET pid = 99 WHERE id = 10; +UPDATE fc SET pid = 1 WHERE id = 10; +COMMIT; +-- a bad INSERT fixed, then broken again: fails on the last key, 98 +BEGIN; +INSERT INTO fc VALUES (11, 99, 0); +UPDATE fc SET pid = 1 WHERE id = 11; +UPDATE fc SET pid = 98 WHERE id = 11; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(98) is not present in table "fp". +-- a bad key, then a non-key UPDATE: fails on 97 +BEGIN; +UPDATE fc SET pid = 97 WHERE id = 10; +UPDATE fc SET w = 1 WHERE id = 10; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(97) is not present in table "fp". +-- a bad key, then the row deleted: commits +BEGIN; +INSERT INTO fc VALUES (12, 96, 0); +UPDATE fc SET pid = 95 WHERE id = 12; +DELETE FROM fc WHERE id = 12; +COMMIT; +-- SET CONSTRAINTS ALL IMMEDIATE midway, after the bad key was fixed: commits +BEGIN; +INSERT INTO fc VALUES (13, 94, 0); +UPDATE fc SET pid = 2 WHERE id = 13; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 3 WHERE id = 13; +COMMIT; +-- ... and before it was fixed: fails at SET CONSTRAINTS, on 93 +BEGIN; +INSERT INTO fc VALUES (14, 93, 0); +SET CONSTRAINTS ALL IMMEDIATE; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(93) is not present in table "fp". +ROLLBACK; +-- the bad key's fix made in a subtransaction that commits: commits +BEGIN; +INSERT INTO fc VALUES (15, 92, 0); +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 15; +RELEASE SAVEPOINT s; +COMMIT; +-- a good key's breakage made in a subtransaction that rolls back: commits +BEGIN; +INSERT INTO fc VALUES (16, 1, 0); +UPDATE fc SET w = 1 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 91 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +COMMIT; +-- the same, then broken for real after the rollback: fails on 90 +BEGIN; +UPDATE fc SET w = 2 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +UPDATE fc SET pid = 90 WHERE id = 16; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(90) is not present in table "fp". +SELECT * FROM fc ORDER BY id; + id | pid | w +----+-----+--- + 10 | 1 | 0 + 13 | 3 | 0 + 15 | 1 | 0 + 16 | 1 | 1 +(4 rows) + +-- +-- 6. A deferred FK check whose parent row is updated in place between the +-- check being queued and firing. Each case has its own parent row, so no +-- earlier case's row locks or aborted versions are in its history. +-- +CREATE TABLE gp (id int PRIMARY KEY, v int); +CREATE TABLE gc (id int PRIMARY KEY, pid int REFERENCES gp DEFERRABLE INITIALLY DEFERRED); +INSERT INTO gp VALUES (1, 0), (2, 0), (3, 0), (4, 0), (5, 0); +INSERT INTO gc VALUES (40, 4), (50, 5); +-- a non-key column of the parent: commits +BEGIN; +INSERT INTO gc VALUES (10, 1); +UPDATE gp SET v = 5 WHERE id = 1; +COMMIT; +-- the parent's key moved away: fails +BEGIN; +INSERT INTO gc VALUES (20, 2); +UPDATE gp SET id = 20 WHERE id = 2; +COMMIT; +ERROR: insert or update on table "gc" violates foreign key constraint "gc_pid_fkey" +DETAIL: Key (pid)=(2) is not present in table "gp". +-- the parent's key moved away and back: commits +BEGIN; +INSERT INTO gc VALUES (30, 3); +UPDATE gp SET id = 30 WHERE id = 3; +UPDATE gp SET v = 6 WHERE id = 30; +UPDATE gp SET id = 3 WHERE id = 30; +COMMIT; +-- a referenced parent's key moved away and back (the parent's own deferred +-- NO ACTION check): commits +BEGIN; +UPDATE gp SET id = 41 WHERE id = 4; +UPDATE gp SET id = 4 WHERE id = 41; +COMMIT; +-- ... and moved away for good: fails +BEGIN; +UPDATE gp SET id = 51 WHERE id = 5; +UPDATE gp SET v = 7 WHERE id = 51; +COMMIT; +ERROR: update or delete on table "gp" violates foreign key constraint "gc_pid_fkey" on table "gc" +DETAIL: Key (id)=(5) is still referenced from table "gc". +SELECT * FROM gp ORDER BY id; + id | v +----+--- + 1 | 5 + 2 | 0 + 3 | 6 + 4 | 0 + 5 | 0 +(5 rows) + +SELECT * FROM gc ORDER BY id; + id | pid +----+----- + 10 | 1 + 30 | 3 + 40 | 4 + 50 | 5 +(4 rows) + +-- +-- 7. The original report (stale.sql). +-- +CREATE TABLE sp (id int PRIMARY KEY); +CREATE TABLE sc (id int PRIMARY KEY, pid int REFERENCES sp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO sp VALUES (1), (2); +INSERT INTO sc VALUES (1, 1, 0); +BEGIN; +INSERT INTO sc VALUES (2, 99, 0); +UPDATE sc SET pid = 1 WHERE id = 2; +COMMIT; +BEGIN; +UPDATE sc SET pid = 95 WHERE id = 1; +UPDATE sc SET w = 6 WHERE id = 1; +UPDATE sc SET pid = 1 WHERE id = 1; +COMMIT; +BEGIN; +UPDATE sc SET pid = 94 WHERE id = 2; +UPDATE sc SET pid = 2 WHERE id = 2; +UPDATE sc SET pid = 93, w = 1 WHERE id = 2; +COMMIT; +ERROR: insert or update on table "sc" violates foreign key constraint "sc_pid_fkey" +DETAIL: Key (pid)=(93) is not present in table "sp". +SELECT * FROM sc ORDER BY id; + id | pid | w +----+-----+--- + 1 | 1 | 6 + 2 | 1 | 0 +(2 rows) + +-- +-- flux +-- +\set am flux +\set schema dt_flux +SET default_table_access_method = :am; +SET search_path = :schema, public; +-- +-- 1. A deferred constraint trigger sees each event's own OLD and NEW, even +-- after later statements rewrote the row: INSERT, UPDATE, UPDATE; then +-- UPDATE, DELETE. +-- +CREATE TABLE dt (id int PRIMARY KEY, v int); +CREATE CONSTRAINT TRIGGER dt_show AFTER INSERT OR UPDATE OR DELETE ON dt + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.dt_show_row(); +BEGIN; +INSERT INTO dt VALUES (1, 0); +UPDATE dt SET v = 1 WHERE id = 1; +UPDATE dt SET v = 2 WHERE id = 1; +COMMIT; +NOTICE: dt dt_show INSERT: old new (1,0) +NOTICE: dt dt_show UPDATE: old (1,0) new (1,1) +NOTICE: dt dt_show UPDATE: old (1,1) new (1,2) +BEGIN; +UPDATE dt SET v = 3 WHERE id = 1; +DELETE FROM dt WHERE id = 1; +COMMIT; +NOTICE: dt dt_show UPDATE: old (1,2) new (1,3) +NOTICE: dt dt_show DELETE: old (1,3) new +-- +-- 2. The same, with SET CONSTRAINTS IMMEDIATE midway (fires what is queued), +-- then DEFERRED again. +-- +BEGIN; +INSERT INTO dt VALUES (2, 0); +UPDATE dt SET v = 1 WHERE id = 2; +SET CONSTRAINTS dt_show IMMEDIATE; +NOTICE: dt dt_show INSERT: old new (2,0) +NOTICE: dt dt_show UPDATE: old (2,0) new (2,1) +UPDATE dt SET v = 2 WHERE id = 2; +NOTICE: dt dt_show UPDATE: old (2,1) new (2,2) +SET CONSTRAINTS dt_show DEFERRED; +UPDATE dt SET v = 3 WHERE id = 2; +UPDATE dt SET v = 4 WHERE id = 2; +COMMIT; +NOTICE: dt dt_show UPDATE: old (2,2) new (2,3) +NOTICE: dt dt_show UPDATE: old (2,3) new (2,4) +SELECT * FROM dt ORDER BY id; + id | v +----+--- + 2 | 4 +(1 row) + +-- +-- 3. Non-deferred AFTER triggers whose event fires after a later statement +-- rewrote the row: trigger "A_fix" (which fires first) re-updates the row in +-- a nested statement; "z_show" and the RI check on the outer event fire after +-- it. The outer event's NEW is superseded by then, so heap's RI check skips +-- it. +-- +CREATE TABLE np (id int PRIMARY KEY); +INSERT INTO np VALUES (1), (2); +CREATE TABLE nt (id int PRIMARY KEY, pid int REFERENCES np, v int); +CREATE TRIGGER "A_fix" AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_fix_pid(); +CREATE TRIGGER z_show AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_show_row(); +INSERT INTO nt VALUES (1, 1, 0), (2, 2, 0); +UPDATE nt SET pid = 99 WHERE id = 1; +NOTICE: nt z_show UPDATE: old (1,99,0) new (1,1,10) +NOTICE: nt z_show UPDATE: old (1,1,0) new (1,99,0) +-- a later statement's event on the twice-rewritten row +UPDATE nt SET v = v + 1 WHERE id = 1; +NOTICE: nt z_show UPDATE: old (1,1,10) new (1,1,11) +SELECT * FROM nt ORDER BY id; + id | pid | v +----+-----+---- + 1 | 1 | 11 + 2 | 2 | 0 +(2 rows) + +-- +-- 4. Transition tables, statement- and row-level, across statements of one +-- transaction. +-- +CREATE TABLE tt (id int PRIMARY KEY, v int); +CREATE TRIGGER tt_stmt_ins AFTER INSERT ON tt REFERENCING NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_new_table(); +CREATE TRIGGER tt_stmt_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_transition(); +CREATE TRIGGER tt_stmt_del AFTER DELETE ON tt REFERENCING OLD TABLE AS oldtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_old_table(); +CREATE TRIGGER tt_row_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH ROW EXECUTE FUNCTION public.dt_show_transition(); +BEGIN; +INSERT INTO tt VALUES (1, 0), (2, 0), (3, 0); +NOTICE: tt tt_stmt_ins INSERT: new (1,0) (2,0) (3,0) +UPDATE tt SET v = v + 1 WHERE id <= 2; +NOTICE: tt tt_row_upd UPDATE ROW: old (1,0) (2,0) new (1,1) (2,1) +NOTICE: tt tt_row_upd UPDATE ROW: old (1,0) (2,0) new (1,1) (2,1) +NOTICE: tt tt_stmt_upd UPDATE STATEMENT: old (1,0) (2,0) new (1,1) (2,1) +UPDATE tt SET v = v + 10; +NOTICE: tt tt_row_upd UPDATE ROW: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +NOTICE: tt tt_row_upd UPDATE ROW: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +NOTICE: tt tt_row_upd UPDATE ROW: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +NOTICE: tt tt_stmt_upd UPDATE STATEMENT: old (1,1) (2,1) (3,0) new (1,11) (2,11) (3,10) +DELETE FROM tt WHERE id = 1; +NOTICE: tt tt_stmt_del DELETE: old (1,11) +COMMIT; +SELECT * FROM tt ORDER BY id; + id | v +----+---- + 2 | 11 + 3 | 10 +(2 rows) + +-- +-- 5. Deferred FK checks of a row the transaction rewrote. The check must +-- judge only the row's last version; each earlier one is superseded. +-- +CREATE TABLE fp (id int PRIMARY KEY, v int); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1, 0), (2, 0), (3, 0); +-- INSERT, UPDATE the FK column to a bad key, UPDATE it back: commits +BEGIN; +INSERT INTO fc VALUES (10, 1, 0); +UPDATE fc SET pid = 99 WHERE id = 10; +UPDATE fc SET pid = 1 WHERE id = 10; +COMMIT; +-- a bad INSERT fixed, then broken again: fails on the last key, 98 +BEGIN; +INSERT INTO fc VALUES (11, 99, 0); +UPDATE fc SET pid = 1 WHERE id = 11; +UPDATE fc SET pid = 98 WHERE id = 11; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(98) is not present in table "fp". +-- a bad key, then a non-key UPDATE: fails on 97 +BEGIN; +UPDATE fc SET pid = 97 WHERE id = 10; +UPDATE fc SET w = 1 WHERE id = 10; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(97) is not present in table "fp". +-- a bad key, then the row deleted: commits +BEGIN; +INSERT INTO fc VALUES (12, 96, 0); +UPDATE fc SET pid = 95 WHERE id = 12; +DELETE FROM fc WHERE id = 12; +COMMIT; +-- SET CONSTRAINTS ALL IMMEDIATE midway, after the bad key was fixed: commits +BEGIN; +INSERT INTO fc VALUES (13, 94, 0); +UPDATE fc SET pid = 2 WHERE id = 13; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 3 WHERE id = 13; +COMMIT; +-- ... and before it was fixed: fails at SET CONSTRAINTS, on 93 +BEGIN; +INSERT INTO fc VALUES (14, 93, 0); +SET CONSTRAINTS ALL IMMEDIATE; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(93) is not present in table "fp". +ROLLBACK; +-- the bad key's fix made in a subtransaction that commits: commits +BEGIN; +INSERT INTO fc VALUES (15, 92, 0); +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 15; +RELEASE SAVEPOINT s; +COMMIT; +-- a good key's breakage made in a subtransaction that rolls back: commits +BEGIN; +INSERT INTO fc VALUES (16, 1, 0); +UPDATE fc SET w = 1 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 91 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +COMMIT; +-- the same, then broken for real after the rollback: fails on 90 +BEGIN; +UPDATE fc SET w = 2 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +UPDATE fc SET pid = 90 WHERE id = 16; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(90) is not present in table "fp". +SELECT * FROM fc ORDER BY id; + id | pid | w +----+-----+--- + 10 | 1 | 0 + 13 | 3 | 0 + 15 | 1 | 0 + 16 | 1 | 1 +(4 rows) + +-- +-- 6. A deferred FK check whose parent row is updated in place between the +-- check being queued and firing. Each case has its own parent row, so no +-- earlier case's row locks or aborted versions are in its history. +-- +CREATE TABLE gp (id int PRIMARY KEY, v int); +CREATE TABLE gc (id int PRIMARY KEY, pid int REFERENCES gp DEFERRABLE INITIALLY DEFERRED); +INSERT INTO gp VALUES (1, 0), (2, 0), (3, 0), (4, 0), (5, 0); +INSERT INTO gc VALUES (40, 4), (50, 5); +-- a non-key column of the parent: commits +BEGIN; +INSERT INTO gc VALUES (10, 1); +UPDATE gp SET v = 5 WHERE id = 1; +COMMIT; +-- the parent's key moved away: fails +BEGIN; +INSERT INTO gc VALUES (20, 2); +UPDATE gp SET id = 20 WHERE id = 2; +COMMIT; +ERROR: insert or update on table "gc" violates foreign key constraint "gc_pid_fkey" +DETAIL: Key (pid)=(2) is not present in table "gp". +-- the parent's key moved away and back: commits +BEGIN; +INSERT INTO gc VALUES (30, 3); +UPDATE gp SET id = 30 WHERE id = 3; +UPDATE gp SET v = 6 WHERE id = 30; +UPDATE gp SET id = 3 WHERE id = 30; +COMMIT; +-- a referenced parent's key moved away and back (the parent's own deferred +-- NO ACTION check): commits +BEGIN; +UPDATE gp SET id = 41 WHERE id = 4; +UPDATE gp SET id = 4 WHERE id = 41; +COMMIT; +-- ... and moved away for good: fails +BEGIN; +UPDATE gp SET id = 51 WHERE id = 5; +UPDATE gp SET v = 7 WHERE id = 51; +COMMIT; +ERROR: update or delete on table "gp" violates foreign key constraint "gc_pid_fkey" on table "gc" +DETAIL: Key (id)=(5) is still referenced from table "gc". +SELECT * FROM gp ORDER BY id; + id | v +----+--- + 1 | 5 + 2 | 0 + 3 | 6 + 4 | 0 + 5 | 0 +(5 rows) + +SELECT * FROM gc ORDER BY id; + id | pid +----+----- + 10 | 1 + 30 | 3 + 40 | 4 + 50 | 5 +(4 rows) + +-- +-- 7. The original report (stale.sql). +-- +CREATE TABLE sp (id int PRIMARY KEY); +CREATE TABLE sc (id int PRIMARY KEY, pid int REFERENCES sp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO sp VALUES (1), (2); +INSERT INTO sc VALUES (1, 1, 0); +BEGIN; +INSERT INTO sc VALUES (2, 99, 0); +UPDATE sc SET pid = 1 WHERE id = 2; +COMMIT; +BEGIN; +UPDATE sc SET pid = 95 WHERE id = 1; +UPDATE sc SET w = 6 WHERE id = 1; +UPDATE sc SET pid = 1 WHERE id = 1; +COMMIT; +BEGIN; +UPDATE sc SET pid = 94 WHERE id = 2; +UPDATE sc SET pid = 2 WHERE id = 2; +UPDATE sc SET pid = 93, w = 1 WHERE id = 2; +COMMIT; +ERROR: insert or update on table "sc" violates foreign key constraint "sc_pid_fkey" +DETAIL: Key (pid)=(93) is not present in table "sp". +SELECT * FROM sc ORDER BY id; + id | pid | w +----+-----+--- + 1 | 1 | 6 + 2 | 1 | 0 +(2 rows) + +-- +-- The twins must agree on every table's contents. +-- +RESET search_path; +RESET default_table_access_method; +CREATE FUNCTION pg_temp.dt_twin_diff(t text) RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM ((SELECT * FROM dt_heap.%1$I EXCEPT ALL SELECT * FROM dt_flux.%1$I)' + ' UNION ALL (SELECT * FROM dt_flux.%1$I EXCEPT ALL SELECT * FROM dt_heap.%1$I)) d', t) + INTO n; + RETURN n; +END $$; +SELECT c.relname, pg_temp.dt_twin_diff(c.relname) AS differing_rows + FROM pg_class c + WHERE c.relnamespace = 'dt_heap'::regnamespace AND c.relkind = 'r' + ORDER BY 1; + relname | differing_rows +---------+---------------- + dt | 0 + fc | 0 + fp | 0 + gc | 0 + gp | 0 + np | 0 + nt | 0 + sc | 0 + sp | 0 + tt | 0 +(10 rows) + +SELECT c.relname, a.amname + FROM pg_class c JOIN pg_am a ON a.oid = c.relam + WHERE c.relnamespace = 'dt_flux'::regnamespace AND c.relkind = 'r' + ORDER BY 1; + relname | amname +---------+-------- + dt | flux + fc | flux + fp | flux + gc | flux + gp | flux + np | flux + nt | flux + sc | flux + sp | flux + tt | flux +(10 rows) + +DROP SCHEMA dt_heap CASCADE; +NOTICE: drop cascades to 10 other objects +DETAIL: drop cascades to table dt_heap.dt +drop cascades to table dt_heap.np +drop cascades to table dt_heap.nt +drop cascades to table dt_heap.tt +drop cascades to table dt_heap.fp +drop cascades to table dt_heap.fc +drop cascades to table dt_heap.gp +drop cascades to table dt_heap.gc +drop cascades to table dt_heap.sp +drop cascades to table dt_heap.sc +DROP SCHEMA dt_flux CASCADE; +NOTICE: drop cascades to 10 other objects +DETAIL: drop cascades to table dt_flux.dt +drop cascades to table dt_flux.np +drop cascades to table dt_flux.nt +drop cascades to table dt_flux.tt +drop cascades to table dt_flux.fp +drop cascades to table dt_flux.fc +drop cascades to table dt_flux.gp +drop cascades to table dt_flux.gc +drop cascades to table dt_flux.sp +drop cascades to table dt_flux.sc +DROP FUNCTION public.dt_show_row(), public.dt_fix_pid(), public.dt_show_transition(), + public.dt_show_new_table(), public.dt_show_old_table(); diff --git a/src/test/regress/expected/flux_dml_semantics.out b/src/test/regress/expected/flux_dml_semantics.out new file mode 100644 index 0000000000000..3f32cad2d7a7c --- /dev/null +++ b/src/test/regress/expected/flux_dml_semantics.out @@ -0,0 +1,202 @@ +-- Run the same statements against heap and FLUX. +CREATE SCHEMA semantics_heap; +CREATE SCHEMA semantics_flux; +SET search_path = semantics_heap, public; +CREATE TABLE m (tid int PRIMARY KEY, bal int) USING heap; +INSERT INTO m VALUES (1, 10); +MERGE INTO m USING (VALUES (1),(1)) s(sid) ON m.tid = s.sid + WHEN MATCHED THEN UPDATE SET bal = 0; +ERROR: MERGE command cannot affect row a second time +HINT: Ensure that not more than one source row matches any one target row. +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time +HINT: Ensure that no rows proposed for insertion within the same command have duplicate constrained values. +TRUNCATE m; +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time +HINT: Ensure that no rows proposed for insertion within the same command have duplicate constrained values. +CREATE TABLE cc (a int) USING heap; +BEGIN; +INSERT INTO cc VALUES (1); +INSERT INTO cc VALUES (2); +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; + a | cmin | cmax | own_xmin | no_xmax +---+------+------+----------+--------- + 1 | 0 | 0 | t | t + 2 | 1 | 1 | t | t +(2 rows) + +UPDATE cc SET a = a + 10; +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; + a | cmin | cmax | own_xmin | no_xmax +----+------+------+----------+--------- + 11 | 2 | 2 | t | t + 12 | 2 | 2 | t | t +(2 rows) + +-- FLUX has no heap combo-CID word: old undo versions retain their +-- inserting CID, not heap's raw combo index. Test the live versions here. +INSERT INTO cc VALUES (3) RETURNING a, cmin, cmax; + a | cmin | cmax +---+------+------ + 3 | 3 | 3 +(1 row) + +UPDATE cc SET a = a + 10 WHERE a = 3 RETURNING a, cmin, cmax; + a | cmin | cmax +----+------+------ + 13 | 4 | 4 +(1 row) + +COMMIT; +CREATE FUNCTION check_oid() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE '%: old tableoid valid %, new tableoid valid %', + TG_NAME, OLD.tableoid = TG_RELID, NEW.tableoid = TG_RELID; + RETURN NEW; +END +$$; +CREATE TABLE tw (a int) USING heap; +INSERT INTO tw VALUES (1); +CREATE TRIGGER b_after AFTER UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +CREATE TRIGGER c_when AFTER UPDATE ON tw FOR EACH ROW + WHEN (new.tableoid = old.tableoid AND new.tableoid <> 0) + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +NOTICE: b_after: old tableoid valid t, new tableoid valid t +NOTICE: c_when: old tableoid valid t, new tableoid valid t +CREATE TRIGGER a_before BEFORE UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +NOTICE: a_before: old tableoid valid t, new tableoid valid f +NOTICE: b_after: old tableoid valid t, new tableoid valid t +NOTICE: c_when: old tableoid valid t, new tableoid valid t +-- Transition tables contain row values, not system columns. +CREATE FUNCTION check_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'transition: old %, new %', + (SELECT array_agg(a ORDER BY a) FROM old_rows), + (SELECT array_agg(a ORDER BY a) FROM new_rows); + RETURN NULL; +END +$$; +CREATE TRIGGER d_transition AFTER UPDATE ON tw + REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows + FOR EACH STATEMENT EXECUTE FUNCTION check_transition(); +UPDATE tw SET a = a + 1; +NOTICE: a_before: old tableoid valid t, new tableoid valid f +NOTICE: b_after: old tableoid valid t, new tableoid valid t +NOTICE: c_when: old tableoid valid t, new tableoid valid t +NOTICE: transition: old {3}, new {4} +SET search_path = semantics_flux, public; +CREATE TABLE m (tid int PRIMARY KEY, bal int) USING flux; +INSERT INTO m VALUES (1, 10); +MERGE INTO m USING (VALUES (1),(1)) s(sid) ON m.tid = s.sid + WHEN MATCHED THEN UPDATE SET bal = 0; +ERROR: MERGE command cannot affect row a second time +HINT: Ensure that not more than one source row matches any one target row. +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time +HINT: Ensure that no rows proposed for insertion within the same command have duplicate constrained values. +TRUNCATE m; +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time +HINT: Ensure that no rows proposed for insertion within the same command have duplicate constrained values. +CREATE TABLE cc (a int) USING flux; +BEGIN; +INSERT INTO cc VALUES (1); +INSERT INTO cc VALUES (2); +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; + a | cmin | cmax | own_xmin | no_xmax +---+------+------+----------+--------- + 1 | 0 | 0 | t | t + 2 | 1 | 1 | t | t +(2 rows) + +UPDATE cc SET a = a + 10; +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; + a | cmin | cmax | own_xmin | no_xmax +----+------+------+----------+--------- + 11 | 2 | 2 | t | t + 12 | 2 | 2 | t | t +(2 rows) + +-- FLUX has no heap combo-CID word: old undo versions retain their +-- inserting CID, not heap's raw combo index. Test the live versions here. +INSERT INTO cc VALUES (3) RETURNING a, cmin, cmax; + a | cmin | cmax +---+------+------ + 3 | 3 | 3 +(1 row) + +UPDATE cc SET a = a + 10 WHERE a = 3 RETURNING a, cmin, cmax; + a | cmin | cmax +----+------+------ + 13 | 4 | 4 +(1 row) + +COMMIT; +CREATE FUNCTION check_oid() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE '%: old tableoid valid %, new tableoid valid %', + TG_NAME, OLD.tableoid = TG_RELID, NEW.tableoid = TG_RELID; + RETURN NEW; +END +$$; +CREATE TABLE tw (a int) USING flux; +INSERT INTO tw VALUES (1); +CREATE TRIGGER b_after AFTER UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +CREATE TRIGGER c_when AFTER UPDATE ON tw FOR EACH ROW + WHEN (new.tableoid = old.tableoid AND new.tableoid <> 0) + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +NOTICE: b_after: old tableoid valid t, new tableoid valid t +NOTICE: c_when: old tableoid valid t, new tableoid valid t +CREATE TRIGGER a_before BEFORE UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +NOTICE: a_before: old tableoid valid t, new tableoid valid f +NOTICE: b_after: old tableoid valid t, new tableoid valid t +NOTICE: c_when: old tableoid valid t, new tableoid valid t +-- Transition tables contain row values, not system columns. +CREATE FUNCTION check_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'transition: old %, new %', + (SELECT array_agg(a ORDER BY a) FROM old_rows), + (SELECT array_agg(a ORDER BY a) FROM new_rows); + RETURN NULL; +END +$$; +CREATE TRIGGER d_transition AFTER UPDATE ON tw + REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows + FOR EACH STATEMENT EXECUTE FUNCTION check_transition(); +UPDATE tw SET a = a + 1; +NOTICE: a_before: old tableoid valid t, new tableoid valid f +NOTICE: b_after: old tableoid valid t, new tableoid valid t +NOTICE: c_when: old tableoid valid t, new tableoid valid t +NOTICE: transition: old {3}, new {4} +RESET search_path; +DROP SCHEMA semantics_heap CASCADE; +NOTICE: drop cascades to 5 other objects +DETAIL: drop cascades to table semantics_heap.m +drop cascades to table semantics_heap.cc +drop cascades to function semantics_heap.check_oid() +drop cascades to table semantics_heap.tw +drop cascades to function semantics_heap.check_transition() +DROP SCHEMA semantics_flux CASCADE; +NOTICE: drop cascades to 5 other objects +DETAIL: drop cascades to table semantics_flux.m +drop cascades to table semantics_flux.cc +drop cascades to function semantics_flux.check_oid() +drop cascades to table semantics_flux.tw +drop cascades to function semantics_flux.check_transition() diff --git a/src/test/regress/expected/flux_estimate.out b/src/test/regress/expected/flux_estimate.out new file mode 100644 index 0000000000000..0924770ecacb8 --- /dev/null +++ b/src/test/regress/expected/flux_estimate.out @@ -0,0 +1,201 @@ +-- +-- FLUX planner size estimates (flux_relation_estimate_size), heap as control. +-- +-- Every result is a boolean so the expected output does not depend on +-- platform details; heap's column shows what the shared block estimator +-- gives for the same shape. +-- +-- Estimated rows and total cost of a whole-table scan. +CREATE FUNCTION est_plan(tbl regclass, what text) RETURNS float8 +LANGUAGE plpgsql AS $$ +DECLARE + plan json; +BEGIN + EXECUTE format('EXPLAIN (FORMAT JSON) SELECT * FROM %s', tbl) INTO plan; + RETURN (plan->0->'Plan'->>what)::float8; +END $$; +CREATE FUNCTION est_rows(tbl regclass) RETURNS float8 LANGUAGE sql AS $$ + SELECT est_plan(tbl, 'Plan Rows') +$$; +CREATE FUNCTION real_rows(tbl regclass) RETURNS float8 LANGUAGE plpgsql AS $$ +DECLARE + n float8; +BEGIN + EXECUTE format('SELECT count(*) FROM %s', tbl) INTO n; + RETURN n; +END $$; +CREATE FUNCTION real_pages(tbl regclass) RETURNS float8 LANGUAGE sql AS $$ + SELECT pg_relation_size(tbl) / current_setting('block_size')::float8 +$$; +-- The estimate is within [0.5x, 2x] of the real row count. +CREATE FUNCTION est_ok(tbl regclass) RETURNS bool LANGUAGE sql AS $$ + SELECT est_rows(tbl) BETWEEN 0.5 * real_rows(tbl) AND 2 * real_rows(tbl) +$$; +-- A never-analyzed table: the rows per page the planner assumes (the +-- estimate over the page count, which is at least 10) is within 10% of the +-- rows a full page really holds (the most rows on any block of a table +-- filled by INSERT alone). +CREATE FUNCTION real_density(tbl regclass) RETURNS float8 +LANGUAGE plpgsql AS $$ +DECLARE + n float8; +BEGIN + EXECUTE format('SELECT max(c) FROM (SELECT count(*) AS c FROM %s + GROUP BY (ctid::text::point)[0]) s', tbl) INTO n; + RETURN n; +END $$; +CREATE FUNCTION density_ok(tbl regclass) RETURNS bool LANGUAGE sql AS $$ + SELECT abs(est_rows(tbl) / greatest(real_pages(tbl), 10) / + real_density(tbl) - 1) <= 0.10 +$$; +CREATE TABLE est_result (shape text, am text, ok bool); +CREATE TABLE est_am (am text); +INSERT INTO est_am VALUES ('heap'), ('flux'); +-- Run one statement per access method; %1$s is the AM name. The +-- statements are not echoed: only est_result is shown. +\set ECHO none +SELECT h.shape, h.ok AS heap, f.ok AS flux +FROM est_result h JOIN est_result f USING (shape) +WHERE h.am = 'heap' AND f.am = 'flux' +ORDER BY h.ctid; + shape | heap | flux +------------------------------------+------+------ + fresh 2 rows: 10-page rule | t | t + empty after VACUUM: 0 pages | t | t + fresh default fillfactor: density | t | t + fresh 1000 columns: density | t | t + fresh narrow: density | t | t + fresh narrow: rows | t | t + fresh wide: density | t | t + fresh wide: rows | t | t + fresh with nulls: rows | t | t + fresh fillfactor 50: density | t | t + fresh fillfactor 50: rows | t | t + after ANALYZE: exact | t | t + grown 3x since ANALYZE: rows | t | t + 3 non-key UPDATE rounds: rows | f | t + 3 non-key UPDATE rounds: no drift | f | t + key UPDATE + DELETE + VACUUM: rows | t | t +(16 rows) + +-- ANALYZE's average column width is the stored width, as for heap: a +-- compressed value inline, an external value's TOAST pointer. +\set ECHO none +SELECT attname, h.avg_width = f.avg_width AS same_width, + f.avg_width < 100 AS stored_width +FROM pg_stats h JOIN pg_stats f USING (attname) +WHERE h.tablename = 'est_width_heap' AND f.tablename = 'est_width_flux' +ORDER BY attname; + attname | same_width | stored_width +---------+------------+-------------- + a | t | t + e | t | t + m | t | t +(3 rows) + +-- Planning a never-analyzed table reads no table block: 50 plans, 0 reads. +\set ECHO none +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT pg_stat_clear_snapshot(); + pg_stat_clear_snapshot +------------------------ + +(1 row) + +CREATE TEMP TABLE est_io_before AS + SELECT relname, heap_blks_hit + heap_blks_read AS blks + FROM pg_statio_user_tables WHERE relname IN ('est_io_heap', 'est_io_flux'); +DO $$ +BEGIN + FOR i IN 1..50 LOOP + EXECUTE 'EXPLAIN SELECT * FROM est_io_heap WHERE a = 1'; + EXECUTE 'EXPLAIN SELECT * FROM est_io_flux WHERE a = 1'; + END LOOP; +END $$; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT pg_stat_clear_snapshot(); + pg_stat_clear_snapshot +------------------------ + +(1 row) + +SELECT s.relname, + s.heap_blks_hit + s.heap_blks_read - b.blks AS blocks_read_by_planning +FROM pg_statio_user_tables s JOIN est_io_before b USING (relname) +ORDER BY s.relname; + relname | blocks_read_by_planning +-------------+------------------------- + est_io_flux | 0 + est_io_heap | 0 +(2 rows) + +-- An index scan of a range in physical order visits each table page once, as +-- the planner's index-correlation costing assumes: 3000 rows, 0 to 30 pages. +\set ECHO none +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT pg_stat_clear_snapshot(); + pg_stat_clear_snapshot +------------------------ + +(1 row) + +CREATE TEMP TABLE est_corr_before AS + SELECT relname, heap_blks_hit + heap_blks_read AS blks + FROM pg_statio_user_tables WHERE relname IN ('est_corr_heap', 'est_corr_flux'); +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT sum(v) FROM est_corr_heap WHERE id BETWEEN 1001 AND 4000; + sum +--------- + 7501500 +(1 row) + +SELECT sum(v) FROM est_corr_flux WHERE id BETWEEN 1001 AND 4000; + sum +--------- + 7501500 +(1 row) + +RESET enable_seqscan; +RESET enable_bitmapscan; +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT pg_stat_clear_snapshot(); + pg_stat_clear_snapshot +------------------------ + +(1 row) + +SELECT s.relname, + s.heap_blks_hit + s.heap_blks_read - b.blks BETWEEN 1 AND 30 AS pages_once +FROM pg_statio_user_tables s JOIN est_corr_before b USING (relname) +ORDER BY s.relname; + relname | pages_once +---------------+------------ + est_corr_flux | t + est_corr_heap | t +(2 rows) + +\set ECHO none +DROP TABLE est_result, est_am; +DROP FUNCTION est_ok, density_ok, real_density, est_rows, est_plan, real_rows, + real_pages; diff --git a/src/test/regress/expected/flux_fk.out b/src/test/regress/expected/flux_fk.out new file mode 100644 index 0000000000000..dd37bc1cfa550 --- /dev/null +++ b/src/test/regress/expected/flux_fk.out @@ -0,0 +1,1054 @@ +-- +-- Foreign keys and AFTER triggers on FLUX tables, each case run first on a +-- heap twin (the control) and then on FLUX. The two halves must print the +-- same thing; the final query compares their table contents. +-- +-- An AM that updates rows in place hands the AFTER-trigger machinery copies +-- of the old and new row images, and the RI triggers ask such a copy whether +-- the current transaction created it (slot_is_current_xact_tuple). +-- +CREATE SCHEMA fk_heap; +CREATE SCHEMA fk_flux; +CREATE FUNCTION public.fk_log_row() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO log VALUES (nextval('log_seq'), TG_TABLE_NAME, + TG_NAME || ' ' || TG_OP, OLD::text, NEW::text); + RETURN NULL; +END $$; +CREATE FUNCTION public.fk_show_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE o text; n text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO o FROM oldtab t; + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO n FROM newtab t; + RAISE NOTICE '% %: old % new %', TG_TABLE_NAME, TG_OP, o, n; + RETURN NULL; +END $$; +CREATE FUNCTION public.fk_bump_w() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + NEW.w := NEW.w + 100; + IF NEW.w = 99 THEN NEW.pid := 8; END IF; + RETURN NEW; +END $$; +-- +-- heap +-- +\set am heap +\set other flux +\set schema fk_heap +SET default_table_access_method = :am; +SET search_path = :schema, public; +-- Event log for the row triggers below; compared across the twins at the end. +CREATE TABLE log (seq int, tbl text, op text, old_row text, new_row text); +CREATE SEQUENCE log_seq; +-- +-- 1. Child UPDATE, of a non-FK column and of the FK column. The second +-- block's row was inserted by this transaction (the is_current_xact_tuple +-- branch of RI_FKey_fk_upd_check_required). +-- +CREATE TABLE pp (id int PRIMARY KEY, v int); +INSERT INTO pp VALUES (1, 0), (2, 0); +CREATE TABLE pc (id int PRIMARY KEY, pid int REFERENCES pp, w int); +INSERT INTO pc VALUES (1, 1, 0), (2, 2, 0); +UPDATE pc SET w = 5 WHERE id = 1; +UPDATE pc SET pid = 2 WHERE id = 1; +UPDATE pc SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "pc" violates foreign key constraint "pc_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "pp". +UPDATE pc SET w = w + 1; +BEGIN; +INSERT INTO pc VALUES (3, 1, 0); +UPDATE pc SET w = 1 WHERE id = 3; +UPDATE pc SET pid = 2, w = 2 WHERE id = 3; +COMMIT; +BEGIN; +INSERT INTO pc VALUES (4, 1, 0); +UPDATE pc SET pid = 3 WHERE id = 4; +ERROR: insert or update on table "pc" violates foreign key constraint "pc_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "pp". +ROLLBACK; +SELECT * FROM pc ORDER BY id; + id | pid | w +----+-----+--- + 1 | 2 | 6 + 2 | 2 | 1 + 3 | 2 | 2 +(3 rows) + +-- +-- 2. Parent UPDATE of a non-key column and of the key, under each action. +-- Child rows reference parent 1; parent 2 is unreferenced. +-- +CREATE TABLE p_na (id int PRIMARY KEY, v int); +CREATE TABLE c_na (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_na ON UPDATE NO ACTION, w int); +CREATE TABLE p_re (id int PRIMARY KEY, v int); +CREATE TABLE c_re (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_re ON UPDATE RESTRICT, w int); +CREATE TABLE p_ca (id int PRIMARY KEY, v int); +CREATE TABLE c_ca (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_ca ON UPDATE CASCADE, w int); +CREATE TABLE p_sn (id int PRIMARY KEY, v int); +CREATE TABLE c_sn (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sn ON UPDATE SET NULL, w int); +CREATE TABLE p_sd (id int PRIMARY KEY, v int); +CREATE TABLE c_sd (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sd ON UPDATE SET DEFAULT, w int); +INSERT INTO p_na VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_re VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_ca VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sn VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sd VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO c_na VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_re VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_ca VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sn VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sd VALUES (1, 1, 0), (2, 1, 0); +UPDATE p_na SET v = 1 WHERE id = 1; +UPDATE p_na SET id = 10 WHERE id = 1; +ERROR: update or delete on table "p_na" violates foreign key constraint "c_na_pid_fkey" on table "c_na" +DETAIL: Key (id)=(1) is still referenced from table "c_na". +UPDATE p_na SET id = 20 WHERE id = 2; +UPDATE p_re SET v = 1 WHERE id = 1; +UPDATE p_re SET id = 10 WHERE id = 1; +ERROR: update or delete on table "p_re" violates RESTRICT setting of foreign key constraint "c_re_pid_fkey" on table "c_re" +DETAIL: Key (id)=(1) is referenced from table "c_re". +UPDATE p_re SET id = 20 WHERE id = 2; +UPDATE p_ca SET v = 1 WHERE id = 1; +UPDATE p_ca SET id = 10 WHERE id = 1; +UPDATE p_ca SET id = 20 WHERE id = 2; +UPDATE p_ca SET id = 11 WHERE id = 10; +UPDATE p_sn SET v = 1 WHERE id = 1; +UPDATE p_sn SET id = 10 WHERE id = 1; +UPDATE p_sn SET id = 20 WHERE id = 2; +UPDATE p_sd SET v = 1 WHERE id = 1; +UPDATE p_sd SET id = 10 WHERE id = 1; +UPDATE p_sd SET id = 20 WHERE id = 2; +UPDATE p_sd SET id = 30 WHERE id = 0; +ERROR: update or delete on table "p_sd" violates foreign key constraint "c_sd_pid_fkey" on table "c_sd" +DETAIL: Key (id)=(0) is still referenced from table "c_sd". +SELECT 'na' AS t, * FROM c_na UNION ALL SELECT 're', * FROM c_re + UNION ALL SELECT 'ca', * FROM c_ca UNION ALL SELECT 'sn', * FROM c_sn + UNION ALL SELECT 'sd', * FROM c_sd ORDER BY 1, 2; + t | id | pid | w +----+----+-----+--- + ca | 1 | 11 | 0 + ca | 2 | 11 | 0 + na | 1 | 1 | 0 + na | 2 | 1 | 0 + re | 1 | 1 | 0 + re | 2 | 1 | 0 + sd | 1 | 0 | 0 + sd | 2 | 0 | 0 + sn | 1 | | 0 + sn | 2 | | 0 +(10 rows) + +-- the cascaded rows can be updated again, and the cascade repeated +UPDATE c_ca SET w = 7; +UPDATE p_ca SET id = 12 WHERE id = 11; +SELECT * FROM c_ca ORDER BY id; + id | pid | w +----+-----+--- + 1 | 12 | 7 + 2 | 12 | 7 +(2 rows) + +-- +-- 3. DELETE with ON DELETE CASCADE and SET NULL. +-- +CREATE TABLE dp (id int PRIMARY KEY); +CREATE TABLE dc_ca (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE CASCADE, w int); +CREATE TABLE dc_sn (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE SET NULL, w int); +INSERT INTO dp VALUES (1), (2); +INSERT INTO dc_ca VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +INSERT INTO dc_sn VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +UPDATE dc_ca SET w = 1; +UPDATE dc_sn SET w = 1; +DELETE FROM dp WHERE id = 1; +UPDATE dc_sn SET w = 2 WHERE pid IS NULL; +UPDATE dc_sn SET pid = 2 WHERE id = 1; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + t | id | pid | w +----+----+-----+--- + ca | 2 | 2 | 1 + sn | 1 | 2 | 2 + sn | 2 | 2 | 1 + sn | 3 | | 2 +(4 rows) + +-- +-- 4. Deferrable FKs. +-- +CREATE TABLE fp (id int PRIMARY KEY); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED, w int); +CREATE TABLE fc_na (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1); +INSERT INTO fc VALUES (1, 1, 0); +INSERT INTO fc_na VALUES (1, 1, 0); +-- the parent row appears later in the transaction +BEGIN; +UPDATE fc SET w = 1 WHERE id = 1; +UPDATE fc SET pid = 2 WHERE id = 1; +INSERT INTO fp VALUES (2); +COMMIT; +-- a row inserted, then updated, with a good key throughout +BEGIN; +INSERT INTO fc VALUES (2, 1, 0); +UPDATE fc SET w = 1 WHERE id = 2; +UPDATE fc SET pid = 2 WHERE id = 2; +COMMIT; +-- a bad key that is never fixed +BEGIN; +UPDATE fc SET pid = 98 WHERE id = 1; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(98) is not present in table "fp". +-- a bad key, then an UPDATE of another column of the same row +BEGIN; +UPDATE fc SET pid = 96 WHERE id = 1; +UPDATE fc SET w = 5 WHERE id = 1; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(96) is not present in table "fp". +-- a row inserted and then updated without touching its (bad) key +BEGIN; +INSERT INTO fc VALUES (3, 92, 0); +UPDATE fc SET w = 1 WHERE id = 3; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(92) is not present in table "fp". +-- a parent key changed and changed back (NO ACTION) is fine at commit +BEGIN; +UPDATE fp SET id = 3 WHERE id = 1; +UPDATE fp SET id = 1 WHERE id = 3; +COMMIT; +-- the same, checked immediately +BEGIN; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 97 WHERE id = 1; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(97) is not present in table "fp". +ROLLBACK; +SELECT 'fc' AS t, * FROM fc UNION ALL SELECT 'fc_na', * FROM fc_na ORDER BY 1, 2; + t | id | pid | w +-------+----+-----+--- + fc | 1 | 2 | 1 + fc | 2 | 2 | 1 + fc_na | 1 | 1 | 0 +(3 rows) + +-- +-- 5. A self-referencing FK. +-- +CREATE TABLE tree (id int PRIMARY KEY, parent int REFERENCES tree ON UPDATE CASCADE ON DELETE CASCADE, w int); +INSERT INTO tree VALUES (1, NULL, 0), (2, 1, 0), (3, 2, 0), (4, 1, 0); +UPDATE tree SET w = 1; +UPDATE tree SET id = 10 WHERE id = 1; +UPDATE tree SET id = 20 WHERE id = 2; +UPDATE tree SET parent = 4 WHERE id = 3; +UPDATE tree SET parent = 5 WHERE id = 3; +ERROR: insert or update on table "tree" violates foreign key constraint "tree_parent_fkey" +DETAIL: Key (parent)=(5) is not present in table "tree". +SELECT * FROM tree ORDER BY id; + id | parent | w +----+--------+--- + 3 | 4 | 1 + 4 | 10 | 1 + 10 | | 1 + 20 | 10 | 1 +(4 rows) + +DELETE FROM tree WHERE id = 10; +SELECT count(*) FROM tree; + count +------- + 0 +(1 row) + +-- +-- 6. An FK across access methods, in both directions. +-- +CREATE TABLE xp_o (id int PRIMARY KEY, v int) USING :other; +CREATE TABLE xc_d (id int PRIMARY KEY, pid int REFERENCES xp_o ON UPDATE CASCADE ON DELETE SET NULL, w int); +CREATE TABLE xp_d (id int PRIMARY KEY, v int); +CREATE TABLE xc_o (id int PRIMARY KEY, pid int REFERENCES xp_d ON UPDATE CASCADE ON DELETE SET NULL, w int) USING :other; +INSERT INTO xp_o VALUES (1, 0), (2, 0); +INSERT INTO xp_d VALUES (1, 0), (2, 0); +INSERT INTO xc_d VALUES (1, 1, 0), (2, 2, 0); +INSERT INTO xc_o VALUES (1, 1, 0), (2, 2, 0); +UPDATE xc_d SET w = 1; +UPDATE xc_o SET w = 1; +UPDATE xc_d SET pid = 2 WHERE id = 1; +UPDATE xc_o SET pid = 2 WHERE id = 1; +UPDATE xc_d SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "xc_d" violates foreign key constraint "xc_d_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "xp_o". +UPDATE xc_o SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "xc_o" violates foreign key constraint "xc_o_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "xp_d". +UPDATE xp_o SET v = 1; +UPDATE xp_d SET v = 1; +UPDATE xp_o SET id = 5 WHERE id = 2; +UPDATE xp_d SET id = 5 WHERE id = 2; +DELETE FROM xp_o WHERE id = 1; +DELETE FROM xp_d WHERE id = 1; +SELECT 'xc_d' AS t, * FROM xc_d UNION ALL SELECT 'xc_o', * FROM xc_o ORDER BY 1, 2; + t | id | pid | w +------+----+-----+--- + xc_d | 1 | 5 | 1 + xc_d | 2 | 5 | 1 + xc_o | 1 | 5 | 1 + xc_o | 2 | 5 | 1 +(4 rows) + +-- +-- 7. Partitioned tables, and 9. UPDATEs that move a row across partitions. +-- +CREATE TABLE ptp (id int PRIMARY KEY, v int) PARTITION BY RANGE (id); +CREATE TABLE ptp1 PARTITION OF ptp FOR VALUES FROM (0) TO (100); +CREATE TABLE ptp2 PARTITION OF ptp FOR VALUES FROM (100) TO (200); +CREATE TABLE ptc (id int PRIMARY KEY, pid int REFERENCES ptp ON UPDATE CASCADE ON DELETE SET NULL, w int) PARTITION BY RANGE (id); +CREATE TABLE ptc1 PARTITION OF ptc FOR VALUES FROM (0) TO (100); +CREATE TABLE ptc2 PARTITION OF ptc FOR VALUES FROM (100) TO (200); +INSERT INTO ptp VALUES (1, 0), (2, 0), (150, 0); +INSERT INTO ptc VALUES (1, 1, 0), (2, 2, 0), (101, 150, 0); +UPDATE ptc SET w = 1; +UPDATE ptc SET pid = 2 WHERE id = 1; +UPDATE ptc SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "ptc1" violates foreign key constraint "ptc_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "ptp". +UPDATE ptp SET v = 1; +UPDATE ptp SET id = 3 WHERE id = 2; +UPDATE ptp SET id = 160 WHERE id = 150; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + tableoid | id | pid | w +----------+-----+-----+--- + ptc1 | 1 | 3 | 1 + ptc1 | 2 | 3 | 1 + ptc2 | 101 | 160 | 1 +(3 rows) + +-- parent row moves partition; its children follow by cascade +UPDATE ptp SET id = 120 WHERE id = 3; +-- child row moves partition, with and without an FK column change +UPDATE ptc SET id = 150 WHERE id = 1; +UPDATE ptc SET id = 50, pid = 160 WHERE id = 101; +UPDATE ptc SET id = 151, pid = 7 WHERE id = 150; +ERROR: insert or update on table "ptc2" violates foreign key constraint "ptc_pid_fkey" +DETAIL: Key (pid)=(7) is not present in table "ptp". +SELECT tableoid::regclass, * FROM ptp ORDER BY id; + tableoid | id | v +----------+-----+--- + ptp1 | 1 | 1 + ptp2 | 120 | 1 + ptp2 | 160 | 1 +(3 rows) + +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + tableoid | id | pid | w +----------+-----+-----+--- + ptc1 | 2 | 120 | 1 + ptc1 | 50 | 160 | 1 + ptc2 | 150 | 120 | 1 +(3 rows) + +DELETE FROM ptp WHERE id = 120; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + tableoid | id | pid | w +----------+-----+-----+--- + ptc1 | 2 | | 1 + ptc1 | 50 | 160 | 1 + ptc2 | 150 | | 1 +(3 rows) + +-- +-- 8. MERGE on FK tables, child and parent side. +-- +MERGE INTO pc USING (VALUES (1, 1, 7), (5, 2, 0), (2, NULL, 9)) s(id, pid, w) + ON pc.id = s.id + WHEN MATCHED AND s.pid IS NULL THEN UPDATE SET w = s.w + WHEN MATCHED THEN UPDATE SET pid = s.pid, w = s.w + WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.pid, s.w); +MERGE INTO pc USING (VALUES (1, 4)) s(id, pid) ON pc.id = s.id + WHEN MATCHED THEN UPDATE SET pid = s.pid; +ERROR: insert or update on table "pc" violates foreign key constraint "pc_pid_fkey" +DETAIL: Key (pid)=(4) is not present in table "pp". +SELECT * FROM pc ORDER BY id; + id | pid | w +----+-----+--- + 1 | 1 | 7 + 2 | 2 | 9 + 3 | 2 | 2 + 5 | 2 | 0 +(4 rows) + +MERGE INTO p_ca USING (VALUES (12, 13), (20, 21)) s(id, nid) ON p_ca.id = s.id + WHEN MATCHED THEN UPDATE SET id = s.nid; +SELECT * FROM c_ca ORDER BY id; + id | pid | w +----+-----+--- + 1 | 13 | 7 + 2 | 13 | 7 +(2 rows) + +MERGE INTO dp USING (VALUES (2)) s(id) ON dp.id = s.id + WHEN MATCHED THEN DELETE; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + t | id | pid | w +----+----+-----+--- + sn | 1 | | 2 + sn | 2 | | 1 + sn | 3 | | 2 +(3 rows) + +-- +-- 10. Other AFTER-trigger users of the old/new slot: a row trigger that +-- reads OLD and NEW (immediate and deferred), transition tables, and a +-- BEFORE trigger combined with an FK. +-- +CREATE TABLE tp (id int PRIMARY KEY, v int); +CREATE TABLE tc (id int PRIMARY KEY, pid int REFERENCES tp ON UPDATE CASCADE, w int); +INSERT INTO tp VALUES (1, 0), (2, 0); +INSERT INTO tc VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +CREATE TRIGGER tc_after AFTER UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE CONSTRAINT TRIGGER tc_deferred AFTER UPDATE ON tc + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE TRIGGER tc_trans AFTER UPDATE ON tc + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tp_trans AFTER UPDATE ON tp + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tc_before BEFORE UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_bump_w(); +UPDATE tc SET w = 5 WHERE id = 1; +NOTICE: tc UPDATE: old (1,1,0) new (1,1,105) +UPDATE tc SET pid = 2 WHERE id = 1; +NOTICE: tc UPDATE: old (1,1,105) new (1,2,205) +UPDATE tc SET pid = 9 WHERE id = 1; +ERROR: insert or update on table "tc" violates foreign key constraint "tc_pid_fkey" +DETAIL: Key (pid)=(9) is not present in table "tp". +UPDATE tp SET id = 10 WHERE id = 1; +NOTICE: tp UPDATE: old (1,0) new (10,0) +NOTICE: tc UPDATE: old (3,1,0) new (3,10,100) +BEGIN; +UPDATE tc SET w = 0 WHERE id = 2; +NOTICE: tc UPDATE: old (2,2,0) new (2,2,100) +UPDATE tc SET w = 1 WHERE id = 2; +NOTICE: tc UPDATE: old (2,2,100) new (2,2,101) +UPDATE tc SET pid = 10 WHERE id = 2; +NOTICE: tc UPDATE: old (2,2,101) new (2,10,201) +COMMIT; +-- the BEFORE trigger redirects the FK to a missing parent +UPDATE tc SET w = -1 WHERE id = 2; +ERROR: insert or update on table "tc" violates foreign key constraint "tc_pid_fkey" +DETAIL: Key (pid)=(8) is not present in table "tp". +SELECT * FROM tc ORDER BY id; + id | pid | w +----+-----+----- + 1 | 2 | 205 + 2 | 10 | 201 + 3 | 10 | 100 +(3 rows) + +SELECT tbl, op, old_row, new_row FROM log ORDER BY seq; + tbl | op | old_row | new_row +-----+--------------------+-----------+------------ + tc | tc_after UPDATE | (1,1,0) | (1,1,105) + tc | tc_deferred UPDATE | (1,1,0) | (1,1,105) + tc | tc_after UPDATE | (1,1,105) | (1,2,205) + tc | tc_deferred UPDATE | (1,1,105) | (1,2,205) + tc | tc_after UPDATE | (3,1,0) | (3,10,100) + tc | tc_deferred UPDATE | (3,1,0) | (3,10,100) + tc | tc_after UPDATE | (2,2,0) | (2,2,100) + tc | tc_after UPDATE | (2,2,100) | (2,2,101) + tc | tc_after UPDATE | (2,2,101) | (2,10,201) + tc | tc_deferred UPDATE | (2,2,0) | (2,2,100) + tc | tc_deferred UPDATE | (2,2,100) | (2,2,101) + tc | tc_deferred UPDATE | (2,2,101) | (2,10,201) +(12 rows) + +-- +-- flux +-- +\set am flux +\set other heap +\set schema fk_flux +SET default_table_access_method = :am; +SET search_path = :schema, public; +-- Event log for the row triggers below; compared across the twins at the end. +CREATE TABLE log (seq int, tbl text, op text, old_row text, new_row text); +CREATE SEQUENCE log_seq; +-- +-- 1. Child UPDATE, of a non-FK column and of the FK column. The second +-- block's row was inserted by this transaction (the is_current_xact_tuple +-- branch of RI_FKey_fk_upd_check_required). +-- +CREATE TABLE pp (id int PRIMARY KEY, v int); +INSERT INTO pp VALUES (1, 0), (2, 0); +CREATE TABLE pc (id int PRIMARY KEY, pid int REFERENCES pp, w int); +INSERT INTO pc VALUES (1, 1, 0), (2, 2, 0); +UPDATE pc SET w = 5 WHERE id = 1; +UPDATE pc SET pid = 2 WHERE id = 1; +UPDATE pc SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "pc" violates foreign key constraint "pc_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "pp". +UPDATE pc SET w = w + 1; +BEGIN; +INSERT INTO pc VALUES (3, 1, 0); +UPDATE pc SET w = 1 WHERE id = 3; +UPDATE pc SET pid = 2, w = 2 WHERE id = 3; +COMMIT; +BEGIN; +INSERT INTO pc VALUES (4, 1, 0); +UPDATE pc SET pid = 3 WHERE id = 4; +ERROR: insert or update on table "pc" violates foreign key constraint "pc_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "pp". +ROLLBACK; +SELECT * FROM pc ORDER BY id; + id | pid | w +----+-----+--- + 1 | 2 | 6 + 2 | 2 | 1 + 3 | 2 | 2 +(3 rows) + +-- +-- 2. Parent UPDATE of a non-key column and of the key, under each action. +-- Child rows reference parent 1; parent 2 is unreferenced. +-- +CREATE TABLE p_na (id int PRIMARY KEY, v int); +CREATE TABLE c_na (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_na ON UPDATE NO ACTION, w int); +CREATE TABLE p_re (id int PRIMARY KEY, v int); +CREATE TABLE c_re (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_re ON UPDATE RESTRICT, w int); +CREATE TABLE p_ca (id int PRIMARY KEY, v int); +CREATE TABLE c_ca (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_ca ON UPDATE CASCADE, w int); +CREATE TABLE p_sn (id int PRIMARY KEY, v int); +CREATE TABLE c_sn (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sn ON UPDATE SET NULL, w int); +CREATE TABLE p_sd (id int PRIMARY KEY, v int); +CREATE TABLE c_sd (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sd ON UPDATE SET DEFAULT, w int); +INSERT INTO p_na VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_re VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_ca VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sn VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sd VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO c_na VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_re VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_ca VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sn VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sd VALUES (1, 1, 0), (2, 1, 0); +UPDATE p_na SET v = 1 WHERE id = 1; +UPDATE p_na SET id = 10 WHERE id = 1; +ERROR: update or delete on table "p_na" violates foreign key constraint "c_na_pid_fkey" on table "c_na" +DETAIL: Key (id)=(1) is still referenced from table "c_na". +UPDATE p_na SET id = 20 WHERE id = 2; +UPDATE p_re SET v = 1 WHERE id = 1; +UPDATE p_re SET id = 10 WHERE id = 1; +ERROR: update or delete on table "p_re" violates RESTRICT setting of foreign key constraint "c_re_pid_fkey" on table "c_re" +DETAIL: Key (id)=(1) is referenced from table "c_re". +UPDATE p_re SET id = 20 WHERE id = 2; +UPDATE p_ca SET v = 1 WHERE id = 1; +UPDATE p_ca SET id = 10 WHERE id = 1; +UPDATE p_ca SET id = 20 WHERE id = 2; +UPDATE p_ca SET id = 11 WHERE id = 10; +UPDATE p_sn SET v = 1 WHERE id = 1; +UPDATE p_sn SET id = 10 WHERE id = 1; +UPDATE p_sn SET id = 20 WHERE id = 2; +UPDATE p_sd SET v = 1 WHERE id = 1; +UPDATE p_sd SET id = 10 WHERE id = 1; +UPDATE p_sd SET id = 20 WHERE id = 2; +UPDATE p_sd SET id = 30 WHERE id = 0; +ERROR: update or delete on table "p_sd" violates foreign key constraint "c_sd_pid_fkey" on table "c_sd" +DETAIL: Key (id)=(0) is still referenced from table "c_sd". +SELECT 'na' AS t, * FROM c_na UNION ALL SELECT 're', * FROM c_re + UNION ALL SELECT 'ca', * FROM c_ca UNION ALL SELECT 'sn', * FROM c_sn + UNION ALL SELECT 'sd', * FROM c_sd ORDER BY 1, 2; + t | id | pid | w +----+----+-----+--- + ca | 1 | 11 | 0 + ca | 2 | 11 | 0 + na | 1 | 1 | 0 + na | 2 | 1 | 0 + re | 1 | 1 | 0 + re | 2 | 1 | 0 + sd | 1 | 0 | 0 + sd | 2 | 0 | 0 + sn | 1 | | 0 + sn | 2 | | 0 +(10 rows) + +-- the cascaded rows can be updated again, and the cascade repeated +UPDATE c_ca SET w = 7; +UPDATE p_ca SET id = 12 WHERE id = 11; +SELECT * FROM c_ca ORDER BY id; + id | pid | w +----+-----+--- + 1 | 12 | 7 + 2 | 12 | 7 +(2 rows) + +-- +-- 3. DELETE with ON DELETE CASCADE and SET NULL. +-- +CREATE TABLE dp (id int PRIMARY KEY); +CREATE TABLE dc_ca (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE CASCADE, w int); +CREATE TABLE dc_sn (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE SET NULL, w int); +INSERT INTO dp VALUES (1), (2); +INSERT INTO dc_ca VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +INSERT INTO dc_sn VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +UPDATE dc_ca SET w = 1; +UPDATE dc_sn SET w = 1; +DELETE FROM dp WHERE id = 1; +UPDATE dc_sn SET w = 2 WHERE pid IS NULL; +UPDATE dc_sn SET pid = 2 WHERE id = 1; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + t | id | pid | w +----+----+-----+--- + ca | 2 | 2 | 1 + sn | 1 | 2 | 2 + sn | 2 | 2 | 1 + sn | 3 | | 2 +(4 rows) + +-- +-- 4. Deferrable FKs. +-- +CREATE TABLE fp (id int PRIMARY KEY); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED, w int); +CREATE TABLE fc_na (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1); +INSERT INTO fc VALUES (1, 1, 0); +INSERT INTO fc_na VALUES (1, 1, 0); +-- the parent row appears later in the transaction +BEGIN; +UPDATE fc SET w = 1 WHERE id = 1; +UPDATE fc SET pid = 2 WHERE id = 1; +INSERT INTO fp VALUES (2); +COMMIT; +-- a row inserted, then updated, with a good key throughout +BEGIN; +INSERT INTO fc VALUES (2, 1, 0); +UPDATE fc SET w = 1 WHERE id = 2; +UPDATE fc SET pid = 2 WHERE id = 2; +COMMIT; +-- a bad key that is never fixed +BEGIN; +UPDATE fc SET pid = 98 WHERE id = 1; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(98) is not present in table "fp". +-- a bad key, then an UPDATE of another column of the same row +BEGIN; +UPDATE fc SET pid = 96 WHERE id = 1; +UPDATE fc SET w = 5 WHERE id = 1; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(96) is not present in table "fp". +-- a row inserted and then updated without touching its (bad) key +BEGIN; +INSERT INTO fc VALUES (3, 92, 0); +UPDATE fc SET w = 1 WHERE id = 3; +COMMIT; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(92) is not present in table "fp". +-- a parent key changed and changed back (NO ACTION) is fine at commit +BEGIN; +UPDATE fp SET id = 3 WHERE id = 1; +UPDATE fp SET id = 1 WHERE id = 3; +COMMIT; +-- the same, checked immediately +BEGIN; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 97 WHERE id = 1; +ERROR: insert or update on table "fc" violates foreign key constraint "fc_pid_fkey" +DETAIL: Key (pid)=(97) is not present in table "fp". +ROLLBACK; +SELECT 'fc' AS t, * FROM fc UNION ALL SELECT 'fc_na', * FROM fc_na ORDER BY 1, 2; + t | id | pid | w +-------+----+-----+--- + fc | 1 | 2 | 1 + fc | 2 | 2 | 1 + fc_na | 1 | 1 | 0 +(3 rows) + +-- +-- 5. A self-referencing FK. +-- +CREATE TABLE tree (id int PRIMARY KEY, parent int REFERENCES tree ON UPDATE CASCADE ON DELETE CASCADE, w int); +INSERT INTO tree VALUES (1, NULL, 0), (2, 1, 0), (3, 2, 0), (4, 1, 0); +UPDATE tree SET w = 1; +UPDATE tree SET id = 10 WHERE id = 1; +UPDATE tree SET id = 20 WHERE id = 2; +UPDATE tree SET parent = 4 WHERE id = 3; +UPDATE tree SET parent = 5 WHERE id = 3; +ERROR: insert or update on table "tree" violates foreign key constraint "tree_parent_fkey" +DETAIL: Key (parent)=(5) is not present in table "tree". +SELECT * FROM tree ORDER BY id; + id | parent | w +----+--------+--- + 3 | 4 | 1 + 4 | 10 | 1 + 10 | | 1 + 20 | 10 | 1 +(4 rows) + +DELETE FROM tree WHERE id = 10; +SELECT count(*) FROM tree; + count +------- + 0 +(1 row) + +-- +-- 6. An FK across access methods, in both directions. +-- +CREATE TABLE xp_o (id int PRIMARY KEY, v int) USING :other; +CREATE TABLE xc_d (id int PRIMARY KEY, pid int REFERENCES xp_o ON UPDATE CASCADE ON DELETE SET NULL, w int); +CREATE TABLE xp_d (id int PRIMARY KEY, v int); +CREATE TABLE xc_o (id int PRIMARY KEY, pid int REFERENCES xp_d ON UPDATE CASCADE ON DELETE SET NULL, w int) USING :other; +INSERT INTO xp_o VALUES (1, 0), (2, 0); +INSERT INTO xp_d VALUES (1, 0), (2, 0); +INSERT INTO xc_d VALUES (1, 1, 0), (2, 2, 0); +INSERT INTO xc_o VALUES (1, 1, 0), (2, 2, 0); +UPDATE xc_d SET w = 1; +UPDATE xc_o SET w = 1; +UPDATE xc_d SET pid = 2 WHERE id = 1; +UPDATE xc_o SET pid = 2 WHERE id = 1; +UPDATE xc_d SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "xc_d" violates foreign key constraint "xc_d_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "xp_o". +UPDATE xc_o SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "xc_o" violates foreign key constraint "xc_o_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "xp_d". +UPDATE xp_o SET v = 1; +UPDATE xp_d SET v = 1; +UPDATE xp_o SET id = 5 WHERE id = 2; +UPDATE xp_d SET id = 5 WHERE id = 2; +DELETE FROM xp_o WHERE id = 1; +DELETE FROM xp_d WHERE id = 1; +SELECT 'xc_d' AS t, * FROM xc_d UNION ALL SELECT 'xc_o', * FROM xc_o ORDER BY 1, 2; + t | id | pid | w +------+----+-----+--- + xc_d | 1 | 5 | 1 + xc_d | 2 | 5 | 1 + xc_o | 1 | 5 | 1 + xc_o | 2 | 5 | 1 +(4 rows) + +-- +-- 7. Partitioned tables, and 9. UPDATEs that move a row across partitions. +-- +CREATE TABLE ptp (id int PRIMARY KEY, v int) PARTITION BY RANGE (id); +CREATE TABLE ptp1 PARTITION OF ptp FOR VALUES FROM (0) TO (100); +CREATE TABLE ptp2 PARTITION OF ptp FOR VALUES FROM (100) TO (200); +CREATE TABLE ptc (id int PRIMARY KEY, pid int REFERENCES ptp ON UPDATE CASCADE ON DELETE SET NULL, w int) PARTITION BY RANGE (id); +CREATE TABLE ptc1 PARTITION OF ptc FOR VALUES FROM (0) TO (100); +CREATE TABLE ptc2 PARTITION OF ptc FOR VALUES FROM (100) TO (200); +INSERT INTO ptp VALUES (1, 0), (2, 0), (150, 0); +INSERT INTO ptc VALUES (1, 1, 0), (2, 2, 0), (101, 150, 0); +UPDATE ptc SET w = 1; +UPDATE ptc SET pid = 2 WHERE id = 1; +UPDATE ptc SET pid = 3 WHERE id = 1; +ERROR: insert or update on table "ptc1" violates foreign key constraint "ptc_pid_fkey" +DETAIL: Key (pid)=(3) is not present in table "ptp". +UPDATE ptp SET v = 1; +UPDATE ptp SET id = 3 WHERE id = 2; +UPDATE ptp SET id = 160 WHERE id = 150; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + tableoid | id | pid | w +----------+-----+-----+--- + ptc1 | 1 | 3 | 1 + ptc1 | 2 | 3 | 1 + ptc2 | 101 | 160 | 1 +(3 rows) + +-- parent row moves partition; its children follow by cascade +UPDATE ptp SET id = 120 WHERE id = 3; +-- child row moves partition, with and without an FK column change +UPDATE ptc SET id = 150 WHERE id = 1; +UPDATE ptc SET id = 50, pid = 160 WHERE id = 101; +UPDATE ptc SET id = 151, pid = 7 WHERE id = 150; +ERROR: insert or update on table "ptc2" violates foreign key constraint "ptc_pid_fkey" +DETAIL: Key (pid)=(7) is not present in table "ptp". +SELECT tableoid::regclass, * FROM ptp ORDER BY id; + tableoid | id | v +----------+-----+--- + ptp1 | 1 | 1 + ptp2 | 120 | 1 + ptp2 | 160 | 1 +(3 rows) + +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + tableoid | id | pid | w +----------+-----+-----+--- + ptc1 | 2 | 120 | 1 + ptc1 | 50 | 160 | 1 + ptc2 | 150 | 120 | 1 +(3 rows) + +DELETE FROM ptp WHERE id = 120; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + tableoid | id | pid | w +----------+-----+-----+--- + ptc1 | 2 | | 1 + ptc1 | 50 | 160 | 1 + ptc2 | 150 | | 1 +(3 rows) + +-- +-- 8. MERGE on FK tables, child and parent side. +-- +MERGE INTO pc USING (VALUES (1, 1, 7), (5, 2, 0), (2, NULL, 9)) s(id, pid, w) + ON pc.id = s.id + WHEN MATCHED AND s.pid IS NULL THEN UPDATE SET w = s.w + WHEN MATCHED THEN UPDATE SET pid = s.pid, w = s.w + WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.pid, s.w); +MERGE INTO pc USING (VALUES (1, 4)) s(id, pid) ON pc.id = s.id + WHEN MATCHED THEN UPDATE SET pid = s.pid; +ERROR: insert or update on table "pc" violates foreign key constraint "pc_pid_fkey" +DETAIL: Key (pid)=(4) is not present in table "pp". +SELECT * FROM pc ORDER BY id; + id | pid | w +----+-----+--- + 1 | 1 | 7 + 2 | 2 | 9 + 3 | 2 | 2 + 5 | 2 | 0 +(4 rows) + +MERGE INTO p_ca USING (VALUES (12, 13), (20, 21)) s(id, nid) ON p_ca.id = s.id + WHEN MATCHED THEN UPDATE SET id = s.nid; +SELECT * FROM c_ca ORDER BY id; + id | pid | w +----+-----+--- + 1 | 13 | 7 + 2 | 13 | 7 +(2 rows) + +MERGE INTO dp USING (VALUES (2)) s(id) ON dp.id = s.id + WHEN MATCHED THEN DELETE; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + t | id | pid | w +----+----+-----+--- + sn | 1 | | 2 + sn | 2 | | 1 + sn | 3 | | 2 +(3 rows) + +-- +-- 10. Other AFTER-trigger users of the old/new slot: a row trigger that +-- reads OLD and NEW (immediate and deferred), transition tables, and a +-- BEFORE trigger combined with an FK. +-- +CREATE TABLE tp (id int PRIMARY KEY, v int); +CREATE TABLE tc (id int PRIMARY KEY, pid int REFERENCES tp ON UPDATE CASCADE, w int); +INSERT INTO tp VALUES (1, 0), (2, 0); +INSERT INTO tc VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +CREATE TRIGGER tc_after AFTER UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE CONSTRAINT TRIGGER tc_deferred AFTER UPDATE ON tc + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE TRIGGER tc_trans AFTER UPDATE ON tc + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tp_trans AFTER UPDATE ON tp + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tc_before BEFORE UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_bump_w(); +UPDATE tc SET w = 5 WHERE id = 1; +NOTICE: tc UPDATE: old (1,1,0) new (1,1,105) +UPDATE tc SET pid = 2 WHERE id = 1; +NOTICE: tc UPDATE: old (1,1,105) new (1,2,205) +UPDATE tc SET pid = 9 WHERE id = 1; +ERROR: insert or update on table "tc" violates foreign key constraint "tc_pid_fkey" +DETAIL: Key (pid)=(9) is not present in table "tp". +UPDATE tp SET id = 10 WHERE id = 1; +NOTICE: tp UPDATE: old (1,0) new (10,0) +NOTICE: tc UPDATE: old (3,1,0) new (3,10,100) +BEGIN; +UPDATE tc SET w = 0 WHERE id = 2; +NOTICE: tc UPDATE: old (2,2,0) new (2,2,100) +UPDATE tc SET w = 1 WHERE id = 2; +NOTICE: tc UPDATE: old (2,2,100) new (2,2,101) +UPDATE tc SET pid = 10 WHERE id = 2; +NOTICE: tc UPDATE: old (2,2,101) new (2,10,201) +COMMIT; +-- the BEFORE trigger redirects the FK to a missing parent +UPDATE tc SET w = -1 WHERE id = 2; +ERROR: insert or update on table "tc" violates foreign key constraint "tc_pid_fkey" +DETAIL: Key (pid)=(8) is not present in table "tp". +SELECT * FROM tc ORDER BY id; + id | pid | w +----+-----+----- + 1 | 2 | 205 + 2 | 10 | 201 + 3 | 10 | 100 +(3 rows) + +SELECT tbl, op, old_row, new_row FROM log ORDER BY seq; + tbl | op | old_row | new_row +-----+--------------------+-----------+------------ + tc | tc_after UPDATE | (1,1,0) | (1,1,105) + tc | tc_deferred UPDATE | (1,1,0) | (1,1,105) + tc | tc_after UPDATE | (1,1,105) | (1,2,205) + tc | tc_deferred UPDATE | (1,1,105) | (1,2,205) + tc | tc_after UPDATE | (3,1,0) | (3,10,100) + tc | tc_deferred UPDATE | (3,1,0) | (3,10,100) + tc | tc_after UPDATE | (2,2,0) | (2,2,100) + tc | tc_after UPDATE | (2,2,100) | (2,2,101) + tc | tc_after UPDATE | (2,2,101) | (2,10,201) + tc | tc_deferred UPDATE | (2,2,0) | (2,2,100) + tc | tc_deferred UPDATE | (2,2,100) | (2,2,101) + tc | tc_deferred UPDATE | (2,2,101) | (2,10,201) +(12 rows) + +-- +-- The twins must agree on every table's contents. +-- +RESET search_path; +RESET default_table_access_method; +CREATE FUNCTION pg_temp.fk_twin_diff(t text) RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM ((SELECT * FROM fk_heap.%1$I EXCEPT ALL SELECT * FROM fk_flux.%1$I)' + ' UNION ALL (SELECT * FROM fk_flux.%1$I EXCEPT ALL SELECT * FROM fk_heap.%1$I)) d', t) + INTO n; + RETURN n; +END $$; +SELECT c.relname, pg_temp.fk_twin_diff(c.relname) AS differing_rows + FROM pg_class c + WHERE c.relnamespace = 'fk_heap'::regnamespace AND c.relkind IN ('r', 'p') + AND NOT c.relispartition + ORDER BY 1; + relname | differing_rows +---------+---------------- + c_ca | 0 + c_na | 0 + c_re | 0 + c_sd | 0 + c_sn | 0 + dc_ca | 0 + dc_sn | 0 + dp | 0 + fc | 0 + fc_na | 0 + fp | 0 + log | 0 + p_ca | 0 + p_na | 0 + p_re | 0 + p_sd | 0 + p_sn | 0 + pc | 0 + pp | 0 + ptc | 0 + ptp | 0 + tc | 0 + tp | 0 + tree | 0 + xc_d | 0 + xc_o | 0 + xp_d | 0 + xp_o | 0 +(28 rows) + +SELECT c.relname, a.amname + FROM pg_class c JOIN pg_am a ON a.oid = c.relam + WHERE c.relnamespace = 'fk_flux'::regnamespace AND c.relkind = 'r' + ORDER BY 1; + relname | amname +---------+-------- + c_ca | flux + c_na | flux + c_re | flux + c_sd | flux + c_sn | flux + dc_ca | flux + dc_sn | flux + dp | flux + fc | flux + fc_na | flux + fp | flux + log | flux + p_ca | flux + p_na | flux + p_re | flux + p_sd | flux + p_sn | flux + pc | flux + pp | flux + ptc1 | flux + ptc2 | flux + ptp1 | flux + ptp2 | flux + tc | flux + tp | flux + tree | flux + xc_d | flux + xc_o | heap + xp_d | flux + xp_o | heap +(30 rows) + +DROP SCHEMA fk_heap CASCADE; +NOTICE: drop cascades to 29 other objects +DETAIL: drop cascades to table fk_heap.log +drop cascades to sequence fk_heap.log_seq +drop cascades to table fk_heap.pp +drop cascades to table fk_heap.pc +drop cascades to table fk_heap.p_na +drop cascades to table fk_heap.c_na +drop cascades to table fk_heap.p_re +drop cascades to table fk_heap.c_re +drop cascades to table fk_heap.p_ca +drop cascades to table fk_heap.c_ca +drop cascades to table fk_heap.p_sn +drop cascades to table fk_heap.c_sn +drop cascades to table fk_heap.p_sd +drop cascades to table fk_heap.c_sd +drop cascades to table fk_heap.dp +drop cascades to table fk_heap.dc_ca +drop cascades to table fk_heap.dc_sn +drop cascades to table fk_heap.fp +drop cascades to table fk_heap.fc +drop cascades to table fk_heap.fc_na +drop cascades to table fk_heap.tree +drop cascades to table fk_heap.xp_o +drop cascades to table fk_heap.xc_d +drop cascades to table fk_heap.xp_d +drop cascades to table fk_heap.xc_o +drop cascades to table fk_heap.ptp +drop cascades to table fk_heap.ptc +drop cascades to table fk_heap.tp +drop cascades to table fk_heap.tc +DROP SCHEMA fk_flux CASCADE; +NOTICE: drop cascades to 29 other objects +DETAIL: drop cascades to table fk_flux.log +drop cascades to sequence fk_flux.log_seq +drop cascades to table fk_flux.pp +drop cascades to table fk_flux.pc +drop cascades to table fk_flux.p_na +drop cascades to table fk_flux.c_na +drop cascades to table fk_flux.p_re +drop cascades to table fk_flux.c_re +drop cascades to table fk_flux.p_ca +drop cascades to table fk_flux.c_ca +drop cascades to table fk_flux.p_sn +drop cascades to table fk_flux.c_sn +drop cascades to table fk_flux.p_sd +drop cascades to table fk_flux.c_sd +drop cascades to table fk_flux.dp +drop cascades to table fk_flux.dc_ca +drop cascades to table fk_flux.dc_sn +drop cascades to table fk_flux.fp +drop cascades to table fk_flux.fc +drop cascades to table fk_flux.fc_na +drop cascades to table fk_flux.tree +drop cascades to table fk_flux.xp_o +drop cascades to table fk_flux.xc_d +drop cascades to table fk_flux.xp_d +drop cascades to table fk_flux.xc_o +drop cascades to table fk_flux.ptp +drop cascades to table fk_flux.ptc +drop cascades to table fk_flux.tp +drop cascades to table fk_flux.tc +DROP FUNCTION public.fk_log_row(), public.fk_show_transition(), public.fk_bump_w(); diff --git a/src/test/regress/expected/flux_index_aba.out b/src/test/regress/expected/flux_index_aba.out new file mode 100644 index 0000000000000..05f97c0d548f9 --- /dev/null +++ b/src/test/regress/expected/flux_index_aba.out @@ -0,0 +1,873 @@ +-- +-- FLUX: an indexed column that returns to an earlier value (A -> B -> A) +-- inside one transaction, then is rolled back. +-- +-- An in-place key UPDATE keeps the row at its TID: it inserts (k_new, TID), +-- or revives a delete-marked (k_new, TID) left by an earlier UPDATE of the +-- same row, and delete-marks (k_old, TID). Rolling the UPDATEs back must +-- put every index back as it was: one live entry for the row's key, and +-- nothing that an index scan or amcheck could tell from a heap table that +-- had done the same. Each FLUX table here has a heap twin that receives the +-- same statements; heap never reuses a TID, so it is the control. +-- +-- aba_check() reports 'ok' or what is wrong: for both tables, an index scan +-- through the index under test must return what a sequential scan returns, +-- for every key the row has held; bt_index_check(heapallindexed) must pass +-- on every btree index (when amcheck is available); and the FLUX table must +-- hold what its heap twin holds. +-- +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'amcheck') AND + NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'amcheck') THEN + CREATE EXTENSION amcheck; + CREATE TABLE aba_created_amcheck (); + END IF; +END $$; +-- One FLUX/heap pair per index kind. k is the column that cycles; j stays 0. +CREATE FUNCTION aba_make(kind text) RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + am text; + t text; +BEGIN + FOREACH am IN ARRAY ARRAY['flux', 'heap'] LOOP + t := am || '_' || kind; + EXECUTE format('CREATE TABLE %I (id int PRIMARY KEY, j int, k int) USING %s', t, am); + EXECUTE format('INSERT INTO %I SELECT g, 0, g FROM generate_series(1, 40) g', t); + EXECUTE format(CASE kind + WHEN 'plain' THEN 'CREATE INDEX %I ON %I (k)' + WHEN 'uniq' THEN 'CREATE UNIQUE INDEX %I ON %I (k)' + WHEN 'multi' THEN 'CREATE INDEX %I ON %I (j, k)' + WHEN 'expr' THEN 'CREATE INDEX %I ON %I ((k * 2))' + WHEN 'partial' THEN 'CREATE INDEX %I ON %I (k) WHERE k < 1000' + WHEN 'hash' THEN 'CREATE INDEX %I ON %I USING hash (k)' + END, t || '_ix', t); + END LOOP; +END $$; +CREATE FUNCTION aba_drop(kind text) RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format('DROP TABLE %I, %I', 'flux_' || kind, 'heap_' || kind); +END $$; +-- Set row id's k on both tables of the pair. +CREATE FUNCTION aba_set(kind text, rid int, newk int) RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format('UPDATE %I SET k = $1 WHERE id = $2', 'flux_' || kind) USING newk, rid; + EXECUTE format('UPDATE %I SET k = $1 WHERE id = $2', 'heap_' || kind) USING newk, rid; +END $$; +-- Try an UPDATE on one table; report whether it raised unique_violation. +CREATE FUNCTION aba_try(t text, rid int, newk int) RETURNS text +LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format('UPDATE %I SET k = $1 WHERE id = $2', t) USING newk, rid; + RETURN 'updated'; +EXCEPTION WHEN unique_violation THEN + RETURN 'unique_violation'; +END $$; +CREATE FUNCTION aba_count(q text, idxscan bool) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE + n bigint; +BEGIN + PERFORM set_config('enable_seqscan', CASE WHEN idxscan THEN 'off' ELSE 'on' END, true); + PERFORM set_config('enable_indexscan', CASE WHEN idxscan THEN 'on' ELSE 'off' END, true); + PERFORM set_config('enable_bitmapscan', 'off', true); + PERFORM set_config('enable_indexonlyscan', 'off', true); + EXECUTE 'SELECT count(*) FROM ' || q INTO n; + PERFORM set_config('enable_seqscan', 'on', true); + PERFORM set_config('enable_indexscan', 'on', true); + PERFORM set_config('enable_bitmapscan', 'on', true); + PERFORM set_config('enable_indexonlyscan', 'on', true); + RETURN n; +END $$; +CREATE FUNCTION aba_check(kind text, keys int[]) RETURNS text +LANGUAGE plpgsql AS $$ +DECLARE + am text; + t text; + key int; + q text; + ni bigint; + ns bigint; + i regclass; + res text := ''; + fx text; + hp text; +BEGIN + FOREACH am IN ARRAY ARRAY['flux', 'heap'] LOOP + t := am || '_' || kind; + FOREACH key IN ARRAY keys LOOP + -- a partial index holds only keys < 1000 + CONTINUE WHEN kind = 'partial' AND key >= 1000; + q := format('%I WHERE ', t) || CASE kind + WHEN 'multi' THEN format('j = 0 AND k = %s', key) + WHEN 'expr' THEN format('k * 2 = 2 * %s', key) + ELSE format('k = %s', key) END; + ni := aba_count(q, true); + ns := aba_count(q, false); + IF ni <> ns THEN + res := res || format('%s k=%s: idxscan %s seqscan %s; ', t, key, ni, ns); + END IF; + END LOOP; + FOR i IN SELECT indexrelid FROM pg_index x JOIN pg_class c ON c.oid = x.indexrelid + JOIN pg_am a ON a.oid = c.relam + WHERE x.indrelid = t::regclass AND a.amname = 'btree' ORDER BY 1 LOOP + IF to_regprocedure('bt_index_check(regclass,boolean)') IS NOT NULL THEN + BEGIN + PERFORM bt_index_check(i, true); + EXCEPTION WHEN OTHERS THEN + res := res || format('%s: %s; ', i, SQLERRM); + END; + END IF; + END LOOP; + END LOOP; + EXECUTE format('SELECT string_agg(format(''%%s:%%s:%%s'', id, j, k), '','' ORDER BY id) FROM %I', 'flux_' || kind) INTO fx; + EXECUTE format('SELECT string_agg(format(''%%s:%%s:%%s'', id, j, k), '','' ORDER BY id) FROM %I', 'heap_' || kind) INTO hp; + IF fx IS DISTINCT FROM hp THEN + res := res || 'flux and heap rows differ; '; + END IF; + RETURN CASE WHEN res = '' THEN 'ok' ELSE res END; +END $$; +-- The plan aba_count(q, true) gets: a plain index scan of the index under +-- test. Index-only scans are off, as a heap table could otherwise answer +-- from the index alone, and a FLUX table never does. +CREATE FUNCTION aba_plan(kind text, key int) RETURNS SETOF text +LANGUAGE plpgsql AS $$ +DECLARE + q text; + l text; +BEGIN + q := format('%I WHERE ', 'flux_' || kind) || CASE kind + WHEN 'multi' THEN format('j = 0 AND k = %s', key) + WHEN 'expr' THEN format('k * 2 = 2 * %s', key) + ELSE format('k = %s', key) END; + PERFORM set_config('enable_seqscan', 'off', true); + PERFORM set_config('enable_bitmapscan', 'off', true); + PERFORM set_config('enable_indexonlyscan', 'off', true); + FOR l IN EXECUTE 'EXPLAIN (COSTS OFF) SELECT count(*) FROM ' || q LOOP + RETURN NEXT l; + END LOOP; + PERFORM set_config('enable_seqscan', 'on', true); + PERFORM set_config('enable_bitmapscan', 'on', true); + PERFORM set_config('enable_indexonlyscan', 'on', true); +END $$; +CREATE VIEW aba_kinds AS SELECT unnest(ARRAY['plain', 'uniq', 'multi', 'expr', 'partial', 'hash']) AS kind; +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +SELECT kind, aba_plan(kind, 12) FROM aba_kinds; + kind | aba_plan +---------+-------------------------------------------------------- + plain | Aggregate + plain | -> Index Scan using flux_plain_ix on flux_plain + plain | Index Cond: (k = 12) + uniq | Aggregate + uniq | -> Index Scan using flux_uniq_ix on flux_uniq + uniq | Index Cond: (k = 12) + multi | Aggregate + multi | -> Index Scan using flux_multi_ix on flux_multi + multi | Index Cond: ((j = 0) AND (k = 12)) + expr | Aggregate + expr | -> Index Scan using flux_expr_ix on flux_expr + expr | Index Cond: ((k * 2) = 24) + partial | Aggregate + partial | -> Index Scan using flux_partial_ix on flux_partial + partial | Index Cond: (k = 12) + hash | Aggregate + hash | -> Index Scan using flux_hash_ix on flux_hash + hash | Index Cond: (k = 12) +(18 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- Every case starts from fresh tables (amcheck reports only the first +-- missing entry) and checks the indexes before and after a VACUUM, which +-- reclaims the tombstones a case leaves and must keep the indexes intact. +-- B = A + 3000000 and C = A + 4000000 lie outside the partial index. +-- 1. A -> B -> A, ROLLBACK +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 12, 3000012), aba_set(kind, 12, 12) + FROM aba_kinds; + aba_set | aba_set +---------+--------- + | + | + | + | + | + | +(6 rows) + +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[12, 3000012]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[12, 3000012]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 2. A -> B -> C -> A, ROLLBACK +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 13, 3000013), aba_set(kind, 13, 4000013), aba_set(kind, 13, 13) + FROM aba_kinds; + aba_set | aba_set | aba_set +---------+---------+--------- + | | + | | + | | + | | + | | + | | +(6 rows) + +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[13, 3000013, 4000013]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[13, 3000013, 4000013]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 3. A -> B, then B -> A in a savepoint that is rolled back, COMMIT: B +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 14, 3000014) + FROM aba_kinds; + aba_set +--------- + + + + + + +(6 rows) + +SAVEPOINT s; +SELECT aba_set(kind, 14, 14) + FROM aba_kinds; + aba_set +--------- + + + + + + +(6 rows) + +ROLLBACK TO s; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[14, 3000014]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[14, 3000014]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 4. A -> B -> C, then C -> A in a savepoint that is rolled back, COMMIT: C +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 15, 3000015), aba_set(kind, 15, 4000015) + FROM aba_kinds; + aba_set | aba_set +---------+--------- + | + | + | + | + | + | +(6 rows) + +SAVEPOINT s; +SELECT aba_set(kind, 15, 15) + FROM aba_kinds; + aba_set +--------- + + + + + + +(6 rows) + +ROLLBACK TO s; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[15, 3000015, 4000015]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[15, 3000015, 4000015]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 5. A -> B, then B -> C -> A in a savepoint that is rolled back, COMMIT: B +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 16, 3000016) + FROM aba_kinds; + aba_set +--------- + + + + + + +(6 rows) + +SAVEPOINT s; +SELECT aba_set(kind, 16, 4000016), aba_set(kind, 16, 16) + FROM aba_kinds; + aba_set | aba_set +---------+--------- + | + | + | + | + | + | +(6 rows) + +ROLLBACK TO s; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[16, 3000016, 4000016]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[16, 3000016, 4000016]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 6. COMMIT controls: A -> B -> A and A -> B -> C -> A +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 17, 3000017), aba_set(kind, 17, 17), + aba_set(kind, 18, 3000018), aba_set(kind, 18, 4000018), aba_set(kind, 18, 18) + FROM aba_kinds; + aba_set | aba_set | aba_set | aba_set | aba_set +---------+---------+---------+---------+--------- + | | | | + | | | | + | | | | + | | | | + | | | | + | | | | +(6 rows) + +COMMIT; +SELECT kind, aba_check(kind, ARRAY[17, 3000017, 18, 3000018, 4000018]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[17, 3000017, 18, 3000018, 4000018]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 7. Committed A -> C, then C -> A rolled back: C +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +SELECT aba_set(kind, 19, 4000019) + FROM aba_kinds; + aba_set +--------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 19, 19) + FROM aba_kinds; + aba_set +--------- + + + + + + +(6 rows) + +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[19, 4000019]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[19, 4000019]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 8. The same row cycled and rolled back round after round, then committed +-- back to A: no round may lose an entry or leave a live one behind. +SELECT aba_make(kind) FROM aba_kinds; + aba_make +---------- + + + + + + +(6 rows) + +BEGIN; +SELECT aba_set(kind, 20, 3000020), aba_set(kind, 20, 20) + FROM aba_kinds; + aba_set | aba_set +---------+--------- + | + | + | + | + | + | +(6 rows) + +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +BEGIN; +SELECT aba_set(kind, 20, 3000020), aba_set(kind, 20, 20), aba_set(kind, 20, 3000020) + FROM aba_kinds; + aba_set | aba_set | aba_set +---------+---------+--------- + | | + | | + | | + | | + | | + | | +(6 rows) + +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +BEGIN; +SELECT aba_set(kind, 20, 3000020), aba_set(kind, 20, 20) + FROM aba_kinds; + aba_set | aba_set +---------+--------- + | + | + | + | + | + | +(6 rows) + +COMMIT; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; + kind | aba_check +---------+----------- + plain | ok + uniq | ok + multi | ok + expr | ok + partial | ok + hash | ok +(6 rows) + +SELECT aba_drop(kind) FROM aba_kinds; + aba_drop +---------- + + + + + + +(6 rows) + +-- 9. Unique index. A key the row has left is free for another row, and the +-- row cannot take it back while that row holds it. An aborted A -> B -> A +-- keeps A taken: no other row may take it. +SELECT aba_make('uniq'); + aba_make +---------- + +(1 row) + +SELECT aba_set('uniq', 21, 3000021), aba_set('uniq', 22, 21); + aba_set | aba_set +---------+--------- + | +(1 row) + +SELECT aba_try('flux_uniq', 21, 21) AS flux, aba_try('heap_uniq', 21, 21) AS heap; + flux | heap +------------------+------------------ + unique_violation | unique_violation +(1 row) + +BEGIN; +SELECT aba_set('uniq', 23, 3000023), aba_set('uniq', 23, 23); + aba_set | aba_set +---------+--------- + | +(1 row) + +ROLLBACK; +SELECT aba_try('flux_uniq', 24, 23) AS flux, aba_try('heap_uniq', 24, 23) AS heap; + flux | heap +------------------+------------------ + unique_violation | unique_violation +(1 row) + +-- once the row has moved off it again (committed), the key is free +SELECT aba_set('uniq', 23, 3000023); + aba_set +--------- + +(1 row) + +SELECT aba_try('flux_uniq', 24, 23) AS flux, aba_try('heap_uniq', 24, 23) AS heap; + flux | heap +---------+--------- + updated | updated +(1 row) + +SELECT aba_check('uniq', ARRAY[21, 22, 3000021, 23, 24, 3000023]); + aba_check +----------- + ok +(1 row) + +VACUUM flux_uniq, heap_uniq; +SELECT aba_check('uniq', ARRAY[21, 22, 3000021, 23, 24, 3000023]); + aba_check +----------- + ok +(1 row) + +SELECT aba_drop('uniq'); + aba_drop +---------- + +(1 row) + +DROP VIEW aba_kinds; +DROP FUNCTION aba_make, aba_drop, aba_set, aba_try, aba_count, aba_check, aba_plan; +DO $$ +BEGIN + IF to_regclass('aba_created_amcheck') IS NOT NULL THEN + DROP EXTENSION amcheck; + DROP TABLE aba_created_amcheck; + END IF; +END $$; diff --git a/src/test/regress/expected/flux_merge_delete.out b/src/test/regress/expected/flux_merge_delete.out new file mode 100644 index 0000000000000..b1d4c4689d0ce --- /dev/null +++ b/src/test/regress/expected/flux_merge_delete.out @@ -0,0 +1,136 @@ +-- +-- FLUX: a DELETE (plain or MERGE) must stay deleted when a BEFORE STATEMENT +-- trigger advanced the command counter before the row was deleted. +-- +-- The trigger's PERFORM makes plpgsql run CommandCounterIncrement, so the +-- current command id is past the statement's output cid when the row is +-- deleted. The undo record of the delete must carry the output cid, as +-- heap's cmax does; otherwise the next command treats the delete as a later +-- command of its own transaction, reconstructs the deleted row from undo +-- and serves it again. Heap is the control. +-- +CREATE FUNCTION fmd_trig() RETURNS trigger LANGUAGE plpgsql AS +$$ BEGIN PERFORM 1; RETURN NULL; END $$; +CREATE TABLE fmd_heap (tid int, balance int) USING heap; +CREATE TABLE fmd_flux (tid int, balance int) USING flux; +CREATE TABLE fmd_src (sid int, delta int); +INSERT INTO fmd_src VALUES (2, 5), (3, 20), (4, 40); +DO $$ +DECLARE + t text; +BEGIN + FOREACH t IN ARRAY ARRAY['fmd_heap', 'fmd_flux'] LOOP + EXECUTE format('INSERT INTO %I VALUES (1, 10), (2, 20), (3, 30)', t); + EXECUTE format('CREATE TRIGGER fmd_bsd BEFORE DELETE ON %I + FOR EACH STATEMENT EXECUTE FUNCTION fmd_trig()', t); + EXECUTE format('CREATE TRIGGER fmd_bsu BEFORE UPDATE ON %I + FOR EACH STATEMENT EXECUTE FUNCTION fmd_trig()', t); + END LOOP; +END $$; +-- plain DELETE of a row not yet modified by this transaction +BEGIN; +DELETE FROM fmd_heap WHERE tid = 3; +DELETE FROM fmd_flux WHERE tid = 3; +SELECT tid, balance FROM fmd_heap ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 20 +(2 rows) + +SELECT tid, balance FROM fmd_flux ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 20 +(2 rows) + +ROLLBACK; +-- plain DELETE of a row this transaction updated in an earlier command +BEGIN; +UPDATE fmd_heap SET balance = 0 WHERE tid = 3; +UPDATE fmd_flux SET balance = 0 WHERE tid = 3; +DELETE FROM fmd_heap WHERE tid = 3; +DELETE FROM fmd_flux WHERE tid = 3; +SELECT tid, balance FROM fmd_heap ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 20 +(2 rows) + +SELECT tid, balance FROM fmd_flux ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 20 +(2 rows) + +ROLLBACK; +-- the merge.sql "classic UPSERT, with a DELETE" +BEGIN; +UPDATE fmd_heap SET balance = 0 WHERE tid = 3; +UPDATE fmd_flux SET balance = 0 WHERE tid = 3; +MERGE INTO fmd_heap t USING fmd_src s ON t.tid = s.sid +WHEN MATCHED AND t.balance > s.delta THEN + UPDATE SET balance = t.balance - s.delta +WHEN MATCHED THEN + DELETE +WHEN NOT MATCHED THEN + INSERT VALUES (s.sid, s.delta); +MERGE INTO fmd_flux t USING fmd_src s ON t.tid = s.sid +WHEN MATCHED AND t.balance > s.delta THEN + UPDATE SET balance = t.balance - s.delta +WHEN MATCHED THEN + DELETE +WHEN NOT MATCHED THEN + INSERT VALUES (s.sid, s.delta); +SELECT tid, balance FROM fmd_heap ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 15 + 4 | 40 +(3 rows) + +SELECT tid, balance FROM fmd_flux ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 15 + 4 | 40 +(3 rows) + +ROLLBACK; +-- an UPDATE behind the same trigger must keep its new value, too +BEGIN; +UPDATE fmd_flux SET balance = 0 WHERE tid = 3; +UPDATE fmd_flux SET balance = 7 WHERE tid = 3; +SELECT tid, balance FROM fmd_flux ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 20 + 3 | 7 +(3 rows) + +ROLLBACK; +-- after ROLLBACK both tables are back to the original rows +SELECT tid, balance FROM fmd_heap ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 20 + 3 | 30 +(3 rows) + +SELECT tid, balance FROM fmd_flux ORDER BY tid; + tid | balance +-----+--------- + 1 | 10 + 2 | 20 + 3 | 30 +(3 rows) + +DROP TABLE fmd_heap, fmd_flux, fmd_src; +DROP FUNCTION fmd_trig(); diff --git a/src/test/regress/expected/flux_rewrite_xact.out b/src/test/regress/expected/flux_rewrite_xact.out new file mode 100644 index 0000000000000..952b7113c311e --- /dev/null +++ b/src/test/regress/expected/flux_rewrite_xact.out @@ -0,0 +1,651 @@ +-- +-- FLUX: commit, abort and PREPARE work after the relation's storage was +-- replaced in the same transaction (SET ACCESS METHOD, ALTER COLUMN TYPE, +-- CLUSTER, TRUNCATE). +-- +-- FLUX remembers the tuples a transaction wrote by relation OID and TID, and +-- at commit it clears a hint bit on each of them, or marks rows a rolled-back +-- savepoint inserted as deleted. A rewrite keeps the OID but moves the rows +-- to new storage, where the same TIDs name other tuples (or are past the end +-- of the file, or belong to a heap page). Every case runs on a heap twin and +-- on FLUX, with COMMIT and with ROLLBACK; the rows written before and after +-- the rewrite have equal widths, so their TIDs collide. The two columns of +-- the result must agree. +-- +-- amcheck's verify_heapam checks every table that ends up heap when the +-- extension is available; the result is the same either way. It goes into a +-- schema of its own, off the search path of the tests running alongside. +CREATE SCHEMA rx_amcheck; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'amcheck') THEN + CREATE EXTENSION amcheck SCHEMA rx_amcheck; + END IF; +EXCEPTION WHEN OTHERS THEN + NULL; +END $$; +-- A wide value that compresses inline, as heap and FLUX both store it. +CREATE FUNCTION rx_val(k int) RETURNS text LANGUAGE sql IMMUTABLE + RETURN repeat(md5(k::text), 150); +-- Rows, their ids, the rows whose value is intact, then FOR UPDATE, UPDATE +-- and DELETE counts in the same transaction, and amcheck problems. +CREATE FUNCTION rx_check(t text) RETURNS text LANGUAGE plpgsql AS $$ +DECLARE + rel regclass := to_regclass(t); + vh text := (SELECT format('%I.verify_heapam', n.nspname) + FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'amcheck'); + n bigint; ids bigint; ks bigint; good bigint; lck bigint; upd bigint; + del bigint; bad bigint := 0; +BEGIN + IF rel IS NULL THEN + RETURN 'no table'; + END IF; + IF (SELECT a.amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam + WHERE c.oid = rel) = 'heap' + AND vh IS NOT NULL THEN + EXECUTE format('SELECT count(*) FROM %s(%L)', vh, rel) INTO bad; + END IF; + EXECUTE format('SELECT count(*), sum(id), sum(k), + count(*) FILTER (WHERE w = rx_val(k)) + FROM %s', rel) INTO n, ids, ks, good; + EXECUTE format('SELECT count(*) FROM (SELECT 1 FROM %s FOR UPDATE) x', rel) + INTO lck; + EXECUTE format('WITH x AS (UPDATE %s SET w = w RETURNING 1) + SELECT count(*) FROM x', rel) INTO upd; + EXECUTE format('WITH x AS (DELETE FROM %s RETURNING 1) + SELECT count(*) FROM x', rel) INTO del; + RETURN format('rows=%s ids=%s ks=%s good=%s lock=%s upd=%s del=%s amcheck=%s', + n, ids, ks, good, lck, upd, del, bad); +EXCEPTION WHEN OTHERS THEN + RETURN 'ERROR: ' || regexp_replace(SQLERRM, '(pglz|lz4)', 'lzN'); +END $$; +-- Each case: statements before BEGIN, then the transaction body. $T is the +-- table, $AM its access method. The base is 20 committed rows, inserted in +-- descending id order so that CLUSTER moves them. +CREATE TABLE rx_case (n int, name text, setup text[], body text[]); +INSERT INTO rx_case VALUES +-- flux -> heap +(1, 'fh', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ALTER TABLE $T SET ACCESS METHOD heap']), +-- flux -> heap -> flux +(2, 'fhf', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ALTER TABLE $T SET ACCESS METHOD heap', + 'ALTER TABLE $T SET ACCESS METHOD $AM']), +-- flux -> heap, after rows rolled back to a savepoint +(3, 'sp_fh', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ROLLBACK TO a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(41, 60) g', + 'ALTER TABLE $T SET ACCESS METHOD heap']), +-- TRUNCATE, then new rows on the same TIDs +(4, 'trunc', NULL, ARRAY[ + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g']), +-- rows rolled back to a savepoint, then TRUNCATE and new rows on their TIDs +(5, 'sp_trunc', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ROLLBACK TO a', + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 40) g']), +-- ALTER COLUMN TYPE +(6, 'act', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint']), +-- rows rolled back to a savepoint, then a rewrite moves live rows onto their +-- TIDs +(7, 'sp_act', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ROLLBACK TO a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(41, 60) g', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint']), +-- CLUSTER after updates and deletes in place (no rows rolled back) +(8, 'cluster', NULL, ARRAY[ + 'UPDATE $T SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0', + 'DELETE FROM $T WHERE id % 5 = 0', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(40, 21, -1) g', + 'CLUSTER $T USING $T_pkey']), +-- TRUNCATE + ALTER TYPE: the rows before TRUNCATE fill blocks the rewritten +-- relation does not have +(9, 'trunc_act', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 400) g', + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint']), +-- the same with those rows rolled back to a savepoint; the new rows go in +-- after the rewrite +(10, 'sp_trunc_act', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 400) g', + 'ROLLBACK TO a', + 'TRUNCATE $T', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g']), +-- a table created in this transaction is truncated in place, keeping its +-- storage: rows rolled back to a savepoint, then new rows on their TIDs +(11, 'inplace', ARRAY[]::text[], ARRAY[ + 'CREATE TABLE $T (id int PRIMARY KEY, k int, w text) USING $AM', + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(1, 20) g', + 'ROLLBACK TO a', + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g']), +-- a rewrite inside a savepoint that rolls back: the old storage is current +-- again, and the rows written to it before the savepoint still commit +(12, 'sp_rewrite_rb', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'UPDATE $T SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0', + 'SAVEPOINT a', + 'ALTER TABLE $T SET ACCESS METHOD heap', + 'ROLLBACK TO a', + 'SAVEPOINT b', + 'TRUNCATE $T', + 'ROLLBACK TO b']); +CREATE FUNCTION rx_script() RETURNS SETOF text LANGUAGE plpgsql AS $$ +DECLARE + c rx_case; + fin text; + am text; + t text; + s text; +BEGIN + FOR c IN SELECT * FROM rx_case ORDER BY n LOOP + FOREACH fin IN ARRAY ARRAY['COMMIT', 'ROLLBACK'] LOOP + FOREACH am IN ARRAY ARRAY['heap', 'flux'] LOOP + t := format('rx_%s_%s_%s', c.name, lower(fin), am); + FOREACH s IN ARRAY coalesce(c.setup, ARRAY[ + 'CREATE TABLE $T (id int PRIMARY KEY, k int, w text) USING $AM', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g']) + || ARRAY['BEGIN'] || c.body || fin LOOP + RETURN NEXT replace(replace(s, '$T', t), '$AM', am); + END LOOP; + END LOOP; + END LOOP; + END LOOP; +END $$; +SELECT rx_script() \gexec +CREATE TABLE rx_fh_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_fh_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fh_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fh_commit_heap SET ACCESS METHOD heap +COMMIT +CREATE TABLE rx_fh_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_fh_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fh_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fh_commit_flux SET ACCESS METHOD heap +COMMIT +CREATE TABLE rx_fh_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_fh_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fh_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fh_rollback_heap SET ACCESS METHOD heap +ROLLBACK +CREATE TABLE rx_fh_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_fh_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fh_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fh_rollback_flux SET ACCESS METHOD heap +ROLLBACK +CREATE TABLE rx_fhf_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_fhf_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fhf_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fhf_commit_heap SET ACCESS METHOD heap +ALTER TABLE rx_fhf_commit_heap SET ACCESS METHOD heap +COMMIT +CREATE TABLE rx_fhf_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_fhf_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fhf_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fhf_commit_flux SET ACCESS METHOD heap +ALTER TABLE rx_fhf_commit_flux SET ACCESS METHOD flux +COMMIT +CREATE TABLE rx_fhf_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_fhf_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fhf_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fhf_rollback_heap SET ACCESS METHOD heap +ALTER TABLE rx_fhf_rollback_heap SET ACCESS METHOD heap +ROLLBACK +CREATE TABLE rx_fhf_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_fhf_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_fhf_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_fhf_rollback_flux SET ACCESS METHOD heap +ALTER TABLE rx_fhf_rollback_flux SET ACCESS METHOD flux +ROLLBACK +CREATE TABLE rx_sp_fh_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_fh_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_fh_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_fh_commit_heap SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_fh_commit_heap SET ACCESS METHOD heap +COMMIT +CREATE TABLE rx_sp_fh_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_fh_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_fh_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_fh_commit_flux SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_fh_commit_flux SET ACCESS METHOD heap +COMMIT +CREATE TABLE rx_sp_fh_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_fh_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_fh_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_fh_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_fh_rollback_heap SET ACCESS METHOD heap +ROLLBACK +CREATE TABLE rx_sp_fh_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_fh_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_fh_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_fh_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_fh_rollback_flux SET ACCESS METHOD heap +ROLLBACK +CREATE TABLE rx_trunc_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_trunc_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +TRUNCATE rx_trunc_commit_heap +INSERT INTO rx_trunc_commit_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +COMMIT +CREATE TABLE rx_trunc_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_trunc_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +TRUNCATE rx_trunc_commit_flux +INSERT INTO rx_trunc_commit_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +COMMIT +CREATE TABLE rx_trunc_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_trunc_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +TRUNCATE rx_trunc_rollback_heap +INSERT INTO rx_trunc_rollback_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ROLLBACK +CREATE TABLE rx_trunc_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_trunc_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +TRUNCATE rx_trunc_rollback_flux +INSERT INTO rx_trunc_rollback_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ROLLBACK +CREATE TABLE rx_sp_trunc_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_trunc_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_commit_heap +INSERT INTO rx_sp_trunc_commit_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 40) g +COMMIT +CREATE TABLE rx_sp_trunc_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_trunc_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_commit_flux +INSERT INTO rx_sp_trunc_commit_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 40) g +COMMIT +CREATE TABLE rx_sp_trunc_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_trunc_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_rollback_heap +INSERT INTO rx_sp_trunc_rollback_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 40) g +ROLLBACK +CREATE TABLE rx_sp_trunc_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_trunc_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_rollback_flux +INSERT INTO rx_sp_trunc_rollback_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 40) g +ROLLBACK +CREATE TABLE rx_act_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_act_commit_heap ALTER COLUMN id TYPE bigint +COMMIT +CREATE TABLE rx_act_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_act_commit_flux ALTER COLUMN id TYPE bigint +COMMIT +CREATE TABLE rx_act_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_act_rollback_heap ALTER COLUMN id TYPE bigint +ROLLBACK +CREATE TABLE rx_act_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ALTER TABLE rx_act_rollback_flux ALTER COLUMN id TYPE bigint +ROLLBACK +CREATE TABLE rx_sp_act_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_act_commit_heap ALTER COLUMN id TYPE bigint +COMMIT +CREATE TABLE rx_sp_act_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_act_commit_flux ALTER COLUMN id TYPE bigint +COMMIT +CREATE TABLE rx_sp_act_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_act_rollback_heap ALTER COLUMN id TYPE bigint +ROLLBACK +CREATE TABLE rx_sp_act_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +ROLLBACK TO a +INSERT INTO rx_sp_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(41, 60) g +ALTER TABLE rx_sp_act_rollback_flux ALTER COLUMN id TYPE bigint +ROLLBACK +CREATE TABLE rx_cluster_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_cluster_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +UPDATE rx_cluster_commit_heap SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +DELETE FROM rx_cluster_commit_heap WHERE id % 5 = 0 +INSERT INTO rx_cluster_commit_heap SELECT g, g, rx_val(g) FROM generate_series(40, 21, -1) g +CLUSTER rx_cluster_commit_heap USING rx_cluster_commit_heap_pkey +COMMIT +CREATE TABLE rx_cluster_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_cluster_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +UPDATE rx_cluster_commit_flux SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +DELETE FROM rx_cluster_commit_flux WHERE id % 5 = 0 +INSERT INTO rx_cluster_commit_flux SELECT g, g, rx_val(g) FROM generate_series(40, 21, -1) g +CLUSTER rx_cluster_commit_flux USING rx_cluster_commit_flux_pkey +COMMIT +CREATE TABLE rx_cluster_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_cluster_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +UPDATE rx_cluster_rollback_heap SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +DELETE FROM rx_cluster_rollback_heap WHERE id % 5 = 0 +INSERT INTO rx_cluster_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(40, 21, -1) g +CLUSTER rx_cluster_rollback_heap USING rx_cluster_rollback_heap_pkey +ROLLBACK +CREATE TABLE rx_cluster_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_cluster_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +UPDATE rx_cluster_rollback_flux SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +DELETE FROM rx_cluster_rollback_flux WHERE id % 5 = 0 +INSERT INTO rx_cluster_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(40, 21, -1) g +CLUSTER rx_cluster_rollback_flux USING rx_cluster_rollback_flux_pkey +ROLLBACK +CREATE TABLE rx_trunc_act_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_trunc_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_trunc_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +TRUNCATE rx_trunc_act_commit_heap +INSERT INTO rx_trunc_act_commit_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ALTER TABLE rx_trunc_act_commit_heap ALTER COLUMN id TYPE bigint +COMMIT +CREATE TABLE rx_trunc_act_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_trunc_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_trunc_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +TRUNCATE rx_trunc_act_commit_flux +INSERT INTO rx_trunc_act_commit_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ALTER TABLE rx_trunc_act_commit_flux ALTER COLUMN id TYPE bigint +COMMIT +CREATE TABLE rx_trunc_act_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_trunc_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_trunc_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +TRUNCATE rx_trunc_act_rollback_heap +INSERT INTO rx_trunc_act_rollback_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ALTER TABLE rx_trunc_act_rollback_heap ALTER COLUMN id TYPE bigint +ROLLBACK +CREATE TABLE rx_trunc_act_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_trunc_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_trunc_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +TRUNCATE rx_trunc_act_rollback_flux +INSERT INTO rx_trunc_act_rollback_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ALTER TABLE rx_trunc_act_rollback_flux ALTER COLUMN id TYPE bigint +ROLLBACK +CREATE TABLE rx_sp_trunc_act_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_trunc_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_act_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_act_commit_heap +ALTER TABLE rx_sp_trunc_act_commit_heap ALTER COLUMN id TYPE bigint +INSERT INTO rx_sp_trunc_act_commit_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +COMMIT +CREATE TABLE rx_sp_trunc_act_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_trunc_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_act_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_act_commit_flux +ALTER TABLE rx_sp_trunc_act_commit_flux ALTER COLUMN id TYPE bigint +INSERT INTO rx_sp_trunc_act_commit_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +COMMIT +CREATE TABLE rx_sp_trunc_act_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_trunc_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_act_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_act_rollback_heap +ALTER TABLE rx_sp_trunc_act_rollback_heap ALTER COLUMN id TYPE bigint +INSERT INTO rx_sp_trunc_act_rollback_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ROLLBACK +CREATE TABLE rx_sp_trunc_act_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_trunc_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +SAVEPOINT a +INSERT INTO rx_sp_trunc_act_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 400) g +ROLLBACK TO a +TRUNCATE rx_sp_trunc_act_rollback_flux +ALTER TABLE rx_sp_trunc_act_rollback_flux ALTER COLUMN id TYPE bigint +INSERT INTO rx_sp_trunc_act_rollback_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ROLLBACK +BEGIN +CREATE TABLE rx_inplace_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +SAVEPOINT a +INSERT INTO rx_inplace_commit_heap SELECT g, g, rx_val(g) FROM generate_series(1, 20) g +ROLLBACK TO a +TRUNCATE rx_inplace_commit_heap +INSERT INTO rx_inplace_commit_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +COMMIT +BEGIN +CREATE TABLE rx_inplace_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +SAVEPOINT a +INSERT INTO rx_inplace_commit_flux SELECT g, g, rx_val(g) FROM generate_series(1, 20) g +ROLLBACK TO a +TRUNCATE rx_inplace_commit_flux +INSERT INTO rx_inplace_commit_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +COMMIT +BEGIN +CREATE TABLE rx_inplace_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +SAVEPOINT a +INSERT INTO rx_inplace_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(1, 20) g +ROLLBACK TO a +TRUNCATE rx_inplace_rollback_heap +INSERT INTO rx_inplace_rollback_heap SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ROLLBACK +BEGIN +CREATE TABLE rx_inplace_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +SAVEPOINT a +INSERT INTO rx_inplace_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(1, 20) g +ROLLBACK TO a +TRUNCATE rx_inplace_rollback_flux +INSERT INTO rx_inplace_rollback_flux SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g +ROLLBACK +CREATE TABLE rx_sp_rewrite_rb_commit_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_rewrite_rb_commit_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_sp_rewrite_rb_commit_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +UPDATE rx_sp_rewrite_rb_commit_heap SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +SAVEPOINT a +ALTER TABLE rx_sp_rewrite_rb_commit_heap SET ACCESS METHOD heap +ROLLBACK TO a +SAVEPOINT b +TRUNCATE rx_sp_rewrite_rb_commit_heap +ROLLBACK TO b +COMMIT +CREATE TABLE rx_sp_rewrite_rb_commit_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_rewrite_rb_commit_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_sp_rewrite_rb_commit_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +UPDATE rx_sp_rewrite_rb_commit_flux SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +SAVEPOINT a +ALTER TABLE rx_sp_rewrite_rb_commit_flux SET ACCESS METHOD heap +ROLLBACK TO a +SAVEPOINT b +TRUNCATE rx_sp_rewrite_rb_commit_flux +ROLLBACK TO b +COMMIT +CREATE TABLE rx_sp_rewrite_rb_rollback_heap (id int PRIMARY KEY, k int, w text) USING heap +INSERT INTO rx_sp_rewrite_rb_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_sp_rewrite_rb_rollback_heap SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +UPDATE rx_sp_rewrite_rb_rollback_heap SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +SAVEPOINT a +ALTER TABLE rx_sp_rewrite_rb_rollback_heap SET ACCESS METHOD heap +ROLLBACK TO a +SAVEPOINT b +TRUNCATE rx_sp_rewrite_rb_rollback_heap +ROLLBACK TO b +ROLLBACK +CREATE TABLE rx_sp_rewrite_rb_rollback_flux (id int PRIMARY KEY, k int, w text) USING flux +INSERT INTO rx_sp_rewrite_rb_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g +BEGIN +INSERT INTO rx_sp_rewrite_rb_rollback_flux SELECT g, g, rx_val(g) FROM generate_series(21, 40) g +UPDATE rx_sp_rewrite_rb_rollback_flux SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0 +SAVEPOINT a +ALTER TABLE rx_sp_rewrite_rb_rollback_flux SET ACCESS METHOD heap +ROLLBACK TO a +SAVEPOINT b +TRUNCATE rx_sp_rewrite_rb_rollback_flux +ROLLBACK TO b +ROLLBACK +\pset format unaligned +SELECT name, fin, heap, CASE WHEN flux = heap THEN 'same' ELSE flux END AS flux +FROM (SELECT c.n, c.name, f.fin, + rx_check(format('rx_%s_%s_heap', c.name, lower(f.fin))) AS heap, + rx_check(format('rx_%s_%s_flux', c.name, lower(f.fin))) AS flux + FROM rx_case c, (VALUES ('COMMIT'), ('ROLLBACK')) f(fin)) r +ORDER BY n, fin; +name|fin|heap|flux +fh|COMMIT|rows=40 ids=820 ks=820 good=40 lock=40 upd=40 del=40 amcheck=0|same +fh|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +fhf|COMMIT|rows=40 ids=820 ks=820 good=40 lock=40 upd=40 del=40 amcheck=0|same +fhf|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +sp_fh|COMMIT|rows=40 ids=1220 ks=1220 good=40 lock=40 upd=40 del=40 amcheck=0|same +sp_fh|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +trunc|COMMIT|rows=20 ids=210 ks=2210 good=20 lock=20 upd=20 del=20 amcheck=0|same +trunc|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +sp_trunc|COMMIT|rows=40 ids=820 ks=4820 good=40 lock=40 upd=40 del=40 amcheck=0|same +sp_trunc|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +act|COMMIT|rows=40 ids=820 ks=820 good=40 lock=40 upd=40 del=40 amcheck=0|same +act|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +sp_act|COMMIT|rows=40 ids=1220 ks=1220 good=40 lock=40 upd=40 del=40 amcheck=0|same +sp_act|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +cluster|COMMIT|rows=36 ids=770 ks=1570 good=36 lock=36 upd=36 del=36 amcheck=0|same +cluster|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +trunc_act|COMMIT|rows=20 ids=210 ks=2210 good=20 lock=20 upd=20 del=20 amcheck=0|same +trunc_act|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +sp_trunc_act|COMMIT|rows=20 ids=210 ks=2210 good=20 lock=20 upd=20 del=20 amcheck=0|same +sp_trunc_act|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +inplace|COMMIT|rows=20 ids=210 ks=2210 good=20 lock=20 upd=20 del=20 amcheck=0|same +inplace|ROLLBACK|no table|same +sp_rewrite_rb|COMMIT|rows=40 ids=820 ks=2820 good=40 lock=40 upd=40 del=40 amcheck=0|same +sp_rewrite_rb|ROLLBACK|rows=20 ids=210 ks=210 good=20 lock=20 upd=20 del=20 amcheck=0|same +(24 rows) +\pset format aligned +-- Clean up. +SELECT format('DROP TABLE %I', relname) FROM pg_class +WHERE relname ~ '^rx_.*_(heap|flux)$' AND relkind = 'r' ORDER BY 1 \gexec +DROP TABLE rx_act_commit_flux +DROP TABLE rx_act_commit_heap +DROP TABLE rx_act_rollback_flux +DROP TABLE rx_act_rollback_heap +DROP TABLE rx_cluster_commit_flux +DROP TABLE rx_cluster_commit_heap +DROP TABLE rx_cluster_rollback_flux +DROP TABLE rx_cluster_rollback_heap +DROP TABLE rx_fh_commit_flux +DROP TABLE rx_fh_commit_heap +DROP TABLE rx_fh_rollback_flux +DROP TABLE rx_fh_rollback_heap +DROP TABLE rx_fhf_commit_flux +DROP TABLE rx_fhf_commit_heap +DROP TABLE rx_fhf_rollback_flux +DROP TABLE rx_fhf_rollback_heap +DROP TABLE rx_inplace_commit_flux +DROP TABLE rx_inplace_commit_heap +DROP TABLE rx_sp_act_commit_flux +DROP TABLE rx_sp_act_commit_heap +DROP TABLE rx_sp_act_rollback_flux +DROP TABLE rx_sp_act_rollback_heap +DROP TABLE rx_sp_fh_commit_flux +DROP TABLE rx_sp_fh_commit_heap +DROP TABLE rx_sp_fh_rollback_flux +DROP TABLE rx_sp_fh_rollback_heap +DROP TABLE rx_sp_rewrite_rb_commit_flux +DROP TABLE rx_sp_rewrite_rb_commit_heap +DROP TABLE rx_sp_rewrite_rb_rollback_flux +DROP TABLE rx_sp_rewrite_rb_rollback_heap +DROP TABLE rx_sp_trunc_act_commit_flux +DROP TABLE rx_sp_trunc_act_commit_heap +DROP TABLE rx_sp_trunc_act_rollback_flux +DROP TABLE rx_sp_trunc_act_rollback_heap +DROP TABLE rx_sp_trunc_commit_flux +DROP TABLE rx_sp_trunc_commit_heap +DROP TABLE rx_sp_trunc_rollback_flux +DROP TABLE rx_sp_trunc_rollback_heap +DROP TABLE rx_trunc_act_commit_flux +DROP TABLE rx_trunc_act_commit_heap +DROP TABLE rx_trunc_act_rollback_flux +DROP TABLE rx_trunc_act_rollback_heap +DROP TABLE rx_trunc_commit_flux +DROP TABLE rx_trunc_commit_heap +DROP TABLE rx_trunc_rollback_flux +DROP TABLE rx_trunc_rollback_heap +DROP FUNCTION rx_script(), rx_check(text), rx_val(int); +DROP TABLE rx_case; +SET client_min_messages = warning; +DROP SCHEMA rx_amcheck CASCADE; +RESET client_min_messages; diff --git a/src/test/regress/expected/flux_tidbitmap.out b/src/test/regress/expected/flux_tidbitmap.out new file mode 100644 index 0000000000000..9822a68e9c5c5 --- /dev/null +++ b/src/test/regress/expected/flux_tidbitmap.out @@ -0,0 +1,129 @@ +-- +-- FLUX: line-pointer offsets stay within MaxHeapTuplesPerPage. +-- +-- tidbitmap.c sizes each exact page entry for TBM_MAX_TUPLES_PER_PAGE == +-- MaxHeapTuplesPerPage offsets, for every table AM. FLUX must therefore +-- never place a tuple at a higher offset, or a bitmap scan over that page +-- fails with "tuple offset out of range". The narrowest FLUX rows cannot get +-- there by geometry alone (every item carries a 36-byte tuple header), but +-- line pointers left LP_DEAD by pruning can: they are not reused until VACUUM, +-- so repeated insert/delete churn on one page keeps appending new ones. +-- +CREATE FUNCTION tbm_off(t tid) RETURNS int + LANGUAGE sql IMMUTABLE AS $$ SELECT (t::text::point)[1]::int $$; +CREATE FUNCTION tbm_blk(t tid) RETURNS int + LANGUAGE sql IMMUTABLE AS $$ SELECT (t::text::point)[0]::int $$; +-- MaxHeapTuplesPerPage: (BLCKSZ - page header) / (tuple header + line pointer) +CREATE FUNCTION tbm_max_off() RETURNS int + LANGUAGE sql STABLE AS $$ SELECT (current_setting('block_size')::int - 24) / 28 $$; +-- narrowest rows: a page fills up by space, well under the offset limit +CREATE TABLE tbm_z0 () USING flux; +INSERT INTO tbm_z0 SELECT FROM generate_series(1, 400); +SELECT count(*) > 0 AS some_on_blk0, + count(*) <= tbm_max_off() AS blk0_fits + FROM tbm_z0 WHERE tbm_blk(ctid) = 0; + some_on_blk0 | blk0_fits +--------------+----------- + t | t +(1 row) + +CREATE TABLE tbm_b (b bool) USING flux; +CREATE INDEX tbm_b_idx ON tbm_b (b); +INSERT INTO tbm_b SELECT true FROM generate_series(1, 400); +SELECT count(*) > 0 AS some_on_blk0, + count(*) <= tbm_max_off() AS blk0_fits + FROM tbm_b WHERE tbm_blk(ctid) = 0; + some_on_blk0 | blk0_fits +--------------+----------- + t | t +(1 row) + +-- insert/delete churn: pruning leaves LP_DEAD line pointers behind +CREATE TABLE tbm_churn (i int) USING flux WITH (autovacuum_enabled = off); +CREATE INDEX tbm_churn_idx ON tbm_churn (i); +CREATE TABLE tbm_maxoff (round int, maxoff int); +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 1, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page + count +------- + 0 +(1 row) + +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 2, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page + count +------- + 0 +(1 row) + +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 3, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page + count +------- + 0 +(1 row) + +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 4, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page + count +------- + 0 +(1 row) + +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 5, max(tbm_off(ctid)) FROM tbm_churn; +SELECT round, maxoff <= tbm_max_off() AS fits FROM tbm_maxoff ORDER BY round; + round | fits +-------+------ + 1 | t + 2 | t + 3 | t + 4 | t + 5 | t +(5 rows) + +SELECT count(*) AS beyond_max_off FROM tbm_churn WHERE tbm_off(ctid) > tbm_max_off(); + beyond_max_off +---------------- + 0 +(1 row) + +-- a bitmap scan over every page returns every row +SET enable_seqscan = off; +SET enable_indexscan = off; +SET enable_indexonlyscan = off; +EXPLAIN (COSTS OFF) SELECT count(*) FROM tbm_churn WHERE i > 0; + QUERY PLAN +------------------------------------------------ + Aggregate + -> Bitmap Heap Scan on tbm_churn + Recheck Cond: (i > 0) + -> Bitmap Index Scan on tbm_churn_idx + Index Cond: (i > 0) +(5 rows) + +SELECT count(*) FROM tbm_churn WHERE i > 0; + count +------- + 150 +(1 row) + +SELECT count(*) FROM tbm_b WHERE b; + count +------- + 400 +(1 row) + +RESET enable_seqscan; +RESET enable_indexscan; +RESET enable_indexonlyscan; +DROP TABLE tbm_z0, tbm_b, tbm_churn, tbm_maxoff; +DROP FUNCTION tbm_off, tbm_blk, tbm_max_off; diff --git a/src/test/regress/expected/flux_toast_foreign.out b/src/test/regress/expected/flux_toast_foreign.out new file mode 100644 index 0000000000000..0af266677a5c1 --- /dev/null +++ b/src/test/regress/expected/flux_toast_foreign.out @@ -0,0 +1,462 @@ +-- +-- FLUX and foreign TOAST pointers. +-- +-- A value read from another table can reach a table AM as an on-disk TOAST +-- pointer into that table's TOAST relation. The AM must not store such a +-- pointer verbatim: the value has to go into the target's own TOAST relation +-- (or inline), otherwise dropping the source makes the row unreadable. heap +-- does this in heap_toast_insert_or_update. Likewise an oversized inline +-- value must be compressed/externalized the way heap would. +-- +-- Every path below runs against heap (the control) and FLUX. The sources are +-- then dropped and each row is re-read and compared by md5 and length, every +-- external pointer is checked to resolve in the table's own TOAST relation, +-- and the stored shape (size, compression, external or not) of every FLUX row +-- is compared with its heap twin. +-- +\getenv abs_builddir PG_ABS_BUILDDIR +\set tf_file :abs_builddir '/results/flux_toast_foreign.data' +SELECT set_config('tf.file', :'tf_file', false) IS NOT NULL AS ok, + set_config('tf.cut', '0', false) IS NOT NULL AS ok; + ok | ok +----+---- + t | t +(1 row) + +-- Row 1: 100 kB incompressible EXTERNAL v, 1 MB compressible w (external, +-- compressed). Rows 2-3: 100 kB v. Row 4: short v, 5000-byte compressible w +-- (inline compressed in the source). +CREATE TABLE heap_src (id int PRIMARY KEY, v text, w text) USING heap; +ALTER TABLE heap_src ALTER COLUMN v SET STORAGE EXTERNAL; +INSERT INTO heap_src +SELECT g, + CASE WHEN g < 4 THEN (SELECT string_agg(md5((g * 10000 + i)::text), '') + FROM generate_series(1, 3125) i) + ELSE 'short' END, + CASE g WHEN 1 THEN repeat(md5('w'), 32768) WHEN 2 THEN NULL + WHEN 3 THEN 'w3' ELSE repeat('x', 5000) END +FROM generate_series(1, 4) g; +SELECT id, length(v) AS lv, + pg_column_toast_chunk_id(v) IS NOT NULL AS v_ext, + pg_column_compression(v) IS NOT NULL AS v_cmp, + length(w) AS lw, + pg_column_toast_chunk_id(w) IS NOT NULL AS w_ext, + pg_column_compression(w) IS NOT NULL AS w_cmp +FROM heap_src ORDER BY id; + id | lv | v_ext | v_cmp | lw | w_ext | w_cmp +----+--------+-------+-------+---------+-------+------- + 1 | 100000 | t | f | 1048576 | t | t + 2 | 100000 | t | f | | f | f + 3 | 100000 | t | f | 2 | f | f + 4 | 5 | f | f | 5000 | f | t +(4 rows) + +CREATE TABLE tf_ref AS + SELECT id, length(v) AS lv, md5(v) AS mv, length(w) AS lw, md5(w) AS mw + FROM heap_src; +-- The reverse-direction source: FLUX values in FLUX's own TOAST relation. +CREATE TABLE flux_src USING flux AS SELECT * FROM heap_src; +COPY heap_src TO :'tf_file'; +\set tf_file1 :abs_builddir '/results/flux_toast_foreign_1.data' +SELECT set_config('tf.file1', :'tf_file1', false) IS NOT NULL AS ok; + ok +---- + t +(1 row) + +COPY (SELECT 1 FROM generate_series(1, 4)) TO :'tf_file1'; +-- STABLE, so COPY keeps its multi-insert path for a column defaulting to it; +-- the result is heap_src's TOAST pointer, not a detoasted copy. +-- Materialized views read heap_src through dynamic SQL, so that heap_src can +-- be dropped while they exist. +CREATE FUNCTION tf_src(cut int) RETURNS TABLE (id int, v text, w text) + LANGUAGE plpgsql STABLE AS $$ +BEGIN + RETURN QUERY EXECUTE 'SELECT * FROM public.heap_src WHERE id <= $1' USING cut; +END $$; +CREATE FUNCTION tf_def_v() RETURNS text STABLE LANGUAGE sql + AS 'SELECT v FROM heap_src WHERE id = 1'; +CREATE FUNCTION tf_def_w() RETURNS text STABLE LANGUAGE sql + AS 'SELECT w FROM heap_src WHERE id = 1'; +CREATE FUNCTION tf_rdef_v() RETURNS text STABLE LANGUAGE sql + AS 'SELECT v FROM flux_src WHERE id = 1'; +CREATE FUNCTION tf_rdef_w() RETURNS text STABLE LANGUAGE sql + AS 'SELECT w FROM flux_src WHERE id = 1'; +-- Row count, rows whose content differs from tf_ref, and rows holding an +-- external pointer that does not resolve in the table's own TOAST relation. +CREATE FUNCTION tf_check(tbl regclass) RETURNS text LANGUAGE plpgsql AS $$ +DECLARE + toast regclass; + nrows int; + nbad int; + nforeign int; +BEGIN + SELECT reltoastrelid::regclass INTO toast FROM pg_class WHERE oid = tbl; + EXECUTE format($q$ + SELECT count(*), + count(*) FILTER (WHERE r.id IS NULL + OR length(t.v) IS DISTINCT FROM r.lv + OR md5(t.v) IS DISTINCT FROM r.mv + OR length(t.w) IS DISTINCT FROM r.lw + OR md5(t.w) IS DISTINCT FROM r.mw), + count(*) FILTER (WHERE + (pg_column_toast_chunk_id(t.v) IS NOT NULL AND NOT EXISTS + (SELECT 1 FROM %2$s c WHERE c.chunk_id::text = pg_column_toast_chunk_id(t.v)::text)) + OR (pg_column_toast_chunk_id(t.w) IS NOT NULL AND NOT EXISTS + (SELECT 1 FROM %2$s c WHERE c.chunk_id::text = pg_column_toast_chunk_id(t.w)::text))) + FROM %1$s t LEFT JOIN tf_ref r USING (id)$q$, tbl, toast) + INTO nrows, nbad, nforeign; + RETURN format('rows=%s bad=%s foreign=%s', nrows, nbad, nforeign); +EXCEPTION WHEN OTHERS THEN + RETURN 'ERROR: ' || regexp_replace(SQLERRM, '[0-9]+', 'N', 'g'); +END $$; +-- How each value is stored: compressed, external, and whether it is held +-- inline at more than the TOAST target. (Exact sizes differ by design: FLUX +-- does not convert short varlenas to 1-byte headers.) +CREATE FUNCTION tf_shape(tbl regclass) +RETURNS TABLE (id int, bv bool, cv bool, ev bool, bw bool, cw bool, ew bool) +LANGUAGE plpgsql AS $$ +BEGIN + IF tbl IS NULL THEN + RETURN; + END IF; + RETURN QUERY EXECUTE format($q$ + SELECT id, pg_column_size(v) > 2000 AND pg_column_toast_chunk_id(v) IS NULL, + pg_column_compression(v) IS NOT NULL, + pg_column_toast_chunk_id(v) IS NOT NULL, + pg_column_size(w) > 2000 AND pg_column_toast_chunk_id(w) IS NULL, + pg_column_compression(w) IS NOT NULL, + pg_column_toast_chunk_id(w) IS NOT NULL + FROM %s$q$, tbl); +END $$; +-- Run one path's statements against table tbl of access method am. A failure +-- is reported as a NOTICE and leaves the table absent from the listings. +CREATE FUNCTION tf_path(tbl text, am text, VARIADIC stmts text[]) +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + s text; +BEGIN + FOREACH s IN ARRAY stmts LOOP + EXECUTE replace(replace(s, '$T', quote_ident(tbl)), '$AM', am); + END LOOP; +EXCEPTION WHEN OTHERS THEN + RAISE NOTICE '%: %', tbl, regexp_replace(SQLERRM, '[0-9]+', 'N', 'g'); +END $$; +-- Phase 1: build and load. One transaction. +DO $do$ +DECLARE + am text; + cols text := '(id int, v text, w text)'; + pk text := '(id int PRIMARY KEY, v text, w text)'; +BEGIN + FOREACH am IN ARRAY ARRAY['heap', 'flux'] LOOP + -- INSERT ... SELECT, one row and several rows + PERFORM tf_path('tf_ins1_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src WHERE id = 1'); + PERFORM tf_path('tf_insm_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src'); + -- INSERT ... ON CONFLICT DO NOTHING (speculative insertion) + PERFORM tf_path('tf_ocn_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src ON CONFLICT DO NOTHING'); + -- the same into a partition + PERFORM tf_path('tf_ocnp_' || am, am, + 'CREATE TABLE $T ' || pk || ' PARTITION BY LIST (id)', + 'CREATE TABLE tf_ocnp_part_$AM PARTITION OF $T DEFAULT USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src ON CONFLICT DO NOTHING'); + -- the same with oversized inline values + PERFORM tf_path('tf_ocbig_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT id, v || '''', w || '''' FROM heap_src ON CONFLICT DO NOTHING'); + -- ON CONFLICT DO UPDATE: row 1 exists (committed before phase 2) + PERFORM tf_path('tf_ocu_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T VALUES (1, ''a'', ''b'')'); + -- UPDATE targets, loaded with placeholders or own values + PERFORM tf_path('tf_upd_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', ''b'' FROM heap_src'); + PERFORM tf_path('tf_upd1_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', w FROM heap_src'); + PERFORM tf_path('tf_updk_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', ''b'' FROM heap_src'); + -- same-size in-place UPDATE of a MAIN column (CAS fast path in FLUX) + PERFORM tf_path('tf_cas_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'ALTER TABLE $T ALTER COLUMN w SET STORAGE MAIN', + 'INSERT INTO $T SELECT 4, ''short'', left(string_agg(md5(i::text), ''''), 5000) + FROM generate_series(1, 200) i'); + -- COPY FROM a file (multi_insert) + PERFORM tf_path('tf_copy_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + format('COPY $T FROM %L', current_setting('tf.file'))); + PERFORM tf_path('tf_pcopy_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM'); + -- COPY FROM with defaults that return heap_src's pointers (multi_insert), + -- into a plain table and into a partition + PERFORM tf_path('tf_cdef_' || am, am, + 'CREATE TABLE $T (id int, v text DEFAULT tf_def_v(), + w text DEFAULT tf_def_w()) USING $AM', + format('COPY $T (id) FROM %L', current_setting('tf.file1'))); + PERFORM tf_path('tf_cdefp_' || am, am, + 'CREATE TABLE $T (id int, v text DEFAULT tf_def_v(), + w text DEFAULT tf_def_w()) PARTITION BY LIST (id)', + 'CREATE TABLE tf_cdefp_part_$AM PARTITION OF $T DEFAULT USING $AM', + format('COPY $T (id) FROM %L', current_setting('tf.file1'))); + -- CREATE TABLE AS + PERFORM tf_path('tf_ctas_' || am, am, + 'CREATE TABLE $T USING $AM AS SELECT * FROM heap_src'); + -- materialized views: plain REFRESH, and REFRESH CONCURRENTLY from empty + PERFORM tf_path('tf_mv_' || am, am, + 'CREATE MATERIALIZED VIEW $T USING $AM AS SELECT * FROM tf_src(4)', + 'REFRESH MATERIALIZED VIEW $T'); + PERFORM tf_path('tf_mvc_' || am, am, + 'CREATE MATERIALIZED VIEW $T USING $AM AS + SELECT * FROM tf_src(current_setting(''tf.cut'')::int)', + 'CREATE UNIQUE INDEX ON $T (id)', + 'SELECT set_config(''tf.cut'', ''4'', false)', + 'REFRESH MATERIALIZED VIEW CONCURRENTLY $T', + 'SELECT set_config(''tf.cut'', ''0'', false)'); + -- plpgsql: scalar variables and a record variable + PERFORM tf_path('tf_plv_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'DO $p$ DECLARE xv text; xw text; BEGIN + FOR i IN 1..4 LOOP + SELECT v, w INTO xv, xw FROM heap_src WHERE id = i; + INSERT INTO $T VALUES (i, xv, xw); + END LOOP; END $p$'); + PERFORM tf_path('tf_plr_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'DO $p$ DECLARE r record; BEGIN + FOR r IN SELECT * FROM heap_src LOOP + INSERT INTO $T VALUES (r.id, r.v, r.w); + END LOOP; END $p$'); + -- SET ACCESS METHOD to the other AM, and there and back + PERFORM tf_path('tf_sam_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src', + 'ALTER TABLE $T SET ACCESS METHOD ' || CASE am WHEN 'heap' THEN 'flux' ELSE 'heap' END); + PERFORM tf_path('tf_samrt_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src', + 'ALTER TABLE $T SET ACCESS METHOD ' || CASE am WHEN 'heap' THEN 'flux' ELSE 'heap' END, + 'ALTER TABLE $T SET ACCESS METHOD $AM'); + -- VACUUM FULL target (run below, outside a transaction block) + PERFORM tf_path('tf_vf_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src'); + -- reverse direction: FLUX values into heap and into another FLUX table + PERFORM tf_path('tf_rins_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM flux_src'); + PERFORM tf_path('tf_rocn_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT * FROM flux_src ON CONFLICT DO NOTHING'); + PERFORM tf_path('tf_rcdef_' || am, am, + 'CREATE TABLE $T (id int, v text DEFAULT tf_rdef_v(), + w text DEFAULT tf_rdef_w()) USING $AM', + format('COPY $T (id) FROM %L', current_setting('tf.file1'))); + PERFORM tf_path('tf_rctas_' || am, am, + 'CREATE TABLE $T USING $AM AS SELECT * FROM flux_src'); + PERFORM tf_path('tf_rupd_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', ''b'' FROM flux_src'); + END LOOP; +END +$do$; +-- Phase 2: UPDATEs against committed rows. +DO $do$ +DECLARE + am text; +BEGIN + FOREACH am IN ARRAY ARRAY['heap', 'flux'] LOOP + PERFORM tf_path('tf_ocu_' || am, am, + 'INSERT INTO $T SELECT * FROM heap_src + ON CONFLICT (id) DO UPDATE SET v = EXCLUDED.v, w = EXCLUDED.w'); + PERFORM tf_path('tf_upd_' || am, am, + 'UPDATE $T t SET v = (SELECT v FROM heap_src s WHERE s.id = t.id), + w = (SELECT w FROM heap_src s WHERE s.id = t.id)'); + PERFORM tf_path('tf_upd1_' || am, am, + 'UPDATE $T t SET v = (SELECT v FROM heap_src s WHERE s.id = t.id)'); + PERFORM tf_path('tf_updk_' || am, am, + 'UPDATE $T t SET id = -t.id, + v = (SELECT v FROM heap_src s WHERE s.id = t.id), + w = (SELECT w FROM heap_src s WHERE s.id = t.id)', + 'UPDATE $T SET id = -id'); + PERFORM tf_path('tf_rupd_' || am, am, + 'UPDATE $T t SET v = (SELECT v FROM flux_src s WHERE s.id = t.id), + w = (SELECT w FROM flux_src s WHERE s.id = t.id)'); + END LOOP; +END +$do$; +UPDATE tf_cas_heap SET w = repeat('x', 5000); +UPDATE tf_cas_flux SET w = repeat('x', 5000); +-- psql's \copy takes its arguments verbatim, so use a relative path. +\cd :abs_builddir +\copy tf_pcopy_heap FROM 'results/flux_toast_foreign.data' +\copy tf_pcopy_flux FROM 'results/flux_toast_foreign.data' +VACUUM FULL tf_vf_heap; +VACUUM FULL tf_vf_flux; +-- Before the drop. Each check query lists only failures. +SELECT c.relname, a.amname, r.res +FROM pg_class c JOIN pg_am a ON a.oid = c.relam, LATERAL tf_check(c.oid) r(res) +WHERE (c.relname LIKE 'tf\_%' OR c.relname IN ('heap_src', 'flux_src')) + AND c.relname <> 'tf_ref' AND c.relkind IN ('r', 'm') + AND r.res !~ '^rows=[14] bad=0 foreign=0$' +ORDER BY 1; -- expect none + relname | amname | res +---------+--------+----- +(0 rows) + +-- Storage shape: every FLUX table must store its rows the way its heap twin +-- does. Lists the rows that differ. +SELECT p.path, d.* +FROM (SELECT left(relname, -5) AS path FROM pg_class + WHERE relname LIKE 'tf\_%\_heap' AND relkind IN ('r', 'm')) p, + LATERAL (SELECT 'heap' AS side, * FROM + (SELECT * FROM tf_shape(to_regclass(p.path || '_heap')) + EXCEPT SELECT * FROM tf_shape(to_regclass(p.path || '_flux'))) x + UNION ALL + SELECT 'flux', * FROM + (SELECT * FROM tf_shape(to_regclass(p.path || '_flux')) + EXCEPT SELECT * FROM tf_shape(to_regclass(p.path || '_heap'))) y) d +ORDER BY 1, 2, 3; -- expect none + path | side | id | bv | cv | ev | bw | cw | ew +------+------+----+----+----+----+----+----+---- +(0 rows) + +DROP TABLE heap_src, flux_src; +-- After the drop, in a new session. +\c +SELECT c.relname, a.amname, r.res +FROM pg_class c JOIN pg_am a ON a.oid = c.relam, LATERAL tf_check(c.oid) r(res) +WHERE c.relname LIKE 'tf\_%' AND c.relname <> 'tf_ref' AND c.relkind IN ('r', 'm') + AND r.res !~ '^rows=[14] bad=0 foreign=0$' +ORDER BY 1; -- expect none + relname | amname | res +---------+--------+----- +(0 rows) + +-- 27 paths, each on heap and flux; a path that failed is missing. +SELECT n FROM (SELECT count(*) AS n FROM pg_class + WHERE relname LIKE 'tf\_%' AND relname <> 'tf_ref' + AND relkind IN ('r', 'm')) s +WHERE n <> 54; -- expect none + n +--- +(0 rows) + +-- +-- FLUX commit-time work after the relation's storage was replaced in the same +-- transaction. Rows written to the old storage must not be confused with the +-- tuples at the same TIDs in the new storage. (VACUUM FULL cannot run in a +-- transaction block; CLUSTER is the in-transaction rewrite.) +-- +CREATE TABLE tr_ref AS + SELECT g AS id, repeat(md5(g::text), 150) AS w FROM generate_series(1, 20) g; +CREATE FUNCTION tr_bad(tbl regclass) RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE + n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM + ((SELECT id::int, w FROM %1$s EXCEPT ALL SELECT * FROM tr_ref) + UNION ALL + (SELECT * FROM tr_ref EXCEPT ALL SELECT id::int, w FROM %1$s)) x', tbl) + INTO n; + RETURN n; +EXCEPTION WHEN OTHERS THEN + RETURN -1; +END $$; +CREATE TABLE tr_fh (id int, w text) USING flux; +CREATE TABLE tr_fhf (id int, w text) USING flux; +CREATE TABLE tr_cl (id int PRIMARY KEY, w text) USING flux; +CREATE TABLE tr_act (id int, w text) USING flux; +CREATE TABLE tr_trunc (id int, w text) USING flux; +CREATE TABLE tr_spa (id int, w text) USING flux; +CREATE TABLE tr_spr (id int, w text) USING flux; +CREATE TABLE tr_spr2 (id int, w text) USING flux; +-- flux -> heap +BEGIN; +INSERT INTO tr_fh SELECT * FROM tr_ref; +ALTER TABLE tr_fh SET ACCESS METHOD heap; +COMMIT; +-- flux -> heap -> flux +BEGIN; +INSERT INTO tr_fhf SELECT * FROM tr_ref; +ALTER TABLE tr_fhf SET ACCESS METHOD heap; +ALTER TABLE tr_fhf SET ACCESS METHOD flux; +COMMIT; +-- CLUSTER (reorders the rows, so TIDs name other rows) +BEGIN; +INSERT INTO tr_cl SELECT * FROM tr_ref ORDER BY id DESC; +CLUSTER tr_cl USING tr_cl_pkey; +COMMIT; +-- ALTER COLUMN TYPE +BEGIN; +INSERT INTO tr_act SELECT * FROM tr_ref; +ALTER TABLE tr_act ALTER COLUMN id TYPE bigint; +COMMIT; +-- rows rolled back to a savepoint, then TRUNCATE and refill the same TIDs +BEGIN; +SAVEPOINT a; +INSERT INTO tr_trunc SELECT -id, w FROM tr_ref; +ROLLBACK TO a; +TRUNCATE tr_trunc; +INSERT INTO tr_trunc SELECT * FROM tr_ref; +COMMIT; +-- rows rolled back to a savepoint, then a rewrite moves live rows onto their TIDs +BEGIN; +SAVEPOINT a; +INSERT INTO tr_spa SELECT -id, w FROM tr_ref; +ROLLBACK TO a; +INSERT INTO tr_spa SELECT * FROM tr_ref; +ALTER TABLE tr_spa ALTER COLUMN id TYPE bigint; +COMMIT; +-- a rewrite inside a savepoint that rolls back: the old storage is current +-- again and its commit-time work must still be done +BEGIN; +INSERT INTO tr_spr SELECT * FROM tr_ref; +SAVEPOINT a; +ALTER TABLE tr_spr SET ACCESS METHOD heap; +ROLLBACK TO a; +COMMIT; +BEGIN; +SAVEPOINT a; +INSERT INTO tr_spr2 SELECT -id, w FROM tr_ref; +ROLLBACK TO a; +INSERT INTO tr_spr2 SELECT * FROM tr_ref; +SAVEPOINT b; +ALTER TABLE tr_spr2 ALTER COLUMN id TYPE bigint; +ROLLBACK TO b; +COMMIT; +SELECT t, tr_bad(t::regclass) AS bad +FROM unnest(ARRAY['tr_fh', 'tr_fhf', 'tr_cl', 'tr_act', 'tr_trunc', 'tr_spa', + 'tr_spr', 'tr_spr2']) t +WHERE tr_bad(t::regclass) <> 0 +ORDER BY 1; -- expect none + t | bad +---+----- +(0 rows) + +DROP TABLE tr_fh, tr_fhf, tr_cl, tr_act, tr_trunc, tr_spa, tr_spr, tr_spr2, tr_ref; +DROP FUNCTION tr_bad(regclass); +-- Clean up. +DO $do$ +DECLARE + r record; +BEGIN + FOR r IN SELECT relname, relkind FROM pg_class + WHERE relname LIKE 'tf\_%' AND relkind IN ('r', 'm', 'p') + AND NOT relispartition LOOP + EXECUTE format('DROP %s %I', + CASE r.relkind WHEN 'm' THEN 'MATERIALIZED VIEW' ELSE 'TABLE' END, + r.relname); + END LOOP; +END +$do$; +DROP FUNCTION tf_check(regclass), tf_shape(regclass), tf_path(text, text, text[]), + tf_def_v(), tf_def_w(), tf_rdef_v(), tf_rdef_w(), tf_src(int); diff --git a/src/test/regress/expected/flux_trigger_memory.out b/src/test/regress/expected/flux_trigger_memory.out new file mode 100644 index 0000000000000..8a551cb17bcd1 --- /dev/null +++ b/src/test/regress/expected/flux_trigger_memory.out @@ -0,0 +1,115 @@ +-- +-- Memory held for AFTER ROW trigger events across a long transaction. +-- +-- A FLUX row event carries copies of its row images (the TID names only the +-- newest version). Those copies must go away with the event: an immediate +-- event is gone at the end of its statement, an event of an aborted +-- subtransaction is gone at its rollback. Each case runs many top-level +-- statements in one transaction and compares the after-trigger event memory +-- after the first statement with that after the last; heap is the control. +-- +CREATE FUNCTION tm_ctx_bytes() RETURNS bigint LANGUAGE sql AS $$ + SELECT coalesce(sum(total_bytes), 0)::bigint FROM pg_backend_memory_contexts + WHERE name LIKE 'AfterTrigger%' +$$; +CREATE TEMP TABLE tm_mem (k text, n int, bytes bigint); +CREATE FUNCTION tm_noop() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RETURN NULL; +END $$; +-- Immediate AFTER UPDATE row trigger +CREATE TABLE tm_trig_heap (id int PRIMARY KEY, v int) USING heap; +CREATE TABLE tm_trig_flux (id int PRIMARY KEY, v int) USING flux; +INSERT INTO tm_trig_heap SELECT g, 0 FROM generate_series(1, 1000) g; +INSERT INTO tm_trig_flux SELECT g, 0 FROM generate_series(1, 1000) g; +CREATE TRIGGER tm_after AFTER UPDATE ON tm_trig_heap + FOR EACH ROW EXECUTE FUNCTION tm_noop(); +CREATE TRIGGER tm_after AFTER UPDATE ON tm_trig_flux + FOR EACH ROW EXECUTE FUNCTION tm_noop(); +-- Immediate foreign key check (RI trigger on the referencing table) +CREATE TABLE tm_pk_heap (id int PRIMARY KEY) USING heap; +CREATE TABLE tm_pk_flux (id int PRIMARY KEY) USING flux; +INSERT INTO tm_pk_heap VALUES (1), (2); +INSERT INTO tm_pk_flux VALUES (1), (2); +CREATE TABLE tm_fk_heap (id int PRIMARY KEY, pid int REFERENCES tm_pk_heap) + USING heap; +CREATE TABLE tm_fk_flux (id int PRIMARY KEY, pid int REFERENCES tm_pk_flux) + USING flux; +INSERT INTO tm_fk_heap SELECT g, 1 FROM generate_series(1, 1000) g; +INSERT INTO tm_fk_flux SELECT g, 1 FROM generate_series(1, 1000) g; +-- Each case: one transaction of 200 top-level UPDATEs of 1000 rows (each in a +-- rolled-back savepoint for the subxact cases), recording the event memory +-- after the first and after the last. +\set ECHO none +-- Deferred trigger events of rolled-back subtransactions +CREATE CONSTRAINT TRIGGER tm_deferred AFTER UPDATE ON tm_trig_heap + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_noop(); +CREATE CONSTRAINT TRIGGER tm_deferred AFTER UPDATE ON tm_trig_flux + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_noop(); +\set ECHO none +-- One statement's FLUX images take about 250 kB, so keeping them for the +-- transaction grows the context by ~50 MB here. Heap grows by well under +-- 1 MB (per-statement bookkeeping, e.g. the modified-columns bitmaps). +SELECT a.k, b.bytes - a.bytes < 4 * 1024 * 1024 AS flat +FROM tm_mem a JOIN tm_mem b ON a.k = b.k AND a.n = 1 AND b.n > 1 +ORDER BY a.k; + k | flat +--------------+------ + fk_flux | t + fk_heap | t + subxact_flux | t + subxact_heap | t + trig_flux | t + trig_heap | t +(6 rows) + +-- The deferred events of a committed statement still fire, with their images +CREATE TABLE tm_log (tab text, o int, n int); +CREATE FUNCTION tm_log_row() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO tm_log VALUES (TG_TABLE_NAME, OLD.v, NEW.v); + RETURN NULL; +END $$; +UPDATE tm_trig_heap SET v = 0; +UPDATE tm_trig_flux SET v = 0; +CREATE CONSTRAINT TRIGGER tm_deferred_log AFTER UPDATE ON tm_trig_heap + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_log_row(); +CREATE CONSTRAINT TRIGGER tm_deferred_log AFTER UPDATE ON tm_trig_flux + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_log_row(); +BEGIN; +UPDATE tm_trig_heap SET v = 1000 WHERE id <= 3; +UPDATE tm_trig_flux SET v = 1000 WHERE id <= 3; +SAVEPOINT s1; +UPDATE tm_trig_heap SET v = 2000 WHERE id <= 3; +UPDATE tm_trig_flux SET v = 2000 WHERE id <= 3; +ROLLBACK TO s1; +SAVEPOINT s2; +UPDATE tm_trig_heap SET v = v + 1 WHERE id <= 3; +UPDATE tm_trig_flux SET v = v + 1 WHERE id <= 3; +RELEASE s2; +UPDATE tm_trig_heap SET v = v + 1 WHERE id <= 2; +UPDATE tm_trig_flux SET v = v + 1 WHERE id <= 2; +COMMIT; +SELECT tab, o, n, count(*) FROM tm_log GROUP BY 1, 2, 3 ORDER BY 1, 2, 3; + tab | o | n | count +--------------+------+------+------- + tm_trig_flux | 0 | 1000 | 3 + tm_trig_flux | 1000 | 1001 | 3 + tm_trig_flux | 1001 | 1002 | 2 + tm_trig_heap | 0 | 1000 | 3 + tm_trig_heap | 1000 | 1001 | 3 + tm_trig_heap | 1001 | 1002 | 2 +(6 rows) + +SELECT h.id, h.v AS heap_v, f.v AS flux_v +FROM tm_trig_heap h JOIN tm_trig_flux f USING (id) WHERE id <= 3 ORDER BY id; + id | heap_v | flux_v +----+--------+-------- + 1 | 1002 | 1002 + 2 | 1002 | 1002 + 3 | 1001 | 1001 +(3 rows) + +DROP TABLE tm_trig_heap, tm_trig_flux, tm_fk_heap, tm_fk_flux, tm_pk_heap, + tm_pk_flux, tm_log; +DROP FUNCTION tm_ctx_bytes(), tm_noop(), tm_log_row(); diff --git a/src/test/regress/expected/flux_user_catalog.out b/src/test/regress/expected/flux_user_catalog.out new file mode 100644 index 0000000000000..7d70ca67646b6 --- /dev/null +++ b/src/test/regress/expected/flux_user_catalog.out @@ -0,0 +1,38 @@ +-- +-- FLUX: user_catalog_table is refused. +-- +-- A user_catalog_table is read by logical decoding under a historic snapshot +-- that reconstructs a past catalog state from the heap's dead row versions. +-- FLUX keeps old versions in undo and reuses the tuple in place, so it cannot +-- answer those historic-snapshot reads. Setting user_catalog_table on a FLUX +-- relation must therefore be refused, at CREATE, at ALTER TABLE ... SET, and +-- when converting a marked heap table to FLUX with SET ACCESS METHOD. The heap +-- twin of each case must still succeed, which is the control: the refusal is +-- specific to the FLUX access method, not to the option itself. +-- +-- CREATE: heap succeeds, FLUX is refused. +CREATE TABLE uct_heap_create (id int) USING heap + WITH (user_catalog_table = true); +CREATE TABLE uct_flux_create (id int) USING flux + WITH (user_catalog_table = true); +ERROR: user_catalog_table is not supported for the "flux" access method +HINT: Put logical-decoding plugin metadata in a table that uses the "heap" access method. +-- ALTER TABLE ... SET: heap succeeds, FLUX is refused. +CREATE TABLE uct_heap_set (id int) USING heap; +ALTER TABLE uct_heap_set SET (user_catalog_table = true); +CREATE TABLE uct_flux_set (id int) USING flux; +ALTER TABLE uct_flux_set SET (user_catalog_table = true); +ERROR: user_catalog_table is not supported for the "flux" access method +HINT: Put logical-decoding plugin metadata in a table that uses the "heap" access method. +-- SET ACCESS METHOD: converting a marked heap table to FLUX is refused with the +-- FLUX-specific message, before core's generic "cannot rewrite a catalog table" +-- restriction is reached. +CREATE TABLE uct_conv (id int) USING heap WITH (user_catalog_table = true); +ALTER TABLE uct_conv SET ACCESS METHOD flux; +ERROR: user_catalog_table is not supported for the "flux" access method +HINT: Put logical-decoding plugin metadata in a table that uses the "heap" access method. +-- Clearing the option, and other FLUX reloptions, still work on a FLUX table. +CREATE TABLE uct_flux_clear (id int) USING flux WITH (fillfactor = 90); +ALTER TABLE uct_flux_clear SET (user_catalog_table = false); +ALTER TABLE uct_flux_clear SET (fillfactor = 80); +DROP TABLE uct_heap_create, uct_heap_set, uct_flux_set, uct_conv, uct_flux_clear; diff --git a/src/test/regress/expected/flux_vacuum_stats.out b/src/test/regress/expected/flux_vacuum_stats.out new file mode 100644 index 0000000000000..928f7844318e1 --- /dev/null +++ b/src/test/regress/expected/flux_vacuum_stats.out @@ -0,0 +1,100 @@ +-- A one-page heap twin makes physical page counts comparable despite headers. +CREATE TABLE fvs_heap (a int PRIMARY KEY) USING heap WITH (autovacuum_enabled=false); +CREATE TABLE fvs_flux (a int PRIMARY KEY) USING flux WITH (autovacuum_enabled=false); +INSERT INTO fvs_heap SELECT generate_series(1,20); +INSERT INTO fvs_flux SELECT generate_series(1,20); +VACUUM fvs_heap; +VACUUM fvs_flux; +SELECT relname, relpages = 1 AS pages, reltuples = 20 AS tuples, + relallvisible = 1 AS visible +FROM pg_class WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; + relname | pages | tuples | visible +----------+-------+--------+--------- + fvs_flux | t | t | t + fvs_heap | t | t | t +(2 rows) + +SELECT pg_stat_force_next_flush(); + pg_stat_force_next_flush +-------------------------- + +(1 row) + +SELECT pg_stat_clear_snapshot(); + pg_stat_clear_snapshot +------------------------ + +(1 row) + +SELECT relname, vacuum_count = 1 AS vacuum_reported +FROM pg_stat_user_tables WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; + relname | vacuum_reported +----------+----------------- + fvs_flux | t + fvs_heap | t +(2 rows) + +-- Burn an xid so a no-op freeze cannot pass on a newly created relation. +SELECT pg_current_xact_id() IS NOT NULL AS assigned; + assigned +---------- + t +(1 row) + +VACUUM (FREEZE) fvs_heap; +VACUUM (FREEZE) fvs_flux; +SELECT relname, age(relfrozenxid) <= 1 AS frozen, + relallfrozen = 1 AS frozen_page +FROM pg_class WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; + relname | frozen | frozen_page +----------+--------+------------- + fvs_flux | t | t + fvs_heap | t | t +(2 rows) + +-- A repeated vacuum must count pages skipped as all-frozen, not report zero. +VACUUM fvs_heap; +VACUUM fvs_flux; +SELECT relname, reltuples = 20 AS tuples +FROM pg_class WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; + relname | tuples +----------+-------- + fvs_flux | t + fvs_heap | t +(2 rows) + +-- Force bulk-delete so both index AMs return accurate cleanup statistics. +INSERT INTO fvs_heap VALUES (99); +INSERT INTO fvs_flux VALUES (99); +DELETE FROM fvs_heap WHERE a=99; +DELETE FROM fvs_flux WHERE a=99; +VACUUM (INDEX_CLEANUP ON) fvs_heap; +VACUUM (INDEX_CLEANUP ON) fvs_flux; +SELECT relname, relpages > 0 AS pages, reltuples = 20 AS tuples +FROM pg_class WHERE relname IN ('fvs_heap_pkey', 'fvs_flux_pkey') ORDER BY relname; + relname | pages | tuples +---------------+-------+-------- + fvs_flux_pkey | t | t + fvs_heap_pkey | t | t +(2 rows) + +-- A cursor stores a version identity before DML freezes its before-image. +BEGIN; +DECLARE fvs_h CURSOR FOR SELECT * FROM fvs_heap WHERE a=1; +DECLARE fvs_f CURSOR FOR SELECT * FROM fvs_flux WHERE a=1; +FETCH fvs_h; + a +--- + 1 +(1 row) + +FETCH fvs_f; + a +--- + 1 +(1 row) + +UPDATE fvs_heap SET a=101 WHERE CURRENT OF fvs_h; +UPDATE fvs_flux SET a=101 WHERE CURRENT OF fvs_f; +ROLLBACK; +DROP TABLE fvs_heap, fvs_flux; diff --git a/src/test/regress/expected/psql.out b/src/test/regress/expected/psql.out index e8605dd041f78..bce189fb99dd4 100644 --- a/src/test/regress/expected/psql.out +++ b/src/test/regress/expected/psql.out @@ -5207,13 +5207,14 @@ List of access methods --------+------- brin | Index btree | Index + flux | Table gin | Index gist | Index hash | Index heap | Table heap2 | Table spgist | Index -(8 rows) +(9 rows) \dA * List of access methods @@ -5221,13 +5222,14 @@ List of access methods --------+------- brin | Index btree | Index + flux | Table gin | Index gist | Index hash | Index heap | Table heap2 | Table spgist | Index -(8 rows) +(9 rows) \dA h* List of access methods @@ -5257,13 +5259,14 @@ List of access methods --------+-------+----------------------+---------------------------------------- brin | Index | brinhandler | block range index (BRIN) access method btree | Index | bthandler | b-tree index access method + flux | Table | flux_tableam_handler | flux table access method gin | Index | ginhandler | GIN index access method gist | Index | gisthandler | GiST index access method hash | Index | hashhandler | hash index access method heap | Table | heap_tableam_handler | heap table access method heap2 | Table | heap_tableam_handler | spgist | Index | spghandler | SP-GiST index access method -(8 rows) +(9 rows) \dA+ * List of access methods @@ -5271,13 +5274,14 @@ List of access methods --------+-------+----------------------+---------------------------------------- brin | Index | brinhandler | block range index (BRIN) access method btree | Index | bthandler | b-tree index access method + flux | Table | flux_tableam_handler | flux table access method gin | Index | ginhandler | GIN index access method gist | Index | gisthandler | GiST index access method hash | Index | hashhandler | hash index access method heap | Table | heap_tableam_handler | heap table access method heap2 | Table | heap_tableam_handler | spgist | Index | spghandler | SP-GiST index access method -(8 rows) +(9 rows) \dA+ h* List of access methods diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule index 75063f87a4a7b..97e8964dcea8e 100644 --- a/src/test/regress/parallel_schedule +++ b/src/test/regress/parallel_schedule @@ -72,6 +72,15 @@ test: brin gin gist spgist privileges init_privs security_label collate matview # Additional BRIN tests # ---------- test: brin_bloom brin_multi +test: flux flux_toast_foreign flux_fk flux_deferred_trigger flux_rewrite_xact +test: flux_index_aba +test: flux_dml_semantics +test: flux_cluster +test: flux_abort_ddl +test: flux_merge_delete +test: flux_tidbitmap +test: flux_trigger_memory +test: flux_user_catalog # ---------- # Another group of parallel tests @@ -146,3 +155,9 @@ test: fast_default # run tablespace test at the end because it drops the tablespace created during # setup that other tests may use. test: tablespace + +# Heap-controlled FLUX VACUUM catalog and cumulative statistics. +test: flux_vacuum_stats + +# FLUX planner size estimates, heap as control. +test: flux_estimate diff --git a/src/test/regress/sql/flux.sql b/src/test/regress/sql/flux.sql new file mode 100644 index 0000000000000..eb48ac70e215d --- /dev/null +++ b/src/test/regress/sql/flux.sql @@ -0,0 +1,790 @@ +-- +-- FLUX table access method: core functional + index-integrity tests. +-- +-- The critical property is that +-- index scans, sequential scans, and bitmap scans agree after key-changing +-- UPDATEs, including A -> B -> A recurrences. FLUX achieves this by doing a +-- non-in-place (new-TID) UPDATE whenever an indexed column changes, so +-- secondary indexes are maintained by the standard heap-TID path. +-- + +-- +-- amcheck (bt_index_check) is a contrib extension, so it may be absent when +-- the core regression suite runs without EXTRA_INSTALL=contrib/amcheck (for +-- example when another test module re-runs the schedule, or on a packager's +-- "make check"). Verify indexes with it when present; otherwise skip, so the +-- expected output is identical either way. The idxscan-vs-seqscan checks +-- below are the primary consistency assertion; amcheck is a stronger backstop. +-- +CREATE FUNCTION flux_check_index(regclass) RETURNS text +LANGUAGE plpgsql AS $$ +BEGIN + IF to_regprocedure('bt_index_check(regclass,boolean)') IS NOT NULL THEN + PERFORM bt_index_check($1, true); + END IF; + RETURN 'ok'; +END; +$$; + +CREATE TABLE flux_basic (id int, k int, v text) USING flux; +INSERT INTO flux_basic SELECT g, g, 'v' || g FROM generate_series(1, 20) g; +CREATE INDEX flux_basic_k_idx ON flux_basic (k); + +-- non-key UPDATE (in place): TID and index unchanged +UPDATE flux_basic SET v = 'updated' WHERE id = 5; +SELECT id, k, v FROM flux_basic WHERE id = 5; + +-- key-changing UPDATEs (out of place): old index entry dies, new one inserted +UPDATE flux_basic SET k = 105 WHERE id = 5; +UPDATE flux_basic SET k = 106 WHERE id = 6; +-- A -> B -> A recurrence on an indexed key +UPDATE flux_basic SET k = 999 WHERE id = 7; +UPDATE flux_basic SET k = 7 WHERE id = 7; + +DELETE FROM flux_basic WHERE id = 20; + +-- The gate: idxscan == seqscan == bitmapscan. +SET enable_seqscan = on; SET enable_indexscan = off; SET enable_bitmapscan = off; +SELECT count(*) AS seq_count, sum(k) AS seq_sumk FROM flux_basic; +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_count, sum(k) AS idx_sumk FROM flux_basic WHERE k > -2147483648; +SELECT count(*) AS idx_k_eq7 FROM flux_basic WHERE k = 7; -- 1, not 2 +SELECT count(*) AS idx_k_eq5 FROM flux_basic WHERE k = 5; -- 0 (moved to 105) +SELECT count(*) AS idx_k_eq105 FROM flux_basic WHERE k = 105; -- 1 +SET enable_seqscan = off; SET enable_indexscan = off; SET enable_bitmapscan = on; +SELECT count(*) AS bmp_count, sum(k) AS bmp_sumk FROM flux_basic WHERE k > -2147483648; +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; + +-- VACUUM then amcheck (heapallindexed): no error means the index is consistent +-- with the heap after key churn. +VACUUM flux_basic; +SELECT flux_check_index('flux_basic_k_idx'::regclass); + +-- ROLLBACK restores the old value (non-key) and old key + index entry (key). +BEGIN; +UPDATE flux_basic SET v = 'should_not_persist' WHERE id = 10; +ROLLBACK; +SELECT v FROM flux_basic WHERE id = 10; + +BEGIN; +UPDATE flux_basic SET k = 5000 WHERE id = 11; +ROLLBACK; +SELECT k FROM flux_basic WHERE id = 11; +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_k_eq11 FROM flux_basic WHERE k = 11; -- 1 +SELECT count(*) AS idx_k_eq5000 FROM flux_basic WHERE k = 5000; -- 0 +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; + +-- TOAST: a >8KB value round-trips exactly and the relation gets a TOAST table. +CREATE TABLE flux_toast (id int, big text) USING flux; +INSERT INTO flux_toast VALUES (1, repeat('X', 100000)); +SELECT id, length(big) AS len, (big = repeat('X', 100000)) AS exact FROM flux_toast; +SELECT reltoastrelid <> 0 AS has_toast_table FROM pg_class WHERE relname = 'flux_toast'; + +DROP TABLE flux_basic; +DROP TABLE flux_toast; + +-- +-- BUG 2 regression: an in-place UPDATE of a NON-indexed column that GROWS the +-- row on a full page must NOT error with "does not fit on page". FLUX falls +-- back to an out-of-place (new-TID) update and rebuilds all index entries, so +-- the row survives and every index stays consistent with the heap. +-- +CREATE TABLE flux_grow (id int, k int, pad text, v text) USING flux + WITH (fillfactor = 100); +CREATE INDEX flux_grow_k_idx ON flux_grow (k); +-- Pack pages tight: many rows with a NULL v (narrowest) so the page is full +-- of committed tuples with no free space and no slack for in-place growth. +INSERT INTO flux_grow SELECT g, g, repeat('p', 200), NULL + FROM generate_series(1, 2000) g; +VACUUM flux_grow; +-- Grow a NON-indexed column (v: NULL -> long string) on rows across full +-- pages. Pre-fix this raised: ERROR updated flux tuple does not fit on page. +UPDATE flux_grow SET v = repeat('w', 400) WHERE id <= 2000; +-- All rows survived with the grown value. +SELECT count(*) AS grown, count(*) FILTER (WHERE v = repeat('w', 400)) AS correct + FROM flux_grow; +-- The non-indexed grow may have moved TIDs; every index must still agree with +-- the heap (idxscan == seqscan) and amcheck must pass. +VACUUM flux_grow; +SELECT flux_check_index('flux_grow_k_idx'::regclass); +SET enable_seqscan = on; SET enable_indexscan = off; SET enable_bitmapscan = off; +SELECT count(*) AS seq_count, sum(k) AS seq_sumk FROM flux_grow; +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_count, sum(k) AS idx_sumk FROM flux_grow; +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +-- A specific indexed lookup after the TID-moving grow returns exactly one row. +SELECT id, k, length(v) AS vlen FROM flux_grow WHERE k = 1000; +DROP TABLE flux_grow; + +-- +-- BUG 7/8 regression: SAVEPOINT interaction with in-place UPDATE. +-- (7) Re-UPDATE inside a savepoint a row already updated by an earlier command +-- must NOT self-wait/assert. (8) ROLLBACK TO SAVEPOINT must RESTORE the +-- pre-savepoint value of in-place updates (and undo in-savepoint deletes), +-- not vanish the row or over-revert to the original committed value. +-- +CREATE TABLE flux_sp (id int PRIMARY KEY, v int) USING flux; +INSERT INTO flux_sp VALUES (1,100),(2,200),(3,300); +BEGIN; +UPDATE flux_sp SET v = 101 WHERE id = 1; -- before savepoint +SAVEPOINT s1; +UPDATE flux_sp SET v = 999 WHERE id = 1; -- re-update same row (bug 7) +DELETE FROM flux_sp WHERE id = 2; -- in-savepoint delete +UPDATE flux_sp SET v = 301 WHERE id = 3; -- in-savepoint update +ROLLBACK TO s1; -- must restore pre-savepoint state +-- Expect 1|101 (pre-savepoint update kept), 2|200 (delete undone), 3|300 (update undone) +SELECT id, v FROM flux_sp ORDER BY id; +COMMIT; +SELECT id, v FROM flux_sp ORDER BY id; +DROP TABLE flux_sp; + +-- +-- ALWAYS-IN-PLACE indexed-column UPDATE via delete-marking. +-- An UPDATE of an indexed column keeps the STABLE TID (ctid unchanged), the +-- index sees the new key (old key delete-marked), unique is respected, and +-- ROLLBACK of an in-place key update restores the old key + removes the new +-- index entry. amcheck stays clean. +-- +CREATE TABLE flux_ip (id int PRIMARY KEY, k int, v text) USING flux; +INSERT INTO flux_ip SELECT g, g, 'v'||g FROM generate_series(1,10) g; +CREATE INDEX flux_ip_k ON flux_ip (k); +CREATE UNIQUE INDEX flux_ip_uk ON flux_ip (k); +-- (a) STABLE TID: ctid identical before and after an indexed-column UPDATE. +SELECT ctid AS ip_before FROM flux_ip WHERE id = 3; +UPDATE flux_ip SET k = 333 WHERE id = 3; +SELECT ctid AS ip_after, k FROM flux_ip WHERE id = 3; -- same ctid, k=333 +-- index scan sees new key, not old; idxscan == seqscan (no duplicate rows). +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS ip_k3 FROM flux_ip WHERE k = 3; -- 0 (moved) +SELECT count(*) AS ip_k333 FROM flux_ip WHERE k = 333; -- 1 +SELECT count(*) AS ip_idx_total, sum(k) AS ip_idx_sumk FROM flux_ip WHERE k > -2147483648; +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +SELECT count(*) AS ip_seq_total, sum(k) AS ip_seq_sumk FROM flux_ip; -- must match idx +-- (d) UNIQUE respected: another row cannot take the new key. +INSERT INTO flux_ip VALUES (11, 11, 'x'); +UPDATE flux_ip SET k = 333 WHERE id = 11; -- ERROR: duplicate key +-- (c) ROLLBACK of an in-place key UPDATE restores old key and removes new entry. +SELECT ctid AS ip_roll_before FROM flux_ip WHERE id = 4; +BEGIN; +UPDATE flux_ip SET k = 4444 WHERE id = 4; +SELECT ctid AS ip_roll_intxn, k FROM flux_ip WHERE id = 4; -- same ctid, k=4444 +ROLLBACK; +SELECT ctid AS ip_roll_after, k FROM flux_ip WHERE id = 4; -- same ctid, k=4 restored +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS ip_roll_k4 FROM flux_ip WHERE k = 4; -- 1 (restored) +SELECT count(*) AS ip_roll_k4444 FROM flux_ip WHERE k = 4444; -- 0 (new entry gone) +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +-- Unique still works after rollback: k=4444 free to insert, k=4 taken. +INSERT INTO flux_ip VALUES (12, 4444, 'y'); -- ok +INSERT INTO flux_ip VALUES (13, 4, 'z'); -- ERROR: duplicate key (4 live) +-- (e) amcheck clean on both indexes carrying delete-marks. +SELECT flux_check_index('flux_ip_k'::regclass); +SELECT flux_check_index('flux_ip_uk'::regclass); +DROP TABLE flux_ip; + +-- +-- Consumer-reported regressions (undo-bug-report): the AFTER-trigger OLD image +-- and the same-transaction snapshot must match heap for an in-place AM. +-- +-- BUG 1: an AFTER ROW UPDATE trigger, and RI, must see the pre-update OLD row. +CREATE TABLE flux_bug1 (id int PRIMARY KEY, v int) USING flux; +INSERT INTO flux_bug1 VALUES (1, 10); +CREATE FUNCTION flux_bug1_log() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN RAISE NOTICE 'OLD.v=% NEW.v=%', OLD.v, NEW.v; RETURN NULL; END $$; +CREATE TRIGGER t AFTER UPDATE ON flux_bug1 FOR EACH ROW + EXECUTE FUNCTION flux_bug1_log(); +UPDATE flux_bug1 SET v = 20; -- expect NOTICE OLD.v=10 NEW.v=20 +DROP TABLE flux_bug1; +DROP FUNCTION flux_bug1_log(); + +-- BUG 1 (RI): ON UPDATE RESTRICT must refuse a parent-key change that a child +-- still references; the pre-fix in-place AM saw OLD.k==NEW.k and allowed it. +CREATE TABLE flux_pr (k int PRIMARY KEY) USING flux; +INSERT INTO flux_pr VALUES (10); +CREATE TABLE flux_fr (k int REFERENCES flux_pr ON UPDATE RESTRICT); +INSERT INTO flux_fr VALUES (10); +UPDATE flux_pr SET k = 20; -- expect ERROR: RESTRICT violation +SELECT count(*) AS flux_orphans FROM flux_fr f + WHERE NOT EXISTS (SELECT 1 FROM flux_pr p WHERE p.k = f.k); -- 0 +DROP TABLE flux_fr; DROP TABLE flux_pr; + +-- BUG 2: a snapshot taken between two in-place UPDATEs in one transaction must +-- see the first UPDATE's effect, not the last committed value. +CREATE TABLE flux_bug2 (id int PRIMARY KEY, v int) USING flux; +INSERT INTO flux_bug2 VALUES (1, 10); +BEGIN; +DECLARE c1 CURSOR FOR SELECT v FROM flux_bug2; -- before UPDATE 1 +UPDATE flux_bug2 SET v = 20; +DECLARE c2 CURSOR FOR SELECT v FROM flux_bug2; -- between the two UPDATEs +UPDATE flux_bug2 SET v = 30; +FETCH c1; -- 10 +FETCH c2; -- 20 +SELECT v FROM flux_bug2; -- 30 +COMMIT; +DROP TABLE flux_bug2; + +-- FLUX: CREATE UNIQUE INDEX must ignore rows of aborted INSERTs. +CREATE TABLE fu (id int) USING flux; +INSERT INTO fu VALUES (1); +BEGIN; INSERT INTO fu VALUES (1); ROLLBACK; +BEGIN; INSERT INTO fu VALUES (2); SAVEPOINT s; INSERT INTO fu VALUES (2); +ROLLBACK TO s; COMMIT; +CREATE UNIQUE INDEX fu_id ON fu (id); +SET enable_seqscan = off; +SELECT id FROM fu WHERE id IN (1, 2) ORDER BY id; +RESET enable_seqscan; +DROP INDEX fu_id; +-- a committed duplicate still fails +INSERT INTO fu VALUES (1); +CREATE UNIQUE INDEX fu_id ON fu (id); +DROP TABLE fu; + +-- A varlena with double alignment followed by one with int alignment: the +-- index-fetch decode must not pad after the first by its own alignment. +CREATE TABLE fal (a int8range, b text) USING flux; +CREATE INDEX fal_b ON fal (b); +INSERT INTO fal VALUES ('[7,8)', 'foo'); +SET enable_seqscan = off; SET enable_bitmapscan = off; +SELECT a, b FROM fal WHERE b = 'foo'; +RESET enable_seqscan; RESET enable_bitmapscan; +DROP TABLE fal; + +DROP FUNCTION flux_check_index(regclass); + +-- Ordinary VACUUM must not move a live row to another TID. Without an index +-- FLUX's former cross-page relocation moved tail rows forward and freed their +-- slots while their writer still referenced them. +CREATE TABLE flux_vmove (id int, v int, pad text) USING flux + WITH (autovacuum_enabled = off); +INSERT INTO flux_vmove SELECT g, 0, repeat('x', 200) FROM generate_series(1, 400) g; +DELETE FROM flux_vmove WHERE id <= 300 OR id % 3 = 0; +CREATE TEMP TABLE flux_vmove_before AS SELECT id, ctid AS c FROM flux_vmove; +VACUUM flux_vmove; +VACUUM flux_vmove; +SELECT count(*) AS moved FROM flux_vmove JOIN flux_vmove_before b USING (id) + WHERE flux_vmove.ctid <> b.c; -- 0 +SELECT count(*) AS n FROM flux_vmove; -- 67 +DROP TABLE flux_vmove, flux_vmove_before; + +-- VACUUM keeps an empty tail when told to (VACUUM (TRUNCATE false), the +-- vacuum_truncate reloption) and when it is too short to be worth the lock: +-- under 1000 pages and 1/16 of the table. Heap alongside as the control. +CREATE TEMP TABLE flux_tk_h (id int, pad text) USING heap; +CREATE TEMP TABLE flux_tk (id int, pad text) USING flux; +INSERT INTO flux_tk_h SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +INSERT INTO flux_tk SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +CREATE TEMP TABLE flux_tk_sz AS + SELECT pg_relation_size('flux_tk_h') AS h, pg_relation_size('flux_tk') AS r; +DELETE FROM flux_tk_h WHERE id > 19900; +DELETE FROM flux_tk WHERE id > 19900; +VACUUM flux_tk_h; +VACUUM flux_tk; +SELECT pg_relation_size('flux_tk_h') = h AS heap_kept, + pg_relation_size('flux_tk') = r AS flux_kept FROM flux_tk_sz; +DELETE FROM flux_tk_h WHERE id > 1; +DELETE FROM flux_tk WHERE id > 1; +VACUUM (TRUNCATE false) flux_tk_h; +VACUUM (TRUNCATE false) flux_tk; +SELECT pg_relation_size('flux_tk_h') = h AS heap_kept, + pg_relation_size('flux_tk') = r AS flux_kept FROM flux_tk_sz; +ALTER TABLE flux_tk_h SET (vacuum_truncate = false); +ALTER TABLE flux_tk SET (vacuum_truncate = false); +VACUUM flux_tk_h; +VACUUM flux_tk; +SELECT pg_relation_size('flux_tk_h') = h AS heap_kept, + pg_relation_size('flux_tk') = r AS flux_kept FROM flux_tk_sz; +ALTER TABLE flux_tk_h RESET (vacuum_truncate); +ALTER TABLE flux_tk RESET (vacuum_truncate); +VACUUM flux_tk_h; +VACUUM flux_tk; +SELECT pg_relation_size('flux_tk_h') = 8192 AS heap_truncated, + pg_relation_size('flux_tk') = 8192 AS flux_truncated; +DROP TABLE flux_tk_h, flux_tk, flux_tk_sz; + +-- VACUUM (DISABLE_PAGE_SKIPPING) still truncates an empty tail, as heap does. +CREATE TEMP TABLE flux_dps_h (id int, pad text) USING heap; +CREATE TEMP TABLE flux_dps (id int, pad text) USING flux; +INSERT INTO flux_dps_h SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +INSERT INTO flux_dps SELECT g, repeat('t', 200) FROM generate_series(1, 20000) g; +DELETE FROM flux_dps_h WHERE id > 1; +DELETE FROM flux_dps WHERE id > 1; +VACUUM (DISABLE_PAGE_SKIPPING) flux_dps_h; +VACUUM (DISABLE_PAGE_SKIPPING) flux_dps; +SELECT pg_relation_size('flux_dps_h') = 8192 AS heap_truncated, + pg_relation_size('flux_dps') = 8192 AS flux_truncated; +DROP TABLE flux_dps_h, flux_dps; + +-- Heap control, then the same operations with FLUX. +CREATE TABLE scan_semantics (a int) USING heap; +INSERT INTO scan_semantics SELECT generate_series(1,5); +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH 3 FROM c; +FETCH BACKWARD 1 FROM c; +FETCH LAST FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +MOVE BACKWARD ALL FROM c; +FETCH NEXT FROM c; +COMMIT; +SET enable_seqscan = off; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics + WHERE ctid >= '(0,2)' AND ctid <= '(0,4)'; +FETCH LAST FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +FETCH NEXT FROM c; +COMMIT; +RESET enable_seqscan; +DROP TABLE scan_semantics; + +CREATE TABLE scan_semantics (a int, pad text) USING heap; +INSERT INTO scan_semantics SELECT g, repeat('x', 100) FROM generate_series(1,1000) g; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +MOVE 500 FROM c; +FETCH PRIOR FROM c; +FETCH NEXT FROM c; +FETCH LAST FROM c; +FETCH NEXT FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +FETCH PRIOR FROM c; +FETCH NEXT FROM c; +COMMIT; +SET enable_seqscan = off; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT true AS found FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; +FETCH FIRST FROM c; +FETCH LAST FROM c; +FETCH NEXT FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +FETCH PRIOR FROM c; +FETCH NEXT FROM c; +COMMIT; +RESET enable_seqscan; +TRUNCATE scan_semantics; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH LAST FROM c; +FETCH FIRST FROM c; +COMMIT; +DROP TABLE scan_semantics; + +CREATE TABLE scan_semantics (a int) USING flux; +INSERT INTO scan_semantics SELECT generate_series(1,5); +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH 3 FROM c; +FETCH BACKWARD 1 FROM c; +FETCH LAST FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +MOVE BACKWARD ALL FROM c; +FETCH NEXT FROM c; +COMMIT; +SET enable_seqscan = off; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics + WHERE ctid >= '(0,2)' AND ctid <= '(0,4)'; +FETCH LAST FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +FETCH NEXT FROM c; +COMMIT; +RESET enable_seqscan; +DROP TABLE scan_semantics; + +CREATE TABLE scan_semantics (a int, pad text) USING flux; +INSERT INTO scan_semantics SELECT g, repeat('x', 100) FROM generate_series(1,1000) g; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +MOVE 500 FROM c; +FETCH PRIOR FROM c; +FETCH NEXT FROM c; +FETCH LAST FROM c; +FETCH NEXT FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +FETCH PRIOR FROM c; +FETCH NEXT FROM c; +COMMIT; +SET enable_seqscan = off; +EXPLAIN (COSTS OFF) SELECT a FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT true AS found FROM scan_semantics + WHERE ctid >= '(1,1)' AND ctid < '(2,1)'; +FETCH FIRST FROM c; +FETCH LAST FROM c; +FETCH NEXT FROM c; +FETCH PRIOR FROM c; +FETCH FIRST FROM c; +FETCH PRIOR FROM c; +FETCH NEXT FROM c; +COMMIT; +RESET enable_seqscan; +TRUNCATE scan_semantics; +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT a FROM scan_semantics; +FETCH LAST FROM c; +FETCH FIRST FROM c; +COMMIT; +DROP TABLE scan_semantics; + +-- Heap control, then the same operations with FLUX. +CREATE TABLE scan_semantics () USING heap; +INSERT INTO scan_semantics DEFAULT VALUES; +ALTER TABLE scan_semantics ADD COLUMN a int DEFAULT 1; +CREATE INDEX ON scan_semantics(a); +UPDATE scan_semantics SET a = NULL; +SET enable_indexscan = off; +SET enable_bitmapscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; +RESET enable_indexscan; +SET enable_seqscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; + +CREATE TABLE scan_semantics () USING flux; +INSERT INTO scan_semantics DEFAULT VALUES; +ALTER TABLE scan_semantics ADD COLUMN a int DEFAULT 1; +CREATE INDEX ON scan_semantics(a); +UPDATE scan_semantics SET a = NULL; +SET enable_indexscan = off; +SET enable_bitmapscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; +RESET enable_indexscan; +SET enable_seqscan = off; +SELECT count(*) FROM scan_semantics WHERE a IS NULL; +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; + +-- Heap control, then the same operations with FLUX. +CREATE TABLE scan_semantics (pad text, a int) USING heap; +INSERT INTO scan_semantics SELECT repeat('x',100), generate_series(1,1000); +CREATE INDEX ON scan_semantics(a); +VACUUM ANALYZE scan_semantics; +SET enable_seqscan = off; +SET enable_bitmapscan = off; +CREATE AGGREGATE scan_count (*) (sfunc = int8inc, stype = int8, + initcond = '0', parallel = safe); +SELECT scan_count(*) FROM scan_semantics; +SET debug_parallel_query = on; +SELECT scan_count(*) FROM scan_semantics; +SELECT count(*) FROM scan_semantics; +RESET debug_parallel_query; +DROP AGGREGATE scan_count(*); +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; + +CREATE TABLE scan_semantics (pad text, a int) USING flux; +INSERT INTO scan_semantics SELECT repeat('x',100), generate_series(1,1000); +CREATE INDEX ON scan_semantics(a); +VACUUM ANALYZE scan_semantics; +SET enable_seqscan = off; +SET enable_bitmapscan = off; +CREATE AGGREGATE scan_count (*) (sfunc = int8inc, stype = int8, + initcond = '0', parallel = safe); +SELECT scan_count(*) FROM scan_semantics; +SET debug_parallel_query = on; +SELECT scan_count(*) FROM scan_semantics; +SELECT count(*) FROM scan_semantics; +RESET debug_parallel_query; +DROP AGGREGATE scan_count(*); +RESET enable_seqscan; +RESET enable_bitmapscan; +DROP TABLE scan_semantics; + +-- Heap control, then the same operations with FLUX. +CREATE TABLE self_direct (id int, v int) USING heap; +INSERT INTO self_direct VALUES (1,0); +CREATE FUNCTION self_direct_fn() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF pg_trigger_depth() = 1 THEN + UPDATE self_direct SET v = v + 1 WHERE id = OLD.id; + END IF; + RETURN OLD; +END $$; +CREATE TRIGGER self_direct_tr BEFORE DELETE OR UPDATE ON self_direct + FOR EACH ROW EXECUTE FUNCTION self_direct_fn(); +DELETE FROM self_direct; +SELECT * FROM self_direct; +UPDATE self_direct SET v = 10; +SELECT * FROM self_direct; +DROP TABLE self_direct; +DROP FUNCTION self_direct_fn(); + +-- Parent-first cascade succeeds; child-first cascade self-modifies. +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING heap; + +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); + +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); + +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); + +update self_ref_trigger set data = 'root!' where id = 1; + +select * from self_ref_trigger ORDER BY id; + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id ASC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; +delete from self_ref_trigger; + +select * from self_ref_trigger ORDER BY id; + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING heap; + +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); + +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); + +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); + +update self_ref_trigger set data = 'root!' where id = 1; + +select * from self_ref_trigger ORDER BY id; + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id DESC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; +delete from self_ref_trigger; + +select * from self_ref_trigger ORDER BY id; + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); + +CREATE TABLE self_direct (id int, v int) USING flux; +INSERT INTO self_direct VALUES (1,0); +CREATE FUNCTION self_direct_fn() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF pg_trigger_depth() = 1 THEN + UPDATE self_direct SET v = v + 1 WHERE id = OLD.id; + END IF; + RETURN OLD; +END $$; +CREATE TRIGGER self_direct_tr BEFORE DELETE OR UPDATE ON self_direct + FOR EACH ROW EXECUTE FUNCTION self_direct_fn(); +DELETE FROM self_direct; +SELECT * FROM self_direct; +UPDATE self_direct SET v = 10; +SELECT * FROM self_direct; +DROP TABLE self_direct; +DROP FUNCTION self_direct_fn(); + +-- Parent-first cascade succeeds; child-first cascade self-modifies. +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING flux; + +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); + +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); + +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); + +update self_ref_trigger set data = 'root!' where id = 1; + +select * from self_ref_trigger ORDER BY id; + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id ASC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; +delete from self_ref_trigger; + +select * from self_ref_trigger ORDER BY id; + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); +create temp table self_ref_trigger ( + id int primary key, + parent int references self_ref_trigger, + data text, + nchildren int not null default 0 +) USING flux; + +create function self_ref_trigger_ins_func() + returns trigger language plpgsql as +$$ +begin + if new.parent is not null then + update self_ref_trigger set nchildren = nchildren + 1 + where id = new.parent; + end if; + return new; +end; +$$; +create trigger self_ref_trigger_ins_trig before insert on self_ref_trigger + for each row execute procedure self_ref_trigger_ins_func(); + +create function self_ref_trigger_del_func() + returns trigger language plpgsql as +$$ +begin + if old.parent is not null then + update self_ref_trigger set nchildren = nchildren - 1 + where id = old.parent; + end if; + return old; +end; +$$; +create trigger self_ref_trigger_del_trig before delete on self_ref_trigger + for each row execute procedure self_ref_trigger_del_func(); + +insert into self_ref_trigger values (1, null, 'root'); +insert into self_ref_trigger values (2, 1, 'root child A'); +insert into self_ref_trigger values (3, 1, 'root child B'); +insert into self_ref_trigger values (4, 2, 'grandchild 1'); +insert into self_ref_trigger values (5, 3, 'grandchild 2'); + +update self_ref_trigger set data = 'root!' where id = 1; + +select * from self_ref_trigger ORDER BY id; + +ALTER TABLE self_ref_trigger DISABLE TRIGGER ALL; +TRUNCATE self_ref_trigger; +INSERT INTO self_ref_trigger SELECT * FROM (VALUES + (1,null::int,'root!',2),(2,1,'root child A',1),(3,1,'root child B',1), + (4,2,'grandchild 1',0),(5,3,'grandchild 2',0)) v(id,parent,data,nchildren) + ORDER BY id DESC; +ALTER TABLE self_ref_trigger ENABLE TRIGGER ALL; +SELECT id FROM self_ref_trigger; +delete from self_ref_trigger; + +select * from self_ref_trigger ORDER BY id; + +drop table self_ref_trigger; +drop function self_ref_trigger_ins_func(); +drop function self_ref_trigger_del_func(); diff --git a/src/test/regress/sql/flux_abort_ddl.sql b/src/test/regress/sql/flux_abort_ddl.sql new file mode 100644 index 0000000000000..a3c54248d413d --- /dev/null +++ b/src/test/regress/sql/flux_abort_ddl.sql @@ -0,0 +1,197 @@ +-- +-- FLUX: a transaction updates rows in place, then replaces or drops storage +-- the update touched (DROP INDEX, REINDEX, ALTER COLUMN TYPE, TRUNCATE, +-- CLUSTER), then rolls back, entirely or to a savepoint. +-- +-- The update's undo names the files it changed. While the transaction is +-- aborting, its catalog changes are not rolled back yet, so the relcache +-- maps the new files (or no index at all). The rollback must still leave +-- the table and its indexes as they were, so that the same update applied +-- again gives the same rows and index entries as on heap. Each case runs on +-- a heap twin and on FLUX, permanent and temporary; the two columns of the +-- result must agree. +-- +CREATE SCHEMA ad_amcheck; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'amcheck') THEN + CREATE EXTENSION amcheck SCHEMA ad_amcheck; + END IF; +EXCEPTION WHEN OTHERS THEN + NULL; +END $$; + +-- ad_h (heap) and ad_f (FLUX), 200 rows with k = id, an index on k. +CREATE PROCEDURE ad_setup(temp bool) LANGUAGE plpgsql AS $$ +DECLARE + t text; +BEGIN + DROP TABLE IF EXISTS pg_temp.ad_h, pg_temp.ad_f, public.ad_h, public.ad_f; + FOREACH t IN ARRAY ARRAY['ad_h', 'ad_f'] LOOP + EXECUTE format('CREATE %s TABLE %I (id int PRIMARY KEY, k int) USING %s', + CASE WHEN temp THEN 'TEMP' ELSE '' END, t, + CASE WHEN t = 'ad_h' THEN 'heap' ELSE 'flux' END); + EXECUTE format('CREATE INDEX %1$I_k ON %1$I (k)', t); + EXECUTE format('INSERT INTO %I SELECT g, g FROM generate_series(1, 200) g', t); + END LOOP; +END $$; + +-- The update: move the first 50 rows' keys, in place on FLUX. +CREATE PROCEDURE ad_upd() LANGUAGE plpgsql AS $$ +BEGIN + UPDATE ad_h SET k = k + 1000 WHERE id <= 50; + UPDATE ad_f SET k = k + 1000 WHERE id <= 50; +END $$; + +-- The DDL, on both tables; %1$I is the table. +CREATE PROCEDURE ad_ddl(ddl text) LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format(ddl, 'ad_h'); + EXECUTE format(ddl, 'ad_f'); +END $$; + +-- Rows and sum of k, then index scans of the moved and the old keys, then +-- amcheck of every index. +CREATE FUNCTION ad_check1(t text) RETURNS text LANGUAGE plpgsql + SET enable_seqscan = off SET enable_bitmapscan = off AS $$ +DECLARE + chk text := (SELECT format('%I.bt_index_check', n.nspname) + FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'amcheck'); + n bigint; s bigint; moved bigint; old bigint; idx regclass; problems text := ''; +BEGIN + EXECUTE format('SELECT count(*), sum(k) FROM %I', t) INTO n, s; + EXECUTE format('SELECT count(*) FROM %I WHERE k > 1000', t) INTO moved; + EXECUTE format('SELECT count(*) FROM %I WHERE k BETWEEN 1 AND 50', t) INTO old; + IF chk IS NOT NULL THEN + FOR idx IN SELECT indexrelid FROM pg_index WHERE indrelid = t::regclass ORDER BY 1 LOOP + BEGIN + EXECUTE format('SELECT %s($1, true)', chk) USING idx; + EXCEPTION WHEN OTHERS THEN + problems := problems || ' ' || split_part(SQLERRM, ' for index', 1); + END; + END LOOP; + END IF; + RETURN format('%s|%s idx %s/%s%s', n, s, moved, old, + CASE WHEN problems = '' THEN '' ELSE ' amcheck:' || problems END); +END $$; + +CREATE FUNCTION ad_check(OUT heap text, OUT flux text) LANGUAGE sql + RETURN (ad_check1('ad_h'), ad_check1('ad_f')); + +-- Top-level ROLLBACK, then the update again in a new transaction. +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + +-- The same with temporary tables. +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); + +-- ROLLBACK TO SAVEPOINT, then the update again in the same transaction. +CALL ad_setup(false); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + +CALL ad_setup(true); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('ALTER TABLE %1$I ALTER COLUMN k TYPE bigint'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('CLUSTER %1$I USING %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + +-- An error aborts the transaction the same way, in a transaction block or +-- in a single statement. +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); SELECT 1 / 0; ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); SELECT 1 / 0; ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(true); +DO $$ BEGIN CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); PERFORM 1 / 0; END $$; +CALL ad_upd(); SELECT * FROM ad_check(); + +-- ROLLBACK in a procedure, and ROLLBACK AND CHAIN: the next transaction +-- starts right away. +CREATE PROCEDURE ad_txn(ddl text) LANGUAGE plpgsql AS $$ +BEGIN + CALL ad_upd(); CALL ad_ddl(ddl); ROLLBACK; + CALL ad_upd(); +END $$; +CALL ad_setup(false); +CALL ad_txn('DROP INDEX %1$I_k'); SELECT * FROM ad_check(); +CALL ad_setup(true); +CALL ad_txn('TRUNCATE %1$I'); SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK AND CHAIN; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK AND CHAIN; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); + +-- Each row updated twice before the DDL: the rollback applies its two +-- records newest first. +CALL ad_setup(false); +BEGIN; CALL ad_upd(); CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); CALL ad_upd(); CALL ad_ddl('TRUNCATE %1$I'); ROLLBACK; +CALL ad_upd(); SELECT * FROM ad_check(); +CALL ad_setup(false); +BEGIN; SAVEPOINT s; CALL ad_upd(); CALL ad_upd(); CALL ad_ddl('DROP INDEX %1$I_k'); ROLLBACK TO s; +CALL ad_upd(); COMMIT; SELECT * FROM ad_check(); +CALL ad_setup(true); +BEGIN; CALL ad_upd(); SAVEPOINT s; CALL ad_upd(); CALL ad_ddl('REINDEX INDEX %1$I_k'); ROLLBACK TO s; +ROLLBACK; CALL ad_upd(); SELECT * FROM ad_check(); + +DROP TABLE ad_h, ad_f; +DROP FUNCTION ad_check, ad_check1; +DROP PROCEDURE ad_setup, ad_upd, ad_ddl, ad_txn; +DROP SCHEMA ad_amcheck CASCADE; diff --git a/src/test/regress/sql/flux_cluster.sql b/src/test/regress/sql/flux_cluster.sql new file mode 100644 index 0000000000000..a0f7c9d5db74c --- /dev/null +++ b/src/test/regress/sql/flux_cluster.sql @@ -0,0 +1,54 @@ +-- +-- CLUSTER orders a FLUX table by the index, whichever plan CLUSTER picks: +-- an index scan, or a sequential scan plus sort (an expression index, or a +-- big table with little maintenance_work_mem, makes it sort). Heap is the +-- control; both halves must give the same answer. +-- +CREATE FUNCTION flux_cluster_misordered(t regclass, key text) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM (SELECT %s AS k, lag(%s) OVER (ORDER BY ctid) AS lk FROM %s) s WHERE lk > k', + key, key, t) INTO n; + RETURN n; +END $$; + +-- Expression index: CLUSTER always sorts. +CREATE TABLE heap_clx (id serial PRIMARY KEY, a int, b text COLLATE "C") USING heap; +CREATE TABLE flux_clx (id serial PRIMARY KEY, a int, b text COLLATE "C") USING flux; +INSERT INTO heap_clx (a, b) SELECT g % 42, 'prefix' || g FROM generate_series(1, 133) g; +INSERT INTO flux_clx (a, b) SELECT g % 42, 'prefix' || g FROM generate_series(1, 133) g; +CREATE INDEX heap_clx_minus_a ON heap_clx ((-a), b); +CREATE INDEX flux_clx_minus_a ON flux_clx ((-a), b); +CLUSTER heap_clx USING heap_clx_minus_a; +CLUSTER flux_clx USING flux_clx_minus_a; +SELECT flux_cluster_misordered('heap_clx', '-a') AS heap_misordered, + flux_cluster_misordered('flux_clx', '-a') AS flux_misordered; +SELECT (SELECT count(*) FROM heap_clx) AS heap_rows, (SELECT count(*) FROM flux_clx) AS flux_rows; + +-- Plain index, sort forced by planner settings. +CREATE TABLE heap_cls (k int, pad text) USING heap; +CREATE TABLE flux_cls (k int, pad text) USING flux; +INSERT INTO heap_cls SELECT (g * 7919) % 5000, 'p' FROM generate_series(1, 5000) g; +INSERT INTO flux_cls SELECT (g * 7919) % 5000, 'p' FROM generate_series(1, 5000) g; +CREATE INDEX heap_cls_k ON heap_cls (k); +CREATE INDEX flux_cls_k ON flux_cls (k); +ANALYZE heap_cls, flux_cls; +SET enable_indexscan = off; +CLUSTER heap_cls USING heap_cls_k; +CLUSTER flux_cls USING flux_cls_k; +RESET enable_indexscan; +SELECT flux_cluster_misordered('heap_cls', 'k') AS heap_misordered, + flux_cluster_misordered('flux_cls', 'k') AS flux_misordered; + +-- Rows deleted but still visible to an open snapshot survive the sort. +CREATE TABLE flux_clr (k int) USING flux; +INSERT INTO flux_clr SELECT g FROM generate_series(1, 20) g; +CREATE INDEX flux_clr_e ON flux_clr ((-k)); +DELETE FROM flux_clr WHERE k > 10; +CLUSTER flux_clr USING flux_clr_e; +SELECT count(*), min(k), max(k) FROM flux_clr; +SELECT flux_cluster_misordered('flux_clr', '-k') AS flux_misordered; + +DROP TABLE heap_clx, flux_clx, heap_cls, flux_cls, flux_clr; +DROP FUNCTION flux_cluster_misordered(regclass, text); diff --git a/src/test/regress/sql/flux_deferred_trigger.sql b/src/test/regress/sql/flux_deferred_trigger.sql new file mode 100644 index 0000000000000..32d9aee6d6951 --- /dev/null +++ b/src/test/regress/sql/flux_deferred_trigger.sql @@ -0,0 +1,500 @@ +-- +-- AFTER-trigger row events on FLUX tables whose row was rewritten in place +-- before the event fired: deferred constraint triggers, SET CONSTRAINTS, +-- triggers firing after a nested statement, transition tables and deferred +-- foreign keys. Each case runs first on a heap twin (the control) and then +-- on FLUX; the two halves must print the same thing, and the final query +-- compares their table contents. +-- +-- Heap re-fetches an event's row version by TID when the trigger fires, and +-- RI's check skips a version that was since superseded. A FLUX row keeps its +-- TID across UPDATEs, so the event must carry its version. +-- +CREATE SCHEMA dt_heap; +CREATE SCHEMA dt_flux; + +CREATE FUNCTION public.dt_show_row() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE '% % %: old % new %', TG_TABLE_NAME, TG_NAME, TG_OP, OLD, NEW; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_fix_pid() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.pid = 99 THEN + UPDATE nt SET pid = 1, v = v + 10 WHERE id = NEW.id; + END IF; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_show_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE o text; n text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO o FROM oldtab t; + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO n FROM newtab t; + RAISE NOTICE '% % % %: old % new %', TG_TABLE_NAME, TG_NAME, TG_OP, TG_LEVEL, o, n; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_show_new_table() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE n text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO n FROM newtab t; + RAISE NOTICE '% % %: new %', TG_TABLE_NAME, TG_NAME, TG_OP, n; + RETURN NULL; +END $$; +CREATE FUNCTION public.dt_show_old_table() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE o text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO o FROM oldtab t; + RAISE NOTICE '% % %: old %', TG_TABLE_NAME, TG_NAME, TG_OP, o; + RETURN NULL; +END $$; + +-- +-- heap +-- +\set am heap +\set schema dt_heap +SET default_table_access_method = :am; +SET search_path = :schema, public; + +-- +-- 1. A deferred constraint trigger sees each event's own OLD and NEW, even +-- after later statements rewrote the row: INSERT, UPDATE, UPDATE; then +-- UPDATE, DELETE. +-- +CREATE TABLE dt (id int PRIMARY KEY, v int); +CREATE CONSTRAINT TRIGGER dt_show AFTER INSERT OR UPDATE OR DELETE ON dt + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.dt_show_row(); +BEGIN; +INSERT INTO dt VALUES (1, 0); +UPDATE dt SET v = 1 WHERE id = 1; +UPDATE dt SET v = 2 WHERE id = 1; +COMMIT; +BEGIN; +UPDATE dt SET v = 3 WHERE id = 1; +DELETE FROM dt WHERE id = 1; +COMMIT; + +-- +-- 2. The same, with SET CONSTRAINTS IMMEDIATE midway (fires what is queued), +-- then DEFERRED again. +-- +BEGIN; +INSERT INTO dt VALUES (2, 0); +UPDATE dt SET v = 1 WHERE id = 2; +SET CONSTRAINTS dt_show IMMEDIATE; +UPDATE dt SET v = 2 WHERE id = 2; +SET CONSTRAINTS dt_show DEFERRED; +UPDATE dt SET v = 3 WHERE id = 2; +UPDATE dt SET v = 4 WHERE id = 2; +COMMIT; +SELECT * FROM dt ORDER BY id; + +-- +-- 3. Non-deferred AFTER triggers whose event fires after a later statement +-- rewrote the row: trigger "A_fix" (which fires first) re-updates the row in +-- a nested statement; "z_show" and the RI check on the outer event fire after +-- it. The outer event's NEW is superseded by then, so heap's RI check skips +-- it. +-- +CREATE TABLE np (id int PRIMARY KEY); +INSERT INTO np VALUES (1), (2); +CREATE TABLE nt (id int PRIMARY KEY, pid int REFERENCES np, v int); +CREATE TRIGGER "A_fix" AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_fix_pid(); +CREATE TRIGGER z_show AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_show_row(); +INSERT INTO nt VALUES (1, 1, 0), (2, 2, 0); +UPDATE nt SET pid = 99 WHERE id = 1; +-- a later statement's event on the twice-rewritten row +UPDATE nt SET v = v + 1 WHERE id = 1; +SELECT * FROM nt ORDER BY id; + +-- +-- 4. Transition tables, statement- and row-level, across statements of one +-- transaction. +-- +CREATE TABLE tt (id int PRIMARY KEY, v int); +CREATE TRIGGER tt_stmt_ins AFTER INSERT ON tt REFERENCING NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_new_table(); +CREATE TRIGGER tt_stmt_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_transition(); +CREATE TRIGGER tt_stmt_del AFTER DELETE ON tt REFERENCING OLD TABLE AS oldtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_old_table(); +CREATE TRIGGER tt_row_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH ROW EXECUTE FUNCTION public.dt_show_transition(); +BEGIN; +INSERT INTO tt VALUES (1, 0), (2, 0), (3, 0); +UPDATE tt SET v = v + 1 WHERE id <= 2; +UPDATE tt SET v = v + 10; +DELETE FROM tt WHERE id = 1; +COMMIT; +SELECT * FROM tt ORDER BY id; + +-- +-- 5. Deferred FK checks of a row the transaction rewrote. The check must +-- judge only the row's last version; each earlier one is superseded. +-- +CREATE TABLE fp (id int PRIMARY KEY, v int); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1, 0), (2, 0), (3, 0); +-- INSERT, UPDATE the FK column to a bad key, UPDATE it back: commits +BEGIN; +INSERT INTO fc VALUES (10, 1, 0); +UPDATE fc SET pid = 99 WHERE id = 10; +UPDATE fc SET pid = 1 WHERE id = 10; +COMMIT; +-- a bad INSERT fixed, then broken again: fails on the last key, 98 +BEGIN; +INSERT INTO fc VALUES (11, 99, 0); +UPDATE fc SET pid = 1 WHERE id = 11; +UPDATE fc SET pid = 98 WHERE id = 11; +COMMIT; +-- a bad key, then a non-key UPDATE: fails on 97 +BEGIN; +UPDATE fc SET pid = 97 WHERE id = 10; +UPDATE fc SET w = 1 WHERE id = 10; +COMMIT; +-- a bad key, then the row deleted: commits +BEGIN; +INSERT INTO fc VALUES (12, 96, 0); +UPDATE fc SET pid = 95 WHERE id = 12; +DELETE FROM fc WHERE id = 12; +COMMIT; +-- SET CONSTRAINTS ALL IMMEDIATE midway, after the bad key was fixed: commits +BEGIN; +INSERT INTO fc VALUES (13, 94, 0); +UPDATE fc SET pid = 2 WHERE id = 13; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 3 WHERE id = 13; +COMMIT; +-- ... and before it was fixed: fails at SET CONSTRAINTS, on 93 +BEGIN; +INSERT INTO fc VALUES (14, 93, 0); +SET CONSTRAINTS ALL IMMEDIATE; +ROLLBACK; +-- the bad key's fix made in a subtransaction that commits: commits +BEGIN; +INSERT INTO fc VALUES (15, 92, 0); +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 15; +RELEASE SAVEPOINT s; +COMMIT; +-- a good key's breakage made in a subtransaction that rolls back: commits +BEGIN; +INSERT INTO fc VALUES (16, 1, 0); +UPDATE fc SET w = 1 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 91 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +COMMIT; +-- the same, then broken for real after the rollback: fails on 90 +BEGIN; +UPDATE fc SET w = 2 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +UPDATE fc SET pid = 90 WHERE id = 16; +COMMIT; +SELECT * FROM fc ORDER BY id; + +-- +-- 6. A deferred FK check whose parent row is updated in place between the +-- check being queued and firing. Each case has its own parent row, so no +-- earlier case's row locks or aborted versions are in its history. +-- +CREATE TABLE gp (id int PRIMARY KEY, v int); +CREATE TABLE gc (id int PRIMARY KEY, pid int REFERENCES gp DEFERRABLE INITIALLY DEFERRED); +INSERT INTO gp VALUES (1, 0), (2, 0), (3, 0), (4, 0), (5, 0); +INSERT INTO gc VALUES (40, 4), (50, 5); +-- a non-key column of the parent: commits +BEGIN; +INSERT INTO gc VALUES (10, 1); +UPDATE gp SET v = 5 WHERE id = 1; +COMMIT; +-- the parent's key moved away: fails +BEGIN; +INSERT INTO gc VALUES (20, 2); +UPDATE gp SET id = 20 WHERE id = 2; +COMMIT; +-- the parent's key moved away and back: commits +BEGIN; +INSERT INTO gc VALUES (30, 3); +UPDATE gp SET id = 30 WHERE id = 3; +UPDATE gp SET v = 6 WHERE id = 30; +UPDATE gp SET id = 3 WHERE id = 30; +COMMIT; +-- a referenced parent's key moved away and back (the parent's own deferred +-- NO ACTION check): commits +BEGIN; +UPDATE gp SET id = 41 WHERE id = 4; +UPDATE gp SET id = 4 WHERE id = 41; +COMMIT; +-- ... and moved away for good: fails +BEGIN; +UPDATE gp SET id = 51 WHERE id = 5; +UPDATE gp SET v = 7 WHERE id = 51; +COMMIT; +SELECT * FROM gp ORDER BY id; +SELECT * FROM gc ORDER BY id; + +-- +-- 7. The original report (stale.sql). +-- +CREATE TABLE sp (id int PRIMARY KEY); +CREATE TABLE sc (id int PRIMARY KEY, pid int REFERENCES sp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO sp VALUES (1), (2); +INSERT INTO sc VALUES (1, 1, 0); +BEGIN; +INSERT INTO sc VALUES (2, 99, 0); +UPDATE sc SET pid = 1 WHERE id = 2; +COMMIT; +BEGIN; +UPDATE sc SET pid = 95 WHERE id = 1; +UPDATE sc SET w = 6 WHERE id = 1; +UPDATE sc SET pid = 1 WHERE id = 1; +COMMIT; +BEGIN; +UPDATE sc SET pid = 94 WHERE id = 2; +UPDATE sc SET pid = 2 WHERE id = 2; +UPDATE sc SET pid = 93, w = 1 WHERE id = 2; +COMMIT; +SELECT * FROM sc ORDER BY id; + +-- +-- flux +-- +\set am flux +\set schema dt_flux +SET default_table_access_method = :am; +SET search_path = :schema, public; + +-- +-- 1. A deferred constraint trigger sees each event's own OLD and NEW, even +-- after later statements rewrote the row: INSERT, UPDATE, UPDATE; then +-- UPDATE, DELETE. +-- +CREATE TABLE dt (id int PRIMARY KEY, v int); +CREATE CONSTRAINT TRIGGER dt_show AFTER INSERT OR UPDATE OR DELETE ON dt + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.dt_show_row(); +BEGIN; +INSERT INTO dt VALUES (1, 0); +UPDATE dt SET v = 1 WHERE id = 1; +UPDATE dt SET v = 2 WHERE id = 1; +COMMIT; +BEGIN; +UPDATE dt SET v = 3 WHERE id = 1; +DELETE FROM dt WHERE id = 1; +COMMIT; + +-- +-- 2. The same, with SET CONSTRAINTS IMMEDIATE midway (fires what is queued), +-- then DEFERRED again. +-- +BEGIN; +INSERT INTO dt VALUES (2, 0); +UPDATE dt SET v = 1 WHERE id = 2; +SET CONSTRAINTS dt_show IMMEDIATE; +UPDATE dt SET v = 2 WHERE id = 2; +SET CONSTRAINTS dt_show DEFERRED; +UPDATE dt SET v = 3 WHERE id = 2; +UPDATE dt SET v = 4 WHERE id = 2; +COMMIT; +SELECT * FROM dt ORDER BY id; + +-- +-- 3. Non-deferred AFTER triggers whose event fires after a later statement +-- rewrote the row: trigger "A_fix" (which fires first) re-updates the row in +-- a nested statement; "z_show" and the RI check on the outer event fire after +-- it. The outer event's NEW is superseded by then, so heap's RI check skips +-- it. +-- +CREATE TABLE np (id int PRIMARY KEY); +INSERT INTO np VALUES (1), (2); +CREATE TABLE nt (id int PRIMARY KEY, pid int REFERENCES np, v int); +CREATE TRIGGER "A_fix" AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_fix_pid(); +CREATE TRIGGER z_show AFTER UPDATE ON nt FOR EACH ROW + EXECUTE FUNCTION public.dt_show_row(); +INSERT INTO nt VALUES (1, 1, 0), (2, 2, 0); +UPDATE nt SET pid = 99 WHERE id = 1; +-- a later statement's event on the twice-rewritten row +UPDATE nt SET v = v + 1 WHERE id = 1; +SELECT * FROM nt ORDER BY id; + +-- +-- 4. Transition tables, statement- and row-level, across statements of one +-- transaction. +-- +CREATE TABLE tt (id int PRIMARY KEY, v int); +CREATE TRIGGER tt_stmt_ins AFTER INSERT ON tt REFERENCING NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_new_table(); +CREATE TRIGGER tt_stmt_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_transition(); +CREATE TRIGGER tt_stmt_del AFTER DELETE ON tt REFERENCING OLD TABLE AS oldtab + FOR EACH STATEMENT EXECUTE FUNCTION public.dt_show_old_table(); +CREATE TRIGGER tt_row_upd AFTER UPDATE ON tt REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH ROW EXECUTE FUNCTION public.dt_show_transition(); +BEGIN; +INSERT INTO tt VALUES (1, 0), (2, 0), (3, 0); +UPDATE tt SET v = v + 1 WHERE id <= 2; +UPDATE tt SET v = v + 10; +DELETE FROM tt WHERE id = 1; +COMMIT; +SELECT * FROM tt ORDER BY id; + +-- +-- 5. Deferred FK checks of a row the transaction rewrote. The check must +-- judge only the row's last version; each earlier one is superseded. +-- +CREATE TABLE fp (id int PRIMARY KEY, v int); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1, 0), (2, 0), (3, 0); +-- INSERT, UPDATE the FK column to a bad key, UPDATE it back: commits +BEGIN; +INSERT INTO fc VALUES (10, 1, 0); +UPDATE fc SET pid = 99 WHERE id = 10; +UPDATE fc SET pid = 1 WHERE id = 10; +COMMIT; +-- a bad INSERT fixed, then broken again: fails on the last key, 98 +BEGIN; +INSERT INTO fc VALUES (11, 99, 0); +UPDATE fc SET pid = 1 WHERE id = 11; +UPDATE fc SET pid = 98 WHERE id = 11; +COMMIT; +-- a bad key, then a non-key UPDATE: fails on 97 +BEGIN; +UPDATE fc SET pid = 97 WHERE id = 10; +UPDATE fc SET w = 1 WHERE id = 10; +COMMIT; +-- a bad key, then the row deleted: commits +BEGIN; +INSERT INTO fc VALUES (12, 96, 0); +UPDATE fc SET pid = 95 WHERE id = 12; +DELETE FROM fc WHERE id = 12; +COMMIT; +-- SET CONSTRAINTS ALL IMMEDIATE midway, after the bad key was fixed: commits +BEGIN; +INSERT INTO fc VALUES (13, 94, 0); +UPDATE fc SET pid = 2 WHERE id = 13; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 3 WHERE id = 13; +COMMIT; +-- ... and before it was fixed: fails at SET CONSTRAINTS, on 93 +BEGIN; +INSERT INTO fc VALUES (14, 93, 0); +SET CONSTRAINTS ALL IMMEDIATE; +ROLLBACK; +-- the bad key's fix made in a subtransaction that commits: commits +BEGIN; +INSERT INTO fc VALUES (15, 92, 0); +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 15; +RELEASE SAVEPOINT s; +COMMIT; +-- a good key's breakage made in a subtransaction that rolls back: commits +BEGIN; +INSERT INTO fc VALUES (16, 1, 0); +UPDATE fc SET w = 1 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 91 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +COMMIT; +-- the same, then broken for real after the rollback: fails on 90 +BEGIN; +UPDATE fc SET w = 2 WHERE id = 16; +SAVEPOINT s; +UPDATE fc SET pid = 1 WHERE id = 16; +ROLLBACK TO SAVEPOINT s; +UPDATE fc SET pid = 90 WHERE id = 16; +COMMIT; +SELECT * FROM fc ORDER BY id; + +-- +-- 6. A deferred FK check whose parent row is updated in place between the +-- check being queued and firing. Each case has its own parent row, so no +-- earlier case's row locks or aborted versions are in its history. +-- +CREATE TABLE gp (id int PRIMARY KEY, v int); +CREATE TABLE gc (id int PRIMARY KEY, pid int REFERENCES gp DEFERRABLE INITIALLY DEFERRED); +INSERT INTO gp VALUES (1, 0), (2, 0), (3, 0), (4, 0), (5, 0); +INSERT INTO gc VALUES (40, 4), (50, 5); +-- a non-key column of the parent: commits +BEGIN; +INSERT INTO gc VALUES (10, 1); +UPDATE gp SET v = 5 WHERE id = 1; +COMMIT; +-- the parent's key moved away: fails +BEGIN; +INSERT INTO gc VALUES (20, 2); +UPDATE gp SET id = 20 WHERE id = 2; +COMMIT; +-- the parent's key moved away and back: commits +BEGIN; +INSERT INTO gc VALUES (30, 3); +UPDATE gp SET id = 30 WHERE id = 3; +UPDATE gp SET v = 6 WHERE id = 30; +UPDATE gp SET id = 3 WHERE id = 30; +COMMIT; +-- a referenced parent's key moved away and back (the parent's own deferred +-- NO ACTION check): commits +BEGIN; +UPDATE gp SET id = 41 WHERE id = 4; +UPDATE gp SET id = 4 WHERE id = 41; +COMMIT; +-- ... and moved away for good: fails +BEGIN; +UPDATE gp SET id = 51 WHERE id = 5; +UPDATE gp SET v = 7 WHERE id = 51; +COMMIT; +SELECT * FROM gp ORDER BY id; +SELECT * FROM gc ORDER BY id; + +-- +-- 7. The original report (stale.sql). +-- +CREATE TABLE sp (id int PRIMARY KEY); +CREATE TABLE sc (id int PRIMARY KEY, pid int REFERENCES sp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO sp VALUES (1), (2); +INSERT INTO sc VALUES (1, 1, 0); +BEGIN; +INSERT INTO sc VALUES (2, 99, 0); +UPDATE sc SET pid = 1 WHERE id = 2; +COMMIT; +BEGIN; +UPDATE sc SET pid = 95 WHERE id = 1; +UPDATE sc SET w = 6 WHERE id = 1; +UPDATE sc SET pid = 1 WHERE id = 1; +COMMIT; +BEGIN; +UPDATE sc SET pid = 94 WHERE id = 2; +UPDATE sc SET pid = 2 WHERE id = 2; +UPDATE sc SET pid = 93, w = 1 WHERE id = 2; +COMMIT; +SELECT * FROM sc ORDER BY id; + +-- +-- The twins must agree on every table's contents. +-- +RESET search_path; +RESET default_table_access_method; +CREATE FUNCTION pg_temp.dt_twin_diff(t text) RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM ((SELECT * FROM dt_heap.%1$I EXCEPT ALL SELECT * FROM dt_flux.%1$I)' + ' UNION ALL (SELECT * FROM dt_flux.%1$I EXCEPT ALL SELECT * FROM dt_heap.%1$I)) d', t) + INTO n; + RETURN n; +END $$; +SELECT c.relname, pg_temp.dt_twin_diff(c.relname) AS differing_rows + FROM pg_class c + WHERE c.relnamespace = 'dt_heap'::regnamespace AND c.relkind = 'r' + ORDER BY 1; +SELECT c.relname, a.amname + FROM pg_class c JOIN pg_am a ON a.oid = c.relam + WHERE c.relnamespace = 'dt_flux'::regnamespace AND c.relkind = 'r' + ORDER BY 1; + +DROP SCHEMA dt_heap CASCADE; +DROP SCHEMA dt_flux CASCADE; +DROP FUNCTION public.dt_show_row(), public.dt_fix_pid(), public.dt_show_transition(), + public.dt_show_new_table(), public.dt_show_old_table(); diff --git a/src/test/regress/sql/flux_dml_semantics.sql b/src/test/regress/sql/flux_dml_semantics.sql new file mode 100644 index 0000000000000..125c6ef6d18ff --- /dev/null +++ b/src/test/regress/sql/flux_dml_semantics.sql @@ -0,0 +1,116 @@ +-- Run the same statements against heap and FLUX. +CREATE SCHEMA semantics_heap; +CREATE SCHEMA semantics_flux; +SET search_path = semantics_heap, public; +CREATE TABLE m (tid int PRIMARY KEY, bal int) USING heap; +INSERT INTO m VALUES (1, 10); +MERGE INTO m USING (VALUES (1),(1)) s(sid) ON m.tid = s.sid + WHEN MATCHED THEN UPDATE SET bal = 0; +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +TRUNCATE m; +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +CREATE TABLE cc (a int) USING heap; +BEGIN; +INSERT INTO cc VALUES (1); +INSERT INTO cc VALUES (2); +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; +UPDATE cc SET a = a + 10; +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; +-- FLUX has no heap combo-CID word: old undo versions retain their +-- inserting CID, not heap's raw combo index. Test the live versions here. +INSERT INTO cc VALUES (3) RETURNING a, cmin, cmax; +UPDATE cc SET a = a + 10 WHERE a = 3 RETURNING a, cmin, cmax; +COMMIT; +CREATE FUNCTION check_oid() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE '%: old tableoid valid %, new tableoid valid %', + TG_NAME, OLD.tableoid = TG_RELID, NEW.tableoid = TG_RELID; + RETURN NEW; +END +$$; +CREATE TABLE tw (a int) USING heap; +INSERT INTO tw VALUES (1); +CREATE TRIGGER b_after AFTER UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +CREATE TRIGGER c_when AFTER UPDATE ON tw FOR EACH ROW + WHEN (new.tableoid = old.tableoid AND new.tableoid <> 0) + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +CREATE TRIGGER a_before BEFORE UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +-- Transition tables contain row values, not system columns. +CREATE FUNCTION check_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'transition: old %, new %', + (SELECT array_agg(a ORDER BY a) FROM old_rows), + (SELECT array_agg(a ORDER BY a) FROM new_rows); + RETURN NULL; +END +$$; +CREATE TRIGGER d_transition AFTER UPDATE ON tw + REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows + FOR EACH STATEMENT EXECUTE FUNCTION check_transition(); +UPDATE tw SET a = a + 1; +SET search_path = semantics_flux, public; +CREATE TABLE m (tid int PRIMARY KEY, bal int) USING flux; +INSERT INTO m VALUES (1, 10); +MERGE INTO m USING (VALUES (1),(1)) s(sid) ON m.tid = s.sid + WHEN MATCHED THEN UPDATE SET bal = 0; +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +TRUNCATE m; +INSERT INTO m VALUES (1, 1), (1, 2) + ON CONFLICT (tid) DO UPDATE SET bal = 99; +CREATE TABLE cc (a int) USING flux; +BEGIN; +INSERT INTO cc VALUES (1); +INSERT INTO cc VALUES (2); +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; +UPDATE cc SET a = a + 10; +SELECT a, cmin, cmax, xmin = pg_current_xact_id()::text::xid AS own_xmin, + xmax = '0'::xid AS no_xmax FROM cc ORDER BY a; +-- FLUX has no heap combo-CID word: old undo versions retain their +-- inserting CID, not heap's raw combo index. Test the live versions here. +INSERT INTO cc VALUES (3) RETURNING a, cmin, cmax; +UPDATE cc SET a = a + 10 WHERE a = 3 RETURNING a, cmin, cmax; +COMMIT; +CREATE FUNCTION check_oid() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE '%: old tableoid valid %, new tableoid valid %', + TG_NAME, OLD.tableoid = TG_RELID, NEW.tableoid = TG_RELID; + RETURN NEW; +END +$$; +CREATE TABLE tw (a int) USING flux; +INSERT INTO tw VALUES (1); +CREATE TRIGGER b_after AFTER UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +CREATE TRIGGER c_when AFTER UPDATE ON tw FOR EACH ROW + WHEN (new.tableoid = old.tableoid AND new.tableoid <> 0) + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +CREATE TRIGGER a_before BEFORE UPDATE ON tw FOR EACH ROW + EXECUTE FUNCTION check_oid(); +UPDATE tw SET a = a + 1; +-- Transition tables contain row values, not system columns. +CREATE FUNCTION check_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'transition: old %, new %', + (SELECT array_agg(a ORDER BY a) FROM old_rows), + (SELECT array_agg(a ORDER BY a) FROM new_rows); + RETURN NULL; +END +$$; +CREATE TRIGGER d_transition AFTER UPDATE ON tw + REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows + FOR EACH STATEMENT EXECUTE FUNCTION check_transition(); +UPDATE tw SET a = a + 1; +RESET search_path; +DROP SCHEMA semantics_heap CASCADE; +DROP SCHEMA semantics_flux CASCADE; diff --git a/src/test/regress/sql/flux_estimate.sql b/src/test/regress/sql/flux_estimate.sql new file mode 100644 index 0000000000000..c820634038bcc --- /dev/null +++ b/src/test/regress/sql/flux_estimate.sql @@ -0,0 +1,240 @@ +-- +-- FLUX planner size estimates (flux_relation_estimate_size), heap as control. +-- +-- Every result is a boolean so the expected output does not depend on +-- platform details; heap's column shows what the shared block estimator +-- gives for the same shape. +-- + +-- Estimated rows and total cost of a whole-table scan. +CREATE FUNCTION est_plan(tbl regclass, what text) RETURNS float8 +LANGUAGE plpgsql AS $$ +DECLARE + plan json; +BEGIN + EXECUTE format('EXPLAIN (FORMAT JSON) SELECT * FROM %s', tbl) INTO plan; + RETURN (plan->0->'Plan'->>what)::float8; +END $$; +CREATE FUNCTION est_rows(tbl regclass) RETURNS float8 LANGUAGE sql AS $$ + SELECT est_plan(tbl, 'Plan Rows') +$$; +CREATE FUNCTION real_rows(tbl regclass) RETURNS float8 LANGUAGE plpgsql AS $$ +DECLARE + n float8; +BEGIN + EXECUTE format('SELECT count(*) FROM %s', tbl) INTO n; + RETURN n; +END $$; +CREATE FUNCTION real_pages(tbl regclass) RETURNS float8 LANGUAGE sql AS $$ + SELECT pg_relation_size(tbl) / current_setting('block_size')::float8 +$$; +-- The estimate is within [0.5x, 2x] of the real row count. +CREATE FUNCTION est_ok(tbl regclass) RETURNS bool LANGUAGE sql AS $$ + SELECT est_rows(tbl) BETWEEN 0.5 * real_rows(tbl) AND 2 * real_rows(tbl) +$$; +-- A never-analyzed table: the rows per page the planner assumes (the +-- estimate over the page count, which is at least 10) is within 10% of the +-- rows a full page really holds (the most rows on any block of a table +-- filled by INSERT alone). +CREATE FUNCTION real_density(tbl regclass) RETURNS float8 +LANGUAGE plpgsql AS $$ +DECLARE + n float8; +BEGIN + EXECUTE format('SELECT max(c) FROM (SELECT count(*) AS c FROM %s + GROUP BY (ctid::text::point)[0]) s', tbl) INTO n; + RETURN n; +END $$; +CREATE FUNCTION density_ok(tbl regclass) RETURNS bool LANGUAGE sql AS $$ + SELECT abs(est_rows(tbl) / greatest(real_pages(tbl), 10) / + real_density(tbl) - 1) <= 0.10 +$$; + +CREATE TABLE est_result (shape text, am text, ok bool); +CREATE TABLE est_am (am text); +INSERT INTO est_am VALUES ('heap'), ('flux'); + +-- Run one statement per access method; %1$s is the AM name. The +-- statements are not echoed: only est_result is shown. +\set ECHO none +\set per_am 'SELECT format(:''stmt'', am) FROM est_am ORDER BY am DESC \\gexec' + +\set stmt 'CREATE TABLE est_empty_%1$s (a int, b int, c bigint) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'CREATE TABLE est_two_%1$s (a int, b int, c bigint) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_two_%1$s VALUES (1, 1, 1), (2, 2, 2)' +:per_am +-- A never-vacuumed table is assumed to have at least 10 pages. +\set stmt 'INSERT INTO est_result SELECT ''fresh 2 rows: 10-page rule'', %1$L, est_rows(''est_two_%1$s'') = est_rows(''est_empty_%1$s'') AND est_rows(''est_two_%1$s'') > 100' +:per_am +-- Once vacuumed, an empty table is believed: 0 pages, a zero-cost scan. +\set stmt 'VACUUM est_empty_%1$s' +:per_am +\set stmt 'INSERT INTO est_result SELECT ''empty after VACUUM: 0 pages'', %1$L, est_plan(''est_empty_%1$s'', ''Total Cost'') = 0' +:per_am + +-- Never analyzed: narrow rows, wide rows, NULLs, fillfactor 50. +\set stmt 'CREATE TABLE est_narrow_%1$s (a int, b int, c bigint) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_narrow_%1$s SELECT g, g, g FROM generate_series(1, 5000) g' +:per_am +\set stmt 'CREATE TABLE est_wide_%1$s (a int, n1 name, n2 name, n3 name, n4 name) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_wide_%1$s SELECT g, g::text, g::text, g::text, g::text FROM generate_series(1, 2000) g' +:per_am +\set stmt 'CREATE TABLE est_nulls_%1$s (a int, b int, c bigint, d bigint) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_nulls_%1$s SELECT g, g, CASE WHEN g %% 2 = 0 THEN g END, CASE WHEN g %% 2 = 0 THEN g END FROM generate_series(1, 5000) g' +:per_am +\set stmt 'CREATE TABLE est_ff50_%1$s (a int, b int, c bigint) USING %1$s WITH (autovacuum_enabled = off, fillfactor = 50)' +:per_am +\set stmt 'INSERT INTO est_ff50_%1$s SELECT g, g, g FROM generate_series(1, 5000) g' +:per_am +-- No reloptions at all, so the AM's own default fillfactor applies (a temp +-- table, which autovacuum leaves alone). +\set stmt 'CREATE TEMP TABLE est_ffdef_%1$s (a int, b int, c bigint) USING %1$s' +:per_am +\set stmt 'INSERT INTO est_ffdef_%1$s SELECT g, g, g FROM generate_series(1, 5000) g' +:per_am +\set stmt 'INSERT INTO est_result VALUES (''fresh default fillfactor: density'', %1$L, density_ok(''est_ffdef_%1$s''))' +:per_am +-- 1000 columns: FLUX stores a null bitmap in every tuple, heap only in a +-- tuple that has a NULL. +SELECT format('CREATE TABLE est_cols_%s (%s) USING %s WITH (autovacuum_enabled = off)', + am, string_agg('c' || i || ' bool', ', '), am) +FROM est_am, generate_series(1, 1000) i GROUP BY am ORDER BY am DESC \gexec +SELECT format('INSERT INTO est_cols_%s SELECT %s FROM generate_series(1, 300)', + am, repeat('true, ', 999) || 'true') +FROM est_am ORDER BY am DESC \gexec +\set stmt 'INSERT INTO est_result VALUES (''fresh 1000 columns: density'', %1$L, density_ok(''est_cols_%1$s''))' +:per_am +\set stmt 'INSERT INTO est_result VALUES (''fresh narrow: density'', %1$L, density_ok(''est_narrow_%1$s'')), (''fresh narrow: rows'', %1$L, est_ok(''est_narrow_%1$s'')), (''fresh wide: density'', %1$L, density_ok(''est_wide_%1$s'')), (''fresh wide: rows'', %1$L, est_ok(''est_wide_%1$s'')), (''fresh with nulls: rows'', %1$L, est_ok(''est_nulls_%1$s'')), (''fresh fillfactor 50: density'', %1$L, density_ok(''est_ff50_%1$s'')), (''fresh fillfactor 50: rows'', %1$L, est_ok(''est_ff50_%1$s''))' +:per_am + +-- Analyzed, then grown 3x. +\set stmt 'ANALYZE est_narrow_%1$s' +:per_am +\set stmt 'INSERT INTO est_result SELECT ''after ANALYZE: exact'', %1$L, est_rows(''est_narrow_%1$s'') = real_rows(''est_narrow_%1$s'')' +:per_am +\set stmt 'INSERT INTO est_narrow_%1$s SELECT g, g, g FROM generate_series(5001, 15000) g' +:per_am +\set stmt 'INSERT INTO est_result SELECT ''grown 3x since ANALYZE: rows'', %1$L, est_ok(''est_narrow_%1$s'')' +:per_am + +-- Analyzed, then 3 rounds of same-width UPDATE of an unindexed column. +\set stmt 'CREATE TABLE est_upd_%1$s (a int PRIMARY KEY, b int, c bigint) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_upd_%1$s SELECT g, g, g FROM generate_series(1, 5000) g' +:per_am +\set stmt 'ANALYZE est_upd_%1$s' +:per_am +CREATE TEMP TABLE est_upd_before AS + SELECT am, est_rows(format('est_upd_%s', am)::regclass) AS est FROM est_am; +\set stmt 'UPDATE est_upd_%1$s SET c = c + 1' +:per_am +:per_am +:per_am +\set stmt 'INSERT INTO est_result VALUES (''3 non-key UPDATE rounds: rows'', %1$L, est_ok(''est_upd_%1$s'')), (''3 non-key UPDATE rounds: no drift'', %1$L, est_rows(''est_upd_%1$s'') = (SELECT est FROM est_upd_before WHERE am = %1$L))' +:per_am + +-- Key UPDATEs and DELETEs, then VACUUM. +\set stmt 'CREATE TABLE est_kvac_%1$s (a int PRIMARY KEY, b int, c bigint) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_kvac_%1$s SELECT g, g, g FROM generate_series(1, 5000) g' +:per_am +\set stmt 'UPDATE est_kvac_%1$s SET a = a + 100000 WHERE a %% 2 = 0' +:per_am +\set stmt 'DELETE FROM est_kvac_%1$s WHERE b %% 3 = 0' +:per_am +\set stmt 'VACUUM est_kvac_%1$s' +:per_am +\set stmt 'INSERT INTO est_result SELECT ''key UPDATE + DELETE + VACUUM: rows'', %1$L, est_ok(''est_kvac_%1$s'')' +:per_am + +\set ECHO all + +SELECT h.shape, h.ok AS heap, f.ok AS flux +FROM est_result h JOIN est_result f USING (shape) +WHERE h.am = 'heap' AND f.am = 'flux' +ORDER BY h.ctid; + +-- ANALYZE's average column width is the stored width, as for heap: a +-- compressed value inline, an external value's TOAST pointer. +\set ECHO none +\set stmt 'CREATE TABLE est_width_%1$s (a int, m text, e text) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'ALTER TABLE est_width_%1$s ALTER e SET STORAGE EXTERNAL' +:per_am +\set stmt 'INSERT INTO est_width_%1$s SELECT g, repeat(md5(g::text), 100), repeat(md5(g::text), 100) FROM generate_series(1, 500) g' +:per_am +\set stmt 'ANALYZE est_width_%1$s' +:per_am +\set ECHO all +SELECT attname, h.avg_width = f.avg_width AS same_width, + f.avg_width < 100 AS stored_width +FROM pg_stats h JOIN pg_stats f USING (attname) +WHERE h.tablename = 'est_width_heap' AND f.tablename = 'est_width_flux' +ORDER BY attname; + +-- Planning a never-analyzed table reads no table block: 50 plans, 0 reads. +\set ECHO none +\set stmt 'CREATE TABLE est_io_%1$s (a int, b int) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_io_%1$s SELECT g, g FROM generate_series(1, 5000) g' +:per_am +\set ECHO all +SELECT pg_stat_force_next_flush(); +SELECT pg_stat_clear_snapshot(); +CREATE TEMP TABLE est_io_before AS + SELECT relname, heap_blks_hit + heap_blks_read AS blks + FROM pg_statio_user_tables WHERE relname IN ('est_io_heap', 'est_io_flux'); +DO $$ +BEGIN + FOR i IN 1..50 LOOP + EXECUTE 'EXPLAIN SELECT * FROM est_io_heap WHERE a = 1'; + EXECUTE 'EXPLAIN SELECT * FROM est_io_flux WHERE a = 1'; + END LOOP; +END $$; +SELECT pg_stat_force_next_flush(); +SELECT pg_stat_clear_snapshot(); +SELECT s.relname, + s.heap_blks_hit + s.heap_blks_read - b.blks AS blocks_read_by_planning +FROM pg_statio_user_tables s JOIN est_io_before b USING (relname) +ORDER BY s.relname; + +-- An index scan of a range in physical order visits each table page once, as +-- the planner's index-correlation costing assumes: 3000 rows, 0 to 30 pages. +\set ECHO none +\set stmt 'CREATE TABLE est_corr_%1$s (id int PRIMARY KEY, v int) USING %1$s WITH (autovacuum_enabled = off)' +:per_am +\set stmt 'INSERT INTO est_corr_%1$s SELECT g, g FROM generate_series(1, 20000) g' +:per_am +\set stmt 'ANALYZE est_corr_%1$s' +:per_am +\set ECHO all +SELECT pg_stat_force_next_flush(); +SELECT pg_stat_clear_snapshot(); +CREATE TEMP TABLE est_corr_before AS + SELECT relname, heap_blks_hit + heap_blks_read AS blks + FROM pg_statio_user_tables WHERE relname IN ('est_corr_heap', 'est_corr_flux'); +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT sum(v) FROM est_corr_heap WHERE id BETWEEN 1001 AND 4000; +SELECT sum(v) FROM est_corr_flux WHERE id BETWEEN 1001 AND 4000; +RESET enable_seqscan; +RESET enable_bitmapscan; +SELECT pg_stat_force_next_flush(); +SELECT pg_stat_clear_snapshot(); +SELECT s.relname, + s.heap_blks_hit + s.heap_blks_read - b.blks BETWEEN 1 AND 30 AS pages_once +FROM pg_statio_user_tables s JOIN est_corr_before b USING (relname) +ORDER BY s.relname; + +\set ECHO none +\set stmt 'DROP TABLE est_empty_%1$s, est_two_%1$s, est_narrow_%1$s, est_wide_%1$s, est_nulls_%1$s, est_ff50_%1$s, est_ffdef_%1$s, est_cols_%1$s, est_upd_%1$s, est_kvac_%1$s, est_io_%1$s, est_width_%1$s, est_corr_%1$s' +:per_am +\set ECHO all +DROP TABLE est_result, est_am; +DROP FUNCTION est_ok, density_ok, real_density, est_rows, est_plan, real_rows, + real_pages; diff --git a/src/test/regress/sql/flux_fk.sql b/src/test/regress/sql/flux_fk.sql new file mode 100644 index 0000000000000..1dc482d619a82 --- /dev/null +++ b/src/test/regress/sql/flux_fk.sql @@ -0,0 +1,601 @@ +-- +-- Foreign keys and AFTER triggers on FLUX tables, each case run first on a +-- heap twin (the control) and then on FLUX. The two halves must print the +-- same thing; the final query compares their table contents. +-- +-- An AM that updates rows in place hands the AFTER-trigger machinery copies +-- of the old and new row images, and the RI triggers ask such a copy whether +-- the current transaction created it (slot_is_current_xact_tuple). +-- +CREATE SCHEMA fk_heap; +CREATE SCHEMA fk_flux; + +CREATE FUNCTION public.fk_log_row() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO log VALUES (nextval('log_seq'), TG_TABLE_NAME, + TG_NAME || ' ' || TG_OP, OLD::text, NEW::text); + RETURN NULL; +END $$; +CREATE FUNCTION public.fk_show_transition() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE o text; n text; +BEGIN + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO o FROM oldtab t; + SELECT string_agg(t::text, ' ' ORDER BY t::text) INTO n FROM newtab t; + RAISE NOTICE '% %: old % new %', TG_TABLE_NAME, TG_OP, o, n; + RETURN NULL; +END $$; +CREATE FUNCTION public.fk_bump_w() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + NEW.w := NEW.w + 100; + IF NEW.w = 99 THEN NEW.pid := 8; END IF; + RETURN NEW; +END $$; + +-- +-- heap +-- +\set am heap +\set other flux +\set schema fk_heap +SET default_table_access_method = :am; +SET search_path = :schema, public; + +-- Event log for the row triggers below; compared across the twins at the end. +CREATE TABLE log (seq int, tbl text, op text, old_row text, new_row text); +CREATE SEQUENCE log_seq; + +-- +-- 1. Child UPDATE, of a non-FK column and of the FK column. The second +-- block's row was inserted by this transaction (the is_current_xact_tuple +-- branch of RI_FKey_fk_upd_check_required). +-- +CREATE TABLE pp (id int PRIMARY KEY, v int); +INSERT INTO pp VALUES (1, 0), (2, 0); +CREATE TABLE pc (id int PRIMARY KEY, pid int REFERENCES pp, w int); +INSERT INTO pc VALUES (1, 1, 0), (2, 2, 0); +UPDATE pc SET w = 5 WHERE id = 1; +UPDATE pc SET pid = 2 WHERE id = 1; +UPDATE pc SET pid = 3 WHERE id = 1; +UPDATE pc SET w = w + 1; +BEGIN; +INSERT INTO pc VALUES (3, 1, 0); +UPDATE pc SET w = 1 WHERE id = 3; +UPDATE pc SET pid = 2, w = 2 WHERE id = 3; +COMMIT; +BEGIN; +INSERT INTO pc VALUES (4, 1, 0); +UPDATE pc SET pid = 3 WHERE id = 4; +ROLLBACK; +SELECT * FROM pc ORDER BY id; + +-- +-- 2. Parent UPDATE of a non-key column and of the key, under each action. +-- Child rows reference parent 1; parent 2 is unreferenced. +-- +CREATE TABLE p_na (id int PRIMARY KEY, v int); +CREATE TABLE c_na (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_na ON UPDATE NO ACTION, w int); +CREATE TABLE p_re (id int PRIMARY KEY, v int); +CREATE TABLE c_re (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_re ON UPDATE RESTRICT, w int); +CREATE TABLE p_ca (id int PRIMARY KEY, v int); +CREATE TABLE c_ca (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_ca ON UPDATE CASCADE, w int); +CREATE TABLE p_sn (id int PRIMARY KEY, v int); +CREATE TABLE c_sn (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sn ON UPDATE SET NULL, w int); +CREATE TABLE p_sd (id int PRIMARY KEY, v int); +CREATE TABLE c_sd (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sd ON UPDATE SET DEFAULT, w int); +INSERT INTO p_na VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_re VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_ca VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sn VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sd VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO c_na VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_re VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_ca VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sn VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sd VALUES (1, 1, 0), (2, 1, 0); +UPDATE p_na SET v = 1 WHERE id = 1; +UPDATE p_na SET id = 10 WHERE id = 1; +UPDATE p_na SET id = 20 WHERE id = 2; +UPDATE p_re SET v = 1 WHERE id = 1; +UPDATE p_re SET id = 10 WHERE id = 1; +UPDATE p_re SET id = 20 WHERE id = 2; +UPDATE p_ca SET v = 1 WHERE id = 1; +UPDATE p_ca SET id = 10 WHERE id = 1; +UPDATE p_ca SET id = 20 WHERE id = 2; +UPDATE p_ca SET id = 11 WHERE id = 10; +UPDATE p_sn SET v = 1 WHERE id = 1; +UPDATE p_sn SET id = 10 WHERE id = 1; +UPDATE p_sn SET id = 20 WHERE id = 2; +UPDATE p_sd SET v = 1 WHERE id = 1; +UPDATE p_sd SET id = 10 WHERE id = 1; +UPDATE p_sd SET id = 20 WHERE id = 2; +UPDATE p_sd SET id = 30 WHERE id = 0; +SELECT 'na' AS t, * FROM c_na UNION ALL SELECT 're', * FROM c_re + UNION ALL SELECT 'ca', * FROM c_ca UNION ALL SELECT 'sn', * FROM c_sn + UNION ALL SELECT 'sd', * FROM c_sd ORDER BY 1, 2; +-- the cascaded rows can be updated again, and the cascade repeated +UPDATE c_ca SET w = 7; +UPDATE p_ca SET id = 12 WHERE id = 11; +SELECT * FROM c_ca ORDER BY id; + +-- +-- 3. DELETE with ON DELETE CASCADE and SET NULL. +-- +CREATE TABLE dp (id int PRIMARY KEY); +CREATE TABLE dc_ca (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE CASCADE, w int); +CREATE TABLE dc_sn (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE SET NULL, w int); +INSERT INTO dp VALUES (1), (2); +INSERT INTO dc_ca VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +INSERT INTO dc_sn VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +UPDATE dc_ca SET w = 1; +UPDATE dc_sn SET w = 1; +DELETE FROM dp WHERE id = 1; +UPDATE dc_sn SET w = 2 WHERE pid IS NULL; +UPDATE dc_sn SET pid = 2 WHERE id = 1; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + +-- +-- 4. Deferrable FKs. +-- +CREATE TABLE fp (id int PRIMARY KEY); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED, w int); +CREATE TABLE fc_na (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1); +INSERT INTO fc VALUES (1, 1, 0); +INSERT INTO fc_na VALUES (1, 1, 0); +-- the parent row appears later in the transaction +BEGIN; +UPDATE fc SET w = 1 WHERE id = 1; +UPDATE fc SET pid = 2 WHERE id = 1; +INSERT INTO fp VALUES (2); +COMMIT; +-- a row inserted, then updated, with a good key throughout +BEGIN; +INSERT INTO fc VALUES (2, 1, 0); +UPDATE fc SET w = 1 WHERE id = 2; +UPDATE fc SET pid = 2 WHERE id = 2; +COMMIT; +-- a bad key that is never fixed +BEGIN; +UPDATE fc SET pid = 98 WHERE id = 1; +COMMIT; +-- a bad key, then an UPDATE of another column of the same row +BEGIN; +UPDATE fc SET pid = 96 WHERE id = 1; +UPDATE fc SET w = 5 WHERE id = 1; +COMMIT; +-- a row inserted and then updated without touching its (bad) key +BEGIN; +INSERT INTO fc VALUES (3, 92, 0); +UPDATE fc SET w = 1 WHERE id = 3; +COMMIT; +-- a parent key changed and changed back (NO ACTION) is fine at commit +BEGIN; +UPDATE fp SET id = 3 WHERE id = 1; +UPDATE fp SET id = 1 WHERE id = 3; +COMMIT; +-- the same, checked immediately +BEGIN; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 97 WHERE id = 1; +ROLLBACK; +SELECT 'fc' AS t, * FROM fc UNION ALL SELECT 'fc_na', * FROM fc_na ORDER BY 1, 2; + +-- +-- 5. A self-referencing FK. +-- +CREATE TABLE tree (id int PRIMARY KEY, parent int REFERENCES tree ON UPDATE CASCADE ON DELETE CASCADE, w int); +INSERT INTO tree VALUES (1, NULL, 0), (2, 1, 0), (3, 2, 0), (4, 1, 0); +UPDATE tree SET w = 1; +UPDATE tree SET id = 10 WHERE id = 1; +UPDATE tree SET id = 20 WHERE id = 2; +UPDATE tree SET parent = 4 WHERE id = 3; +UPDATE tree SET parent = 5 WHERE id = 3; +SELECT * FROM tree ORDER BY id; +DELETE FROM tree WHERE id = 10; +SELECT count(*) FROM tree; + +-- +-- 6. An FK across access methods, in both directions. +-- +CREATE TABLE xp_o (id int PRIMARY KEY, v int) USING :other; +CREATE TABLE xc_d (id int PRIMARY KEY, pid int REFERENCES xp_o ON UPDATE CASCADE ON DELETE SET NULL, w int); +CREATE TABLE xp_d (id int PRIMARY KEY, v int); +CREATE TABLE xc_o (id int PRIMARY KEY, pid int REFERENCES xp_d ON UPDATE CASCADE ON DELETE SET NULL, w int) USING :other; +INSERT INTO xp_o VALUES (1, 0), (2, 0); +INSERT INTO xp_d VALUES (1, 0), (2, 0); +INSERT INTO xc_d VALUES (1, 1, 0), (2, 2, 0); +INSERT INTO xc_o VALUES (1, 1, 0), (2, 2, 0); +UPDATE xc_d SET w = 1; +UPDATE xc_o SET w = 1; +UPDATE xc_d SET pid = 2 WHERE id = 1; +UPDATE xc_o SET pid = 2 WHERE id = 1; +UPDATE xc_d SET pid = 3 WHERE id = 1; +UPDATE xc_o SET pid = 3 WHERE id = 1; +UPDATE xp_o SET v = 1; +UPDATE xp_d SET v = 1; +UPDATE xp_o SET id = 5 WHERE id = 2; +UPDATE xp_d SET id = 5 WHERE id = 2; +DELETE FROM xp_o WHERE id = 1; +DELETE FROM xp_d WHERE id = 1; +SELECT 'xc_d' AS t, * FROM xc_d UNION ALL SELECT 'xc_o', * FROM xc_o ORDER BY 1, 2; + +-- +-- 7. Partitioned tables, and 9. UPDATEs that move a row across partitions. +-- +CREATE TABLE ptp (id int PRIMARY KEY, v int) PARTITION BY RANGE (id); +CREATE TABLE ptp1 PARTITION OF ptp FOR VALUES FROM (0) TO (100); +CREATE TABLE ptp2 PARTITION OF ptp FOR VALUES FROM (100) TO (200); +CREATE TABLE ptc (id int PRIMARY KEY, pid int REFERENCES ptp ON UPDATE CASCADE ON DELETE SET NULL, w int) PARTITION BY RANGE (id); +CREATE TABLE ptc1 PARTITION OF ptc FOR VALUES FROM (0) TO (100); +CREATE TABLE ptc2 PARTITION OF ptc FOR VALUES FROM (100) TO (200); +INSERT INTO ptp VALUES (1, 0), (2, 0), (150, 0); +INSERT INTO ptc VALUES (1, 1, 0), (2, 2, 0), (101, 150, 0); +UPDATE ptc SET w = 1; +UPDATE ptc SET pid = 2 WHERE id = 1; +UPDATE ptc SET pid = 3 WHERE id = 1; +UPDATE ptp SET v = 1; +UPDATE ptp SET id = 3 WHERE id = 2; +UPDATE ptp SET id = 160 WHERE id = 150; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; +-- parent row moves partition; its children follow by cascade +UPDATE ptp SET id = 120 WHERE id = 3; +-- child row moves partition, with and without an FK column change +UPDATE ptc SET id = 150 WHERE id = 1; +UPDATE ptc SET id = 50, pid = 160 WHERE id = 101; +UPDATE ptc SET id = 151, pid = 7 WHERE id = 150; +SELECT tableoid::regclass, * FROM ptp ORDER BY id; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; +DELETE FROM ptp WHERE id = 120; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + +-- +-- 8. MERGE on FK tables, child and parent side. +-- +MERGE INTO pc USING (VALUES (1, 1, 7), (5, 2, 0), (2, NULL, 9)) s(id, pid, w) + ON pc.id = s.id + WHEN MATCHED AND s.pid IS NULL THEN UPDATE SET w = s.w + WHEN MATCHED THEN UPDATE SET pid = s.pid, w = s.w + WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.pid, s.w); +MERGE INTO pc USING (VALUES (1, 4)) s(id, pid) ON pc.id = s.id + WHEN MATCHED THEN UPDATE SET pid = s.pid; +SELECT * FROM pc ORDER BY id; +MERGE INTO p_ca USING (VALUES (12, 13), (20, 21)) s(id, nid) ON p_ca.id = s.id + WHEN MATCHED THEN UPDATE SET id = s.nid; +SELECT * FROM c_ca ORDER BY id; +MERGE INTO dp USING (VALUES (2)) s(id) ON dp.id = s.id + WHEN MATCHED THEN DELETE; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + +-- +-- 10. Other AFTER-trigger users of the old/new slot: a row trigger that +-- reads OLD and NEW (immediate and deferred), transition tables, and a +-- BEFORE trigger combined with an FK. +-- +CREATE TABLE tp (id int PRIMARY KEY, v int); +CREATE TABLE tc (id int PRIMARY KEY, pid int REFERENCES tp ON UPDATE CASCADE, w int); +INSERT INTO tp VALUES (1, 0), (2, 0); +INSERT INTO tc VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +CREATE TRIGGER tc_after AFTER UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE CONSTRAINT TRIGGER tc_deferred AFTER UPDATE ON tc + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE TRIGGER tc_trans AFTER UPDATE ON tc + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tp_trans AFTER UPDATE ON tp + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tc_before BEFORE UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_bump_w(); +UPDATE tc SET w = 5 WHERE id = 1; +UPDATE tc SET pid = 2 WHERE id = 1; +UPDATE tc SET pid = 9 WHERE id = 1; +UPDATE tp SET id = 10 WHERE id = 1; +BEGIN; +UPDATE tc SET w = 0 WHERE id = 2; +UPDATE tc SET w = 1 WHERE id = 2; +UPDATE tc SET pid = 10 WHERE id = 2; +COMMIT; +-- the BEFORE trigger redirects the FK to a missing parent +UPDATE tc SET w = -1 WHERE id = 2; +SELECT * FROM tc ORDER BY id; +SELECT tbl, op, old_row, new_row FROM log ORDER BY seq; + +-- +-- flux +-- +\set am flux +\set other heap +\set schema fk_flux +SET default_table_access_method = :am; +SET search_path = :schema, public; + +-- Event log for the row triggers below; compared across the twins at the end. +CREATE TABLE log (seq int, tbl text, op text, old_row text, new_row text); +CREATE SEQUENCE log_seq; + +-- +-- 1. Child UPDATE, of a non-FK column and of the FK column. The second +-- block's row was inserted by this transaction (the is_current_xact_tuple +-- branch of RI_FKey_fk_upd_check_required). +-- +CREATE TABLE pp (id int PRIMARY KEY, v int); +INSERT INTO pp VALUES (1, 0), (2, 0); +CREATE TABLE pc (id int PRIMARY KEY, pid int REFERENCES pp, w int); +INSERT INTO pc VALUES (1, 1, 0), (2, 2, 0); +UPDATE pc SET w = 5 WHERE id = 1; +UPDATE pc SET pid = 2 WHERE id = 1; +UPDATE pc SET pid = 3 WHERE id = 1; +UPDATE pc SET w = w + 1; +BEGIN; +INSERT INTO pc VALUES (3, 1, 0); +UPDATE pc SET w = 1 WHERE id = 3; +UPDATE pc SET pid = 2, w = 2 WHERE id = 3; +COMMIT; +BEGIN; +INSERT INTO pc VALUES (4, 1, 0); +UPDATE pc SET pid = 3 WHERE id = 4; +ROLLBACK; +SELECT * FROM pc ORDER BY id; + +-- +-- 2. Parent UPDATE of a non-key column and of the key, under each action. +-- Child rows reference parent 1; parent 2 is unreferenced. +-- +CREATE TABLE p_na (id int PRIMARY KEY, v int); +CREATE TABLE c_na (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_na ON UPDATE NO ACTION, w int); +CREATE TABLE p_re (id int PRIMARY KEY, v int); +CREATE TABLE c_re (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_re ON UPDATE RESTRICT, w int); +CREATE TABLE p_ca (id int PRIMARY KEY, v int); +CREATE TABLE c_ca (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_ca ON UPDATE CASCADE, w int); +CREATE TABLE p_sn (id int PRIMARY KEY, v int); +CREATE TABLE c_sn (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sn ON UPDATE SET NULL, w int); +CREATE TABLE p_sd (id int PRIMARY KEY, v int); +CREATE TABLE c_sd (id int PRIMARY KEY, pid int DEFAULT 0 REFERENCES p_sd ON UPDATE SET DEFAULT, w int); +INSERT INTO p_na VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_re VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_ca VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sn VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO p_sd VALUES (0, 0), (1, 0), (2, 0); +INSERT INTO c_na VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_re VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_ca VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sn VALUES (1, 1, 0), (2, 1, 0); +INSERT INTO c_sd VALUES (1, 1, 0), (2, 1, 0); +UPDATE p_na SET v = 1 WHERE id = 1; +UPDATE p_na SET id = 10 WHERE id = 1; +UPDATE p_na SET id = 20 WHERE id = 2; +UPDATE p_re SET v = 1 WHERE id = 1; +UPDATE p_re SET id = 10 WHERE id = 1; +UPDATE p_re SET id = 20 WHERE id = 2; +UPDATE p_ca SET v = 1 WHERE id = 1; +UPDATE p_ca SET id = 10 WHERE id = 1; +UPDATE p_ca SET id = 20 WHERE id = 2; +UPDATE p_ca SET id = 11 WHERE id = 10; +UPDATE p_sn SET v = 1 WHERE id = 1; +UPDATE p_sn SET id = 10 WHERE id = 1; +UPDATE p_sn SET id = 20 WHERE id = 2; +UPDATE p_sd SET v = 1 WHERE id = 1; +UPDATE p_sd SET id = 10 WHERE id = 1; +UPDATE p_sd SET id = 20 WHERE id = 2; +UPDATE p_sd SET id = 30 WHERE id = 0; +SELECT 'na' AS t, * FROM c_na UNION ALL SELECT 're', * FROM c_re + UNION ALL SELECT 'ca', * FROM c_ca UNION ALL SELECT 'sn', * FROM c_sn + UNION ALL SELECT 'sd', * FROM c_sd ORDER BY 1, 2; +-- the cascaded rows can be updated again, and the cascade repeated +UPDATE c_ca SET w = 7; +UPDATE p_ca SET id = 12 WHERE id = 11; +SELECT * FROM c_ca ORDER BY id; + +-- +-- 3. DELETE with ON DELETE CASCADE and SET NULL. +-- +CREATE TABLE dp (id int PRIMARY KEY); +CREATE TABLE dc_ca (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE CASCADE, w int); +CREATE TABLE dc_sn (id int PRIMARY KEY, pid int REFERENCES dp ON DELETE SET NULL, w int); +INSERT INTO dp VALUES (1), (2); +INSERT INTO dc_ca VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +INSERT INTO dc_sn VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +UPDATE dc_ca SET w = 1; +UPDATE dc_sn SET w = 1; +DELETE FROM dp WHERE id = 1; +UPDATE dc_sn SET w = 2 WHERE pid IS NULL; +UPDATE dc_sn SET pid = 2 WHERE id = 1; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + +-- +-- 4. Deferrable FKs. +-- +CREATE TABLE fp (id int PRIMARY KEY); +CREATE TABLE fc (id int PRIMARY KEY, pid int REFERENCES fp ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED, w int); +CREATE TABLE fc_na (id int PRIMARY KEY, pid int REFERENCES fp DEFERRABLE INITIALLY DEFERRED, w int); +INSERT INTO fp VALUES (1); +INSERT INTO fc VALUES (1, 1, 0); +INSERT INTO fc_na VALUES (1, 1, 0); +-- the parent row appears later in the transaction +BEGIN; +UPDATE fc SET w = 1 WHERE id = 1; +UPDATE fc SET pid = 2 WHERE id = 1; +INSERT INTO fp VALUES (2); +COMMIT; +-- a row inserted, then updated, with a good key throughout +BEGIN; +INSERT INTO fc VALUES (2, 1, 0); +UPDATE fc SET w = 1 WHERE id = 2; +UPDATE fc SET pid = 2 WHERE id = 2; +COMMIT; +-- a bad key that is never fixed +BEGIN; +UPDATE fc SET pid = 98 WHERE id = 1; +COMMIT; +-- a bad key, then an UPDATE of another column of the same row +BEGIN; +UPDATE fc SET pid = 96 WHERE id = 1; +UPDATE fc SET w = 5 WHERE id = 1; +COMMIT; +-- a row inserted and then updated without touching its (bad) key +BEGIN; +INSERT INTO fc VALUES (3, 92, 0); +UPDATE fc SET w = 1 WHERE id = 3; +COMMIT; +-- a parent key changed and changed back (NO ACTION) is fine at commit +BEGIN; +UPDATE fp SET id = 3 WHERE id = 1; +UPDATE fp SET id = 1 WHERE id = 3; +COMMIT; +-- the same, checked immediately +BEGIN; +SET CONSTRAINTS ALL IMMEDIATE; +UPDATE fc SET pid = 97 WHERE id = 1; +ROLLBACK; +SELECT 'fc' AS t, * FROM fc UNION ALL SELECT 'fc_na', * FROM fc_na ORDER BY 1, 2; + +-- +-- 5. A self-referencing FK. +-- +CREATE TABLE tree (id int PRIMARY KEY, parent int REFERENCES tree ON UPDATE CASCADE ON DELETE CASCADE, w int); +INSERT INTO tree VALUES (1, NULL, 0), (2, 1, 0), (3, 2, 0), (4, 1, 0); +UPDATE tree SET w = 1; +UPDATE tree SET id = 10 WHERE id = 1; +UPDATE tree SET id = 20 WHERE id = 2; +UPDATE tree SET parent = 4 WHERE id = 3; +UPDATE tree SET parent = 5 WHERE id = 3; +SELECT * FROM tree ORDER BY id; +DELETE FROM tree WHERE id = 10; +SELECT count(*) FROM tree; + +-- +-- 6. An FK across access methods, in both directions. +-- +CREATE TABLE xp_o (id int PRIMARY KEY, v int) USING :other; +CREATE TABLE xc_d (id int PRIMARY KEY, pid int REFERENCES xp_o ON UPDATE CASCADE ON DELETE SET NULL, w int); +CREATE TABLE xp_d (id int PRIMARY KEY, v int); +CREATE TABLE xc_o (id int PRIMARY KEY, pid int REFERENCES xp_d ON UPDATE CASCADE ON DELETE SET NULL, w int) USING :other; +INSERT INTO xp_o VALUES (1, 0), (2, 0); +INSERT INTO xp_d VALUES (1, 0), (2, 0); +INSERT INTO xc_d VALUES (1, 1, 0), (2, 2, 0); +INSERT INTO xc_o VALUES (1, 1, 0), (2, 2, 0); +UPDATE xc_d SET w = 1; +UPDATE xc_o SET w = 1; +UPDATE xc_d SET pid = 2 WHERE id = 1; +UPDATE xc_o SET pid = 2 WHERE id = 1; +UPDATE xc_d SET pid = 3 WHERE id = 1; +UPDATE xc_o SET pid = 3 WHERE id = 1; +UPDATE xp_o SET v = 1; +UPDATE xp_d SET v = 1; +UPDATE xp_o SET id = 5 WHERE id = 2; +UPDATE xp_d SET id = 5 WHERE id = 2; +DELETE FROM xp_o WHERE id = 1; +DELETE FROM xp_d WHERE id = 1; +SELECT 'xc_d' AS t, * FROM xc_d UNION ALL SELECT 'xc_o', * FROM xc_o ORDER BY 1, 2; + +-- +-- 7. Partitioned tables, and 9. UPDATEs that move a row across partitions. +-- +CREATE TABLE ptp (id int PRIMARY KEY, v int) PARTITION BY RANGE (id); +CREATE TABLE ptp1 PARTITION OF ptp FOR VALUES FROM (0) TO (100); +CREATE TABLE ptp2 PARTITION OF ptp FOR VALUES FROM (100) TO (200); +CREATE TABLE ptc (id int PRIMARY KEY, pid int REFERENCES ptp ON UPDATE CASCADE ON DELETE SET NULL, w int) PARTITION BY RANGE (id); +CREATE TABLE ptc1 PARTITION OF ptc FOR VALUES FROM (0) TO (100); +CREATE TABLE ptc2 PARTITION OF ptc FOR VALUES FROM (100) TO (200); +INSERT INTO ptp VALUES (1, 0), (2, 0), (150, 0); +INSERT INTO ptc VALUES (1, 1, 0), (2, 2, 0), (101, 150, 0); +UPDATE ptc SET w = 1; +UPDATE ptc SET pid = 2 WHERE id = 1; +UPDATE ptc SET pid = 3 WHERE id = 1; +UPDATE ptp SET v = 1; +UPDATE ptp SET id = 3 WHERE id = 2; +UPDATE ptp SET id = 160 WHERE id = 150; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; +-- parent row moves partition; its children follow by cascade +UPDATE ptp SET id = 120 WHERE id = 3; +-- child row moves partition, with and without an FK column change +UPDATE ptc SET id = 150 WHERE id = 1; +UPDATE ptc SET id = 50, pid = 160 WHERE id = 101; +UPDATE ptc SET id = 151, pid = 7 WHERE id = 150; +SELECT tableoid::regclass, * FROM ptp ORDER BY id; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; +DELETE FROM ptp WHERE id = 120; +SELECT tableoid::regclass, * FROM ptc ORDER BY id; + +-- +-- 8. MERGE on FK tables, child and parent side. +-- +MERGE INTO pc USING (VALUES (1, 1, 7), (5, 2, 0), (2, NULL, 9)) s(id, pid, w) + ON pc.id = s.id + WHEN MATCHED AND s.pid IS NULL THEN UPDATE SET w = s.w + WHEN MATCHED THEN UPDATE SET pid = s.pid, w = s.w + WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.pid, s.w); +MERGE INTO pc USING (VALUES (1, 4)) s(id, pid) ON pc.id = s.id + WHEN MATCHED THEN UPDATE SET pid = s.pid; +SELECT * FROM pc ORDER BY id; +MERGE INTO p_ca USING (VALUES (12, 13), (20, 21)) s(id, nid) ON p_ca.id = s.id + WHEN MATCHED THEN UPDATE SET id = s.nid; +SELECT * FROM c_ca ORDER BY id; +MERGE INTO dp USING (VALUES (2)) s(id) ON dp.id = s.id + WHEN MATCHED THEN DELETE; +SELECT 'ca' AS t, * FROM dc_ca UNION ALL SELECT 'sn', * FROM dc_sn ORDER BY 1, 2; + +-- +-- 10. Other AFTER-trigger users of the old/new slot: a row trigger that +-- reads OLD and NEW (immediate and deferred), transition tables, and a +-- BEFORE trigger combined with an FK. +-- +CREATE TABLE tp (id int PRIMARY KEY, v int); +CREATE TABLE tc (id int PRIMARY KEY, pid int REFERENCES tp ON UPDATE CASCADE, w int); +INSERT INTO tp VALUES (1, 0), (2, 0); +INSERT INTO tc VALUES (1, 1, 0), (2, 2, 0), (3, 1, 0); +CREATE TRIGGER tc_after AFTER UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE CONSTRAINT TRIGGER tc_deferred AFTER UPDATE ON tc + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION fk_log_row(); +CREATE TRIGGER tc_trans AFTER UPDATE ON tc + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tp_trans AFTER UPDATE ON tp + REFERENCING OLD TABLE AS oldtab NEW TABLE AS newtab + FOR EACH STATEMENT EXECUTE FUNCTION fk_show_transition(); +CREATE TRIGGER tc_before BEFORE UPDATE ON tc + FOR EACH ROW EXECUTE FUNCTION fk_bump_w(); +UPDATE tc SET w = 5 WHERE id = 1; +UPDATE tc SET pid = 2 WHERE id = 1; +UPDATE tc SET pid = 9 WHERE id = 1; +UPDATE tp SET id = 10 WHERE id = 1; +BEGIN; +UPDATE tc SET w = 0 WHERE id = 2; +UPDATE tc SET w = 1 WHERE id = 2; +UPDATE tc SET pid = 10 WHERE id = 2; +COMMIT; +-- the BEFORE trigger redirects the FK to a missing parent +UPDATE tc SET w = -1 WHERE id = 2; +SELECT * FROM tc ORDER BY id; +SELECT tbl, op, old_row, new_row FROM log ORDER BY seq; + +-- +-- The twins must agree on every table's contents. +-- +RESET search_path; +RESET default_table_access_method; +CREATE FUNCTION pg_temp.fk_twin_diff(t text) RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM ((SELECT * FROM fk_heap.%1$I EXCEPT ALL SELECT * FROM fk_flux.%1$I)' + ' UNION ALL (SELECT * FROM fk_flux.%1$I EXCEPT ALL SELECT * FROM fk_heap.%1$I)) d', t) + INTO n; + RETURN n; +END $$; +SELECT c.relname, pg_temp.fk_twin_diff(c.relname) AS differing_rows + FROM pg_class c + WHERE c.relnamespace = 'fk_heap'::regnamespace AND c.relkind IN ('r', 'p') + AND NOT c.relispartition + ORDER BY 1; +SELECT c.relname, a.amname + FROM pg_class c JOIN pg_am a ON a.oid = c.relam + WHERE c.relnamespace = 'fk_flux'::regnamespace AND c.relkind = 'r' + ORDER BY 1; + +DROP SCHEMA fk_heap CASCADE; +DROP SCHEMA fk_flux CASCADE; +DROP FUNCTION public.fk_log_row(), public.fk_show_transition(), public.fk_bump_w(); diff --git a/src/test/regress/sql/flux_index_aba.sql b/src/test/regress/sql/flux_index_aba.sql new file mode 100644 index 0000000000000..f3a2e29eaef75 --- /dev/null +++ b/src/test/regress/sql/flux_index_aba.sql @@ -0,0 +1,321 @@ +-- +-- FLUX: an indexed column that returns to an earlier value (A -> B -> A) +-- inside one transaction, then is rolled back. +-- +-- An in-place key UPDATE keeps the row at its TID: it inserts (k_new, TID), +-- or revives a delete-marked (k_new, TID) left by an earlier UPDATE of the +-- same row, and delete-marks (k_old, TID). Rolling the UPDATEs back must +-- put every index back as it was: one live entry for the row's key, and +-- nothing that an index scan or amcheck could tell from a heap table that +-- had done the same. Each FLUX table here has a heap twin that receives the +-- same statements; heap never reuses a TID, so it is the control. +-- +-- aba_check() reports 'ok' or what is wrong: for both tables, an index scan +-- through the index under test must return what a sequential scan returns, +-- for every key the row has held; bt_index_check(heapallindexed) must pass +-- on every btree index (when amcheck is available); and the FLUX table must +-- hold what its heap twin holds. +-- +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'amcheck') AND + NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'amcheck') THEN + CREATE EXTENSION amcheck; + CREATE TABLE aba_created_amcheck (); + END IF; +END $$; + +-- One FLUX/heap pair per index kind. k is the column that cycles; j stays 0. +CREATE FUNCTION aba_make(kind text) RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + am text; + t text; +BEGIN + FOREACH am IN ARRAY ARRAY['flux', 'heap'] LOOP + t := am || '_' || kind; + EXECUTE format('CREATE TABLE %I (id int PRIMARY KEY, j int, k int) USING %s', t, am); + EXECUTE format('INSERT INTO %I SELECT g, 0, g FROM generate_series(1, 40) g', t); + EXECUTE format(CASE kind + WHEN 'plain' THEN 'CREATE INDEX %I ON %I (k)' + WHEN 'uniq' THEN 'CREATE UNIQUE INDEX %I ON %I (k)' + WHEN 'multi' THEN 'CREATE INDEX %I ON %I (j, k)' + WHEN 'expr' THEN 'CREATE INDEX %I ON %I ((k * 2))' + WHEN 'partial' THEN 'CREATE INDEX %I ON %I (k) WHERE k < 1000' + WHEN 'hash' THEN 'CREATE INDEX %I ON %I USING hash (k)' + END, t || '_ix', t); + END LOOP; +END $$; + +CREATE FUNCTION aba_drop(kind text) RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format('DROP TABLE %I, %I', 'flux_' || kind, 'heap_' || kind); +END $$; + +-- Set row id's k on both tables of the pair. +CREATE FUNCTION aba_set(kind text, rid int, newk int) RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format('UPDATE %I SET k = $1 WHERE id = $2', 'flux_' || kind) USING newk, rid; + EXECUTE format('UPDATE %I SET k = $1 WHERE id = $2', 'heap_' || kind) USING newk, rid; +END $$; + +-- Try an UPDATE on one table; report whether it raised unique_violation. +CREATE FUNCTION aba_try(t text, rid int, newk int) RETURNS text +LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE format('UPDATE %I SET k = $1 WHERE id = $2', t) USING newk, rid; + RETURN 'updated'; +EXCEPTION WHEN unique_violation THEN + RETURN 'unique_violation'; +END $$; + +CREATE FUNCTION aba_count(q text, idxscan bool) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE + n bigint; +BEGIN + PERFORM set_config('enable_seqscan', CASE WHEN idxscan THEN 'off' ELSE 'on' END, true); + PERFORM set_config('enable_indexscan', CASE WHEN idxscan THEN 'on' ELSE 'off' END, true); + PERFORM set_config('enable_bitmapscan', 'off', true); + PERFORM set_config('enable_indexonlyscan', 'off', true); + EXECUTE 'SELECT count(*) FROM ' || q INTO n; + PERFORM set_config('enable_seqscan', 'on', true); + PERFORM set_config('enable_indexscan', 'on', true); + PERFORM set_config('enable_bitmapscan', 'on', true); + PERFORM set_config('enable_indexonlyscan', 'on', true); + RETURN n; +END $$; + +CREATE FUNCTION aba_check(kind text, keys int[]) RETURNS text +LANGUAGE plpgsql AS $$ +DECLARE + am text; + t text; + key int; + q text; + ni bigint; + ns bigint; + i regclass; + res text := ''; + fx text; + hp text; +BEGIN + FOREACH am IN ARRAY ARRAY['flux', 'heap'] LOOP + t := am || '_' || kind; + FOREACH key IN ARRAY keys LOOP + -- a partial index holds only keys < 1000 + CONTINUE WHEN kind = 'partial' AND key >= 1000; + q := format('%I WHERE ', t) || CASE kind + WHEN 'multi' THEN format('j = 0 AND k = %s', key) + WHEN 'expr' THEN format('k * 2 = 2 * %s', key) + ELSE format('k = %s', key) END; + ni := aba_count(q, true); + ns := aba_count(q, false); + IF ni <> ns THEN + res := res || format('%s k=%s: idxscan %s seqscan %s; ', t, key, ni, ns); + END IF; + END LOOP; + FOR i IN SELECT indexrelid FROM pg_index x JOIN pg_class c ON c.oid = x.indexrelid + JOIN pg_am a ON a.oid = c.relam + WHERE x.indrelid = t::regclass AND a.amname = 'btree' ORDER BY 1 LOOP + IF to_regprocedure('bt_index_check(regclass,boolean)') IS NOT NULL THEN + BEGIN + PERFORM bt_index_check(i, true); + EXCEPTION WHEN OTHERS THEN + res := res || format('%s: %s; ', i, SQLERRM); + END; + END IF; + END LOOP; + END LOOP; + EXECUTE format('SELECT string_agg(format(''%%s:%%s:%%s'', id, j, k), '','' ORDER BY id) FROM %I', 'flux_' || kind) INTO fx; + EXECUTE format('SELECT string_agg(format(''%%s:%%s:%%s'', id, j, k), '','' ORDER BY id) FROM %I', 'heap_' || kind) INTO hp; + IF fx IS DISTINCT FROM hp THEN + res := res || 'flux and heap rows differ; '; + END IF; + RETURN CASE WHEN res = '' THEN 'ok' ELSE res END; +END $$; + +-- The plan aba_count(q, true) gets: a plain index scan of the index under +-- test. Index-only scans are off, as a heap table could otherwise answer +-- from the index alone, and a FLUX table never does. +CREATE FUNCTION aba_plan(kind text, key int) RETURNS SETOF text +LANGUAGE plpgsql AS $$ +DECLARE + q text; + l text; +BEGIN + q := format('%I WHERE ', 'flux_' || kind) || CASE kind + WHEN 'multi' THEN format('j = 0 AND k = %s', key) + WHEN 'expr' THEN format('k * 2 = 2 * %s', key) + ELSE format('k = %s', key) END; + PERFORM set_config('enable_seqscan', 'off', true); + PERFORM set_config('enable_bitmapscan', 'off', true); + PERFORM set_config('enable_indexonlyscan', 'off', true); + FOR l IN EXECUTE 'EXPLAIN (COSTS OFF) SELECT count(*) FROM ' || q LOOP + RETURN NEXT l; + END LOOP; + PERFORM set_config('enable_seqscan', 'on', true); + PERFORM set_config('enable_bitmapscan', 'on', true); + PERFORM set_config('enable_indexonlyscan', 'on', true); +END $$; + +CREATE VIEW aba_kinds AS SELECT unnest(ARRAY['plain', 'uniq', 'multi', 'expr', 'partial', 'hash']) AS kind; + +SELECT aba_make(kind) FROM aba_kinds; +SELECT kind, aba_plan(kind, 12) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- Every case starts from fresh tables (amcheck reports only the first +-- missing entry) and checks the indexes before and after a VACUUM, which +-- reclaims the tombstones a case leaves and must keep the indexes intact. +-- B = A + 3000000 and C = A + 4000000 lie outside the partial index. + +-- 1. A -> B -> A, ROLLBACK +SELECT aba_make(kind) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 12, 3000012), aba_set(kind, 12, 12) + FROM aba_kinds; +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[12, 3000012]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[12, 3000012]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 2. A -> B -> C -> A, ROLLBACK +SELECT aba_make(kind) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 13, 3000013), aba_set(kind, 13, 4000013), aba_set(kind, 13, 13) + FROM aba_kinds; +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[13, 3000013, 4000013]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[13, 3000013, 4000013]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 3. A -> B, then B -> A in a savepoint that is rolled back, COMMIT: B +SELECT aba_make(kind) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 14, 3000014) + FROM aba_kinds; +SAVEPOINT s; +SELECT aba_set(kind, 14, 14) + FROM aba_kinds; +ROLLBACK TO s; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[14, 3000014]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[14, 3000014]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 4. A -> B -> C, then C -> A in a savepoint that is rolled back, COMMIT: C +SELECT aba_make(kind) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 15, 3000015), aba_set(kind, 15, 4000015) + FROM aba_kinds; +SAVEPOINT s; +SELECT aba_set(kind, 15, 15) + FROM aba_kinds; +ROLLBACK TO s; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[15, 3000015, 4000015]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[15, 3000015, 4000015]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 5. A -> B, then B -> C -> A in a savepoint that is rolled back, COMMIT: B +SELECT aba_make(kind) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 16, 3000016) + FROM aba_kinds; +SAVEPOINT s; +SELECT aba_set(kind, 16, 4000016), aba_set(kind, 16, 16) + FROM aba_kinds; +ROLLBACK TO s; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[16, 3000016, 4000016]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[16, 3000016, 4000016]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 6. COMMIT controls: A -> B -> A and A -> B -> C -> A +SELECT aba_make(kind) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 17, 3000017), aba_set(kind, 17, 17), + aba_set(kind, 18, 3000018), aba_set(kind, 18, 4000018), aba_set(kind, 18, 18) + FROM aba_kinds; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[17, 3000017, 18, 3000018, 4000018]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[17, 3000017, 18, 3000018, 4000018]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 7. Committed A -> C, then C -> A rolled back: C +SELECT aba_make(kind) FROM aba_kinds; +SELECT aba_set(kind, 19, 4000019) + FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 19, 19) + FROM aba_kinds; +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[19, 4000019]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[19, 4000019]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 8. The same row cycled and rolled back round after round, then committed +-- back to A: no round may lose an entry or leave a live one behind. +SELECT aba_make(kind) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 20, 3000020), aba_set(kind, 20, 20) + FROM aba_kinds; +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 20, 3000020), aba_set(kind, 20, 20), aba_set(kind, 20, 3000020) + FROM aba_kinds; +ROLLBACK; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; +BEGIN; +SELECT aba_set(kind, 20, 3000020), aba_set(kind, 20, 20) + FROM aba_kinds; +COMMIT; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; +VACUUM flux_plain, flux_uniq, flux_multi, flux_expr, flux_partial, flux_hash, + heap_plain, heap_uniq, heap_multi, heap_expr, heap_partial, heap_hash; +SELECT kind, aba_check(kind, ARRAY[20, 3000020]) FROM aba_kinds; +SELECT aba_drop(kind) FROM aba_kinds; + +-- 9. Unique index. A key the row has left is free for another row, and the +-- row cannot take it back while that row holds it. An aborted A -> B -> A +-- keeps A taken: no other row may take it. +SELECT aba_make('uniq'); +SELECT aba_set('uniq', 21, 3000021), aba_set('uniq', 22, 21); +SELECT aba_try('flux_uniq', 21, 21) AS flux, aba_try('heap_uniq', 21, 21) AS heap; +BEGIN; +SELECT aba_set('uniq', 23, 3000023), aba_set('uniq', 23, 23); +ROLLBACK; +SELECT aba_try('flux_uniq', 24, 23) AS flux, aba_try('heap_uniq', 24, 23) AS heap; +-- once the row has moved off it again (committed), the key is free +SELECT aba_set('uniq', 23, 3000023); +SELECT aba_try('flux_uniq', 24, 23) AS flux, aba_try('heap_uniq', 24, 23) AS heap; +SELECT aba_check('uniq', ARRAY[21, 22, 3000021, 23, 24, 3000023]); +VACUUM flux_uniq, heap_uniq; +SELECT aba_check('uniq', ARRAY[21, 22, 3000021, 23, 24, 3000023]); +SELECT aba_drop('uniq'); + +DROP VIEW aba_kinds; +DROP FUNCTION aba_make, aba_drop, aba_set, aba_try, aba_count, aba_check, aba_plan; +DO $$ +BEGIN + IF to_regclass('aba_created_amcheck') IS NOT NULL THEN + DROP EXTENSION amcheck; + DROP TABLE aba_created_amcheck; + END IF; +END $$; diff --git a/src/test/regress/sql/flux_merge_delete.sql b/src/test/regress/sql/flux_merge_delete.sql new file mode 100644 index 0000000000000..70910ecde7692 --- /dev/null +++ b/src/test/regress/sql/flux_merge_delete.sql @@ -0,0 +1,85 @@ +-- +-- FLUX: a DELETE (plain or MERGE) must stay deleted when a BEFORE STATEMENT +-- trigger advanced the command counter before the row was deleted. +-- +-- The trigger's PERFORM makes plpgsql run CommandCounterIncrement, so the +-- current command id is past the statement's output cid when the row is +-- deleted. The undo record of the delete must carry the output cid, as +-- heap's cmax does; otherwise the next command treats the delete as a later +-- command of its own transaction, reconstructs the deleted row from undo +-- and serves it again. Heap is the control. +-- +CREATE FUNCTION fmd_trig() RETURNS trigger LANGUAGE plpgsql AS +$$ BEGIN PERFORM 1; RETURN NULL; END $$; + +CREATE TABLE fmd_heap (tid int, balance int) USING heap; +CREATE TABLE fmd_flux (tid int, balance int) USING flux; +CREATE TABLE fmd_src (sid int, delta int); +INSERT INTO fmd_src VALUES (2, 5), (3, 20), (4, 40); + +DO $$ +DECLARE + t text; +BEGIN + FOREACH t IN ARRAY ARRAY['fmd_heap', 'fmd_flux'] LOOP + EXECUTE format('INSERT INTO %I VALUES (1, 10), (2, 20), (3, 30)', t); + EXECUTE format('CREATE TRIGGER fmd_bsd BEFORE DELETE ON %I + FOR EACH STATEMENT EXECUTE FUNCTION fmd_trig()', t); + EXECUTE format('CREATE TRIGGER fmd_bsu BEFORE UPDATE ON %I + FOR EACH STATEMENT EXECUTE FUNCTION fmd_trig()', t); + END LOOP; +END $$; + +-- plain DELETE of a row not yet modified by this transaction +BEGIN; +DELETE FROM fmd_heap WHERE tid = 3; +DELETE FROM fmd_flux WHERE tid = 3; +SELECT tid, balance FROM fmd_heap ORDER BY tid; +SELECT tid, balance FROM fmd_flux ORDER BY tid; +ROLLBACK; + +-- plain DELETE of a row this transaction updated in an earlier command +BEGIN; +UPDATE fmd_heap SET balance = 0 WHERE tid = 3; +UPDATE fmd_flux SET balance = 0 WHERE tid = 3; +DELETE FROM fmd_heap WHERE tid = 3; +DELETE FROM fmd_flux WHERE tid = 3; +SELECT tid, balance FROM fmd_heap ORDER BY tid; +SELECT tid, balance FROM fmd_flux ORDER BY tid; +ROLLBACK; + +-- the merge.sql "classic UPSERT, with a DELETE" +BEGIN; +UPDATE fmd_heap SET balance = 0 WHERE tid = 3; +UPDATE fmd_flux SET balance = 0 WHERE tid = 3; +MERGE INTO fmd_heap t USING fmd_src s ON t.tid = s.sid +WHEN MATCHED AND t.balance > s.delta THEN + UPDATE SET balance = t.balance - s.delta +WHEN MATCHED THEN + DELETE +WHEN NOT MATCHED THEN + INSERT VALUES (s.sid, s.delta); +MERGE INTO fmd_flux t USING fmd_src s ON t.tid = s.sid +WHEN MATCHED AND t.balance > s.delta THEN + UPDATE SET balance = t.balance - s.delta +WHEN MATCHED THEN + DELETE +WHEN NOT MATCHED THEN + INSERT VALUES (s.sid, s.delta); +SELECT tid, balance FROM fmd_heap ORDER BY tid; +SELECT tid, balance FROM fmd_flux ORDER BY tid; +ROLLBACK; + +-- an UPDATE behind the same trigger must keep its new value, too +BEGIN; +UPDATE fmd_flux SET balance = 0 WHERE tid = 3; +UPDATE fmd_flux SET balance = 7 WHERE tid = 3; +SELECT tid, balance FROM fmd_flux ORDER BY tid; +ROLLBACK; + +-- after ROLLBACK both tables are back to the original rows +SELECT tid, balance FROM fmd_heap ORDER BY tid; +SELECT tid, balance FROM fmd_flux ORDER BY tid; + +DROP TABLE fmd_heap, fmd_flux, fmd_src; +DROP FUNCTION fmd_trig(); diff --git a/src/test/regress/sql/flux_rewrite_xact.sql b/src/test/regress/sql/flux_rewrite_xact.sql new file mode 100644 index 0000000000000..016e05a2274f8 --- /dev/null +++ b/src/test/regress/sql/flux_rewrite_xact.sql @@ -0,0 +1,194 @@ +-- +-- FLUX: commit, abort and PREPARE work after the relation's storage was +-- replaced in the same transaction (SET ACCESS METHOD, ALTER COLUMN TYPE, +-- CLUSTER, TRUNCATE). +-- +-- FLUX remembers the tuples a transaction wrote by relation OID and TID, and +-- at commit it clears a hint bit on each of them, or marks rows a rolled-back +-- savepoint inserted as deleted. A rewrite keeps the OID but moves the rows +-- to new storage, where the same TIDs name other tuples (or are past the end +-- of the file, or belong to a heap page). Every case runs on a heap twin and +-- on FLUX, with COMMIT and with ROLLBACK; the rows written before and after +-- the rewrite have equal widths, so their TIDs collide. The two columns of +-- the result must agree. +-- +-- amcheck's verify_heapam checks every table that ends up heap when the +-- extension is available; the result is the same either way. It goes into a +-- schema of its own, off the search path of the tests running alongside. +CREATE SCHEMA rx_amcheck; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'amcheck') THEN + CREATE EXTENSION amcheck SCHEMA rx_amcheck; + END IF; +EXCEPTION WHEN OTHERS THEN + NULL; +END $$; + +-- A wide value that compresses inline, as heap and FLUX both store it. +CREATE FUNCTION rx_val(k int) RETURNS text LANGUAGE sql IMMUTABLE + RETURN repeat(md5(k::text), 150); + +-- Rows, their ids, the rows whose value is intact, then FOR UPDATE, UPDATE +-- and DELETE counts in the same transaction, and amcheck problems. +CREATE FUNCTION rx_check(t text) RETURNS text LANGUAGE plpgsql AS $$ +DECLARE + rel regclass := to_regclass(t); + vh text := (SELECT format('%I.verify_heapam', n.nspname) + FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'amcheck'); + n bigint; ids bigint; ks bigint; good bigint; lck bigint; upd bigint; + del bigint; bad bigint := 0; +BEGIN + IF rel IS NULL THEN + RETURN 'no table'; + END IF; + IF (SELECT a.amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam + WHERE c.oid = rel) = 'heap' + AND vh IS NOT NULL THEN + EXECUTE format('SELECT count(*) FROM %s(%L)', vh, rel) INTO bad; + END IF; + EXECUTE format('SELECT count(*), sum(id), sum(k), + count(*) FILTER (WHERE w = rx_val(k)) + FROM %s', rel) INTO n, ids, ks, good; + EXECUTE format('SELECT count(*) FROM (SELECT 1 FROM %s FOR UPDATE) x', rel) + INTO lck; + EXECUTE format('WITH x AS (UPDATE %s SET w = w RETURNING 1) + SELECT count(*) FROM x', rel) INTO upd; + EXECUTE format('WITH x AS (DELETE FROM %s RETURNING 1) + SELECT count(*) FROM x', rel) INTO del; + RETURN format('rows=%s ids=%s ks=%s good=%s lock=%s upd=%s del=%s amcheck=%s', + n, ids, ks, good, lck, upd, del, bad); +EXCEPTION WHEN OTHERS THEN + RETURN 'ERROR: ' || regexp_replace(SQLERRM, '(pglz|lz4)', 'lzN'); +END $$; + +-- Each case: statements before BEGIN, then the transaction body. $T is the +-- table, $AM its access method. The base is 20 committed rows, inserted in +-- descending id order so that CLUSTER moves them. +CREATE TABLE rx_case (n int, name text, setup text[], body text[]); +INSERT INTO rx_case VALUES +-- flux -> heap +(1, 'fh', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ALTER TABLE $T SET ACCESS METHOD heap']), +-- flux -> heap -> flux +(2, 'fhf', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ALTER TABLE $T SET ACCESS METHOD heap', + 'ALTER TABLE $T SET ACCESS METHOD $AM']), +-- flux -> heap, after rows rolled back to a savepoint +(3, 'sp_fh', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ROLLBACK TO a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(41, 60) g', + 'ALTER TABLE $T SET ACCESS METHOD heap']), +-- TRUNCATE, then new rows on the same TIDs +(4, 'trunc', NULL, ARRAY[ + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g']), +-- rows rolled back to a savepoint, then TRUNCATE and new rows on their TIDs +(5, 'sp_trunc', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ROLLBACK TO a', + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 40) g']), +-- ALTER COLUMN TYPE +(6, 'act', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint']), +-- rows rolled back to a savepoint, then a rewrite moves live rows onto their +-- TIDs +(7, 'sp_act', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'ROLLBACK TO a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(41, 60) g', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint']), +-- CLUSTER after updates and deletes in place (no rows rolled back) +(8, 'cluster', NULL, ARRAY[ + 'UPDATE $T SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0', + 'DELETE FROM $T WHERE id % 5 = 0', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(40, 21, -1) g', + 'CLUSTER $T USING $T_pkey']), +-- TRUNCATE + ALTER TYPE: the rows before TRUNCATE fill blocks the rewritten +-- relation does not have +(9, 'trunc_act', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 400) g', + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint']), +-- the same with those rows rolled back to a savepoint; the new rows go in +-- after the rewrite +(10, 'sp_trunc_act', NULL, ARRAY[ + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 400) g', + 'ROLLBACK TO a', + 'TRUNCATE $T', + 'ALTER TABLE $T ALTER COLUMN id TYPE bigint', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g']), +-- a table created in this transaction is truncated in place, keeping its +-- storage: rows rolled back to a savepoint, then new rows on their TIDs +(11, 'inplace', ARRAY[]::text[], ARRAY[ + 'CREATE TABLE $T (id int PRIMARY KEY, k int, w text) USING $AM', + 'SAVEPOINT a', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(1, 20) g', + 'ROLLBACK TO a', + 'TRUNCATE $T', + 'INSERT INTO $T SELECT g, g + 100, rx_val(g + 100) FROM generate_series(1, 20) g']), +-- a rewrite inside a savepoint that rolls back: the old storage is current +-- again, and the rows written to it before the savepoint still commit +(12, 'sp_rewrite_rb', NULL, ARRAY[ + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(21, 40) g', + 'UPDATE $T SET k = k + 100, w = rx_val(k + 100) WHERE id % 2 = 0', + 'SAVEPOINT a', + 'ALTER TABLE $T SET ACCESS METHOD heap', + 'ROLLBACK TO a', + 'SAVEPOINT b', + 'TRUNCATE $T', + 'ROLLBACK TO b']); + +CREATE FUNCTION rx_script() RETURNS SETOF text LANGUAGE plpgsql AS $$ +DECLARE + c rx_case; + fin text; + am text; + t text; + s text; +BEGIN + FOR c IN SELECT * FROM rx_case ORDER BY n LOOP + FOREACH fin IN ARRAY ARRAY['COMMIT', 'ROLLBACK'] LOOP + FOREACH am IN ARRAY ARRAY['heap', 'flux'] LOOP + t := format('rx_%s_%s_%s', c.name, lower(fin), am); + FOREACH s IN ARRAY coalesce(c.setup, ARRAY[ + 'CREATE TABLE $T (id int PRIMARY KEY, k int, w text) USING $AM', + 'INSERT INTO $T SELECT g, g, rx_val(g) FROM generate_series(20, 1, -1) g']) + || ARRAY['BEGIN'] || c.body || fin LOOP + RETURN NEXT replace(replace(s, '$T', t), '$AM', am); + END LOOP; + END LOOP; + END LOOP; + END LOOP; +END $$; + +SELECT rx_script() \gexec + +\pset format unaligned +SELECT name, fin, heap, CASE WHEN flux = heap THEN 'same' ELSE flux END AS flux +FROM (SELECT c.n, c.name, f.fin, + rx_check(format('rx_%s_%s_heap', c.name, lower(f.fin))) AS heap, + rx_check(format('rx_%s_%s_flux', c.name, lower(f.fin))) AS flux + FROM rx_case c, (VALUES ('COMMIT'), ('ROLLBACK')) f(fin)) r +ORDER BY n, fin; +\pset format aligned + +-- Clean up. +SELECT format('DROP TABLE %I', relname) FROM pg_class +WHERE relname ~ '^rx_.*_(heap|flux)$' AND relkind = 'r' ORDER BY 1 \gexec +DROP FUNCTION rx_script(), rx_check(text), rx_val(int); +DROP TABLE rx_case; +SET client_min_messages = warning; +DROP SCHEMA rx_amcheck CASCADE; +RESET client_min_messages; diff --git a/src/test/regress/sql/flux_tidbitmap.sql b/src/test/regress/sql/flux_tidbitmap.sql new file mode 100644 index 0000000000000..0c8d4743d07d0 --- /dev/null +++ b/src/test/regress/sql/flux_tidbitmap.sql @@ -0,0 +1,71 @@ +-- +-- FLUX: line-pointer offsets stay within MaxHeapTuplesPerPage. +-- +-- tidbitmap.c sizes each exact page entry for TBM_MAX_TUPLES_PER_PAGE == +-- MaxHeapTuplesPerPage offsets, for every table AM. FLUX must therefore +-- never place a tuple at a higher offset, or a bitmap scan over that page +-- fails with "tuple offset out of range". The narrowest FLUX rows cannot get +-- there by geometry alone (every item carries a 36-byte tuple header), but +-- line pointers left LP_DEAD by pruning can: they are not reused until VACUUM, +-- so repeated insert/delete churn on one page keeps appending new ones. +-- +CREATE FUNCTION tbm_off(t tid) RETURNS int + LANGUAGE sql IMMUTABLE AS $$ SELECT (t::text::point)[1]::int $$; +CREATE FUNCTION tbm_blk(t tid) RETURNS int + LANGUAGE sql IMMUTABLE AS $$ SELECT (t::text::point)[0]::int $$; +-- MaxHeapTuplesPerPage: (BLCKSZ - page header) / (tuple header + line pointer) +CREATE FUNCTION tbm_max_off() RETURNS int + LANGUAGE sql STABLE AS $$ SELECT (current_setting('block_size')::int - 24) / 28 $$; + +-- narrowest rows: a page fills up by space, well under the offset limit +CREATE TABLE tbm_z0 () USING flux; +INSERT INTO tbm_z0 SELECT FROM generate_series(1, 400); +SELECT count(*) > 0 AS some_on_blk0, + count(*) <= tbm_max_off() AS blk0_fits + FROM tbm_z0 WHERE tbm_blk(ctid) = 0; + +CREATE TABLE tbm_b (b bool) USING flux; +CREATE INDEX tbm_b_idx ON tbm_b (b); +INSERT INTO tbm_b SELECT true FROM generate_series(1, 400); +SELECT count(*) > 0 AS some_on_blk0, + count(*) <= tbm_max_off() AS blk0_fits + FROM tbm_b WHERE tbm_blk(ctid) = 0; + +-- insert/delete churn: pruning leaves LP_DEAD line pointers behind +CREATE TABLE tbm_churn (i int) USING flux WITH (autovacuum_enabled = off); +CREATE INDEX tbm_churn_idx ON tbm_churn (i); +CREATE TABLE tbm_maxoff (round int, maxoff int); +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 1, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 2, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 3, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 4, max(tbm_off(ctid)) FROM tbm_churn; +DELETE FROM tbm_churn; +SELECT count(*) FROM tbm_churn; -- scan prunes the page +INSERT INTO tbm_churn SELECT g FROM generate_series(1, 150) g; +INSERT INTO tbm_maxoff SELECT 5, max(tbm_off(ctid)) FROM tbm_churn; +SELECT round, maxoff <= tbm_max_off() AS fits FROM tbm_maxoff ORDER BY round; +SELECT count(*) AS beyond_max_off FROM tbm_churn WHERE tbm_off(ctid) > tbm_max_off(); + +-- a bitmap scan over every page returns every row +SET enable_seqscan = off; +SET enable_indexscan = off; +SET enable_indexonlyscan = off; +EXPLAIN (COSTS OFF) SELECT count(*) FROM tbm_churn WHERE i > 0; +SELECT count(*) FROM tbm_churn WHERE i > 0; +SELECT count(*) FROM tbm_b WHERE b; +RESET enable_seqscan; +RESET enable_indexscan; +RESET enable_indexonlyscan; + +DROP TABLE tbm_z0, tbm_b, tbm_churn, tbm_maxoff; +DROP FUNCTION tbm_off, tbm_blk, tbm_max_off; diff --git a/src/test/regress/sql/flux_toast_foreign.sql b/src/test/regress/sql/flux_toast_foreign.sql new file mode 100644 index 0000000000000..dd9afb6f105dc --- /dev/null +++ b/src/test/regress/sql/flux_toast_foreign.sql @@ -0,0 +1,445 @@ +-- +-- FLUX and foreign TOAST pointers. +-- +-- A value read from another table can reach a table AM as an on-disk TOAST +-- pointer into that table's TOAST relation. The AM must not store such a +-- pointer verbatim: the value has to go into the target's own TOAST relation +-- (or inline), otherwise dropping the source makes the row unreadable. heap +-- does this in heap_toast_insert_or_update. Likewise an oversized inline +-- value must be compressed/externalized the way heap would. +-- +-- Every path below runs against heap (the control) and FLUX. The sources are +-- then dropped and each row is re-read and compared by md5 and length, every +-- external pointer is checked to resolve in the table's own TOAST relation, +-- and the stored shape (size, compression, external or not) of every FLUX row +-- is compared with its heap twin. +-- + +\getenv abs_builddir PG_ABS_BUILDDIR +\set tf_file :abs_builddir '/results/flux_toast_foreign.data' +SELECT set_config('tf.file', :'tf_file', false) IS NOT NULL AS ok, + set_config('tf.cut', '0', false) IS NOT NULL AS ok; + +-- Row 1: 100 kB incompressible EXTERNAL v, 1 MB compressible w (external, +-- compressed). Rows 2-3: 100 kB v. Row 4: short v, 5000-byte compressible w +-- (inline compressed in the source). +CREATE TABLE heap_src (id int PRIMARY KEY, v text, w text) USING heap; +ALTER TABLE heap_src ALTER COLUMN v SET STORAGE EXTERNAL; +INSERT INTO heap_src +SELECT g, + CASE WHEN g < 4 THEN (SELECT string_agg(md5((g * 10000 + i)::text), '') + FROM generate_series(1, 3125) i) + ELSE 'short' END, + CASE g WHEN 1 THEN repeat(md5('w'), 32768) WHEN 2 THEN NULL + WHEN 3 THEN 'w3' ELSE repeat('x', 5000) END +FROM generate_series(1, 4) g; + +SELECT id, length(v) AS lv, + pg_column_toast_chunk_id(v) IS NOT NULL AS v_ext, + pg_column_compression(v) IS NOT NULL AS v_cmp, + length(w) AS lw, + pg_column_toast_chunk_id(w) IS NOT NULL AS w_ext, + pg_column_compression(w) IS NOT NULL AS w_cmp +FROM heap_src ORDER BY id; + +CREATE TABLE tf_ref AS + SELECT id, length(v) AS lv, md5(v) AS mv, length(w) AS lw, md5(w) AS mw + FROM heap_src; + +-- The reverse-direction source: FLUX values in FLUX's own TOAST relation. +CREATE TABLE flux_src USING flux AS SELECT * FROM heap_src; + +COPY heap_src TO :'tf_file'; +\set tf_file1 :abs_builddir '/results/flux_toast_foreign_1.data' +SELECT set_config('tf.file1', :'tf_file1', false) IS NOT NULL AS ok; +COPY (SELECT 1 FROM generate_series(1, 4)) TO :'tf_file1'; + +-- STABLE, so COPY keeps its multi-insert path for a column defaulting to it; +-- the result is heap_src's TOAST pointer, not a detoasted copy. +-- Materialized views read heap_src through dynamic SQL, so that heap_src can +-- be dropped while they exist. +CREATE FUNCTION tf_src(cut int) RETURNS TABLE (id int, v text, w text) + LANGUAGE plpgsql STABLE AS $$ +BEGIN + RETURN QUERY EXECUTE 'SELECT * FROM public.heap_src WHERE id <= $1' USING cut; +END $$; +CREATE FUNCTION tf_def_v() RETURNS text STABLE LANGUAGE sql + AS 'SELECT v FROM heap_src WHERE id = 1'; +CREATE FUNCTION tf_def_w() RETURNS text STABLE LANGUAGE sql + AS 'SELECT w FROM heap_src WHERE id = 1'; +CREATE FUNCTION tf_rdef_v() RETURNS text STABLE LANGUAGE sql + AS 'SELECT v FROM flux_src WHERE id = 1'; +CREATE FUNCTION tf_rdef_w() RETURNS text STABLE LANGUAGE sql + AS 'SELECT w FROM flux_src WHERE id = 1'; + +-- Row count, rows whose content differs from tf_ref, and rows holding an +-- external pointer that does not resolve in the table's own TOAST relation. +CREATE FUNCTION tf_check(tbl regclass) RETURNS text LANGUAGE plpgsql AS $$ +DECLARE + toast regclass; + nrows int; + nbad int; + nforeign int; +BEGIN + SELECT reltoastrelid::regclass INTO toast FROM pg_class WHERE oid = tbl; + EXECUTE format($q$ + SELECT count(*), + count(*) FILTER (WHERE r.id IS NULL + OR length(t.v) IS DISTINCT FROM r.lv + OR md5(t.v) IS DISTINCT FROM r.mv + OR length(t.w) IS DISTINCT FROM r.lw + OR md5(t.w) IS DISTINCT FROM r.mw), + count(*) FILTER (WHERE + (pg_column_toast_chunk_id(t.v) IS NOT NULL AND NOT EXISTS + (SELECT 1 FROM %2$s c WHERE c.chunk_id::text = pg_column_toast_chunk_id(t.v)::text)) + OR (pg_column_toast_chunk_id(t.w) IS NOT NULL AND NOT EXISTS + (SELECT 1 FROM %2$s c WHERE c.chunk_id::text = pg_column_toast_chunk_id(t.w)::text))) + FROM %1$s t LEFT JOIN tf_ref r USING (id)$q$, tbl, toast) + INTO nrows, nbad, nforeign; + RETURN format('rows=%s bad=%s foreign=%s', nrows, nbad, nforeign); +EXCEPTION WHEN OTHERS THEN + RETURN 'ERROR: ' || regexp_replace(SQLERRM, '[0-9]+', 'N', 'g'); +END $$; + +-- How each value is stored: compressed, external, and whether it is held +-- inline at more than the TOAST target. (Exact sizes differ by design: FLUX +-- does not convert short varlenas to 1-byte headers.) +CREATE FUNCTION tf_shape(tbl regclass) +RETURNS TABLE (id int, bv bool, cv bool, ev bool, bw bool, cw bool, ew bool) +LANGUAGE plpgsql AS $$ +BEGIN + IF tbl IS NULL THEN + RETURN; + END IF; + RETURN QUERY EXECUTE format($q$ + SELECT id, pg_column_size(v) > 2000 AND pg_column_toast_chunk_id(v) IS NULL, + pg_column_compression(v) IS NOT NULL, + pg_column_toast_chunk_id(v) IS NOT NULL, + pg_column_size(w) > 2000 AND pg_column_toast_chunk_id(w) IS NULL, + pg_column_compression(w) IS NOT NULL, + pg_column_toast_chunk_id(w) IS NOT NULL + FROM %s$q$, tbl); +END $$; + +-- Run one path's statements against table tbl of access method am. A failure +-- is reported as a NOTICE and leaves the table absent from the listings. +CREATE FUNCTION tf_path(tbl text, am text, VARIADIC stmts text[]) +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + s text; +BEGIN + FOREACH s IN ARRAY stmts LOOP + EXECUTE replace(replace(s, '$T', quote_ident(tbl)), '$AM', am); + END LOOP; +EXCEPTION WHEN OTHERS THEN + RAISE NOTICE '%: %', tbl, regexp_replace(SQLERRM, '[0-9]+', 'N', 'g'); +END $$; + +-- Phase 1: build and load. One transaction. +DO $do$ +DECLARE + am text; + cols text := '(id int, v text, w text)'; + pk text := '(id int PRIMARY KEY, v text, w text)'; +BEGIN + FOREACH am IN ARRAY ARRAY['heap', 'flux'] LOOP + -- INSERT ... SELECT, one row and several rows + PERFORM tf_path('tf_ins1_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src WHERE id = 1'); + PERFORM tf_path('tf_insm_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src'); + -- INSERT ... ON CONFLICT DO NOTHING (speculative insertion) + PERFORM tf_path('tf_ocn_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src ON CONFLICT DO NOTHING'); + -- the same into a partition + PERFORM tf_path('tf_ocnp_' || am, am, + 'CREATE TABLE $T ' || pk || ' PARTITION BY LIST (id)', + 'CREATE TABLE tf_ocnp_part_$AM PARTITION OF $T DEFAULT USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src ON CONFLICT DO NOTHING'); + -- the same with oversized inline values + PERFORM tf_path('tf_ocbig_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT id, v || '''', w || '''' FROM heap_src ON CONFLICT DO NOTHING'); + -- ON CONFLICT DO UPDATE: row 1 exists (committed before phase 2) + PERFORM tf_path('tf_ocu_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T VALUES (1, ''a'', ''b'')'); + -- UPDATE targets, loaded with placeholders or own values + PERFORM tf_path('tf_upd_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', ''b'' FROM heap_src'); + PERFORM tf_path('tf_upd1_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', w FROM heap_src'); + PERFORM tf_path('tf_updk_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', ''b'' FROM heap_src'); + -- same-size in-place UPDATE of a MAIN column (CAS fast path in FLUX) + PERFORM tf_path('tf_cas_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'ALTER TABLE $T ALTER COLUMN w SET STORAGE MAIN', + 'INSERT INTO $T SELECT 4, ''short'', left(string_agg(md5(i::text), ''''), 5000) + FROM generate_series(1, 200) i'); + -- COPY FROM a file (multi_insert) + PERFORM tf_path('tf_copy_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + format('COPY $T FROM %L', current_setting('tf.file'))); + PERFORM tf_path('tf_pcopy_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM'); + -- COPY FROM with defaults that return heap_src's pointers (multi_insert), + -- into a plain table and into a partition + PERFORM tf_path('tf_cdef_' || am, am, + 'CREATE TABLE $T (id int, v text DEFAULT tf_def_v(), + w text DEFAULT tf_def_w()) USING $AM', + format('COPY $T (id) FROM %L', current_setting('tf.file1'))); + PERFORM tf_path('tf_cdefp_' || am, am, + 'CREATE TABLE $T (id int, v text DEFAULT tf_def_v(), + w text DEFAULT tf_def_w()) PARTITION BY LIST (id)', + 'CREATE TABLE tf_cdefp_part_$AM PARTITION OF $T DEFAULT USING $AM', + format('COPY $T (id) FROM %L', current_setting('tf.file1'))); + -- CREATE TABLE AS + PERFORM tf_path('tf_ctas_' || am, am, + 'CREATE TABLE $T USING $AM AS SELECT * FROM heap_src'); + -- materialized views: plain REFRESH, and REFRESH CONCURRENTLY from empty + PERFORM tf_path('tf_mv_' || am, am, + 'CREATE MATERIALIZED VIEW $T USING $AM AS SELECT * FROM tf_src(4)', + 'REFRESH MATERIALIZED VIEW $T'); + PERFORM tf_path('tf_mvc_' || am, am, + 'CREATE MATERIALIZED VIEW $T USING $AM AS + SELECT * FROM tf_src(current_setting(''tf.cut'')::int)', + 'CREATE UNIQUE INDEX ON $T (id)', + 'SELECT set_config(''tf.cut'', ''4'', false)', + 'REFRESH MATERIALIZED VIEW CONCURRENTLY $T', + 'SELECT set_config(''tf.cut'', ''0'', false)'); + -- plpgsql: scalar variables and a record variable + PERFORM tf_path('tf_plv_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'DO $p$ DECLARE xv text; xw text; BEGIN + FOR i IN 1..4 LOOP + SELECT v, w INTO xv, xw FROM heap_src WHERE id = i; + INSERT INTO $T VALUES (i, xv, xw); + END LOOP; END $p$'); + PERFORM tf_path('tf_plr_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'DO $p$ DECLARE r record; BEGIN + FOR r IN SELECT * FROM heap_src LOOP + INSERT INTO $T VALUES (r.id, r.v, r.w); + END LOOP; END $p$'); + -- SET ACCESS METHOD to the other AM, and there and back + PERFORM tf_path('tf_sam_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src', + 'ALTER TABLE $T SET ACCESS METHOD ' || CASE am WHEN 'heap' THEN 'flux' ELSE 'heap' END); + PERFORM tf_path('tf_samrt_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src', + 'ALTER TABLE $T SET ACCESS METHOD ' || CASE am WHEN 'heap' THEN 'flux' ELSE 'heap' END, + 'ALTER TABLE $T SET ACCESS METHOD $AM'); + -- VACUUM FULL target (run below, outside a transaction block) + PERFORM tf_path('tf_vf_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM heap_src'); + -- reverse direction: FLUX values into heap and into another FLUX table + PERFORM tf_path('tf_rins_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT * FROM flux_src'); + PERFORM tf_path('tf_rocn_' || am, am, + 'CREATE TABLE $T ' || pk || ' USING $AM', + 'INSERT INTO $T SELECT * FROM flux_src ON CONFLICT DO NOTHING'); + PERFORM tf_path('tf_rcdef_' || am, am, + 'CREATE TABLE $T (id int, v text DEFAULT tf_rdef_v(), + w text DEFAULT tf_rdef_w()) USING $AM', + format('COPY $T (id) FROM %L', current_setting('tf.file1'))); + PERFORM tf_path('tf_rctas_' || am, am, + 'CREATE TABLE $T USING $AM AS SELECT * FROM flux_src'); + PERFORM tf_path('tf_rupd_' || am, am, + 'CREATE TABLE $T ' || cols || ' USING $AM', + 'INSERT INTO $T SELECT id, ''a'', ''b'' FROM flux_src'); + END LOOP; +END +$do$; + +-- Phase 2: UPDATEs against committed rows. +DO $do$ +DECLARE + am text; +BEGIN + FOREACH am IN ARRAY ARRAY['heap', 'flux'] LOOP + PERFORM tf_path('tf_ocu_' || am, am, + 'INSERT INTO $T SELECT * FROM heap_src + ON CONFLICT (id) DO UPDATE SET v = EXCLUDED.v, w = EXCLUDED.w'); + PERFORM tf_path('tf_upd_' || am, am, + 'UPDATE $T t SET v = (SELECT v FROM heap_src s WHERE s.id = t.id), + w = (SELECT w FROM heap_src s WHERE s.id = t.id)'); + PERFORM tf_path('tf_upd1_' || am, am, + 'UPDATE $T t SET v = (SELECT v FROM heap_src s WHERE s.id = t.id)'); + PERFORM tf_path('tf_updk_' || am, am, + 'UPDATE $T t SET id = -t.id, + v = (SELECT v FROM heap_src s WHERE s.id = t.id), + w = (SELECT w FROM heap_src s WHERE s.id = t.id)', + 'UPDATE $T SET id = -id'); + PERFORM tf_path('tf_rupd_' || am, am, + 'UPDATE $T t SET v = (SELECT v FROM flux_src s WHERE s.id = t.id), + w = (SELECT w FROM flux_src s WHERE s.id = t.id)'); + END LOOP; +END +$do$; + +UPDATE tf_cas_heap SET w = repeat('x', 5000); +UPDATE tf_cas_flux SET w = repeat('x', 5000); + +-- psql's \copy takes its arguments verbatim, so use a relative path. +\cd :abs_builddir +\copy tf_pcopy_heap FROM 'results/flux_toast_foreign.data' +\copy tf_pcopy_flux FROM 'results/flux_toast_foreign.data' + +VACUUM FULL tf_vf_heap; +VACUUM FULL tf_vf_flux; + +-- Before the drop. Each check query lists only failures. +SELECT c.relname, a.amname, r.res +FROM pg_class c JOIN pg_am a ON a.oid = c.relam, LATERAL tf_check(c.oid) r(res) +WHERE (c.relname LIKE 'tf\_%' OR c.relname IN ('heap_src', 'flux_src')) + AND c.relname <> 'tf_ref' AND c.relkind IN ('r', 'm') + AND r.res !~ '^rows=[14] bad=0 foreign=0$' +ORDER BY 1; -- expect none + +-- Storage shape: every FLUX table must store its rows the way its heap twin +-- does. Lists the rows that differ. +SELECT p.path, d.* +FROM (SELECT left(relname, -5) AS path FROM pg_class + WHERE relname LIKE 'tf\_%\_heap' AND relkind IN ('r', 'm')) p, + LATERAL (SELECT 'heap' AS side, * FROM + (SELECT * FROM tf_shape(to_regclass(p.path || '_heap')) + EXCEPT SELECT * FROM tf_shape(to_regclass(p.path || '_flux'))) x + UNION ALL + SELECT 'flux', * FROM + (SELECT * FROM tf_shape(to_regclass(p.path || '_flux')) + EXCEPT SELECT * FROM tf_shape(to_regclass(p.path || '_heap'))) y) d +ORDER BY 1, 2, 3; -- expect none + +DROP TABLE heap_src, flux_src; + +-- After the drop, in a new session. +\c +SELECT c.relname, a.amname, r.res +FROM pg_class c JOIN pg_am a ON a.oid = c.relam, LATERAL tf_check(c.oid) r(res) +WHERE c.relname LIKE 'tf\_%' AND c.relname <> 'tf_ref' AND c.relkind IN ('r', 'm') + AND r.res !~ '^rows=[14] bad=0 foreign=0$' +ORDER BY 1; -- expect none +-- 27 paths, each on heap and flux; a path that failed is missing. +SELECT n FROM (SELECT count(*) AS n FROM pg_class + WHERE relname LIKE 'tf\_%' AND relname <> 'tf_ref' + AND relkind IN ('r', 'm')) s +WHERE n <> 54; -- expect none + +-- +-- FLUX commit-time work after the relation's storage was replaced in the same +-- transaction. Rows written to the old storage must not be confused with the +-- tuples at the same TIDs in the new storage. (VACUUM FULL cannot run in a +-- transaction block; CLUSTER is the in-transaction rewrite.) +-- +CREATE TABLE tr_ref AS + SELECT g AS id, repeat(md5(g::text), 150) AS w FROM generate_series(1, 20) g; +CREATE FUNCTION tr_bad(tbl regclass) RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE + n bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM + ((SELECT id::int, w FROM %1$s EXCEPT ALL SELECT * FROM tr_ref) + UNION ALL + (SELECT * FROM tr_ref EXCEPT ALL SELECT id::int, w FROM %1$s)) x', tbl) + INTO n; + RETURN n; +EXCEPTION WHEN OTHERS THEN + RETURN -1; +END $$; +CREATE TABLE tr_fh (id int, w text) USING flux; +CREATE TABLE tr_fhf (id int, w text) USING flux; +CREATE TABLE tr_cl (id int PRIMARY KEY, w text) USING flux; +CREATE TABLE tr_act (id int, w text) USING flux; +CREATE TABLE tr_trunc (id int, w text) USING flux; +CREATE TABLE tr_spa (id int, w text) USING flux; +CREATE TABLE tr_spr (id int, w text) USING flux; +CREATE TABLE tr_spr2 (id int, w text) USING flux; +-- flux -> heap +BEGIN; +INSERT INTO tr_fh SELECT * FROM tr_ref; +ALTER TABLE tr_fh SET ACCESS METHOD heap; +COMMIT; +-- flux -> heap -> flux +BEGIN; +INSERT INTO tr_fhf SELECT * FROM tr_ref; +ALTER TABLE tr_fhf SET ACCESS METHOD heap; +ALTER TABLE tr_fhf SET ACCESS METHOD flux; +COMMIT; +-- CLUSTER (reorders the rows, so TIDs name other rows) +BEGIN; +INSERT INTO tr_cl SELECT * FROM tr_ref ORDER BY id DESC; +CLUSTER tr_cl USING tr_cl_pkey; +COMMIT; +-- ALTER COLUMN TYPE +BEGIN; +INSERT INTO tr_act SELECT * FROM tr_ref; +ALTER TABLE tr_act ALTER COLUMN id TYPE bigint; +COMMIT; +-- rows rolled back to a savepoint, then TRUNCATE and refill the same TIDs +BEGIN; +SAVEPOINT a; +INSERT INTO tr_trunc SELECT -id, w FROM tr_ref; +ROLLBACK TO a; +TRUNCATE tr_trunc; +INSERT INTO tr_trunc SELECT * FROM tr_ref; +COMMIT; +-- rows rolled back to a savepoint, then a rewrite moves live rows onto their TIDs +BEGIN; +SAVEPOINT a; +INSERT INTO tr_spa SELECT -id, w FROM tr_ref; +ROLLBACK TO a; +INSERT INTO tr_spa SELECT * FROM tr_ref; +ALTER TABLE tr_spa ALTER COLUMN id TYPE bigint; +COMMIT; +-- a rewrite inside a savepoint that rolls back: the old storage is current +-- again and its commit-time work must still be done +BEGIN; +INSERT INTO tr_spr SELECT * FROM tr_ref; +SAVEPOINT a; +ALTER TABLE tr_spr SET ACCESS METHOD heap; +ROLLBACK TO a; +COMMIT; +BEGIN; +SAVEPOINT a; +INSERT INTO tr_spr2 SELECT -id, w FROM tr_ref; +ROLLBACK TO a; +INSERT INTO tr_spr2 SELECT * FROM tr_ref; +SAVEPOINT b; +ALTER TABLE tr_spr2 ALTER COLUMN id TYPE bigint; +ROLLBACK TO b; +COMMIT; +SELECT t, tr_bad(t::regclass) AS bad +FROM unnest(ARRAY['tr_fh', 'tr_fhf', 'tr_cl', 'tr_act', 'tr_trunc', 'tr_spa', + 'tr_spr', 'tr_spr2']) t +WHERE tr_bad(t::regclass) <> 0 +ORDER BY 1; -- expect none +DROP TABLE tr_fh, tr_fhf, tr_cl, tr_act, tr_trunc, tr_spa, tr_spr, tr_spr2, tr_ref; +DROP FUNCTION tr_bad(regclass); + +-- Clean up. +DO $do$ +DECLARE + r record; +BEGIN + FOR r IN SELECT relname, relkind FROM pg_class + WHERE relname LIKE 'tf\_%' AND relkind IN ('r', 'm', 'p') + AND NOT relispartition LOOP + EXECUTE format('DROP %s %I', + CASE r.relkind WHEN 'm' THEN 'MATERIALIZED VIEW' ELSE 'TABLE' END, + r.relname); + END LOOP; +END +$do$; +DROP FUNCTION tf_check(regclass), tf_shape(regclass), tf_path(text, text, text[]), + tf_def_v(), tf_def_w(), tf_rdef_v(), tf_rdef_w(), tf_src(int); diff --git a/src/test/regress/sql/flux_trigger_memory.sql b/src/test/regress/sql/flux_trigger_memory.sql new file mode 100644 index 0000000000000..88f868e709399 --- /dev/null +++ b/src/test/regress/sql/flux_trigger_memory.sql @@ -0,0 +1,143 @@ +-- +-- Memory held for AFTER ROW trigger events across a long transaction. +-- +-- A FLUX row event carries copies of its row images (the TID names only the +-- newest version). Those copies must go away with the event: an immediate +-- event is gone at the end of its statement, an event of an aborted +-- subtransaction is gone at its rollback. Each case runs many top-level +-- statements in one transaction and compares the after-trigger event memory +-- after the first statement with that after the last; heap is the control. +-- +CREATE FUNCTION tm_ctx_bytes() RETURNS bigint LANGUAGE sql AS $$ + SELECT coalesce(sum(total_bytes), 0)::bigint FROM pg_backend_memory_contexts + WHERE name LIKE 'AfterTrigger%' +$$; +CREATE TEMP TABLE tm_mem (k text, n int, bytes bigint); +CREATE FUNCTION tm_noop() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RETURN NULL; +END $$; + +-- Immediate AFTER UPDATE row trigger +CREATE TABLE tm_trig_heap (id int PRIMARY KEY, v int) USING heap; +CREATE TABLE tm_trig_flux (id int PRIMARY KEY, v int) USING flux; +INSERT INTO tm_trig_heap SELECT g, 0 FROM generate_series(1, 1000) g; +INSERT INTO tm_trig_flux SELECT g, 0 FROM generate_series(1, 1000) g; +CREATE TRIGGER tm_after AFTER UPDATE ON tm_trig_heap + FOR EACH ROW EXECUTE FUNCTION tm_noop(); +CREATE TRIGGER tm_after AFTER UPDATE ON tm_trig_flux + FOR EACH ROW EXECUTE FUNCTION tm_noop(); + +-- Immediate foreign key check (RI trigger on the referencing table) +CREATE TABLE tm_pk_heap (id int PRIMARY KEY) USING heap; +CREATE TABLE tm_pk_flux (id int PRIMARY KEY) USING flux; +INSERT INTO tm_pk_heap VALUES (1), (2); +INSERT INTO tm_pk_flux VALUES (1), (2); +CREATE TABLE tm_fk_heap (id int PRIMARY KEY, pid int REFERENCES tm_pk_heap) + USING heap; +CREATE TABLE tm_fk_flux (id int PRIMARY KEY, pid int REFERENCES tm_pk_flux) + USING flux; +INSERT INTO tm_fk_heap SELECT g, 1 FROM generate_series(1, 1000) g; +INSERT INTO tm_fk_flux SELECT g, 1 FROM generate_series(1, 1000) g; + +-- Each case: one transaction of 200 top-level UPDATEs of 1000 rows (each in a +-- rolled-back savepoint for the subxact cases), recording the event memory +-- after the first and after the last. +\set ECHO none +\o /dev/null +BEGIN; +SELECT s FROM ( + SELECT 1 AS o, format($q$UPDATE tm_trig_heap SET v = v + 1; INSERT INTO tm_mem SELECT %L, 1, tm_ctx_bytes()$q$, 'trig_heap') AS s + UNION ALL SELECT 2, $q$UPDATE tm_trig_heap SET v = v + 1$q$ FROM generate_series(2, 199) + UNION ALL SELECT 3, format($q$UPDATE tm_trig_heap SET v = v + 1; INSERT INTO tm_mem SELECT %L, 200, tm_ctx_bytes()$q$, 'trig_heap') +) s ORDER BY o \gexec +COMMIT; +BEGIN; +SELECT s FROM ( + SELECT 1 AS o, format($q$UPDATE tm_trig_flux SET v = v + 1; INSERT INTO tm_mem SELECT %L, 1, tm_ctx_bytes()$q$, 'trig_flux') AS s + UNION ALL SELECT 2, $q$UPDATE tm_trig_flux SET v = v + 1$q$ FROM generate_series(2, 199) + UNION ALL SELECT 3, format($q$UPDATE tm_trig_flux SET v = v + 1; INSERT INTO tm_mem SELECT %L, 200, tm_ctx_bytes()$q$, 'trig_flux') +) s ORDER BY o \gexec +COMMIT; +BEGIN; +SELECT s FROM ( + SELECT 1 AS o, format($q$UPDATE tm_fk_heap SET pid = 3 - pid; INSERT INTO tm_mem SELECT %L, 1, tm_ctx_bytes()$q$, 'fk_heap') AS s + UNION ALL SELECT 2, $q$UPDATE tm_fk_heap SET pid = 3 - pid$q$ FROM generate_series(2, 199) + UNION ALL SELECT 3, format($q$UPDATE tm_fk_heap SET pid = 3 - pid; INSERT INTO tm_mem SELECT %L, 200, tm_ctx_bytes()$q$, 'fk_heap') +) s ORDER BY o \gexec +COMMIT; +BEGIN; +SELECT s FROM ( + SELECT 1 AS o, format($q$UPDATE tm_fk_flux SET pid = 3 - pid; INSERT INTO tm_mem SELECT %L, 1, tm_ctx_bytes()$q$, 'fk_flux') AS s + UNION ALL SELECT 2, $q$UPDATE tm_fk_flux SET pid = 3 - pid$q$ FROM generate_series(2, 199) + UNION ALL SELECT 3, format($q$UPDATE tm_fk_flux SET pid = 3 - pid; INSERT INTO tm_mem SELECT %L, 200, tm_ctx_bytes()$q$, 'fk_flux') +) s ORDER BY o \gexec +COMMIT; +\o +\set ECHO all + +-- Deferred trigger events of rolled-back subtransactions +CREATE CONSTRAINT TRIGGER tm_deferred AFTER UPDATE ON tm_trig_heap + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_noop(); +CREATE CONSTRAINT TRIGGER tm_deferred AFTER UPDATE ON tm_trig_flux + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_noop(); +\set ECHO none +\o /dev/null +BEGIN; +SELECT s FROM ( + SELECT 1 AS o, format($q$SAVEPOINT s; UPDATE tm_trig_heap SET v = v + 1; ROLLBACK TO s; RELEASE s; INSERT INTO tm_mem SELECT %L, 1, tm_ctx_bytes()$q$, 'subxact_heap') AS s + UNION ALL SELECT 2, $q$SAVEPOINT s; UPDATE tm_trig_heap SET v = v + 1; ROLLBACK TO s; RELEASE s$q$ FROM generate_series(2, 199) + UNION ALL SELECT 3, format($q$SAVEPOINT s; UPDATE tm_trig_heap SET v = v + 1; ROLLBACK TO s; RELEASE s; INSERT INTO tm_mem SELECT %L, 200, tm_ctx_bytes()$q$, 'subxact_heap') +) s ORDER BY o \gexec +COMMIT; +BEGIN; +SELECT s FROM ( + SELECT 1 AS o, format($q$SAVEPOINT s; UPDATE tm_trig_flux SET v = v + 1; ROLLBACK TO s; RELEASE s; INSERT INTO tm_mem SELECT %L, 1, tm_ctx_bytes()$q$, 'subxact_flux') AS s + UNION ALL SELECT 2, $q$SAVEPOINT s; UPDATE tm_trig_flux SET v = v + 1; ROLLBACK TO s; RELEASE s$q$ FROM generate_series(2, 199) + UNION ALL SELECT 3, format($q$SAVEPOINT s; UPDATE tm_trig_flux SET v = v + 1; ROLLBACK TO s; RELEASE s; INSERT INTO tm_mem SELECT %L, 200, tm_ctx_bytes()$q$, 'subxact_flux') +) s ORDER BY o \gexec +COMMIT; +\o +\set ECHO all + +-- One statement's FLUX images take about 250 kB, so keeping them for the +-- transaction grows the context by ~50 MB here. Heap grows by well under +-- 1 MB (per-statement bookkeeping, e.g. the modified-columns bitmaps). +SELECT a.k, b.bytes - a.bytes < 4 * 1024 * 1024 AS flat +FROM tm_mem a JOIN tm_mem b ON a.k = b.k AND a.n = 1 AND b.n > 1 +ORDER BY a.k; + +-- The deferred events of a committed statement still fire, with their images +CREATE TABLE tm_log (tab text, o int, n int); +CREATE FUNCTION tm_log_row() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO tm_log VALUES (TG_TABLE_NAME, OLD.v, NEW.v); + RETURN NULL; +END $$; +UPDATE tm_trig_heap SET v = 0; +UPDATE tm_trig_flux SET v = 0; +CREATE CONSTRAINT TRIGGER tm_deferred_log AFTER UPDATE ON tm_trig_heap + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_log_row(); +CREATE CONSTRAINT TRIGGER tm_deferred_log AFTER UPDATE ON tm_trig_flux + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION tm_log_row(); +BEGIN; +UPDATE tm_trig_heap SET v = 1000 WHERE id <= 3; +UPDATE tm_trig_flux SET v = 1000 WHERE id <= 3; +SAVEPOINT s1; +UPDATE tm_trig_heap SET v = 2000 WHERE id <= 3; +UPDATE tm_trig_flux SET v = 2000 WHERE id <= 3; +ROLLBACK TO s1; +SAVEPOINT s2; +UPDATE tm_trig_heap SET v = v + 1 WHERE id <= 3; +UPDATE tm_trig_flux SET v = v + 1 WHERE id <= 3; +RELEASE s2; +UPDATE tm_trig_heap SET v = v + 1 WHERE id <= 2; +UPDATE tm_trig_flux SET v = v + 1 WHERE id <= 2; +COMMIT; +SELECT tab, o, n, count(*) FROM tm_log GROUP BY 1, 2, 3 ORDER BY 1, 2, 3; +SELECT h.id, h.v AS heap_v, f.v AS flux_v +FROM tm_trig_heap h JOIN tm_trig_flux f USING (id) WHERE id <= 3 ORDER BY id; + +DROP TABLE tm_trig_heap, tm_trig_flux, tm_fk_heap, tm_fk_flux, tm_pk_heap, + tm_pk_flux, tm_log; +DROP FUNCTION tm_ctx_bytes(), tm_noop(), tm_log_row(); diff --git a/src/test/regress/sql/flux_user_catalog.sql b/src/test/regress/sql/flux_user_catalog.sql new file mode 100644 index 0000000000000..3b77acf931970 --- /dev/null +++ b/src/test/regress/sql/flux_user_catalog.sql @@ -0,0 +1,38 @@ +-- +-- FLUX: user_catalog_table is refused. +-- +-- A user_catalog_table is read by logical decoding under a historic snapshot +-- that reconstructs a past catalog state from the heap's dead row versions. +-- FLUX keeps old versions in undo and reuses the tuple in place, so it cannot +-- answer those historic-snapshot reads. Setting user_catalog_table on a FLUX +-- relation must therefore be refused, at CREATE, at ALTER TABLE ... SET, and +-- when converting a marked heap table to FLUX with SET ACCESS METHOD. The heap +-- twin of each case must still succeed, which is the control: the refusal is +-- specific to the FLUX access method, not to the option itself. +-- + +-- CREATE: heap succeeds, FLUX is refused. +CREATE TABLE uct_heap_create (id int) USING heap + WITH (user_catalog_table = true); +CREATE TABLE uct_flux_create (id int) USING flux + WITH (user_catalog_table = true); + +-- ALTER TABLE ... SET: heap succeeds, FLUX is refused. +CREATE TABLE uct_heap_set (id int) USING heap; +ALTER TABLE uct_heap_set SET (user_catalog_table = true); + +CREATE TABLE uct_flux_set (id int) USING flux; +ALTER TABLE uct_flux_set SET (user_catalog_table = true); + +-- SET ACCESS METHOD: converting a marked heap table to FLUX is refused with the +-- FLUX-specific message, before core's generic "cannot rewrite a catalog table" +-- restriction is reached. +CREATE TABLE uct_conv (id int) USING heap WITH (user_catalog_table = true); +ALTER TABLE uct_conv SET ACCESS METHOD flux; + +-- Clearing the option, and other FLUX reloptions, still work on a FLUX table. +CREATE TABLE uct_flux_clear (id int) USING flux WITH (fillfactor = 90); +ALTER TABLE uct_flux_clear SET (user_catalog_table = false); +ALTER TABLE uct_flux_clear SET (fillfactor = 80); + +DROP TABLE uct_heap_create, uct_heap_set, uct_flux_set, uct_conv, uct_flux_clear; diff --git a/src/test/regress/sql/flux_vacuum_stats.sql b/src/test/regress/sql/flux_vacuum_stats.sql new file mode 100644 index 0000000000000..cee5277cb6c72 --- /dev/null +++ b/src/test/regress/sql/flux_vacuum_stats.sql @@ -0,0 +1,45 @@ +-- A one-page heap twin makes physical page counts comparable despite headers. +CREATE TABLE fvs_heap (a int PRIMARY KEY) USING heap WITH (autovacuum_enabled=false); +CREATE TABLE fvs_flux (a int PRIMARY KEY) USING flux WITH (autovacuum_enabled=false); +INSERT INTO fvs_heap SELECT generate_series(1,20); +INSERT INTO fvs_flux SELECT generate_series(1,20); +VACUUM fvs_heap; +VACUUM fvs_flux; +SELECT relname, relpages = 1 AS pages, reltuples = 20 AS tuples, + relallvisible = 1 AS visible +FROM pg_class WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; +SELECT pg_stat_force_next_flush(); +SELECT pg_stat_clear_snapshot(); +SELECT relname, vacuum_count = 1 AS vacuum_reported +FROM pg_stat_user_tables WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; +-- Burn an xid so a no-op freeze cannot pass on a newly created relation. +SELECT pg_current_xact_id() IS NOT NULL AS assigned; +VACUUM (FREEZE) fvs_heap; +VACUUM (FREEZE) fvs_flux; +SELECT relname, age(relfrozenxid) <= 1 AS frozen, + relallfrozen = 1 AS frozen_page +FROM pg_class WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; +-- A repeated vacuum must count pages skipped as all-frozen, not report zero. +VACUUM fvs_heap; +VACUUM fvs_flux; +SELECT relname, reltuples = 20 AS tuples +FROM pg_class WHERE relname IN ('fvs_heap', 'fvs_flux') ORDER BY relname; +-- Force bulk-delete so both index AMs return accurate cleanup statistics. +INSERT INTO fvs_heap VALUES (99); +INSERT INTO fvs_flux VALUES (99); +DELETE FROM fvs_heap WHERE a=99; +DELETE FROM fvs_flux WHERE a=99; +VACUUM (INDEX_CLEANUP ON) fvs_heap; +VACUUM (INDEX_CLEANUP ON) fvs_flux; +SELECT relname, relpages > 0 AS pages, reltuples = 20 AS tuples +FROM pg_class WHERE relname IN ('fvs_heap_pkey', 'fvs_flux_pkey') ORDER BY relname; +-- A cursor stores a version identity before DML freezes its before-image. +BEGIN; +DECLARE fvs_h CURSOR FOR SELECT * FROM fvs_heap WHERE a=1; +DECLARE fvs_f CURSOR FOR SELECT * FROM fvs_flux WHERE a=1; +FETCH fvs_h; +FETCH fvs_f; +UPDATE fvs_heap SET a=101 WHERE CURRENT OF fvs_h; +UPDATE fvs_flux SET a=101 WHERE CURRENT OF fvs_f; +ROLLBACK; +DROP TABLE fvs_heap, fvs_flux; diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build index e71e95c6297eb..19a40d4994c83 100644 --- a/src/test/subscription/meson.build +++ b/src/test/subscription/meson.build @@ -49,6 +49,13 @@ tests += { 't/037_except.pl', 't/038_walsnd_shutdown_timeout.pl', 't/100_bugs.pl', + 't/101_inplace_am_logicalrep.pl', + 't/102_inplace_am_apply_locks.pl', + 't/103_inplace_am_replica_identity.pl', + 't/104_inplace_am_update_conflict.pl', + 't/105_inplace_am_update_deleted.pl', + 't/106_inplace_am_key_update_wait.pl', + 't/107_inplace_am_outofplace_update.pl', ], }, } diff --git a/src/test/subscription/t/101_inplace_am_logicalrep.pl b/src/test/subscription/t/101_inplace_am_logicalrep.pl new file mode 100644 index 0000000000000..a0ac0f4dd3745 --- /dev/null +++ b/src/test/subscription/t/101_inplace_am_logicalrep.pl @@ -0,0 +1,66 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# Logical replication of INSERT/UPDATE/DELETE into and out of the FLUX +# in-place table AM, in both directions. Exercises FLUX as a subscriber +# (the apply worker's UPDATE/DELETE/INSERT paths) and as a decoding source +# (the same-size in-place UPDATE path and the batch INSERT path both have to +# produce records the logical decoder can turn into a change). +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +sub roundtrip +{ + my ($pub_am, $sub_am) = @_; + + my $pub = PostgreSQL::Test::Cluster->new("pub_${pub_am}_${sub_am}"); + $pub->init(allows_streaming => 'logical'); + $pub->start; + my $sub = PostgreSQL::Test::Cluster->new("sub_${pub_am}_${sub_am}"); + $sub->init; + $sub->start; + + # Same schema, possibly different AM, on each side. + $pub->safe_psql('postgres', + "CREATE TABLE t (id int PRIMARY KEY, v int) USING $pub_am"); + $sub->safe_psql('postgres', + "CREATE TABLE t (id int PRIMARY KEY, v int) USING $sub_am"); + + $pub->safe_psql('postgres', + "INSERT INTO t SELECT g, g FROM generate_series(1, 20) g"); + $pub->safe_psql('postgres', "CREATE PUBLICATION p FOR TABLE t"); + + my $connstr = $pub->connstr . ' dbname=postgres'; + $sub->safe_psql('postgres', + "CREATE SUBSCRIPTION s CONNECTION '$connstr' PUBLICATION p"); + $sub->wait_for_subscription_sync($pub, 's'); + + # Initial COPY sync. + is($sub->safe_psql('postgres', 'SELECT count(*), sum(v) FROM t'), + '20|210', "$pub_am -> $sub_am: initial sync"); + + # UPDATE (the aliasing / CAS-decode / crit-section paths), DELETE, INSERT. + $pub->safe_psql('postgres', 'UPDATE t SET v = v + 100 WHERE id <= 10'); + $pub->safe_psql('postgres', 'DELETE FROM t WHERE id > 15'); + $pub->safe_psql('postgres', 'INSERT INTO t VALUES (100, 999)'); + $pub->wait_for_catchup('s'); + + is($sub->safe_psql('postgres', 'SELECT count(*), sum(v) FROM t'), + '16|2119', "$pub_am -> $sub_am: UPDATE/DELETE/INSERT streamed"); + + # Exact row check for the updated rows. + is($sub->safe_psql('postgres', 'SELECT v FROM t WHERE id = 5'), + '105', "$pub_am -> $sub_am: UPDATE value correct on subscriber"); + + $sub->safe_psql('postgres', 'DROP SUBSCRIPTION s'); + $sub->stop; + $pub->stop; +} + +# Into the FLUX AM and out of it, both directions. +roundtrip('heap', 'flux'); +roundtrip('flux', 'heap'); + +done_testing(); diff --git a/src/test/subscription/t/102_inplace_am_apply_locks.pl b/src/test/subscription/t/102_inplace_am_apply_locks.pl new file mode 100644 index 0000000000000..c6d75ca00b05f --- /dev/null +++ b/src/test/subscription/t/102_inplace_am_apply_locks.pl @@ -0,0 +1,216 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# The logical replication apply worker locks the row it is about to update or +# delete (RelationFindReplTupleByIndex, table_tuple_lock without +# TUPLE_LOCK_FLAG_FIND_LAST_VERSION) and, with a BEFORE ROW trigger enabled +# for replication, locks it again from GetTupleForTrigger, under an +# EvalPlanQual state that has no plan. Run the same changes against +# concurrent local transactions on the subscriber, once with heap on both +# sides and once with FLUX on both sides, and require FLUX to end in heap's +# state. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $schema = q{ + CREATE TABLE t (id int PRIMARY KEY, v int, w int) USING %AM%; + CREATE TABLE tt (id int PRIMARY KEY, v int, w int) USING %AM%; +}; +my $sub_extra = q{ + CREATE FUNCTION tt_bu() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + NEW.w := NEW.w + 100; + RETURN NEW; + END $$; + CREATE TRIGGER tt_bu BEFORE UPDATE OR DELETE ON tt + FOR EACH ROW EXECUTE FUNCTION tt_bu(); + ALTER TABLE tt ENABLE ALWAYS TRIGGER tt_bu; +}; + +# Wait until the apply worker is blocked on a lock; report whether it was. +sub apply_waits +{ + my ($node) = @_; + for (1 .. 50) + { + return 'waited' + if $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM pg_stat_activity + WHERE backend_type = 'logical replication apply worker' + AND wait_event_type = 'Lock'}) > 0; + select(undef, undef, undef, 0.1); + } + return 'no wait'; +} + +sub run_am +{ + my ($am) = @_; + my %r; + + my $pub = PostgreSQL::Test::Cluster->new("pub_$am"); + $pub->init(allows_streaming => 'logical'); + $pub->start; + my $sub = PostgreSQL::Test::Cluster->new("sub_$am"); + $sub->init; + $sub->start; + + (my $ddl = $schema) =~ s/%AM%/$am/g; + $pub->safe_psql('postgres', $ddl); + $sub->safe_psql('postgres', $ddl . $sub_extra); + $pub->safe_psql( + 'postgres', + q{INSERT INTO t SELECT g, g, 0 FROM generate_series(1, 8) g; + INSERT INTO tt SELECT g, g, 0 FROM generate_series(1, 8) g; + CREATE PUBLICATION p FOR TABLE t, tt}); + my $connstr = $pub->connstr . ' dbname=postgres'; + $sub->safe_psql('postgres', + "CREATE SUBSCRIPTION s CONNECTION '$connstr' PUBLICATION p"); + $sub->wait_for_subscription_sync($pub, 's'); + + my $q = + 'SELECT string_agg(format(\'(%s,%s,%s)\', id, v, w), \' \' ORDER BY id) FROM '; + + # A replicated change against an in-progress local transaction that + # updates or deletes the same row: the apply worker waits for it, then + # applies the change to the committed version, if one is left. + my @cases = ( + [ + 'update vs local update', + 't', 'UPDATE t SET w = w + 1 WHERE id = 1', + 'COMMIT', 'UPDATE t SET v = v + 100 WHERE id = 1' + ], + [ + 'update vs local rollback', + 't', + 'UPDATE t SET w = w + 1 WHERE id = 2', + 'ROLLBACK', + 'UPDATE t SET v = v + 100 WHERE id = 2' + ], + [ + 'delete vs local update', + 't', 'UPDATE t SET w = w + 1 WHERE id = 3', + 'COMMIT', 'DELETE FROM t WHERE id = 3' + ], + [ + 'update vs local lock + update', + 't', + 'SELECT * FROM t WHERE id = 5 FOR UPDATE; UPDATE t SET w = w + 1 WHERE id = 5', + 'COMMIT', + 'UPDATE t SET v = v + 100 WHERE id = 5' + ], + [ + 'trigger: update, no conflict', 'tt', + undef, undef, + 'UPDATE tt SET v = v + 100 WHERE id = 1' + ], + [ + 'trigger: update vs local update', + 'tt', + 'UPDATE tt SET w = w + 1 WHERE id = 2', + 'COMMIT', + 'UPDATE tt SET v = v + 100 WHERE id = 2' + ], + [ + 'trigger: update vs local update twice', + 'tt', + 'UPDATE tt SET w = w + 1 WHERE id = 3; UPDATE tt SET w = w + 1 WHERE id = 3', + 'COMMIT', + 'UPDATE tt SET v = v + 100 WHERE id = 3' + ], + [ + 'trigger: delete vs local update', + 'tt', + 'UPDATE tt SET w = w + 1 WHERE id = 4', + 'COMMIT', + 'DELETE FROM tt WHERE id = 4' + ], + [ + 'update vs local delete', + 't', 'DELETE FROM t WHERE id = 6', + 'COMMIT', 'UPDATE t SET v = v + 100 WHERE id = 6' + ], + [ + 'trigger: delete, no conflict', 'tt', + undef, undef, + 'DELETE FROM tt WHERE id = 6' + ], + [ + 'trigger: update vs local delete', + 'tt', 'DELETE FROM tt WHERE id = 7', + 'COMMIT', 'UPDATE tt SET v = v + 100 WHERE id = 7' + ],); + + foreach my $c (@cases) + { + my ($name, $tab, $local, $end, $remote) = @$c; + my $wait = ''; + if (defined $local) + { + my $bg = $sub->background_psql('postgres'); + $bg->query_safe("BEGIN; $local"); + $pub->safe_psql('postgres', $remote); + $wait = apply_waits($sub); + $bg->query_safe($end); + $bg->quit; + } + else + { + $pub->safe_psql('postgres', $remote); + } + $pub->wait_for_catchup('s'); + $r{$name} = "$wait | " . $sub->safe_psql('postgres', $q . $tab); + } + + # The replicated columns agree with the publisher, except on the rows + # a local transaction deleted (the publisher's change to them found + # nothing to apply). + foreach my $tk ([ 't', '6' ], [ 'tt', '7' ]) + { + my ($tab, $skip) = @$tk; + my $rq = + "SELECT string_agg(format('(%s,%s)', id, v), ' ' ORDER BY id) " + . "FROM $tab WHERE id NOT IN ($skip)"; + $r{"$tab matches publisher"} = + $sub->safe_psql('postgres', $rq) eq $pub->safe_psql('postgres', $rq) + ? 'yes' + : 'no: ' + . $sub->safe_psql('postgres', $rq) . ' vs ' + . $pub->safe_psql('postgres', $rq); + } + + # Nothing crashed, and the worker that applied all this is still the + # first one. + $r{'server crashes'} = () = + slurp_file($sub->logfile) =~ /terminated by signal/g; + $r{'apply errors'} = () = slurp_file($sub->logfile) =~ /ERROR:/g; + + $sub->safe_psql('postgres', 'DROP SUBSCRIPTION s'); + $sub->stop; + $pub->stop; + return \%r; +} + +my $heap = run_am('heap'); +my $flux = run_am('flux'); + +foreach my $k (sort keys %$heap) +{ + is($flux->{$k}, $heap->{$k}, "flux matches heap: $k"); + note "heap $k: $heap->{$k}"; +} +foreach my $am ([ 'heap', $heap ], [ 'flux', $flux ]) +{ + my ($name, $r) = @$am; + is($r->{'server crashes'}, + 0, "$name: no \"terminated by signal\" in the subscriber log"); + is($r->{'t matches publisher'}, 'yes', "$name: t matches the publisher"); + is($r->{'tt matches publisher'}, 'yes', + "$name: tt matches the publisher"); +} + +done_testing(); diff --git a/src/test/subscription/t/103_inplace_am_replica_identity.pl b/src/test/subscription/t/103_inplace_am_replica_identity.pl new file mode 100644 index 0000000000000..3f206cb507896 --- /dev/null +++ b/src/test/subscription/t/103_inplace_am_replica_identity.pl @@ -0,0 +1,87 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Exercise sequential and indexed replica identity lookup with heap controls. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +for my $am ('heap', 'flux') +{ + my $pub = PostgreSQL::Test::Cluster->new("pub_$am"); + $pub->init(allows_streaming => 'logical'); + $pub->start; + my $sub = PostgreSQL::Test::Cluster->new("sub_$am"); + $sub->init; + $sub->start; + + my $ddl = qq{ + CREATE TABLE d (id int PRIMARY KEY, v text) USING $am; + CREATE TABLE f (id int, v text) USING $am; + ALTER TABLE f REPLICA IDENTITY FULL; + CREATE TABLE i (id int NOT NULL, v text) USING $am; + CREATE UNIQUE INDEX i_key ON i(id); + ALTER TABLE i REPLICA IDENTITY USING INDEX i_key; + }; + $pub->safe_psql('postgres', $ddl); + $sub->safe_psql('postgres', $ddl); + $pub->safe_psql('postgres', 'CREATE PUBLICATION p FOR TABLE d, f, i'); + my $connstr = $pub->connstr . ' dbname=postgres'; + $sub->safe_psql('postgres', + "CREATE SUBSCRIPTION s CONNECTION '$connstr' PUBLICATION p"); + $sub->wait_for_subscription_sync($pub, 's'); + + for my $tab ('d', 'f', 'i') + { + $pub->safe_psql('postgres', + "INSERT INTO $tab VALUES (1, 'one'), (2, 'two')"); + $pub->wait_for_catchup('s'); + $pub->safe_psql('postgres', "UPDATE $tab SET v = 'new' WHERE id = 1"); + $pub->wait_for_catchup('s'); + is( $sub->safe_psql('postgres', "SELECT id, v FROM $tab ORDER BY id"), + "1|new\n2|two", + "$am $tab update"); + $pub->safe_psql('postgres', "DELETE FROM $tab WHERE id = 2"); + $pub->wait_for_catchup('s'); + is( $sub->safe_psql('postgres', "SELECT id, v FROM $tab ORDER BY id"), + '1|new', + "$am $tab delete"); + } + + # A FULL old image must carry the toasted value needed by a row filter. + for my $node ($pub, $sub) + { + $node->safe_psql( + 'postgres', qq{ + CREATE TABLE toast_filter (id int, b text, c text) USING $am; + ALTER TABLE toast_filter ALTER COLUMN c SET STORAGE EXTERNAL; + ALTER TABLE toast_filter REPLICA IDENTITY FULL; + }); + } + $pub->safe_psql( + 'postgres', q{ + ALTER PUBLICATION p ADD TABLE toast_filter WHERE (length(c) > 1000); + }); + $sub->safe_psql('postgres', 'ALTER SUBSCRIPTION s REFRESH PUBLICATION'); + $sub->wait_for_subscription_sync($pub, 's'); + $pub->safe_psql( + 'postgres', q{ + INSERT INTO toast_filter VALUES (1, 'old', repeat('toast', 1000)); + }); + $pub->wait_for_catchup('s'); + $pub->safe_psql('postgres', "UPDATE toast_filter SET b = '1'"); + $pub->wait_for_catchup('s'); + is( $sub->safe_psql( + 'postgres', 'SELECT id, b, length(c) FROM toast_filter'), + '1|1|5000', + "$am toasted row filter update"); + $pub->safe_psql('postgres', 'DELETE FROM toast_filter'); + $pub->wait_for_catchup('s'); + is($sub->safe_psql('postgres', 'SELECT count(*) FROM toast_filter'), + '0', "$am toasted row filter delete"); + $sub->safe_psql('postgres', 'DROP SUBSCRIPTION s'); + $sub->stop; + $pub->stop; +} +done_testing(); diff --git a/src/test/subscription/t/104_inplace_am_update_conflict.pl b/src/test/subscription/t/104_inplace_am_update_conflict.pl new file mode 100644 index 0000000000000..3b261460f2dcb --- /dev/null +++ b/src/test/subscription/t/104_inplace_am_update_conflict.pl @@ -0,0 +1,117 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# An UPDATE the apply worker cannot apply because its new key already +# exists must be reported as a conflict (update_exists, or +# multiple_unique_conflicts when it collides in several unique indexes), as +# for heap. An in-place AM maintains the indexes of a key change inside +# table_tuple_update, where a duplicate raises a plain unique violation, so +# the apply worker has to look for the conflict before the update. A key +# update that collides only with the row itself is no conflict. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +for my $am ('heap', 'flux') +{ + my $pub = PostgreSQL::Test::Cluster->new("pub_$am"); + $pub->init(allows_streaming => 'logical'); + $pub->start; + my $sub = PostgreSQL::Test::Cluster->new("sub_$am"); + $sub->init; + $sub->start; + + my $ddl = + "CREATE TABLE c (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE) USING $am"; + $pub->safe_psql('postgres', $ddl); + $sub->safe_psql('postgres', $ddl); + $pub->safe_psql('postgres', 'CREATE PUBLICATION p FOR TABLE c'); + my $connstr = $pub->connstr . ' dbname=postgres'; + $sub->safe_psql('postgres', + "CREATE SUBSCRIPTION s CONNECTION '$connstr' PUBLICATION p WITH (disable_on_error = true)" + ); + $sub->wait_for_subscription_sync($pub, 's'); + + $pub->safe_psql('postgres', 'INSERT INTO c VALUES (5, 5, 5)'); + $pub->wait_for_catchup('s'); + + # Wait for the apply worker to stop on the error, then return the log + # written since $offset. + my $error_log = sub { + my ($offset) = @_; + $sub->poll_query_until('postgres', + "SELECT NOT subenabled FROM pg_subscription WHERE subname = 's'") + or die "timed out waiting for the subscription to be disabled"; + return slurp_file($sub->logfile, $offset); + }; + + # The new row collides with three local rows, one per unique index. + $sub->safe_psql('postgres', + 'INSERT INTO c VALUES (6, 6, 6), (7, 7, 7), (8, 8, 8)'); + my $offset = -s $sub->logfile; + $pub->safe_psql('postgres', + 'UPDATE c SET a = 6, b = 7, c = 8 WHERE a = 5'); + my $log = $error_log->($offset); + like( + $log, + qr/conflict detected on relation "public.c": conflict=multiple_unique_conflicts.* +.*Could not apply remote change: remote row \(6, 7, 8\), replica identity \(a\)=\(5\).* +.*Key already exists in unique index "c_pkey", modified in transaction .*: key \(a\)=\(6\), local row \(6, 6, 6\).* +.*Key already exists in unique index "c_b_key", modified in transaction .*: key \(b\)=\(7\), local row \(7, 7, 7\).* +.*Key already exists in unique index "c_c_key", modified in transaction .*: key \(c\)=\(8\), local row \(8, 8, 8\)/, + "$am: multiple_unique_conflicts detected during update"); + unlike( + $log, + qr/duplicate key value violates unique constraint/, + "$am: no plain unique violation instead of the conflict"); + + # Remove the local rows; the retried transaction applies. + $sub->safe_psql('postgres', 'DELETE FROM c WHERE a IN (6, 7, 8)'); + $sub->safe_psql('postgres', 'ALTER SUBSCRIPTION s ENABLE'); + $pub->wait_for_catchup('s'); + is($sub->safe_psql('postgres', 'SELECT a, b, c FROM c'), + '6|7|8', "$am: update applied once the conflicting rows are gone"); + + # One unique index: update_exists. + $sub->safe_psql('postgres', 'INSERT INTO c VALUES (9, 9, 9)'); + $offset = -s $sub->logfile; + $pub->safe_psql('postgres', 'UPDATE c SET a = 9 WHERE a = 6'); + $log = $error_log->($offset); + like( + $log, + qr/conflict detected on relation "public.c": conflict=update_exists.* +.*Could not apply remote change: remote row \(9, 7, 8\), replica identity \(a\)=\(6\).* +.*Key already exists in unique index "c_pkey", modified in transaction .*: key \(a\)=\(9\), local row \(9, 9, 9\)/, + "$am: update_exists detected"); + $sub->safe_psql('postgres', 'DELETE FROM c WHERE a = 9'); + $sub->safe_psql('postgres', 'ALTER SUBSCRIPTION s ENABLE'); + $pub->wait_for_catchup('s'); + is($sub->safe_psql('postgres', 'SELECT a, b, c FROM c'), + '9|7|8', "$am: update_exists update applied after the fix-up"); + + # A key update whose other unique keys stay the same matches only the + # row itself in those indexes: no conflict. + $offset = -s $sub->logfile; + $pub->safe_psql('postgres', 'UPDATE c SET b = 70 WHERE a = 9'); + $pub->safe_psql('postgres', 'UPDATE c SET a = 10 WHERE a = 9'); + $pub->wait_for_catchup('s'); + is($sub->safe_psql('postgres', 'SELECT a, b, c FROM c'), + '10|70|8', + "$am: key updates that collide only with the row itself apply"); + is( $sub->safe_psql( + 'postgres', + "SELECT subenabled FROM pg_subscription WHERE subname = 's'"), + 't', + "$am: no false conflict"); + unlike( + slurp_file($sub->logfile, $offset), + qr/conflict detected/, + "$am: no conflict reported for a self-match"); + + $sub->stop; + $pub->stop; +} + +done_testing(); diff --git a/src/test/subscription/t/105_inplace_am_update_deleted.pl b/src/test/subscription/t/105_inplace_am_update_deleted.pl new file mode 100644 index 0000000000000..4da1b5b2e9108 --- /dev/null +++ b/src/test/subscription/t/105_inplace_am_update_deleted.pl @@ -0,0 +1,116 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# With retain_dead_tuples, a replicated UPDATE of a row that was deleted +# locally must be reported as an update_deleted conflict, naming the local +# deleting transaction, as for heap; not as update_missing. The apply worker +# finds the deleted version with a SnapshotAny scan, through the replica +# identity index or, without a usable one, sequentially. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +for my $am ('heap', 'flux') +{ + my $pub = PostgreSQL::Test::Cluster->new("pub_$am"); + $pub->init(allows_streaming => 'logical'); + $pub->start; + + # retain_dead_tuples needs a replication slot and commit timestamps on + # the subscriber. No autovacuum, so that only the test's own VACUUM runs. + my $sub = PostgreSQL::Test::Cluster->new("sub_$am"); + $sub->init(allows_streaming => 'logical'); + $sub->append_conf( + 'postgresql.conf', qq{ +track_commit_timestamp = on +autovacuum = off +}); + $sub->start; + + my $ddl = qq{ + CREATE TABLE pk (a int PRIMARY KEY, b int) USING $am; + CREATE TABLE full_ri (a int, b int) USING $am; + ALTER TABLE full_ri REPLICA IDENTITY FULL; + }; + $pub->safe_psql('postgres', $ddl); + $sub->safe_psql('postgres', $ddl); + $pub->safe_psql('postgres', 'CREATE PUBLICATION p FOR TABLE pk, full_ri'); + my $connstr = $pub->connstr . ' dbname=postgres'; + $sub->safe_psql('postgres', + "CREATE SUBSCRIPTION s CONNECTION '$connstr' PUBLICATION p WITH (retain_dead_tuples = true)" + ); + $sub->wait_for_subscription_sync($pub, 's'); + ok( $sub->poll_query_until( + 'postgres', + "SELECT xmin IS NOT NULL FROM pg_replication_slots WHERE slot_name = 'pg_conflict_detection'" + ), + "$am: conflict detection slot holds an xmin"); + + $pub->safe_psql('postgres', + 'INSERT INTO pk VALUES (1, 1), (2, 2); INSERT INTO full_ri VALUES (1, 1), (2, 2)' + ); + $pub->wait_for_catchup('s'); + + # Stop the apply worker, so that the remote UPDATEs arrive after the + # local DELETEs. The slot's xmin cannot advance meanwhile. + $sub->safe_psql('postgres', 'ALTER SUBSCRIPTION s DISABLE'); + $sub->poll_query_until('postgres', + "SELECT count(*) = 0 FROM pg_stat_activity WHERE backend_type = 'logical replication apply worker'" + ) or die "timed out waiting for the apply worker to stop"; + + $pub->safe_psql('postgres', + 'UPDATE pk SET b = 10 WHERE a = 1; UPDATE full_ri SET b = 10 WHERE a = 1' + ); + $sub->safe_psql('postgres', + 'DELETE FROM pk WHERE a = 1; DELETE FROM full_ri WHERE a = 1'); + + # The deleted rows are still needed for conflict detection: VACUUM keeps + # them, and says so. + for my $tab ('pk', 'full_ri') + { + my ($ret, $stdout, $stderr) = + $sub->psql('postgres', "VACUUM (VERBOSE) $tab"); + like( + $stderr, + qr/1 are dead but not yet removable/, + "$am: VACUUM keeps the deleted row of $tab"); + } + + my $offset = -s $sub->logfile; + $sub->safe_psql('postgres', 'ALTER SUBSCRIPTION s ENABLE'); + $pub->wait_for_catchup('s'); + my $log = slurp_file($sub->logfile, $offset); + + # Primary key replica identity: the deleted row is found by index. + like( + $log, + qr/conflict detected on relation "public.pk": conflict=update_deleted.* +.*DETAIL:.* Could not find the row to be updated: remote row \(1, 10\), replica identity \(a\)=\(1\). +.*The row to be updated was deleted locally in transaction [0-9]+ at .*/, + "$am: update_deleted detected through the replica identity index"); + + # REPLICA IDENTITY FULL without an index: found by a sequential scan. + like( + $log, + qr/conflict detected on relation "public.full_ri": conflict=update_deleted.* +.*DETAIL:.* Could not find the row to be updated: remote row \(1, 10\), replica identity full \(1, 1\).* +.*The row to be updated was deleted locally in transaction [0-9]+ at .*/, + "$am: update_deleted detected by a sequential scan"); + + unlike($log, qr/conflict=update_missing/, + "$am: no update_missing reported for a locally deleted row"); + + is( $sub->safe_psql( + 'postgres', + 'SELECT (SELECT count(*) FROM pk) || \',\' || (SELECT count(*) FROM full_ri)' + ), + '1,1', + "$am: the remote updates were skipped"); + + $sub->stop; + $pub->stop; +} + +done_testing(); diff --git a/src/test/subscription/t/106_inplace_am_key_update_wait.pl b/src/test/subscription/t/106_inplace_am_key_update_wait.pl new file mode 100644 index 0000000000000..a07f7d0d24830 --- /dev/null +++ b/src/test/subscription/t/106_inplace_am_key_update_wait.pl @@ -0,0 +1,187 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# A replicated UPDATE racing a local transaction that changes the row's +# replica-identity key. Heap's apply worker finds the old version through +# SnapshotDirty with the updater as xmax, waits for it, and searches again; +# when the local key update rolls back, it applies the replicated UPDATE. +# FLUX must do the same: an apply worker that did not wait skipped the +# replicated UPDATE (a lost update). + +# The logical replication apply worker locks the row it is about to update or +# delete (RelationFindReplTupleByIndex, table_tuple_lock without +# TUPLE_LOCK_FLAG_FIND_LAST_VERSION) and, with a BEFORE ROW trigger enabled +# for replication, locks it again from GetTupleForTrigger, under an +# EvalPlanQual state that has no plan. Run the same changes against +# concurrent local transactions on the subscriber, once with heap on both +# sides and once with FLUX on both sides, and require FLUX to end in heap's +# state. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $schema = q{ + CREATE TABLE t (id int PRIMARY KEY, v int, w int) USING %AM%; + CREATE TABLE tt (id int PRIMARY KEY, v int, w int) USING %AM%; +}; +my $sub_extra = q{ + CREATE FUNCTION tt_bu() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + NEW.w := NEW.w + 100; + RETURN NEW; + END $$; + CREATE TRIGGER tt_bu BEFORE UPDATE OR DELETE ON tt + FOR EACH ROW EXECUTE FUNCTION tt_bu(); + ALTER TABLE tt ENABLE ALWAYS TRIGGER tt_bu; +}; + +# Wait until the apply worker is blocked on a lock; report whether it was. +sub apply_waits +{ + my ($node) = @_; + for (1 .. 50) + { + return 'waited' + if $node->safe_psql( + 'postgres', + q{SELECT count(*) FROM pg_stat_activity + WHERE backend_type = 'logical replication apply worker' + AND wait_event_type = 'Lock'}) > 0; + select(undef, undef, undef, 0.1); + } + return 'no wait'; +} + +sub run_am +{ + my ($am) = @_; + my %r; + + my $pub = PostgreSQL::Test::Cluster->new("pub_$am"); + $pub->init(allows_streaming => 'logical'); + $pub->start; + my $sub = PostgreSQL::Test::Cluster->new("sub_$am"); + $sub->init; + $sub->start; + + (my $ddl = $schema) =~ s/%AM%/$am/g; + $pub->safe_psql('postgres', $ddl); + $sub->safe_psql('postgres', $ddl . $sub_extra); + $pub->safe_psql( + 'postgres', + q{INSERT INTO t SELECT g, g, 0 FROM generate_series(1, 8) g; + INSERT INTO tt SELECT g, g, 0 FROM generate_series(1, 8) g; + CREATE PUBLICATION p FOR TABLE t, tt}); + my $connstr = $pub->connstr . ' dbname=postgres'; + $sub->safe_psql('postgres', + "CREATE SUBSCRIPTION s CONNECTION '$connstr' PUBLICATION p"); + $sub->wait_for_subscription_sync($pub, 's'); + + my $q = + 'SELECT string_agg(format(\'(%s,%s,%s)\', id, v, w), \' \' ORDER BY id) FROM '; + + # A replicated change against an in-progress local transaction that + # updates the same row: the apply worker waits for it, then applies + # the change to the committed version. + my @cases = ( + [ + 'update vs local key update', + 't', + 'UPDATE t SET id = 40 WHERE id = 4', + 'COMMIT', + 'UPDATE t SET v = v + 100 WHERE id = 4' + ], + [ + 'update vs local key rollback', + 't', + 'UPDATE t SET id = 70 WHERE id = 7', + 'ROLLBACK', + 'UPDATE t SET v = v + 100 WHERE id = 7' + ], + [ + 'trigger: update vs local key update', + 'tt', + 'UPDATE tt SET id = 50 WHERE id = 5', + 'COMMIT', + 'UPDATE tt SET v = v + 100 WHERE id = 5' + ], + [ + 'trigger: update vs local key rollback', + 'tt', + 'UPDATE tt SET id = 80 WHERE id = 8', + 'ROLLBACK', + 'UPDATE tt SET v = v + 100 WHERE id = 8' + ],); + + foreach my $c (@cases) + { + my ($name, $tab, $local, $end, $remote) = @$c; + my $wait = ''; + if (defined $local) + { + my $bg = $sub->background_psql('postgres'); + $bg->query_safe("BEGIN; $local"); + $pub->safe_psql('postgres', $remote); + $wait = apply_waits($sub); + $bg->query_safe($end); + $bg->quit; + } + else + { + $pub->safe_psql('postgres', $remote); + } + $pub->wait_for_catchup('s'); + $r{$name} = "$wait | " . $sub->safe_psql('postgres', $q . $tab); + } + + # The replicated columns agree with the publisher, except on the rows + # whose key a local transaction changed (the publisher's change to the + # old key found nothing to apply). The rolled-back rows (t 7, tt 8) + # must match: a mismatch there is a lost replicated UPDATE. + foreach my $tk ([ 't', '4, 40' ], [ 'tt', '5, 50' ]) + { + my ($tab, $skip) = @$tk; + my $rq = + "SELECT string_agg(format('(%s,%s)', id, v), ' ' ORDER BY id) " + . "FROM $tab WHERE id NOT IN ($skip)"; + $r{"$tab matches publisher"} = + $sub->safe_psql('postgres', $rq) eq $pub->safe_psql('postgres', $rq) + ? 'yes' + : 'no: ' + . $sub->safe_psql('postgres', $rq) . ' vs ' + . $pub->safe_psql('postgres', $rq); + } + + # Nothing crashed, and the worker that applied all this is still the + # first one. + $r{'server crashes'} = () = + slurp_file($sub->logfile) =~ /terminated by signal/g; + $r{'apply errors'} = () = slurp_file($sub->logfile) =~ /ERROR:/g; + + $sub->safe_psql('postgres', 'DROP SUBSCRIPTION s'); + $sub->stop; + $pub->stop; + return \%r; +} + +my $heap = run_am('heap'); +my $flux = run_am('flux'); + +foreach my $k (sort keys %$heap) +{ + is($flux->{$k}, $heap->{$k}, "flux matches heap: $k"); + note "heap $k: $heap->{$k}"; +} +foreach my $am ([ 'heap', $heap ], [ 'flux', $flux ]) +{ + my ($name, $r) = @$am; + is($r->{'server crashes'}, + 0, "$name: no \"terminated by signal\" in the subscriber log"); + is($r->{'t matches publisher'}, 'yes', "$name: t matches the publisher"); + is($r->{'tt matches publisher'}, 'yes', + "$name: tt matches the publisher"); +} + +done_testing(); diff --git a/src/test/subscription/t/107_inplace_am_outofplace_update.pl b/src/test/subscription/t/107_inplace_am_outofplace_update.pl new file mode 100644 index 0000000000000..381fe9a9fb002 --- /dev/null +++ b/src/test/subscription/t/107_inplace_am_outofplace_update.pl @@ -0,0 +1,134 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# An UPDATE that FLUX performs out of place (a changed exclusion-constraint +# key, or a row that cannot grow on its full page, moves to a new TID) must +# replicate as an UPDATE, as heap's cross-page UPDATE does. Were it decoded +# as a DELETE plus an INSERT, a publication WITH (publish = 'update') would +# drop it, and the subscriber's UPDATE triggers would not fire. +# +# Publish a heap table and a FLUX table that receive the same changes, to one +# subscriber with heap tables and one with FLUX tables, and require the FLUX +# table to replicate as the heap one does. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $schema = q{ + CREATE TABLE xt_%T% (id int PRIMARY KEY, r int4range, v int, + EXCLUDE USING gist (r WITH &&)) USING %AM%; + CREATE TABLE gt_%T% (id int PRIMARY KEY, t text) USING %AM% + WITH (fillfactor = 100); + ALTER TABLE gt_%T% ALTER COLUMN t SET STORAGE PLAIN; +}; +my $sub_extra = q{ + CREATE TABLE ops (tab text, op text, id int); + CREATE FUNCTION log_op() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF TG_OP = 'DELETE' THEN + INSERT INTO public.ops VALUES (TG_TABLE_NAME, TG_OP, OLD.id); + RETURN OLD; + END IF; + INSERT INTO public.ops VALUES (TG_TABLE_NAME, TG_OP, NEW.id); + RETURN NEW; + END $$; +}; +my $trig = q{ + CREATE TRIGGER log_op BEFORE INSERT OR UPDATE OR DELETE ON %TAB% + FOR EACH ROW EXECUTE FUNCTION log_op(); + ALTER TABLE %TAB% ENABLE ALWAYS TRIGGER log_op; +}; + +sub tables_sql +{ + my ($am) = @_; + my $sql = ''; + for my $t ('heap', 'flux') + { + my $s = $schema; + $s =~ s/%T%/$t/g; + $s =~ s/%AM%/$am eq 'pub' ? $t : $am/ge; + $sql .= $s; + } + return $sql; +} + +my $pub = PostgreSQL::Test::Cluster->new('publisher'); +$pub->init(allows_streaming => 'logical'); +$pub->start; +$pub->safe_psql('postgres', tables_sql('pub')); +for my $t ('heap', 'flux') +{ + $pub->safe_psql( + 'postgres', qq{ + INSERT INTO xt_$t VALUES (1, '[1,10)', 0), (2, '[20,30)', 0); + INSERT INTO gt_$t SELECT g, repeat('a', 100) FROM generate_series(1, 200) g; + }); +} +$pub->safe_psql('postgres', + "CREATE PUBLICATION pub FOR TABLE xt_heap, xt_flux, gt_heap, gt_flux WITH (publish = 'update')" +); +my $connstr = $pub->connstr . ' dbname=postgres'; + +my %subs; +for my $am ('heap', 'flux') +{ + my $sub = PostgreSQL::Test::Cluster->new("sub_$am"); + $sub->init; + $sub->start; + $sub->safe_psql('postgres', tables_sql($am) . $sub_extra); + for my $tab ('xt_heap', 'xt_flux', 'gt_heap', 'gt_flux') + { + (my $s = $trig) =~ s/%TAB%/$tab/g; + $sub->safe_psql('postgres', $s); + } + $sub->safe_psql('postgres', + "CREATE SUBSCRIPTION sub_$am CONNECTION '$connstr' PUBLICATION pub WITH (slot_name = sub_$am)" + ); + $sub->wait_for_subscription_sync($pub, "sub_$am"); + $sub->safe_psql('postgres', 'TRUNCATE ops'); + $subs{$am} = $sub; +} + +for my $t ('heap', 'flux') +{ + $pub->safe_psql( + 'postgres', qq{ + UPDATE xt_$t SET r = '[1,11)', v = v + 1 WHERE id = 1; + UPDATE xt_$t SET id = 3, r = '[20,31)' WHERE id = 2; + UPDATE gt_$t SET t = repeat('b', 2000) WHERE id = 1; + }); +} +is( $pub->safe_psql( + 'postgres', + "SELECT (ctid::text::point)[0] > 0 FROM gt_flux WHERE id = 1"), + 't', + 'the grown FLUX row moved to another page'); + +for my $am ('heap', 'flux') +{ + my $sub = $subs{$am}; + $pub->wait_for_catchup("sub_$am"); + for my $t ('heap', 'flux') + { + is( $sub->safe_psql( + 'postgres', "SELECT id, r, v FROM xt_$t ORDER BY id"), + "1|[1,11)|1\n3|[20,31)|0", + "$am subscriber: moved rows of xt_$t updated"); + is( $sub->safe_psql( + 'postgres', + "SELECT id, length(t), left(t, 1) FROM gt_$t WHERE id <= 2 ORDER BY id" + ), + "1|2000|b\n2|100|a", + "$am subscriber: grown row of gt_$t updated"); + is( $sub->safe_psql( + 'postgres', + "SELECT string_agg(op || ' ' || id, ', ' ORDER BY op, id) FROM ops WHERE tab IN ('xt_$t', 'gt_$t')" + ), + 'UPDATE 1, UPDATE 1, UPDATE 3', + "$am subscriber: only UPDATE triggers fired for $t"); + } +} + +done_testing(); diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 7a03411bfb72f..6834aa833a57e 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -2,15 +2,11 @@ ACCESS_ALLOWED_ACE ACL ACL_SIZE_INFORMATION AFFIX -amdeletemark_function -amundomark_function ASN1_INTEGER ASN1_OBJECT ASN1_OCTET_STRING ASN1_STRING ATAlterConstraint -AtmStateHeader -AtmStateRecord AV A_ArrayExpr A_Const @@ -37,6 +33,8 @@ AffixNodeData AfterTriggerEvent AfterTriggerEventChunk AfterTriggerEventData +AfterTriggerEventDataImages +AfterTriggerEventDataOneImage AfterTriggerEventList AfterTriggerShared AfterTriggerSharedData @@ -174,6 +172,8 @@ ArrayType AsyncQueueControl AsyncQueueEntry AsyncRequest +AtmStateHeader +AtmStateRecord AttInMetadata AttStatsSlot AttoptCacheEntry @@ -620,7 +620,6 @@ DBState DCHCacheEntry DEADLOCK_INFO DECountItem -DeferredUndoXact DH DIR DNSServiceErrorType @@ -666,6 +665,7 @@ DecodingWorkerShared DefElem DefElemAction DefaultACLInfo +DeferredUndoXact DefineStmt DefnDumperPtr DeleteStmt @@ -838,16 +838,6 @@ ExtensionVersionInfo FDWCollateState FD_SET FILE -FileOpsTwoPhaseRecord -FileopsUndoChmod -FileopsUndoChown -FileopsUndoCreate -FileopsUndoLink -FileopsUndoMkdir -FileopsUndoRemovexattr -FileopsUndoSetxattr -FileopsUndoSymlink -FileopsUndoTruncate FILETIME FPI FSMAddress @@ -868,8 +858,19 @@ FileBackupMethod FileFdwExecutionState FileFdwPlanState FileNameMap +FileOpsTwoPhaseRecord FileSet FileTag +FileopsUndoChmod +FileopsUndoChown +FileopsUndoCreate +FileopsUndoLink +FileopsUndoMkdir +FileopsUndoRemovexattr +FileopsUndoSetxattr +FileopsUndoSymlink +FileopsUndoTruncate +FileopsUndoWrite FilterCommandType FilterObjectType FilterStateData @@ -885,6 +886,43 @@ Fitness FlagMode Float FlushPosition +FluxChainVersion +FluxCidEntry +FluxCidKey +FluxCompressionAlgoGuc +FluxCompressionHeader +FluxCompressionType +FluxDefragMove +FluxDirtyPageKey +FluxFreeSpaceMap +FluxLogicalImage +FluxMvccShmemData +FluxOffsetMapping +FluxOverflowBuffer +FluxOverflowBuffers +FluxOverflowPtr +FluxOverflowRecordHeader +FluxPageOpaque +FluxPageOpaqueData +FluxPendingVersion +FluxRelationStats +FluxRewriteVersion +FluxScanDesc +FluxScanDescData +FluxSerializeLockEnt +FluxSkippedUpdates +FluxStatsCount +FluxTransactionState +FluxTuple +FluxTupleData +FluxTupleHeader +FluxTupleTableSlot +FluxTwoPhaseRecord +FluxUndoDelta +FluxUndoIndex +FluxUndoIndexWork +FluxUndoPayloadHeader +FluxVacScanState FmgrBuiltin FmgrHookEventType FmgrInfo @@ -1206,7 +1244,6 @@ HASHELEMENT HASHHDR HASHSEGMENT HASH_SEQ_STATUS -HashUndoInsert HE HEntry HIST_ENTRY @@ -1251,6 +1288,7 @@ HashScanPosData HashScanPosItem HashSkewBucket HashState +HashUndoInsert HashValueFunc HbaLine HeadlineJsonState @@ -1338,6 +1376,7 @@ IndexRuntimeKeyInfo IndexScan IndexScanDesc IndexScanDescData +IndexScanFluxData IndexScanHeapData IndexScanInstrumentation IndexScanState @@ -1586,7 +1625,6 @@ LOCKMASK LOCKMETHODID LOCKMODE LOCKTAG -LogicalRevertState LONG LONG_PTR LOOP @@ -1712,6 +1750,7 @@ LogicalRepTupleData LogicalRepTyp LogicalRepWorker LogicalRepWorkerType +LogicalRevertState LogicalRewriteMappingData LogicalSlotInfo LogicalSlotInfoArr @@ -1809,7 +1848,6 @@ MultirangeIOData MultirangeParseState MultirangeType MyUndoLogState -NbtreeUndoInsertLeaf NDBOX NDistinctParseState NDistinctSemanticState @@ -1831,6 +1869,7 @@ NamedLWLockTrancheRequest NamedTuplestoreScan NamedTuplestoreScanState NamespaceInfo +NbtreeUndoInsertLeaf NestLoop NestLoopParam NestLoopState @@ -1926,10 +1965,7 @@ OverridingKind PACE_HEADER PACL PATH -PbuTwoPhaseUndoRecord PCtxtHandle -PendingFileOp -PendingFileOpType PERL_CONTEXT PERL_SI PFN @@ -2113,7 +2149,6 @@ PREDICATELOCK PREDICATELOCKTAG PREDICATELOCKTARGET PREDICATELOCKTARGETTAG -PreparedUndoSpace PROCESS_INFORMATION PROCLOCK PROCLOCKTAG @@ -2250,6 +2285,9 @@ PatternInfoArray Pattern_Prefix_Status Pattern_Type PbuPendingUndo +PbuTwoPhaseUndoRecord +PendingFileOp +PendingFileOpType PendingFsyncEntry PendingListenAction PendingListenEntry @@ -2422,6 +2460,7 @@ PrefetchBufferResult PrepParallelRestorePtrType PrepareStmt PreparedStatement +PreparedUndoSpace PresortedKeyData PrewarmType PrintExtraTocPtrType @@ -2527,8 +2566,6 @@ RI_ConstraintInfo RI_FastPathState RI_QueryHashEntry RI_QueryKey -RollbackHashEntry -RollbackHashKey RTEKind RTEPermissionInfo RWConflict @@ -2711,6 +2748,7 @@ ReturningClause ReturningExpr ReturningOption ReturningOptionKind +RevertRetry RevmapContents RevokeRoleGrantAction RewriteMappingDataEntry @@ -2726,6 +2764,8 @@ RoleNameItem RoleSpec RoleSpecType RoleStmtType +RollbackHashEntry +RollbackHashKey RollupData RowCompareExpr RowExpr @@ -2743,7 +2783,6 @@ RunMode RunningTransactions RunningTransactionsData SASLStatus -SatisfyUndoRecordCallback SC_HANDLE SECURITY_ATTRIBUTES SECURITY_STATUS @@ -2759,24 +2798,19 @@ SID_IDENTIFIER_AUTHORITY SID_NAME_USE SISeg SIZE_T -slog_atm_value_t -SLogFlatBucket -SLogFlatHash -SLogFlatHashScanState -SLogFlatOp -SLogFlatOpKind -SLogFlatPartition SLogInsertTidEntry SLogInsertTidKey SLogOpType SLogSharedState SLogTrackedKey -SLogTrackedKeyCallback SLogTrackedKeyInfo -SLogTupleEntry -SLogTupleIterCallback SLogTupleKey -SLogTupleOp +SLogTupleLockCallback +SLogTupleLockEntry +SLogTupleLockInline +SLogTupleLockRel +SLogTupleLockShared +SLogTupleLockSlot SLogTxnEntry SMgrRelation SMgrRelationData @@ -2814,6 +2848,7 @@ SYSTEM_INFO SampleScan SampleScanGetSampleSize_function SampleScanState +SatisfyUndoRecordCallback SavedTransactionCharacteristics ScalarArrayOpExpr ScalarArrayOpExprHashEntry @@ -3351,12 +3386,16 @@ UINT ULARGE_INTEGER ULONG ULONG_PTR +UV +UVersionInfo UndoApplyCtxStruct UndoApplyResult UndoApplyWorker UndoBatchData -UndoBuffers UndoBufferStat +UndoBuffers +UndoChainOutcome +UndoChainWalkResult UndoEngine UndoErrorQueue UndoFileMru @@ -3371,6 +3410,7 @@ UndoLogStatus UndoPersistence UndoPersistenceLevel UndoRecInfo +UndoRecPtr UndoRecordBlock UndoRecordHeader UndoRecordPayload @@ -3381,8 +3421,6 @@ UndoRecordSetType UndoRecordSize UndoRecordTransaction UndoRecoveryEntry -UndoRecPtr -undorectype UndoRequestInfo UndoRmgrApplyFunc UndoRmgrData @@ -3392,9 +3430,6 @@ UndoWorkerQueue UndoWorkerQueueType UndoWorkerShmemData UndoXidQueue -UnpackedUndoRecord -UV -UVersionInfo UnicodeNormalizationForm UnicodeNormalizationQC Unique @@ -3402,6 +3437,7 @@ UniquePath UniqueRelInfo UniqueState UnlistenStmt +UnpackedUndoRecord UnresolvedTup UnresolvedTupData UpdateContext @@ -3573,38 +3609,6 @@ X509_NAME_ENTRY X509_STORE X509_STORE_CTX X86FeatureId -XactUndoContext -XactUndoData -XactUndoRecordInfo -XactUndoSubTransactionState -xl_atm_abort -xl_atm_forget -xl_btree_delete_mark -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_apply -xl_undo_batch -xl_undo_chain_state -xl_undo_inflight -xl_undo_inflight_xact -xl_undoapply_progress -xl_undolog_attach -xl_undolog_create -xl_undolog_discard -xl_undolog_extend -xl_undolog_meta -xl_undolog_rewind XLTW_Oper XLogCtlData XLogCtlInsert @@ -3644,7 +3648,11 @@ XactCallback XactCallbackItem XactEvent XactLockTableWaitInfo +XactUndoContext +XactUndoData XactUndoInflightSlot +XactUndoRecordInfo +XactUndoSubTransactionState XidBoundsViolation XidCacheStatus XidCommitStatus @@ -3689,6 +3697,7 @@ ambuildphasename_function ambulkdelete_function amcanreturn_function amcostestimate_function +amdeletemark_function amendscan_function amestimateparallelscan_function amgetbitmap_function @@ -3705,6 +3714,7 @@ amrescan_function amrestrpos_function amtranslate_cmptype_function amtranslate_strategy_function +amundomark_function amvacuumcleanup_function amvalidate_function array_iter @@ -4412,6 +4422,7 @@ slist_iter slist_mutable_iter slist_node slock_t +slog_atm_value_t socket_set socklen_t spgBulkDeleteState @@ -4517,6 +4528,7 @@ uint8x16_t uint_fast64_t uintmax_t uintptr_t +undorectype unicodeStyleBorderFormat unicodeStyleColumnFormat unicodeStyleFormat @@ -4569,6 +4581,8 @@ worktable wrap ws_file_info ws_options +xl_atm_abort +xl_atm_forget xl_brin_createidx xl_brin_desummarize xl_brin_insert @@ -4577,6 +4591,7 @@ xl_brin_samepage_update xl_brin_update xl_btree_dedup xl_btree_delete +xl_btree_delete_mark xl_btree_insert xl_btree_mark_page_halfdead xl_btree_metadata @@ -4594,6 +4609,53 @@ xl_dbase_create_file_copy_rec xl_dbase_create_wal_log_rec xl_dbase_drop_rec xl_end_of_recovery +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_flux_cas_update +xl_flux_cas_update_undo +xl_flux_cas_update_undo_fe +xl_flux_compress +xl_flux_compress_fe +xl_flux_defrag +xl_flux_delete +xl_flux_delete_fe +xl_flux_freeze_page +xl_flux_freeze_tuple +xl_flux_init_page +xl_flux_init_page_fe +xl_flux_insert +xl_flux_insert_fe +xl_flux_lock +xl_flux_lock_fe +xl_flux_multi_insert +xl_flux_multi_insert_fe +xl_flux_multi_insert_tuple +xl_flux_overflow_write +xl_flux_overflow_write_fe +xl_flux_prefix_suffix +xl_flux_supersede +xl_flux_supersede_fe +xl_flux_undo +xl_flux_update +xl_flux_update_fe +xl_flux_vacuum +xl_flux_vacuum_fe +xl_flux_vm_clear +xl_flux_vm_clear_fe +xl_flux_vm_set +xl_flux_vm_set_fe +xl_flux_write_dict xl_hash_add_ovfl_page xl_hash_delete xl_hash_init_bitmap_page @@ -4641,6 +4703,19 @@ xl_standby_locks xl_tblspc_create_rec xl_tblspc_drop_rec xl_testcustomrmgrs_message +xl_undo_apply +xl_undo_batch +xl_undo_chain_state +xl_undo_inflight +xl_undo_inflight_xact +xl_undoapply_progress +xl_undoapply_subxact +xl_undolog_attach +xl_undolog_create +xl_undolog_discard +xl_undolog_extend +xl_undolog_meta +xl_undolog_rewind xl_xact_abort xl_xact_assignment xl_xact_commit From 988306459e48ed97117c01141d646c5a3b262004 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 14 Sep 2026 17:40:21 +0000 Subject: [PATCH 22/22] Benchmarks: HEAP vs FLUX --- src/test/benchmarks/am4way/README.md | 42 + src/test/benchmarks/am4way/RESULTS.txt | 12 + src/test/benchmarks/am4way/bench4.sh | 66 ++ src/test/benchmarks/am4way/suite/__init__.py | 2 + src/test/benchmarks/am4way/suite/__main__.py | 228 +++++ .../am4way/suite/benchmark_suite.py | 215 +++++ src/test/benchmarks/am4way/suite/config.py | 204 +++++ .../benchmarks/am4way/suite/data_generator.py | 409 +++++++++ src/test/benchmarks/am4way/suite/database.py | 211 +++++ .../am4way/suite/metrics_collector.py | 260 ++++++ .../am4way/suite/noxu_perf_suite.py | 302 +++++++ .../am4way/suite/result_analyzer.py | 270 ++++++ .../benchmarks/am4way/suite/schema_builder.py | 126 +++ .../benchmarks/am4way/suite/undo/RESULTS.md | 198 +++++ .../am4way/suite/undo/lib/common.sh | 811 ++++++++++++++++++ .../am4way/suite/undo/lib/report.sh | 160 ++++ .../am4way/suite/undo/pgbench/mixed_oltp.sql | 33 + .../suite/undo/pgbench/role_cold_reader.sql | 8 + .../suite/undo/pgbench/role_hot_reader.sql | 8 + .../suite/undo/pgbench/role_scanner.sql | 10 + .../suite/undo/pgbench/role_updater.sql | 17 + .../suite/undo/pgbench/zipfian_hot_cold.sql | 42 + .../am4way/suite/undo/run_undo_bench.sh | 553 ++++++++++++ .../suite/undo/sql/b1_insert_throughput.sql | 70 ++ .../suite/undo/sql/b2_update_performance.sql | 147 ++++ .../suite/undo/sql/b3_delete_performance.sql | 103 +++ .../suite/undo/sql/b4_read_under_writes.sql | 125 +++ .../suite/undo/sql/b5_rollback_cost.sql | 141 +++ .../suite/undo/sql/b6_vacuum_overhead.sql | 93 ++ .../suite/undo/sql/b7_storage_footprint.sql | 74 ++ .../suite/undo/sql/b8_large_transaction.sql | 116 +++ .../benchmarks/am4way/suite/visualizer.py | 585 +++++++++++++ .../am4way/suite/workload_runner.py | 261 ++++++ 33 files changed, 5902 insertions(+) create mode 100644 src/test/benchmarks/am4way/README.md create mode 100644 src/test/benchmarks/am4way/RESULTS.txt create mode 100755 src/test/benchmarks/am4way/bench4.sh create mode 100644 src/test/benchmarks/am4way/suite/__init__.py create mode 100644 src/test/benchmarks/am4way/suite/__main__.py create mode 100644 src/test/benchmarks/am4way/suite/benchmark_suite.py create mode 100644 src/test/benchmarks/am4way/suite/config.py create mode 100644 src/test/benchmarks/am4way/suite/data_generator.py create mode 100644 src/test/benchmarks/am4way/suite/database.py create mode 100644 src/test/benchmarks/am4way/suite/metrics_collector.py create mode 100644 src/test/benchmarks/am4way/suite/noxu_perf_suite.py create mode 100644 src/test/benchmarks/am4way/suite/result_analyzer.py create mode 100644 src/test/benchmarks/am4way/suite/schema_builder.py create mode 100644 src/test/benchmarks/am4way/suite/undo/RESULTS.md create mode 100644 src/test/benchmarks/am4way/suite/undo/lib/common.sh create mode 100644 src/test/benchmarks/am4way/suite/undo/lib/report.sh create mode 100644 src/test/benchmarks/am4way/suite/undo/pgbench/mixed_oltp.sql create mode 100644 src/test/benchmarks/am4way/suite/undo/pgbench/role_cold_reader.sql create mode 100644 src/test/benchmarks/am4way/suite/undo/pgbench/role_hot_reader.sql create mode 100644 src/test/benchmarks/am4way/suite/undo/pgbench/role_scanner.sql create mode 100644 src/test/benchmarks/am4way/suite/undo/pgbench/role_updater.sql create mode 100644 src/test/benchmarks/am4way/suite/undo/pgbench/zipfian_hot_cold.sql create mode 100755 src/test/benchmarks/am4way/suite/undo/run_undo_bench.sh create mode 100644 src/test/benchmarks/am4way/suite/undo/sql/b1_insert_throughput.sql create mode 100644 src/test/benchmarks/am4way/suite/undo/sql/b2_update_performance.sql create mode 100644 src/test/benchmarks/am4way/suite/undo/sql/b3_delete_performance.sql create mode 100644 src/test/benchmarks/am4way/suite/undo/sql/b4_read_under_writes.sql create mode 100644 src/test/benchmarks/am4way/suite/undo/sql/b5_rollback_cost.sql create mode 100644 src/test/benchmarks/am4way/suite/undo/sql/b6_vacuum_overhead.sql create mode 100644 src/test/benchmarks/am4way/suite/undo/sql/b7_storage_footprint.sql create mode 100644 src/test/benchmarks/am4way/suite/undo/sql/b8_large_transaction.sql create mode 100644 src/test/benchmarks/am4way/suite/visualizer.py create mode 100644 src/test/benchmarks/am4way/suite/workload_runner.py diff --git a/src/test/benchmarks/am4way/README.md b/src/test/benchmarks/am4way/README.md new file mode 100644 index 0000000000000..65c8997545031 --- /dev/null +++ b/src/test/benchmarks/am4way/README.md @@ -0,0 +1,42 @@ +# [DO NOT MERGE] Two-way table-AM benchmark: HEAP vs FLUX + +This directory is a development benchmark, NOT for upstream. It compares heap +and FLUX on a single-connection OLTP micro-workload (200k-row load, index +build, 100k-row update, indexed-column update, range select, and WAL volume +per 100k updates). + +## Capability matrix (as of this commit) + +| AM | bulk load | index build | update | indexed-col UPDATE | full OLTP bench | +|-------|-----------|-------------|--------|-----------------------|-----------------| +| heap | yes | yes | yes | new-TID (native) | YES (baseline) | +| flux | yes | yes | yes | in-place (stable TID) | YES | + +## Representative result (16 vCPU, 30G RAM, PG -O2 cassert, fsync=off, single conn) + +| metric | heap | flux | +|-----------------------|---------|----------| +| insert 200k rows | 0.38 s | 2.07 s | +| build index | 0.06 s | 0.07 s | +| update 100k rows | 0.80 s | 1.04 s | +| indexed-col update 4k | 0.06 s | 0.14 s | +| WAL / 100k updates | 32 MB | 863 MB | + +### Reading the numbers +- The headline is **WAL amplification**: FLUX writes far more WAL per update + than heap because each in-place UPDATE durably logs the before-image to UNDO + (heap does not), about 27x heap on this workload. +- heap remains faster and far lighter on WAL for this single-connection load; + FLUX's value proposition (bloat control, in-place indexed UPDATE, stable + TIDs) is not captured by this micro-benchmark and needs a bloat/space and + concurrency study, which this harness does not yet do. + +## Running + src/test/benchmarks/am4way/bench4.sh # writes ~/bench4_results.txt +The Python suite under suite/ is included for reference and further extension +(pgbench/HammerDB-style drivers); it was not re-validated against FLUX in this +commit. + +## Do not merge +Marked [DO NOT MERGE]. Development artifact for comparing the AMs during +bring-up; not upstream-quality. diff --git a/src/test/benchmarks/am4way/RESULTS.txt b/src/test/benchmarks/am4way/RESULTS.txt new file mode 100644 index 0000000000000..ff27130dee17d --- /dev/null +++ b/src/test/benchmarks/am4way/RESULTS.txt @@ -0,0 +1,12 @@ +=== 4-way AM benchmark Sat Aug 29 05:46:14 UTC 2026 === +host: 16 vCPU, 30G RAM; PG -O2 cassert, fsync=off + +----- AM=heap ----- + max sustained bulk insert: 20000 rows + insert200k=0.38s index=0.06s update100k=0.80s idxupd4k=0.06s sel=19600 wal_per_100k_update=32476296B + +----- AM=flux ----- + max sustained bulk insert: 20000 rows + insert200k=2.08s index=0.07s update100k=1.05s idxupd4k=0.14s sel=19600 wal_per_100k_update=862631528B + +BENCH4_DONE diff --git a/src/test/benchmarks/am4way/bench4.sh b/src/test/benchmarks/am4way/bench4.sh new file mode 100755 index 0000000000000..84b426e0551d6 --- /dev/null +++ b/src/test/benchmarks/am4way/bench4.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# [DO NOT MERGE] 2-way table-AM benchmark: HEAP vs FLUX +# Both AMs run the full OLTP workload. +export PATH=$HOME/pgi/bin:$PATH +D=/tmp/bench4data +RESULTS=~/bench4_results.txt +: > $RESULTS +newcluster() { + pkill -9 -f "postgres -D $D" 2>/dev/null; sleep 1 + find $D -mindepth 1 -delete 2>/dev/null; rmdir $D 2>/dev/null + initdb -D $D --no-sync >/dev/null 2>&1 + cat >> $D/postgresql.conf </dev/null 2>&1 +} +alive() { psql -h /tmp -d postgres -tAqc "SELECT 1" 2>/dev/null | grep -q 1; } + +echo "=== 2-way AM benchmark $(date -u) ===" | tee -a $RESULTS +echo "host: $(nproc) vCPU, $(free -g|awk '/Mem/{print $2}')G RAM; PG -O2 cassert, fsync=off" | tee -a $RESULTS +echo "" | tee -a $RESULTS + +for AM in heap flux; do + newcluster + echo "----- AM=$AM -----" | tee -a $RESULTS + # Tier 1: capability probe (scaling insert size to find the sustainable point) + ok_rows=0 + for N in 100 1000 5000 20000; do + if ! alive; then break; fi + r=$(psql -h /tmp -d postgres -tAqc "DROP TABLE IF EXISTS t" -c "CREATE TABLE t(id int, k int, v bigint) USING $AM" \ + -c "INSERT INTO t SELECT g,g%1000,g FROM generate_series(1,$N) g" \ + -c "SELECT count(*) FROM t" 2>&1 | tail -1) + if alive && echo "$r" | grep -qE "^$N$"; then ok_rows=$N; else echo " bulk-insert crashes at N=$N" | tee -a $RESULTS; break; fi + done + echo " max sustained bulk insert: $ok_rows rows" | tee -a $RESULTS + if [ "$ok_rows" -lt 5000 ]; then + echo " => $AM NOT benchmark-capable at OLTP scale (bulk path incomplete)" | tee -a $RESULTS + echo "" | tee -a $RESULTS + continue + fi + # Tier 2: OLTP micro-benchmark (heap/flux) - timed insert / update / indexed-update / select + newcluster + psql -h /tmp -d postgres -tAqc "CREATE TABLE b(id int primary key, k int, v bigint, pad text) USING $AM" >/dev/null 2>&1 + T0=$(date +%s.%N) + psql -h /tmp -d postgres -tAqc "INSERT INTO b SELECT g,g%1000,g,repeat('x',48) FROM generate_series(1,200000) g" >/dev/null 2>&1 + T1=$(date +%s.%N) + psql -h /tmp -d postgres -tAqc "CREATE INDEX b_k ON b(k)" >/dev/null 2>&1 + T2=$(date +%s.%N) + psql -h /tmp -d postgres -tAqc "UPDATE b SET v=v+1 WHERE id%2=0" >/dev/null 2>&1 + T3=$(date +%s.%N) + psql -h /tmp -d postgres -tAqc "UPDATE b SET k=k+1000000 WHERE id%50=0" >/dev/null 2>&1 + T4=$(date +%s.%N) + SEL=$(psql -h /tmp -d postgres -tAqc "SELECT count(*) FROM b WHERE k BETWEEN 100 AND 200" 2>&1|tail -1) + L0=$(psql -h /tmp -d postgres -tAqc "SELECT pg_current_wal_lsn()") + psql -h /tmp -d postgres -tAqc "UPDATE b SET v=v+1 WHERE id%2=0" >/dev/null 2>&1 + L1=$(psql -h /tmp -d postgres -tAqc "SELECT pg_current_wal_lsn()") + WAL=$(psql -h /tmp -d postgres -tAqc "SELECT pg_wal_lsn_diff('$L1','$L0')::bigint") + printf " insert200k=%.2fs index=%.2fs update100k=%.2fs idxupd4k=%.2fs sel=%s wal_per_100k_update=%sB\n" \ + "$(echo "$T1-$T0"|bc)" "$(echo "$T2-$T1"|bc)" "$(echo "$T3-$T2"|bc)" "$(echo "$T4-$T3"|bc)" "$SEL" "$WAL" | tee -a $RESULTS + echo "" | tee -a $RESULTS +done +pkill -9 -f "postgres -D $D" 2>/dev/null +echo "BENCH4_DONE" | tee -a $RESULTS diff --git a/src/test/benchmarks/am4way/suite/__init__.py b/src/test/benchmarks/am4way/suite/__init__.py new file mode 100644 index 0000000000000..335818f2fa11d --- /dev/null +++ b/src/test/benchmarks/am4way/suite/__init__.py @@ -0,0 +1,2 @@ +# Noxu Performance Benchmark Suite +# Comprehensive benchmarking framework for Noxu columnar storage vs PostgreSQL HEAP. diff --git a/src/test/benchmarks/am4way/suite/__main__.py b/src/test/benchmarks/am4way/suite/__main__.py new file mode 100644 index 0000000000000..5b49f8a569cfa --- /dev/null +++ b/src/test/benchmarks/am4way/suite/__main__.py @@ -0,0 +1,228 @@ +""" +CLI entry point for the Noxu benchmark suite. + +Usage: + python -m src.test.benchmarks [OPTIONS] + + # Or from within the benchmarks directory: + python -m benchmarks [OPTIONS] + +Examples: + # Quick run with defaults + python -m src.test.benchmarks + + # Custom database and output + python -m src.test.benchmarks --database mydb --output-dir /tmp/bench + + # Full matrix (all row counts including 10M) + python -m src.test.benchmarks --full-matrix + + # Specific schema and row count + python -m src.test.benchmarks --schema medium --rows 100000 + + # Verbose output + python -m src.test.benchmarks -v +""" + +import argparse +import asyncio +import logging +import sys + +from .config import ( + ALL_SCHEMAS, + BenchmarkConfig, + ConnectionConfig, + DataDistribution, + MEDIUM_SCHEMA, + NARROW_SCHEMA, + QueryPattern, + WIDE_SCHEMA, +) +from .benchmark_suite import run_benchmark + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Noxu Performance Benchmark Suite", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + + # Connection + parser.add_argument("--host", default=None, help="PostgreSQL host") + parser.add_argument("--port", type=int, default=None, help="PostgreSQL port") + parser.add_argument("--database", "-d", default=None, help="Database name") + parser.add_argument("--user", "-U", default=None, help="Database user") + + # Test matrix + parser.add_argument( + "--schema", + choices=["narrow", "medium", "wide", "all"], + default="all", + help="Table schema to test (default: all)", + ) + parser.add_argument( + "--rows", + type=int, + nargs="+", + default=None, + help="Row counts to test (default: 1000 10000 100000)", + ) + parser.add_argument( + "--distribution", + choices=["random", "clustered", "low_cardinality", "high_null", "all"], + default="all", + help="Data distribution (default: all)", + ) + parser.add_argument( + "--pattern", + choices=[p.value for p in QueryPattern] + ["all"], + default="all", + help="Query pattern to test (default: all)", + ) + parser.add_argument( + "--full-matrix", + action="store_true", + help="Run full matrix including 10M rows", + ) + + # Execution + parser.add_argument( + "--warmup", type=int, default=2, help="Warmup iterations (default: 2)" + ) + parser.add_argument( + "--iterations", type=int, default=5, help="Measurement iterations (default: 5)" + ) + parser.add_argument("--seed", type=int, default=42, help="RNG seed (default: 42)") + + # Output + parser.add_argument( + "--output-dir", "-o", default="benchmark_results", help="Output directory" + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help="Verbose logging" + ) + + return parser.parse_args() + + +def build_config(args: argparse.Namespace) -> BenchmarkConfig: + conn = ConnectionConfig() + if args.host: + conn.host = args.host + if args.port: + conn.port = args.port + if args.database: + conn.database = args.database + if args.user: + conn.user = args.user + + schema_map = { + "narrow": [NARROW_SCHEMA], + "medium": [MEDIUM_SCHEMA], + "wide": [WIDE_SCHEMA], + "all": list(ALL_SCHEMAS), + } + schemas = schema_map[args.schema] + + if args.distribution == "all": + distributions = list(DataDistribution) + else: + distributions = [DataDistribution(args.distribution)] + + if args.pattern == "all": + patterns = list(QueryPattern) + else: + patterns = [QueryPattern(args.pattern)] + + config = BenchmarkConfig( + connection=conn, + schemas=schemas, + distributions=distributions, + query_patterns=patterns, + warmup_iterations=args.warmup, + measure_iterations=args.iterations, + seed=args.seed, + output_dir=args.output_dir, + full_matrix=args.full_matrix, + verbose=args.verbose, + ) + + if args.rows: + config.row_counts = args.rows + + return config + + +def main(): + args = parse_args() + + log_level = logging.DEBUG if args.verbose else logging.INFO + logging.basicConfig( + level=log_level, + format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + + config = build_config(args) + + print("=" * 60) + print(" Noxu Performance Benchmark Suite") + print("=" * 60) + print(f" Database : {config.connection.database}") + print(f" Schemas : {[s.name for s in config.schemas]}") + print(f" Row counts: {config.get_row_counts()}") + print(f" Distributions: {[d.value for d in config.distributions]}") + print(f" Patterns : {[p.value for p in config.query_patterns]}") + print(f" Iterations: {config.measure_iterations} (warmup: {config.warmup_iterations})") + print(f" Output : {config.output_dir}") + print("=" * 60) + print() + + try: + report = asyncio.run(run_benchmark(config)) + except KeyboardInterrupt: + print("\nBenchmark interrupted.") + sys.exit(1) + except Exception as e: + logging.error("Benchmark failed: %s", e, exc_info=True) + sys.exit(1) + + # Print summary + s = report.summary + print() + print("=" * 60) + print(" RESULTS SUMMARY") + print("=" * 60) + if s.get("median_speedup"): + print(f" Median query speedup: {s['median_speedup']:.2f}x") + print(f" Best speedup: {s['max_speedup']:.2f}x") + print(f" Worst speedup: {s['min_speedup']:.2f}x") + if s.get("avg_compression_ratio"): + print(f" Avg compression ratio: {s['avg_compression_ratio']:.2f}x") + print(f" Avg space savings: {s.get('avg_space_savings_pct', 0):.1f}%") + if s.get("per_pattern_avg_speedup"): + print() + print(" Per-pattern average speedup:") + for pattern, speedup in sorted(s["per_pattern_avg_speedup"].items()): + indicator = ">>>" if speedup > 1.0 else " " + print(f" {indicator} {pattern:25s} {speedup:.2f}x") + if s.get("best_noxu_scenario"): + best = s["best_noxu_scenario"] + print() + print( + f" Best Noxu scenario: {best['pattern']} on {best['schema']} " + f"({best['distribution']}) = {best['speedup']:.2f}x" + ) + if s.get("worst_noxu_scenario"): + worst = s["worst_noxu_scenario"] + print( + f" Worst Noxu scenario: {worst['pattern']} on {worst['schema']} " + f"({worst['distribution']}) = {worst['speedup']:.2f}x" + ) + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/src/test/benchmarks/am4way/suite/benchmark_suite.py b/src/test/benchmarks/am4way/suite/benchmark_suite.py new file mode 100644 index 0000000000000..14a0689a80667 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/benchmark_suite.py @@ -0,0 +1,215 @@ +""" +Main orchestrator: coordinates data generation, schema creation, workload +execution, metrics collection, analysis, and visualization for the full +benchmark matrix. +""" + +import asyncio +import logging +import os +import time +from datetime import datetime +from typing import List, Optional, Tuple + +from .config import ( + ALL_SCHEMAS, + BenchmarkConfig, + DataDistribution, + QueryPattern, + TableSchema, +) +from .data_generator import DataGenerator +from .database import DatabaseManager +from .metrics_collector import BenchmarkMetrics, MetricsCollector +from .result_analyzer import AnalysisReport, ResultAnalyzer +from .schema_builder import SchemaBuilder +from .visualizer import Visualizer +from .workload_runner import WorkloadResult, WorkloadRunner + +logger = logging.getLogger(__name__) + + +class BenchmarkSuite: + """Orchestrates the full Noxu benchmark suite.""" + + def __init__(self, config: Optional[BenchmarkConfig] = None): + self.config = config or BenchmarkConfig() + self.db = DatabaseManager(self.config.connection) + self.schema_builder = SchemaBuilder(self.db) + self.data_generator = DataGenerator(seed=self.config.seed) + self.workload_runner = WorkloadRunner( + self.db, + warmup_iterations=self.config.warmup_iterations, + measure_iterations=self.config.measure_iterations, + ) + self.metrics_collector = MetricsCollector(self.db) + self.analyzer = ResultAnalyzer() + + # Collected results + self._workload_pairs: List[Tuple[WorkloadResult, WorkloadResult]] = [] + self._metrics_list: List[BenchmarkMetrics] = [] + + async def setup(self): + """Initialize database connections and verify Noxu availability.""" + logger.info("Initializing benchmark suite...") + await self.db.initialize() + + # Check Noxu + if not await self.db.check_noxu_available(): + raise RuntimeError( + "Noxu table AM not found. Ensure PostgreSQL is built with Noxu support." + ) + logger.info("Noxu table AM is available") + + # Try to enable pg_stat_statements + if self.config.enable_pg_stat_statements: + ok = await self.db.ensure_extension("pg_stat_statements") + if not ok: + logger.warning( + "pg_stat_statements not available; some metrics will be missing" + ) + self.config.enable_pg_stat_statements = False + + async def teardown(self): + """Close database connections.""" + await self.db.close() + + async def run_single_benchmark( + self, + schema: TableSchema, + row_count: int, + distribution: DataDistribution, + ) -> Tuple[WorkloadResult, WorkloadResult, BenchmarkMetrics]: + """Run a complete benchmark for one (schema, row_count, distribution) combination.""" + dist_name = distribution.value + logger.info( + "=== Benchmark: %s, %d rows, %s distribution ===", + schema.name, + row_count, + dist_name, + ) + + # 1. Create tables + tables = await self.schema_builder.setup_benchmark_tables(schema) + heap_table = tables["heap_table"] + noxu_table = tables["noxu_table"] + + # 2. Generate and load data + insert_sql_heap = self.data_generator.generate_server_side_insert( + schema, row_count, distribution, table_suffix="_heap" + ) + insert_sql_noxu = self.data_generator.generate_server_side_insert( + schema, row_count, distribution, table_suffix="_noxu" + ) + + logger.info("Loading %d rows into %s...", row_count, heap_table) + t0 = time.perf_counter() + await self.schema_builder.load_data(heap_table, insert_sql_heap) + heap_load_time = time.perf_counter() - t0 + logger.info("HEAP load: %.2fs", heap_load_time) + + logger.info("Loading %d rows into %s...", row_count, noxu_table) + t0 = time.perf_counter() + await self.schema_builder.load_data(noxu_table, insert_sql_noxu) + noxu_load_time = time.perf_counter() - t0 + logger.info("Noxu load: %.2fs", noxu_load_time) + + # 3. Reset stats + if self.config.enable_pg_stat_statements: + await self.db.reset_pg_stat_statements() + + # 4. Run workloads + heap_wr, noxu_wr = await self.workload_runner.run_workload( + schema=schema, + heap_table=heap_table, + noxu_table=noxu_table, + row_count=row_count, + distribution=dist_name, + patterns=self.config.query_patterns, + ) + + # 5. Collect metrics + metrics = await self.metrics_collector.collect_all( + heap_table=heap_table, + noxu_table=noxu_table, + schema_name=schema.name, + row_count=row_count, + distribution=dist_name, + ) + + # 6. Cleanup tables + await self.schema_builder.cleanup(schema) + + return heap_wr, noxu_wr, metrics + + async def run_full_suite(self) -> AnalysisReport: + """Run the complete benchmark matrix and return an analysis report.""" + start_time = time.perf_counter() + self._workload_pairs = [] + self._metrics_list = [] + + total_combos = ( + len(self.config.schemas) + * len(self.config.get_row_counts()) + * len(self.config.distributions) + ) + combo_idx = 0 + + for schema in self.config.schemas: + for row_count in self.config.get_row_counts(): + for dist in self.config.distributions: + combo_idx += 1 + logger.info( + "--- Combination %d/%d ---", combo_idx, total_combos + ) + try: + heap_wr, noxu_wr, metrics = await self.run_single_benchmark( + schema, row_count, dist + ) + self._workload_pairs.append((heap_wr, noxu_wr)) + self._metrics_list.append(metrics) + except Exception as e: + logger.error( + "Benchmark failed for %s/%d/%s: %s", + schema.name, + row_count, + dist.value, + e, + ) + + elapsed = time.perf_counter() - start_time + logger.info("Full suite completed in %.1fs", elapsed) + + # Analyze + report = self.analyzer.build_report(self._workload_pairs, self._metrics_list) + return report + + def generate_output(self, report: AnalysisReport) -> str: + """Generate CSV files, charts, and HTML dashboard. + + Returns the path to the output directory. + """ + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_dir = os.path.join(self.config.output_dir, f"run_{timestamp}") + viz = Visualizer(output_dir) + + csv_path = viz.export_csv(report) + logger.info("CSV results: %s", csv_path) + + dashboard_path = viz.generate_dashboard(report) + logger.info("Dashboard: %s", dashboard_path) + + return output_dir + + +async def run_benchmark(config: Optional[BenchmarkConfig] = None) -> AnalysisReport: + """Convenience entry point: run the full suite and generate output.""" + suite = BenchmarkSuite(config) + try: + await suite.setup() + report = await suite.run_full_suite() + output_dir = suite.generate_output(report) + logger.info("Results written to: %s", output_dir) + return report + finally: + await suite.teardown() diff --git a/src/test/benchmarks/am4way/suite/config.py b/src/test/benchmarks/am4way/suite/config.py new file mode 100644 index 0000000000000..46bf5ffcb5082 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/config.py @@ -0,0 +1,204 @@ +""" +Benchmark configuration: connection pooling, test parameters, and matrix definitions. +""" + +import os +from dataclasses import dataclass, field +from enum import Enum +from typing import List, Optional + + +class TableWidth(Enum): + NARROW = "narrow" # 3-5 columns + MEDIUM = "medium" # 10-30 columns + WIDE = "wide" # 50-120 columns + + +class DataDistribution(Enum): + RANDOM = "random" + CLUSTERED = "clustered" + LOW_CARDINALITY = "low_cardinality" + HIGH_NULL = "high_null" + + +class QueryPattern(Enum): + FULL_SCAN = "full_scan" + COLUMN_PROJECTION = "column_projection" + FILTERED_SCAN = "filtered_scan" + AGGREGATION = "aggregation" + GROUP_BY = "group_by" + INDEX_SCAN = "index_scan" + + +class ColumnType(Enum): + INT = "integer" + BIGINT = "bigint" + TEXT = "text" + BOOLEAN = "boolean" + UUID = "uuid" + TIMESTAMP = "timestamp" + FLOAT = "double precision" + NUMERIC = "numeric(12,2)" + JSONB = "jsonb" + + +ROW_COUNTS = [1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000] + +# Smaller default for quick runs +DEFAULT_ROW_COUNTS = [1_000, 10_000, 100_000] + + +@dataclass +class ConnectionConfig: + host: str = "localhost" + port: int = 5432 + database: str = "benchmark_db" + user: str = "" + password: str = "" + min_pool_size: int = 2 + max_pool_size: int = 10 + statement_cache_size: int = 100 + + def __post_init__(self): + self.host = os.environ.get("PGHOST", self.host) + self.port = int(os.environ.get("PGPORT", str(self.port))) + self.database = os.environ.get("PGDATABASE", self.database) + self.user = os.environ.get("PGUSER", self.user) or os.environ.get("USER", "") + self.password = os.environ.get("PGPASSWORD", self.password) + + @property + def dsn(self) -> str: + parts = [f"host={self.host}", f"port={self.port}", f"dbname={self.database}"] + if self.user: + parts.append(f"user={self.user}") + if self.password: + parts.append(f"password={self.password}") + return " ".join(parts) + + +@dataclass +class TableSchema: + """Defines a table schema for benchmarking.""" + name: str + width: TableWidth + columns: List[tuple] # (col_name, ColumnType) + index_columns: List[str] = field(default_factory=list) + + @property + def column_names(self) -> List[str]: + return [c[0] for c in self.columns] + + @property + def column_types(self) -> List[ColumnType]: + return [c[1] for c in self.columns] + + +# Pre-defined table schemas for the test matrix +NARROW_SCHEMA = TableSchema( + name="bench_narrow", + width=TableWidth.NARROW, + columns=[ + ("id", ColumnType.BIGINT), + ("val_int", ColumnType.INT), + ("val_text", ColumnType.TEXT), + ("flag", ColumnType.BOOLEAN), + ], + index_columns=["id"], +) + +MEDIUM_SCHEMA = TableSchema( + name="bench_medium", + width=TableWidth.MEDIUM, + columns=[ + ("id", ColumnType.BIGINT), + ("category", ColumnType.INT), + ("amount", ColumnType.NUMERIC), + ("description", ColumnType.TEXT), + ("is_active", ColumnType.BOOLEAN), + ("created_at", ColumnType.TIMESTAMP), + ("ref_uuid", ColumnType.UUID), + ("score", ColumnType.FLOAT), + ("status_code", ColumnType.INT), + ("notes", ColumnType.TEXT), + ("metadata", ColumnType.JSONB), + ], + index_columns=["id", "category"], +) + +def _build_wide_columns(): + """Build a wide schema with 55 columns covering all data types.""" + cols = [("id", ColumnType.BIGINT)] + # 8 INT columns + for i in range(1, 9): + cols.append((f"col_int_{i}", ColumnType.INT)) + # 5 BIGINT columns + for i in range(1, 6): + cols.append((f"col_bigint_{i}", ColumnType.BIGINT)) + # 8 TEXT columns + for i in range(1, 9): + cols.append((f"col_text_{i}", ColumnType.TEXT)) + # 6 BOOLEAN columns + for i in range(1, 7): + cols.append((f"col_bool_{i}", ColumnType.BOOLEAN)) + # 5 FLOAT columns + for i in range(1, 6): + cols.append((f"col_float_{i}", ColumnType.FLOAT)) + # 5 NUMERIC columns + for i in range(1, 6): + cols.append((f"col_numeric_{i}", ColumnType.NUMERIC)) + # 5 UUID columns + for i in range(1, 6): + cols.append((f"col_uuid_{i}", ColumnType.UUID)) + # 5 TIMESTAMP columns + for i in range(1, 6): + cols.append((f"col_ts_{i}", ColumnType.TIMESTAMP)) + # 4 JSONB columns + for i in range(1, 5): + cols.append((f"col_jsonb_{i}", ColumnType.JSONB)) + # 3 more INT columns to reach 55 + for i in range(9, 12): + cols.append((f"col_int_{i}", ColumnType.INT)) + return cols + + +WIDE_SCHEMA = TableSchema( + name="bench_wide", + width=TableWidth.WIDE, + columns=_build_wide_columns(), + index_columns=["id", "col_int_1", "col_text_1"], +) + +ALL_SCHEMAS = [NARROW_SCHEMA, MEDIUM_SCHEMA, WIDE_SCHEMA] + + +@dataclass +class BenchmarkConfig: + """Top-level benchmark configuration.""" + connection: ConnectionConfig = field(default_factory=ConnectionConfig) + schemas: List[TableSchema] = field(default_factory=lambda: list(ALL_SCHEMAS)) + row_counts: List[int] = field(default_factory=lambda: list(DEFAULT_ROW_COUNTS)) + distributions: List[DataDistribution] = field( + default_factory=lambda: [ + DataDistribution.RANDOM, + DataDistribution.CLUSTERED, + DataDistribution.LOW_CARDINALITY, + DataDistribution.HIGH_NULL, + ] + ) + query_patterns: List[QueryPattern] = field( + default_factory=lambda: list(QueryPattern) + ) + warmup_iterations: int = 2 + measure_iterations: int = 5 + seed: int = 42 + output_dir: str = "benchmark_results" + enable_pg_stat_statements: bool = True + enable_compression_stats: bool = True + verbose: bool = False + # Run the full matrix or a reduced subset + full_matrix: bool = False + + def get_row_counts(self) -> List[int]: + if self.full_matrix: + return ROW_COUNTS + return self.row_counts diff --git a/src/test/benchmarks/am4way/suite/data_generator.py b/src/test/benchmarks/am4way/suite/data_generator.py new file mode 100644 index 0000000000000..6478d11764663 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/data_generator.py @@ -0,0 +1,409 @@ +""" +Reproducible seeded random data generation for benchmark tables. + +Generates SQL INSERT statements or COPY-compatible data for various +column types and data distributions. +""" + +import hashlib +import logging +import random +import uuid +from datetime import datetime, timedelta +from typing import Any, List, Optional + +from .config import ColumnType, DataDistribution, TableSchema + +logger = logging.getLogger(__name__) + +# Low-cardinality value pools +LOW_CARD_TEXT = [ + "active", "inactive", "pending", "completed", "cancelled", + "processing", "shipped", "returned", "refunded", "on_hold", +] +LOW_CARD_INT_RANGE = 20 +LOW_CARD_STATUS_CODES = [100, 200, 201, 301, 400, 403, 404, 500, 502, 503] + +# Clustered parameters +CLUSTER_CENTERS = 5 +CLUSTER_SPREAD = 100 + +# Base timestamp for reproducible timestamp generation +BASE_TS = datetime(2020, 1, 1) + + +class DataGenerator: + """Generates reproducible test data for benchmark tables.""" + + def __init__(self, seed: int = 42): + self.seed = seed + self._rng = random.Random(seed) + + def reset(self): + """Reset the RNG to produce identical sequences.""" + self._rng = random.Random(self.seed) + + # ------------------------------------------------------------------ + # Value generators per column type and distribution + # ------------------------------------------------------------------ + + def _gen_int(self, dist: DataDistribution, row_idx: int) -> int: + if dist == DataDistribution.RANDOM: + return self._rng.randint(-2_147_483_648, 2_147_483_647) + elif dist == DataDistribution.CLUSTERED: + center = (row_idx % CLUSTER_CENTERS) * 1_000_000 + return center + self._rng.randint(-CLUSTER_SPREAD, CLUSTER_SPREAD) + else: # LOW_CARDINALITY + return self._rng.choice(LOW_CARD_STATUS_CODES) + + def _gen_bigint(self, dist: DataDistribution, row_idx: int) -> int: + if dist == DataDistribution.RANDOM: + return self._rng.randint(0, 2**62) + elif dist == DataDistribution.CLUSTERED: + center = (row_idx % CLUSTER_CENTERS) * 10_000_000_000 + return center + self._rng.randint(-1000, 1000) + else: + return self._rng.randint(1, LOW_CARD_INT_RANGE) + + def _gen_text(self, dist: DataDistribution, row_idx: int) -> str: + if dist == DataDistribution.RANDOM: + # MD5-like random string + h = hashlib.md5(f"{self.seed}-{row_idx}-{self._rng.random()}".encode()) + return h.hexdigest() + elif dist == DataDistribution.CLUSTERED: + group = row_idx % CLUSTER_CENTERS + suffix = self._rng.randint(0, CLUSTER_SPREAD) + return f"group_{group}_item_{suffix}" + else: + return self._rng.choice(LOW_CARD_TEXT) + + def _gen_boolean(self, dist: DataDistribution, row_idx: int) -> bool: + if dist == DataDistribution.RANDOM: + return self._rng.random() < 0.5 + elif dist == DataDistribution.CLUSTERED: + # Runs of True/False + return (row_idx // 100) % 2 == 0 + else: + # Heavily skewed: 95% True + return self._rng.random() < 0.95 + + def _gen_uuid(self, dist: DataDistribution, row_idx: int) -> str: + if dist == DataDistribution.LOW_CARDINALITY: + # Only 10 distinct UUIDs + idx = row_idx % 10 + return str(uuid.UUID(int=idx + 1)) + # For RANDOM and CLUSTERED, use seeded generation + bits = self._rng.getrandbits(128) + return str(uuid.UUID(int=bits, version=4)) + + def _gen_timestamp(self, dist: DataDistribution, row_idx: int) -> str: + if dist == DataDistribution.RANDOM: + days = self._rng.randint(0, 1825) # ~5 years + secs = self._rng.randint(0, 86400) + ts = BASE_TS + timedelta(days=days, seconds=secs) + elif dist == DataDistribution.CLUSTERED: + # Clustered around specific dates + center_day = (row_idx % CLUSTER_CENTERS) * 365 + offset = self._rng.randint(-30, 30) + ts = BASE_TS + timedelta(days=center_day + offset) + else: + # Low cardinality: 10 distinct dates + day_idx = row_idx % 10 + ts = BASE_TS + timedelta(days=day_idx * 100) + return ts.strftime("%Y-%m-%d %H:%M:%S") + + def _gen_float(self, dist: DataDistribution, row_idx: int) -> float: + if dist == DataDistribution.RANDOM: + return self._rng.uniform(-1e6, 1e6) + elif dist == DataDistribution.CLUSTERED: + center = (row_idx % CLUSTER_CENTERS) * 1000.0 + return center + self._rng.gauss(0, 10) + else: + return self._rng.choice([0.0, 1.0, 10.0, 100.0, 1000.0]) + + def _gen_numeric(self, dist: DataDistribution, row_idx: int) -> str: + val = self._gen_float(dist, row_idx) + return f"{val:.2f}" + + def _gen_jsonb(self, dist: DataDistribution, row_idx: int) -> str: + import json + if dist == DataDistribution.RANDOM: + obj = { + "key": self._rng.randint(1, 100000), + "label": hashlib.md5(f"{self.seed}-json-{row_idx}".encode()).hexdigest()[:8], + "value": round(self._rng.uniform(0, 1000), 2), + "active": self._rng.random() < 0.5, + } + elif dist == DataDistribution.CLUSTERED: + group = row_idx % CLUSTER_CENTERS + obj = { + "group": group, + "label": f"cluster_{group}", + "value": group * 100 + self._rng.randint(0, CLUSTER_SPREAD), + } + elif dist == DataDistribution.HIGH_NULL: + # HIGH_NULL: return None most of the time (handled in _gen_value) + obj = {"id": row_idx % 10, "status": self._rng.choice(LOW_CARD_TEXT)} + else: # LOW_CARDINALITY + obj = {"id": row_idx % 10, "status": self._rng.choice(LOW_CARD_TEXT)} + return json.dumps(obj) + + def _gen_value( + self, col_type: ColumnType, dist: DataDistribution, row_idx: int + ) -> Any: + # HIGH_NULL distribution: ~80% of non-id values are NULL + if dist == DataDistribution.HIGH_NULL and col_type != ColumnType.BIGINT: + if self._rng.random() < 0.80: + return None + + generators = { + ColumnType.INT: self._gen_int, + ColumnType.BIGINT: self._gen_bigint, + ColumnType.TEXT: self._gen_text, + ColumnType.BOOLEAN: self._gen_boolean, + ColumnType.UUID: self._gen_uuid, + ColumnType.TIMESTAMP: self._gen_timestamp, + ColumnType.FLOAT: self._gen_float, + ColumnType.NUMERIC: self._gen_numeric, + ColumnType.JSONB: self._gen_jsonb, + } + gen = generators.get(col_type) + if gen is None: + raise ValueError(f"Unsupported column type: {col_type}") + return gen(dist, row_idx) + + # ------------------------------------------------------------------ + # SQL generation helpers + # ------------------------------------------------------------------ + + def generate_insert_sql( + self, + schema: TableSchema, + row_count: int, + dist: DataDistribution, + table_suffix: str = "", + batch_size: int = 1000, + ) -> List[str]: + """Generate INSERT statements in batches for the given schema. + + Returns a list of SQL strings, each inserting up to batch_size rows. + The ``id`` column is always set to the sequential row index. + """ + self.reset() + col_defs = ", ".join(schema.column_names) + statements = [] + + for batch_start in range(0, row_count, batch_size): + batch_end = min(batch_start + batch_size, row_count) + rows_sql = [] + for i in range(batch_start, batch_end): + vals = [] + for col_name, col_type in schema.columns: + if col_name == "id": + vals.append(str(i + 1)) + else: + v = self._gen_value(col_type, dist, i) + vals.append(self._sql_literal(v, col_type)) + rows_sql.append(f"({', '.join(vals)})") + + table_name = f"{schema.name}{table_suffix}" + stmt = f"INSERT INTO {table_name} ({col_defs}) VALUES\n" + stmt += ",\n".join(rows_sql) + statements.append(stmt) + + return statements + + def generate_copy_data( + self, + schema: TableSchema, + row_count: int, + dist: DataDistribution, + ) -> str: + """Generate tab-separated COPY data for the given schema. + + Returns a single string suitable for COPY ... FROM STDIN. + """ + self.reset() + lines = [] + for i in range(row_count): + vals = [] + for col_name, col_type in schema.columns: + if col_name == "id": + vals.append(str(i + 1)) + else: + v = self._gen_value(col_type, dist, i) + vals.append(self._copy_literal(v, col_type)) + lines.append("\t".join(vals)) + return "\n".join(lines) + + def generate_server_side_insert( + self, + schema: TableSchema, + row_count: int, + dist: DataDistribution, + table_suffix: str = "", + ) -> str: + """Generate a single INSERT ... SELECT generate_series SQL statement. + + This is much faster for large datasets because it runs entirely + server-side without sending row data over the wire. + """ + table_name = f"{schema.name}{table_suffix}" + col_exprs = [] + for col_name, col_type in schema.columns: + if col_name == "id": + col_exprs.append("g AS id") + else: + col_exprs.append( + f"{self._server_side_expr(col_name, col_type, dist, row_count)} AS {col_name}" + ) + + select_list = ",\n ".join(col_exprs) + return ( + f"INSERT INTO {table_name} ({', '.join(schema.column_names)})\n" + f"SELECT {select_list}\n" + f"FROM generate_series(1, {row_count}) AS g" + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _sql_literal(value: Any, col_type: ColumnType) -> str: + if value is None: + return "NULL" + if col_type in (ColumnType.TEXT, ColumnType.UUID, ColumnType.TIMESTAMP): + escaped = str(value).replace("'", "''") + return f"'{escaped}'" + if col_type == ColumnType.JSONB: + escaped = str(value).replace("'", "''") + return f"'{escaped}'::jsonb" + if col_type == ColumnType.BOOLEAN: + return "TRUE" if value else "FALSE" + if col_type == ColumnType.NUMERIC: + return str(value) + return str(value) + + @staticmethod + def _copy_literal(value: Any, col_type: ColumnType) -> str: + if value is None: + return "\\N" + if col_type == ColumnType.BOOLEAN: + return "t" if value else "f" + return str(value) + + def _server_side_expr( + self, + col_name: str, + col_type: ColumnType, + dist: DataDistribution, + row_count: int, + ) -> str: + """Return a SQL expression that produces the desired distribution + server-side using generate_series variable ``g``.""" + + seed_val = self.seed + + # HIGH_NULL: wrap the underlying RANDOM expression so ~80% are NULL + if dist == DataDistribution.HIGH_NULL and col_type != ColumnType.BIGINT: + inner = self._server_side_expr( + col_name, col_type, DataDistribution.RANDOM, row_count + ) + return f"CASE WHEN abs(hashint4(g + {seed_val} + 99)) % 5 = 0 THEN {inner} ELSE NULL END" + + if col_type == ColumnType.INT: + if dist == DataDistribution.RANDOM: + return f"(hashint4(g + {seed_val}) % 2147483647)::integer" + elif dist == DataDistribution.CLUSTERED: + return f"((g % {CLUSTER_CENTERS}) * 1000000 + (hashint4(g + {seed_val}) % {CLUSTER_SPREAD}))::integer" + else: + codes = ",".join(str(c) for c in LOW_CARD_STATUS_CODES) + return f"(ARRAY[{codes}])[1 + abs(hashint4(g + {seed_val})) % {len(LOW_CARD_STATUS_CODES)}]" + + if col_type == ColumnType.BIGINT: + if dist == DataDistribution.RANDOM: + return f"(hashint8(g::bigint + {seed_val}) & x'3FFFFFFFFFFFFFFF'::bigint)::bigint" + elif dist == DataDistribution.CLUSTERED: + return f"((g % {CLUSTER_CENTERS})::bigint * 10000000000 + (hashint4(g + {seed_val}) % 1000)::bigint)" + else: + return f"(1 + abs(hashint4(g + {seed_val})) % {LOW_CARD_INT_RANGE})::bigint" + + if col_type == ColumnType.TEXT: + if dist == DataDistribution.RANDOM: + return f"md5(g::text || '{seed_val}')" + elif dist == DataDistribution.CLUSTERED: + return f"'group_' || (g % {CLUSTER_CENTERS})::text || '_item_' || (abs(hashint4(g + {seed_val})) % {CLUSTER_SPREAD})::text" + else: + texts = ",".join(f"'{t}'" for t in LOW_CARD_TEXT) + return f"(ARRAY[{texts}])[1 + abs(hashint4(g + {seed_val})) % {len(LOW_CARD_TEXT)}]" + + if col_type == ColumnType.BOOLEAN: + if dist == DataDistribution.RANDOM: + return f"(hashint4(g + {seed_val}) % 2 = 0)" + elif dist == DataDistribution.CLUSTERED: + return f"((g / 100) % 2 = 0)" + else: + return f"(abs(hashint4(g + {seed_val})) % 20 != 0)" + + if col_type == ColumnType.UUID: + if dist == DataDistribution.LOW_CARDINALITY: + return f"(lpad(((g % 10) + 1)::text, 32, '0'))::uuid" + return f"md5(g::text || '{seed_val}' || random()::text)::uuid" + + if col_type == ColumnType.TIMESTAMP: + if dist == DataDistribution.RANDOM: + return f"'2020-01-01'::timestamp + (abs(hashint4(g + {seed_val})) % 157680000) * interval '1 second'" + elif dist == DataDistribution.CLUSTERED: + return f"'2020-01-01'::timestamp + ((g % {CLUSTER_CENTERS}) * 365 + (abs(hashint4(g + {seed_val})) % 60) - 30) * interval '1 day'" + else: + return f"'2020-01-01'::timestamp + ((g % 10) * 100) * interval '1 day'" + + if col_type == ColumnType.FLOAT: + if dist == DataDistribution.RANDOM: + return f"(hashint4(g + {seed_val})::double precision / 2147483647.0 * 2000000 - 1000000)" + elif dist == DataDistribution.CLUSTERED: + return f"((g % {CLUSTER_CENTERS}) * 1000.0 + (hashint4(g + {seed_val}) % 100)::double precision / 10.0)" + else: + return f"(ARRAY[0.0, 1.0, 10.0, 100.0, 1000.0])[1 + abs(hashint4(g + {seed_val})) % 5]" + + if col_type == ColumnType.NUMERIC: + if dist == DataDistribution.RANDOM: + return f"round((hashint4(g + {seed_val})::numeric / 2147483647.0 * 2000000 - 1000000), 2)" + elif dist == DataDistribution.CLUSTERED: + return f"round(((g % {CLUSTER_CENTERS}) * 1000.0 + (hashint4(g + {seed_val}) % 100)::numeric / 10.0), 2)" + else: + return f"(ARRAY[0.00, 1.00, 10.00, 100.00, 1000.00])[1 + abs(hashint4(g + {seed_val})) % 5]::numeric(12,2)" + + if col_type == ColumnType.JSONB: + if dist == DataDistribution.RANDOM: + return ( + f"jsonb_build_object(" + f"'key', abs(hashint4(g + {seed_val})) % 100000, " + f"'label', left(md5(g::text || '{seed_val}'), 8), " + f"'value', round((hashint4(g + {seed_val})::numeric / 2147483647.0 * 1000), 2), " + f"'active', (hashint4(g + {seed_val}) % 2 = 0))" + ) + elif dist == DataDistribution.CLUSTERED: + return ( + f"jsonb_build_object(" + f"'group', g % {CLUSTER_CENTERS}, " + f"'label', 'cluster_' || (g % {CLUSTER_CENTERS})::text, " + f"'value', (g % {CLUSTER_CENTERS}) * 100 + abs(hashint4(g + {seed_val})) % {CLUSTER_SPREAD})" + ) + elif dist == DataDistribution.HIGH_NULL: + return ( + f"CASE WHEN abs(hashint4(g + {seed_val})) % 5 = 0 THEN " + f"jsonb_build_object('id', g % 10, 'status', " + f"(ARRAY[{','.join(repr(t) for t in LOW_CARD_TEXT)}])" + f"[1 + abs(hashint4(g + {seed_val} + 1)) % {len(LOW_CARD_TEXT)}]) " + f"ELSE NULL END" + ) + else: # LOW_CARDINALITY + texts = ",".join(f"'{t}'" for t in LOW_CARD_TEXT) + return ( + f"jsonb_build_object('id', g % 10, 'status', " + f"(ARRAY[{texts}])[1 + abs(hashint4(g + {seed_val})) % {len(LOW_CARD_TEXT)}])" + ) + + raise ValueError(f"Unsupported column type for server-side generation: {col_type}") diff --git a/src/test/benchmarks/am4way/suite/database.py b/src/test/benchmarks/am4way/suite/database.py new file mode 100644 index 0000000000000..41c8e873331cc --- /dev/null +++ b/src/test/benchmarks/am4way/suite/database.py @@ -0,0 +1,211 @@ +""" +Database connection manager using asyncpg with connection pooling and +pg_stat_statements integration. +""" + +import asyncio +import logging +import time +from contextlib import asynccontextmanager +from typing import Any, Dict, List, Optional, Tuple + +try: + import asyncpg +except ImportError: + asyncpg = None + +from .config import ConnectionConfig + +logger = logging.getLogger(__name__) + + +class DatabaseManager: + """Manages asyncpg connection pool and provides query execution helpers.""" + + def __init__(self, config: ConnectionConfig): + self.config = config + self._pool: Optional[Any] = None + self._use_asyncpg = asyncpg is not None + + async def initialize(self): + """Create the connection pool.""" + if not self._use_asyncpg: + logger.warning( + "asyncpg not installed; falling back to synchronous psycopg2" + ) + return + + self._pool = await asyncpg.create_pool( + host=self.config.host, + port=self.config.port, + database=self.config.database, + user=self.config.user or None, + password=self.config.password or None, + min_size=self.config.min_pool_size, + max_size=self.config.max_pool_size, + statement_cache_size=self.config.statement_cache_size, + ) + logger.info( + "Connection pool created: %s:%s/%s (pool %d-%d)", + self.config.host, + self.config.port, + self.config.database, + self.config.min_pool_size, + self.config.max_pool_size, + ) + + async def close(self): + """Close the connection pool.""" + if self._pool: + await self._pool.close() + self._pool = None + logger.info("Connection pool closed") + + @asynccontextmanager + async def acquire(self): + """Acquire a connection from the pool.""" + if not self._use_asyncpg or not self._pool: + raise RuntimeError("Database not initialized or asyncpg not available") + async with self._pool.acquire() as conn: + yield conn + + async def execute(self, query: str, *args, timeout: float = 300.0) -> str: + """Execute a query and return the status string.""" + async with self.acquire() as conn: + return await conn.execute(query, *args, timeout=timeout) + + async def fetch(self, query: str, *args, timeout: float = 300.0) -> List[Any]: + """Execute a query and return all rows.""" + async with self.acquire() as conn: + return await conn.fetch(query, *args, timeout=timeout) + + async def fetchrow(self, query: str, *args, timeout: float = 300.0) -> Optional[Any]: + """Execute a query and return one row.""" + async with self.acquire() as conn: + return await conn.fetchrow(query, *args, timeout=timeout) + + async def fetchval(self, query: str, *args, timeout: float = 300.0) -> Any: + """Execute a query and return a scalar value.""" + async with self.acquire() as conn: + return await conn.fetchval(query, *args, timeout=timeout) + + async def execute_timed( + self, query: str, *args, timeout: float = 300.0 + ) -> Tuple[Any, float]: + """Execute a query and return (result, elapsed_seconds).""" + start = time.perf_counter() + result = await self.execute(query, *args, timeout=timeout) + elapsed = time.perf_counter() - start + return result, elapsed + + async def fetch_timed( + self, query: str, *args, timeout: float = 300.0 + ) -> Tuple[List[Any], float]: + """Fetch rows and return (rows, elapsed_seconds).""" + start = time.perf_counter() + rows = await self.fetch(query, *args, timeout=timeout) + elapsed = time.perf_counter() - start + return rows, elapsed + + # ------------------------------------------------------------------ + # pg_stat_statements helpers + # ------------------------------------------------------------------ + + async def reset_pg_stat_statements(self): + """Reset pg_stat_statements counters.""" + try: + await self.execute("SELECT pg_stat_statements_reset()") + logger.debug("pg_stat_statements reset") + except Exception as e: + logger.warning("Could not reset pg_stat_statements: %s", e) + + async def get_pg_stat_statements( + self, query_pattern: Optional[str] = None + ) -> List[Dict[str, Any]]: + """Retrieve pg_stat_statements entries, optionally filtered.""" + try: + base = """ + SELECT queryid, query, calls, total_exec_time, mean_exec_time, + min_exec_time, max_exec_time, stddev_exec_time, + rows, shared_blks_hit, shared_blks_read, + shared_blks_written, temp_blks_read, temp_blks_written + FROM pg_stat_statements + WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database()) + """ + if query_pattern: + base += " AND query ILIKE $1" + rows = await self.fetch(base + " ORDER BY total_exec_time DESC", query_pattern) + else: + rows = await self.fetch(base + " ORDER BY total_exec_time DESC") + return [dict(r) for r in rows] + except Exception as e: + logger.warning("Could not query pg_stat_statements: %s", e) + return [] + + # ------------------------------------------------------------------ + # EXPLAIN ANALYZE helper + # ------------------------------------------------------------------ + + async def explain_analyze( + self, query: str, *args, buffers: bool = True + ) -> Dict[str, Any]: + """Run EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) and return the plan.""" + options = "ANALYZE, FORMAT JSON" + if buffers: + options += ", BUFFERS" + explain_query = f"EXPLAIN ({options}) {query}" + rows = await self.fetch(explain_query, *args) + if rows: + plan = rows[0][0] + if isinstance(plan, list): + return plan[0] + return plan + return {} + + # ------------------------------------------------------------------ + # Utility + # ------------------------------------------------------------------ + + async def table_exists(self, table_name: str) -> bool: + val = await self.fetchval( + "SELECT EXISTS(SELECT 1 FROM pg_class WHERE relname = $1)", table_name + ) + return bool(val) + + async def drop_table(self, table_name: str): + await self.execute(f"DROP TABLE IF EXISTS {table_name} CASCADE") + + async def get_table_size(self, table_name: str) -> Dict[str, int]: + """Return table size, index size, and total size in bytes.""" + row = await self.fetchrow( + """ + SELECT pg_relation_size($1) AS table_size, + pg_indexes_size($1) AS index_size, + pg_total_relation_size($1) AS total_size + """, + table_name, + ) + if row: + return dict(row) + return {"table_size": 0, "index_size": 0, "total_size": 0} + + async def vacuum_analyze(self, table_name: str): + """Run VACUUM ANALYZE on a table (requires autocommit).""" + async with self.acquire() as conn: + await conn.execute(f"VACUUM ANALYZE {table_name}") + + async def ensure_extension(self, ext_name: str) -> bool: + """Try to create an extension if it doesn't exist. Return True on success.""" + try: + await self.execute(f"CREATE EXTENSION IF NOT EXISTS {ext_name}") + return True + except Exception as e: + logger.warning("Could not create extension %s: %s", ext_name, e) + return False + + async def check_noxu_available(self) -> bool: + """Check whether the noxu table AM is registered.""" + val = await self.fetchval( + "SELECT EXISTS(SELECT 1 FROM pg_am WHERE amname = 'noxu')" + ) + return bool(val) diff --git a/src/test/benchmarks/am4way/suite/metrics_collector.py b/src/test/benchmarks/am4way/suite/metrics_collector.py new file mode 100644 index 0000000000000..d5506bd4e5972 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/metrics_collector.py @@ -0,0 +1,260 @@ +""" +Metrics collector: extracts pg_stat_statements data and compression +statistics from pg_statistic and Noxu internal catalogs. +""" + +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from .database import DatabaseManager + +logger = logging.getLogger(__name__) + + +@dataclass +class StorageMetrics: + """Storage size and compression metrics for a single table.""" + table_name: str + storage_method: str + table_size_bytes: int = 0 + index_size_bytes: int = 0 + total_size_bytes: int = 0 + row_count: int = 0 + dead_tuples: int = 0 + # Compression stats (Noxu-specific) + compression_ratio: float = 1.0 + pages_compressed: int = 0 + pages_total: int = 0 + + +@dataclass +class QueryMetrics: + """Aggregated query-level metrics from pg_stat_statements.""" + query_pattern: str + calls: int = 0 + total_time_ms: float = 0.0 + mean_time_ms: float = 0.0 + min_time_ms: float = 0.0 + max_time_ms: float = 0.0 + stddev_time_ms: float = 0.0 + rows: int = 0 + shared_blks_hit: int = 0 + shared_blks_read: int = 0 + shared_blks_written: int = 0 + temp_blks_read: int = 0 + temp_blks_written: int = 0 + + @property + def cache_hit_ratio(self) -> float: + total = self.shared_blks_hit + self.shared_blks_read + if total == 0: + return 0.0 + return self.shared_blks_hit / total + + +@dataclass +class BenchmarkMetrics: + """Complete metrics collection for a benchmark run.""" + schema_name: str + row_count: int + distribution: str + heap_storage: Optional[StorageMetrics] = None + noxu_storage: Optional[StorageMetrics] = None + query_metrics: List[QueryMetrics] = field(default_factory=list) + pg_stat_entries: List[Dict[str, Any]] = field(default_factory=list) + compression_stats: Dict[str, Any] = field(default_factory=dict) + + @property + def compression_ratio(self) -> float: + """Overall storage compression ratio (heap_size / noxu_size).""" + if self.heap_storage and self.noxu_storage: + if self.noxu_storage.total_size_bytes > 0: + return ( + self.heap_storage.total_size_bytes + / self.noxu_storage.total_size_bytes + ) + return 1.0 + + +class MetricsCollector: + """Collects storage, query, and compression metrics.""" + + def __init__(self, db: DatabaseManager): + self.db = db + + async def collect_storage_metrics( + self, table_name: str, storage_method: str + ) -> StorageMetrics: + """Collect storage size metrics for a table.""" + metrics = StorageMetrics( + table_name=table_name, + storage_method=storage_method, + ) + + sizes = await self.db.get_table_size(table_name) + metrics.table_size_bytes = sizes["table_size"] + metrics.index_size_bytes = sizes["index_size"] + metrics.total_size_bytes = sizes["total_size"] + + # Row count from pg_stat_user_tables (fast, approximate) + row = await self.db.fetchrow( + """ + SELECT n_live_tup, n_dead_tup + FROM pg_stat_user_tables + WHERE relname = $1 + """, + table_name, + ) + if row: + metrics.row_count = row["n_live_tup"] or 0 + metrics.dead_tuples = row["n_dead_tup"] or 0 + + # Page counts from pg_class + row = await self.db.fetchrow( + "SELECT relpages, reltuples FROM pg_class WHERE relname = $1", + table_name, + ) + if row: + metrics.pages_total = row["relpages"] or 0 + + logger.info( + "Storage metrics for %s: table=%d bytes, index=%d bytes, total=%d bytes", + table_name, + metrics.table_size_bytes, + metrics.index_size_bytes, + metrics.total_size_bytes, + ) + return metrics + + async def collect_compression_stats( + self, table_name: str + ) -> Dict[str, Any]: + """Collect compression statistics from pg_statistic for a table. + + This extracts per-column statistics that indicate compression + effectiveness: null fraction, distinct values, average width, + and most common values. + """ + stats = {} + try: + rows = await self.db.fetch( + """ + SELECT + a.attname AS column_name, + a.atttypid::regtype AS column_type, + s.stanullfrac AS null_fraction, + s.stadistinct AS n_distinct, + s.stawidth AS avg_width, + CASE + WHEN s.stakind1 = 1 THEN s.stanumbers1 + ELSE NULL + END AS most_common_freqs + FROM pg_statistic s + JOIN pg_attribute a ON a.attrelid = s.starelid + AND a.attnum = s.staattnum + WHERE s.starelid = $1::regclass + ORDER BY a.attnum + """, + table_name, + ) + for row in rows: + col_stats = { + "column_type": str(row["column_type"]), + "null_fraction": float(row["null_fraction"] or 0), + "n_distinct": float(row["n_distinct"] or 0), + "avg_width": int(row["avg_width"] or 0), + } + freqs = row["most_common_freqs"] + if freqs: + col_stats["top_freq_sum"] = sum(float(f) for f in freqs[:5]) + stats[row["column_name"]] = col_stats + except Exception as e: + logger.warning( + "Could not collect compression stats for %s: %s", table_name, e + ) + return stats + + async def collect_noxu_internals( + self, table_name: str + ) -> Dict[str, Any]: + """Collect Noxu-specific internal statistics if available. + + Queries noxu_inspect functions for page-level compression data. + """ + internals = {} + try: + # Check if inspect function exists + exists = await self.db.fetchval( + """ + SELECT EXISTS( + SELECT 1 FROM pg_proc WHERE proname = 'noxu_inspect' + ) + """ + ) + if not exists: + logger.debug("noxu_inspect function not found; skipping internals") + return internals + + rows = await self.db.fetch( + f"SELECT * FROM noxu_inspect('{table_name}'::regclass)" + ) + if rows: + internals["pages"] = [dict(r) for r in rows] + total_pages = len(rows) + compressed_pages = sum( + 1 for r in rows if r.get("compressed", False) + ) + internals["total_pages"] = total_pages + internals["compressed_pages"] = compressed_pages + if total_pages > 0: + internals["compression_pct"] = ( + compressed_pages / total_pages * 100 + ) + except Exception as e: + logger.debug("Could not collect Noxu internals for %s: %s", table_name, e) + return internals + + async def collect_all( + self, + heap_table: str, + noxu_table: str, + schema_name: str, + row_count: int, + distribution: str, + ) -> BenchmarkMetrics: + """Collect all metrics for a benchmark pair.""" + metrics = BenchmarkMetrics( + schema_name=schema_name, + row_count=row_count, + distribution=distribution, + ) + + metrics.heap_storage = await self.collect_storage_metrics(heap_table, "heap") + metrics.noxu_storage = await self.collect_storage_metrics( + noxu_table, "noxu" + ) + + # Compression stats from pg_statistic for both + heap_comp = await self.collect_compression_stats(heap_table) + noxu_comp = await self.collect_compression_stats(noxu_table) + metrics.compression_stats = { + "heap": heap_comp, + "noxu": noxu_comp, + } + + # Noxu internal page stats + noxu_internals = await self.collect_noxu_internals(noxu_table) + if noxu_internals: + metrics.compression_stats["noxu_internals"] = noxu_internals + + # pg_stat_statements + metrics.pg_stat_entries = await self.db.get_pg_stat_statements() + + logger.info( + "Compression ratio for %s/%s: %.2fx", + heap_table, + noxu_table, + metrics.compression_ratio, + ) + return metrics diff --git a/src/test/benchmarks/am4way/suite/noxu_perf_suite.py b/src/test/benchmarks/am4way/suite/noxu_perf_suite.py new file mode 100644 index 0000000000000..d6c0d1f97a4f5 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/noxu_perf_suite.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +""" +Noxu Performance Benchmark Suite + +Comprehensive benchmarking framework for comparing Noxu columnar storage +against PostgreSQL's standard HEAP table access method. + +This is the top-level entry point that orchestrates the full benchmark +pipeline: + 1. Configuration and connection setup + 2. Schema creation for HEAP and Noxu table pairs + 3. Reproducible data generation across multiple distributions + 4. Workload execution with warmup and measurement phases + 5. Metrics collection (pg_stat_statements, storage sizes, compression) + 6. Statistical analysis (mean, median, p95, p99, speedup ratios) + 7. Visualization (matplotlib charts + HTML dashboard with recommendations) + 8. CSV result export + +Test Matrix: + - Table shapes: narrow (4 cols), medium (11 cols), wide (55 cols) + - Data types: int, bigint, text, boolean, uuid, timestamp, float, numeric, jsonb + - Distributions: random, clustered, low_cardinality, high_null + - Table sizes: 1K, 10K, 100K (default); up to 100M with --full-matrix + - Query patterns: full_scan, column_projection, filtered_scan, + aggregation, group_by, index_scan + +Usage: + python noxu_perf_suite.py [OPTIONS] + + # Quick run with defaults + python noxu_perf_suite.py + + # Custom database + python noxu_perf_suite.py --database mydb --host localhost + + # Full matrix (all row counts up to 100M) + python noxu_perf_suite.py --full-matrix + + # Specific schema and row count + python noxu_perf_suite.py --schema wide --rows 100000 1000000 + + # Specific distribution + python noxu_perf_suite.py --distribution high_null + + # Verbose output with custom output directory + python noxu_perf_suite.py -v --output-dir /tmp/noxu_bench + +Environment Variables: + PGHOST PostgreSQL host (default: localhost) + PGPORT PostgreSQL port (default: 5432) + PGDATABASE Database name (default: benchmark_db) + PGUSER Database user + PGPASSWORD Database password +""" + +import argparse +import asyncio +import logging +import os +import sys + +# Allow running directly (python noxu_perf_suite.py) or as a module +# (python -m benchmarks.noxu_perf_suite). Ensure the parent of the +# benchmarks package is on sys.path so absolute imports work. +_pkg_dir = os.path.dirname(os.path.abspath(__file__)) +_parent_dir = os.path.dirname(_pkg_dir) +if _parent_dir not in sys.path: + sys.path.insert(0, _parent_dir) + +from benchmarks.config import ( + ALL_SCHEMAS, + BenchmarkConfig, + ConnectionConfig, + DataDistribution, + MEDIUM_SCHEMA, + NARROW_SCHEMA, + QueryPattern, + WIDE_SCHEMA, +) +from benchmarks.benchmark_suite import BenchmarkSuite, run_benchmark + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Noxu Performance Benchmark Suite", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + # Connection + conn_group = parser.add_argument_group("connection") + conn_group.add_argument("--host", default=None, help="PostgreSQL host (env: PGHOST)") + conn_group.add_argument("--port", type=int, default=None, help="PostgreSQL port (env: PGPORT)") + conn_group.add_argument("--database", "-d", default=None, help="Database name (env: PGDATABASE)") + conn_group.add_argument("--user", "-U", default=None, help="Database user (env: PGUSER)") + + # Test matrix + matrix_group = parser.add_argument_group("test matrix") + matrix_group.add_argument( + "--schema", + choices=["narrow", "medium", "wide", "all"], + default="all", + help="Table schema to test (default: all)", + ) + matrix_group.add_argument( + "--rows", + type=int, + nargs="+", + default=None, + help="Row counts to test (default: 1000 10000 100000)", + ) + matrix_group.add_argument( + "--distribution", + choices=["random", "clustered", "low_cardinality", "high_null", "all"], + default="all", + help="Data distribution (default: all)", + ) + matrix_group.add_argument( + "--pattern", + choices=[p.value for p in QueryPattern] + ["all"], + default="all", + help="Query pattern to test (default: all)", + ) + matrix_group.add_argument( + "--full-matrix", + action="store_true", + help="Run full matrix including up to 100M rows", + ) + + # Execution + exec_group = parser.add_argument_group("execution") + exec_group.add_argument( + "--warmup", type=int, default=2, help="Warmup iterations (default: 2)" + ) + exec_group.add_argument( + "--iterations", type=int, default=5, help="Measurement iterations (default: 5)" + ) + exec_group.add_argument( + "--seed", type=int, default=42, help="RNG seed for reproducibility (default: 42)" + ) + + # Output + out_group = parser.add_argument_group("output") + out_group.add_argument( + "--output-dir", "-o", default="benchmark_results", help="Output directory" + ) + out_group.add_argument( + "-v", "--verbose", action="store_true", help="Verbose logging" + ) + out_group.add_argument( + "--json-summary", action="store_true", + help="Print summary as JSON to stdout", + ) + + return parser.parse_args() + + +def build_config(args: argparse.Namespace) -> BenchmarkConfig: + conn = ConnectionConfig() + if args.host: + conn.host = args.host + if args.port: + conn.port = args.port + if args.database: + conn.database = args.database + if args.user: + conn.user = args.user + + schema_map = { + "narrow": [NARROW_SCHEMA], + "medium": [MEDIUM_SCHEMA], + "wide": [WIDE_SCHEMA], + "all": list(ALL_SCHEMAS), + } + schemas = schema_map[args.schema] + + if args.distribution == "all": + distributions = list(DataDistribution) + else: + distributions = [DataDistribution(args.distribution)] + + if args.pattern == "all": + patterns = list(QueryPattern) + else: + patterns = [QueryPattern(args.pattern)] + + config = BenchmarkConfig( + connection=conn, + schemas=schemas, + distributions=distributions, + query_patterns=patterns, + warmup_iterations=args.warmup, + measure_iterations=args.iterations, + seed=args.seed, + output_dir=args.output_dir, + full_matrix=args.full_matrix, + verbose=args.verbose, + ) + + if args.rows: + config.row_counts = args.rows + + return config + + +def print_banner(config: BenchmarkConfig): + """Print the benchmark configuration banner.""" + total_combos = ( + len(config.schemas) + * len(config.get_row_counts()) + * len(config.distributions) + ) + total_queries = total_combos * len(config.query_patterns) * 2 # heap + noxu + + print("=" * 70) + print(" Noxu Performance Benchmark Suite") + print("=" * 70) + print(f" Database : {config.connection.database} " + f"({config.connection.host}:{config.connection.port})") + print(f" Schemas : {[s.name for s in config.schemas]}") + print(f" Row counts : {config.get_row_counts()}") + print(f" Distributions: {[d.value for d in config.distributions]}") + print(f" Patterns : {[p.value for p in config.query_patterns]}") + print(f" Iterations : {config.measure_iterations} " + f"(warmup: {config.warmup_iterations})") + print(f" Total combos: {total_combos} " + f"({total_queries} query executions)") + print(f" Output : {config.output_dir}") + print("=" * 70) + print() + + +def print_results(report): + """Print the results summary to stdout.""" + import json + s = report.summary + + print() + print("=" * 70) + print(" RESULTS SUMMARY") + print("=" * 70) + if s.get("median_speedup"): + print(f" Median query speedup: {s['median_speedup']:.2f}x") + print(f" Best speedup: {s['max_speedup']:.2f}x") + print(f" Worst speedup: {s['min_speedup']:.2f}x") + if s.get("avg_compression_ratio"): + print(f" Avg compression ratio: {s['avg_compression_ratio']:.2f}x") + print(f" Avg space savings: {s.get('avg_space_savings_pct', 0):.1f}%") + if s.get("per_pattern_avg_speedup"): + print() + print(" Per-pattern average speedup:") + for pattern, speedup in sorted(s["per_pattern_avg_speedup"].items()): + indicator = ">>>" if speedup > 1.0 else " " + print(f" {indicator} {pattern:25s} {speedup:.2f}x") + if s.get("best_noxu_scenario"): + best = s["best_noxu_scenario"] + print() + print( + f" Best Noxu scenario: {best['pattern']} on {best['schema']} " + f"({best['distribution']}) = {best['speedup']:.2f}x" + ) + if s.get("worst_noxu_scenario"): + worst = s["worst_noxu_scenario"] + print( + f" Worst Noxu scenario: {worst['pattern']} on {worst['schema']} " + f"({worst['distribution']}) = {worst['speedup']:.2f}x" + ) + print("=" * 70) + + +def main(): + args = parse_args() + + log_level = logging.DEBUG if args.verbose else logging.INFO + logging.basicConfig( + level=log_level, + format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + + config = build_config(args) + print_banner(config) + + try: + report = asyncio.run(run_benchmark(config)) + except KeyboardInterrupt: + print("\nBenchmark interrupted.") + sys.exit(1) + except Exception as e: + logging.error("Benchmark failed: %s", e, exc_info=True) + sys.exit(1) + + print_results(report) + + if args.json_summary: + import json + print() + print("JSON Summary:") + print(json.dumps(report.summary, indent=2, default=str)) + + +if __name__ == "__main__": + main() diff --git a/src/test/benchmarks/am4way/suite/result_analyzer.py b/src/test/benchmarks/am4way/suite/result_analyzer.py new file mode 100644 index 0000000000000..007688e8c605c --- /dev/null +++ b/src/test/benchmarks/am4way/suite/result_analyzer.py @@ -0,0 +1,270 @@ +""" +Statistical analysis of benchmark results: mean, median, p95, p99, +standard deviation, speedup ratios, and confidence intervals. +""" + +import math +import statistics +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from .workload_runner import QueryResult, WorkloadResult +from .metrics_collector import BenchmarkMetrics, StorageMetrics + + +@dataclass +class TimingSummary: + """Statistical summary of timing measurements.""" + values: List[float] + mean: float = 0.0 + median: float = 0.0 + stdev: float = 0.0 + p95: float = 0.0 + p99: float = 0.0 + min_val: float = 0.0 + max_val: float = 0.0 + + def __post_init__(self): + if self.values: + self.mean = statistics.mean(self.values) + self.median = statistics.median(self.values) + self.stdev = statistics.stdev(self.values) if len(self.values) > 1 else 0.0 + self.min_val = min(self.values) + self.max_val = max(self.values) + self.p95 = self._percentile(95) + self.p99 = self._percentile(99) + + def _percentile(self, p: float) -> float: + if not self.values: + return 0.0 + sorted_vals = sorted(self.values) + k = (len(sorted_vals) - 1) * (p / 100.0) + f = math.floor(k) + c = math.ceil(k) + if f == c: + return sorted_vals[int(k)] + return sorted_vals[f] * (c - k) + sorted_vals[c] * (k - f) + + +@dataclass +class ComparisonResult: + """Comparison between HEAP and Noxu for a single query pattern.""" + query_pattern: str + schema_name: str + row_count: int + distribution: str + heap_timing: TimingSummary + noxu_timing: TimingSummary + speedup: float = 0.0 # > 1.0 means noxu is faster + heap_rows: int = 0 + noxu_rows: int = 0 + + def __post_init__(self): + if self.noxu_timing.median > 0: + self.speedup = self.heap_timing.median / self.noxu_timing.median + elif self.heap_timing.median > 0: + self.speedup = float("inf") + + +@dataclass +class StorageComparison: + """Storage size comparison between HEAP and Noxu.""" + schema_name: str + row_count: int + distribution: str + heap_table_bytes: int = 0 + heap_index_bytes: int = 0 + heap_total_bytes: int = 0 + noxu_table_bytes: int = 0 + noxu_index_bytes: int = 0 + noxu_total_bytes: int = 0 + compression_ratio: float = 1.0 + + @property + def space_savings_pct(self) -> float: + if self.heap_total_bytes == 0: + return 0.0 + return (1.0 - self.noxu_total_bytes / self.heap_total_bytes) * 100 + + +@dataclass +class AnalysisReport: + """Complete analysis report for a benchmark suite run.""" + comparisons: List[ComparisonResult] = field(default_factory=list) + storage_comparisons: List[StorageComparison] = field(default_factory=list) + per_column_compression: Dict[str, Dict[str, Any]] = field(default_factory=dict) + summary: Dict[str, Any] = field(default_factory=dict) + + +class ResultAnalyzer: + """Analyzes raw benchmark results into statistical summaries.""" + + def analyze_workload_pair( + self, + heap_result: WorkloadResult, + noxu_result: WorkloadResult, + ) -> List[ComparisonResult]: + """Compare HEAP and Noxu workload results per query pattern.""" + comparisons = [] + + # Group results by query pattern + heap_by_pattern: Dict[str, List[QueryResult]] = {} + for qr in heap_result.results: + heap_by_pattern.setdefault(qr.query_pattern, []).append(qr) + + noxu_by_pattern: Dict[str, List[QueryResult]] = {} + for qr in noxu_result.results: + noxu_by_pattern.setdefault(qr.query_pattern, []).append(qr) + + all_patterns = set(heap_by_pattern.keys()) | set(noxu_by_pattern.keys()) + for pattern in sorted(all_patterns): + heap_timings = [qr.elapsed_seconds for qr in heap_by_pattern.get(pattern, [])] + noxu_timings = [ + qr.elapsed_seconds for qr in noxu_by_pattern.get(pattern, []) + ] + + heap_rows = 0 + noxu_rows = 0 + if heap_by_pattern.get(pattern): + heap_rows = heap_by_pattern[pattern][-1].row_count + if noxu_by_pattern.get(pattern): + noxu_rows = noxu_by_pattern[pattern][-1].row_count + + comp = ComparisonResult( + query_pattern=pattern, + schema_name=heap_result.schema_name, + row_count=heap_result.row_count, + distribution=heap_result.distribution, + heap_timing=TimingSummary(heap_timings or [0.0]), + noxu_timing=TimingSummary(noxu_timings or [0.0]), + heap_rows=heap_rows, + noxu_rows=noxu_rows, + ) + comparisons.append(comp) + + return comparisons + + def analyze_storage( + self, metrics: BenchmarkMetrics + ) -> StorageComparison: + """Create storage comparison from benchmark metrics.""" + sc = StorageComparison( + schema_name=metrics.schema_name, + row_count=metrics.row_count, + distribution=metrics.distribution, + ) + if metrics.heap_storage: + sc.heap_table_bytes = metrics.heap_storage.table_size_bytes + sc.heap_index_bytes = metrics.heap_storage.index_size_bytes + sc.heap_total_bytes = metrics.heap_storage.total_size_bytes + if metrics.noxu_storage: + sc.noxu_table_bytes = metrics.noxu_storage.table_size_bytes + sc.noxu_index_bytes = metrics.noxu_storage.index_size_bytes + sc.noxu_total_bytes = metrics.noxu_storage.total_size_bytes + sc.compression_ratio = metrics.compression_ratio + return sc + + def analyze_compression_per_column( + self, metrics: BenchmarkMetrics + ) -> Dict[str, Dict[str, Any]]: + """Analyze per-column compression characteristics.""" + result = {} + heap_stats = metrics.compression_stats.get("heap", {}) + noxu_stats = metrics.compression_stats.get("noxu", {}) + + all_cols = set(heap_stats.keys()) | set(noxu_stats.keys()) + for col in sorted(all_cols): + h = heap_stats.get(col, {}) + o = noxu_stats.get(col, {}) + col_analysis = { + "column_type": h.get("column_type", o.get("column_type", "unknown")), + "heap_avg_width": h.get("avg_width", 0), + "noxu_avg_width": o.get("avg_width", 0), + "heap_n_distinct": h.get("n_distinct", 0), + "noxu_n_distinct": o.get("n_distinct", 0), + "heap_null_fraction": h.get("null_fraction", 0), + "noxu_null_fraction": o.get("null_fraction", 0), + } + # Width reduction ratio + if h.get("avg_width", 0) > 0 and o.get("avg_width", 0) > 0: + col_analysis["width_ratio"] = h["avg_width"] / o["avg_width"] + result[col] = col_analysis + return result + + def build_report( + self, + workload_pairs: List[tuple], # [(heap_result, noxu_result), ...] + metrics_list: List[BenchmarkMetrics], + ) -> AnalysisReport: + """Build a complete analysis report from all collected data.""" + report = AnalysisReport() + + for heap_wr, noxu_wr in workload_pairs: + comps = self.analyze_workload_pair(heap_wr, noxu_wr) + report.comparisons.extend(comps) + + for metrics in metrics_list: + sc = self.analyze_storage(metrics) + report.storage_comparisons.append(sc) + col_comp = self.analyze_compression_per_column(metrics) + key = f"{metrics.schema_name}_{metrics.row_count}_{metrics.distribution}" + report.per_column_compression[key] = col_comp + + # Build summary + report.summary = self._build_summary(report) + return report + + def _build_summary(self, report: AnalysisReport) -> Dict[str, Any]: + """Generate high-level summary statistics.""" + summary: Dict[str, Any] = {} + + if report.comparisons: + speedups = [c.speedup for c in report.comparisons if c.speedup != float("inf")] + if speedups: + summary["avg_speedup"] = statistics.mean(speedups) + summary["median_speedup"] = statistics.median(speedups) + summary["max_speedup"] = max(speedups) + summary["min_speedup"] = min(speedups) + + # Per-pattern averages + pattern_speedups: Dict[str, List[float]] = {} + for c in report.comparisons: + if c.speedup != float("inf"): + pattern_speedups.setdefault(c.query_pattern, []).append(c.speedup) + summary["per_pattern_avg_speedup"] = { + p: statistics.mean(v) for p, v in pattern_speedups.items() + } + + if report.storage_comparisons: + ratios = [ + sc.compression_ratio + for sc in report.storage_comparisons + if sc.compression_ratio > 0 + ] + if ratios: + summary["avg_compression_ratio"] = statistics.mean(ratios) + summary["max_compression_ratio"] = max(ratios) + summary["min_compression_ratio"] = min(ratios) + + savings = [sc.space_savings_pct for sc in report.storage_comparisons] + if savings: + summary["avg_space_savings_pct"] = statistics.mean(savings) + + # Identify best/worst scenarios for Noxu + if report.comparisons: + best = max(report.comparisons, key=lambda c: c.speedup if c.speedup != float("inf") else 0) + worst = min(report.comparisons, key=lambda c: c.speedup) + summary["best_noxu_scenario"] = { + "pattern": best.query_pattern, + "schema": best.schema_name, + "distribution": best.distribution, + "speedup": best.speedup, + } + summary["worst_noxu_scenario"] = { + "pattern": worst.query_pattern, + "schema": worst.schema_name, + "distribution": worst.distribution, + "speedup": worst.speedup, + } + + return summary diff --git a/src/test/benchmarks/am4way/suite/schema_builder.py b/src/test/benchmarks/am4way/suite/schema_builder.py new file mode 100644 index 0000000000000..248998944a2d4 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/schema_builder.py @@ -0,0 +1,126 @@ +""" +Schema builder: creates matching HEAP and Noxu tables for A/B comparison. +""" + +import logging +from typing import List, Optional + +from .config import ColumnType, TableSchema +from .database import DatabaseManager + +logger = logging.getLogger(__name__) + + +class SchemaBuilder: + """Creates and manages benchmark table schemas for both HEAP and Noxu.""" + + def __init__(self, db: DatabaseManager): + self.db = db + + @staticmethod + def _col_type_sql(col_type: ColumnType) -> str: + return col_type.value + + def _create_table_ddl( + self, + schema: TableSchema, + suffix: str, + access_method: Optional[str] = None, + ) -> str: + """Generate CREATE TABLE DDL.""" + table_name = f"{schema.name}{suffix}" + col_defs = [] + for col_name, col_type in schema.columns: + type_sql = self._col_type_sql(col_type) + if col_name == "id": + col_defs.append(f" {col_name} {type_sql} NOT NULL") + else: + col_defs.append(f" {col_name} {type_sql}") + + ddl = f"CREATE TABLE {table_name} (\n" + ddl += ",\n".join(col_defs) + ddl += "\n)" + if access_method: + ddl += f" USING {access_method}" + return ddl + + async def create_pair( + self, + schema: TableSchema, + drop_existing: bool = True, + ) -> tuple: + """Create a HEAP and an Noxu table from the same schema. + + Returns (heap_table_name, noxu_table_name). + """ + heap_name = f"{schema.name}_heap" + noxu_name = f"{schema.name}_noxu" + + if drop_existing: + await self.db.drop_table(heap_name) + await self.db.drop_table(noxu_name) + + heap_ddl = self._create_table_ddl(schema, "_heap") + noxu_ddl = self._create_table_ddl(schema, "_noxu", access_method="noxu") + + logger.info("Creating HEAP table: %s", heap_name) + await self.db.execute(heap_ddl) + + logger.info("Creating Noxu table: %s", noxu_name) + await self.db.execute(noxu_ddl) + + return heap_name, noxu_name + + async def create_indexes( + self, + schema: TableSchema, + table_name: str, + ) -> List[str]: + """Create indexes on the specified columns. Returns index names.""" + created = [] + for col in schema.index_columns: + idx_name = f"idx_{table_name}_{col}" + ddl = f"CREATE INDEX {idx_name} ON {table_name} ({col})" + logger.info("Creating index: %s", idx_name) + await self.db.execute(ddl) + created.append(idx_name) + return created + + async def setup_benchmark_tables( + self, + schema: TableSchema, + drop_existing: bool = True, + ) -> dict: + """Full setup: create table pair and indexes. + + Returns a dict with table names and index names. + """ + heap_name, noxu_name = await self.create_pair(schema, drop_existing) + + heap_indexes = await self.create_indexes(schema, heap_name) + noxu_indexes = await self.create_indexes(schema, noxu_name) + + return { + "heap_table": heap_name, + "noxu_table": noxu_name, + "heap_indexes": heap_indexes, + "noxu_indexes": noxu_indexes, + } + + async def load_data( + self, + table_name: str, + insert_sql: str, + analyze: bool = True, + ): + """Execute an INSERT statement and optionally ANALYZE.""" + logger.info("Loading data into %s ...", table_name) + await self.db.execute(insert_sql, timeout=600.0) + if analyze: + logger.info("Running VACUUM ANALYZE on %s ...", table_name) + await self.db.vacuum_analyze(table_name) + + async def cleanup(self, schema: TableSchema): + """Drop the HEAP and Noxu tables for a schema.""" + await self.db.drop_table(f"{schema.name}_heap") + await self.db.drop_table(f"{schema.name}_noxu") diff --git a/src/test/benchmarks/am4way/suite/undo/RESULTS.md b/src/test/benchmarks/am4way/suite/undo/RESULTS.md new file mode 100644 index 0000000000000..692d48c7c74d6 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/RESULTS.md @@ -0,0 +1,198 @@ +# UNDO Benchmark Results + +## Test Environment + +- **CPU:** 12th Gen Intel Core i9-12900H (20 cores) +- **RAM:** 32 GB +- **Storage:** NVMe +- **OS:** Linux 6.19.13-200.fc43.x86_64 +- **PostgreSQL:** 19devel (commit 165dbb40c98) +- **shared_buffers:** 1 GB +- **Methodology:** 3 iterations per measurement, median reported, 1 warmup discarded + +## Scenarios + +| Scenario | Branch | Config | Purpose | +|----------|--------|--------|---------| +| baseline | master | N/A | Pristine upstream (no UNDO code) | +| undo_off | undo | `enable_undo=off` | Code-presence overhead | +| undo_on | undo | `enable_undo=on` | Full UNDO overhead/benefits | + +## Summary + +### Code-Presence Overhead (undo_off vs baseline) + +| Workload | Overhead | +|----------|----------| +| SQL micro-benchmarks (20 sub-tests, 1M rows) | **+1.0%** | +| pgbench TPS (standard) | -6.2%* | +| mixed OLTP TPS | -6.7%* | +| zipfian hot/cold TPS | -6.8%* | +| concurrent multi-role TPS | **-1.3%** | + +*pgbench/mixed numbers are inflated by outlier iterations. The concurrent +benchmark (16 parallel clients, most stable measurement) shows -1.3%. + +**Conclusion:** Code-presence overhead is ~1-2%, within the noise floor of +these benchmarks. The GUC check on the hot path is a single boolean test. + +### Enabled Overhead (undo_on vs baseline) + +| Operation Type | Scale | Overhead | Notes | +|----------------|-------|----------|-------| +| Bulk insert | 1M | +12% | Batch UNDO records amortize well | +| Individual insert | 1M | +165% | Per-row UNDO record + WAL | +| Full-table update | 1M | +16% | Amortized across large scan | +| Targeted 1% update | 1M | +9% | Proportional to rows touched | +| Single-row update | 1M | +134% | Fixed per-op UNDO cost dominates | +| Targeted 5% delete | 1M | +33% | | +| Single-row delete | 1M | +114% | | +| Seq scan after writes | 1M | +10-22% | | +| Index scan | 1M | +4% | Minimal impact | +| Vacuum time | 1M | +7% | | +| Vacuum after delete | 10K | **-24%** | UNDO reduces vacuum work | +| Delete rollback | 100K | **-67%** | Major win | +| pgbench TPS | | -8.5% | | +| mixed OLTP TPS | | -10.9% | | +| zipfian TPS | | -15.4% | Hot-key contention amplifies | +| concurrent TPS | | **-1.8%** | Distributed load hides cost | + +## Detailed Results + +### B1: Insert Throughput (median ms, lower is better) + +| Sub-test | Scale | Baseline | Undo Off | Undo On | Off/Base | On/Base | +|----------|-------|----------|----------|---------|----------|---------| +| bulk_insert | 10K | 12.9 | 12.8 | 19.7 | 0.99x | 1.52x | +| bulk_insert | 100K | 95.9 | 95.9 | 109.1 | 1.00x | 1.14x | +| bulk_insert | 1M | 1006.0 | 1035.5 | 1122.9 | 1.03x | 1.12x | +| individual_insert | 10K | 24.7 | 25.2 | 74.5 | 1.02x | 3.01x | +| individual_insert | 100K | 24.1 | 26.9 | 64.8 | 1.11x | 2.69x | +| individual_insert | 1M | 23.9 | 24.9 | 63.4 | 1.04x | 2.65x | + +### B2: Update Performance (median ms, lower is better) + +| Sub-test | Scale | Baseline | Undo Off | Undo On | Off/Base | On/Base | +|----------|-------|----------|----------|---------|----------|---------| +| single_row_update | 1M | 6.9 | 7.3 | 16.2 | 1.06x | 2.34x | +| batch_update_10x100 | 1M | 5.4 | 5.7 | 7.0 | 1.04x | 1.28x | +| targeted_1pct_update | 1M | 65.9 | 67.1 | 71.8 | 1.02x | 1.09x | +| full_table_update_1r | 1M | 2261.9 | 2286.4 | 2627.6 | 1.01x | 1.16x | +| cross_table_update | 1M | 3.5 | 3.4 | 5.4 | 0.97x | 1.53x | + +### B3: Delete Performance (median ms, lower is better) + +| Sub-test | Scale | Baseline | Undo Off | Undo On | Off/Base | On/Base | +|----------|-------|----------|----------|---------|----------|---------| +| single_row_delete | 1M | 2.6 | 2.6 | 5.5 | 1.01x | 2.14x | +| batch_delete_10x50 | 1M | 3.9 | 4.0 | 5.0 | 1.02x | 1.27x | +| targeted_5pct_delete | 1M | 36.4 | 35.5 | 48.4 | 0.98x | 1.33x | + +### B4: Read Under Writes (median ms, lower is better) + +| Sub-test | Scale | Baseline | Undo Off | Undo On | Off/Base | On/Base | +|----------|-------|----------|----------|---------|----------|---------| +| baseline_seqscan | 1M | 21.2 | 21.3 | 25.9 | 1.01x | 1.22x | +| baseline_idxscan | 1M | 6.7 | 7.0 | 7.0 | 1.03x | 1.04x | +| interleaved_rw_100 | 1M | 2.4 | 2.1 | 3.7 | 0.89x | 1.58x | +| post_batch_seqscan | 1M | 25.6 | 25.4 | 28.2 | 0.99x | 1.10x | +| post_vacuum_seqscan | 1M | 17.0 | 18.3 | 17.4 | 1.08x | 1.02x | + +### B5: Rollback Cost (median ms, lower is better) + +| Sub-test | Scale | Baseline | Undo Off | Undo On | Off/Base | On/Base | +|----------|-------|----------|----------|---------|----------|---------| +| ins_rollback_100k | 100K | 0.25 | 0.23 | 0.26 | 0.92x | 1.04x | +| ins_rollback_10k | 100K | 0.09 | 0.09 | 0.14 | 1.00x | 1.56x | +| del_rollback_10k | 100K | 0.24 | 0.07 | 0.08 | **0.29x** | **0.33x** | +| upd_rollback_10k | 100K | 0.11 | 0.20 | 0.11 | 1.82x | 1.00x | + +### B6: VACUUM Overhead (median ms, lower is better) + +| Sub-test | Scale | Baseline | Undo Off | Undo On | Off/Base | On/Base | +|----------|-------|----------|----------|---------|----------|---------| +| vacuum_time | 1M | 48.1 | 46.7 | 51.3 | 0.97x | 1.07x | +| vacuum_after_delete | 10K | 0.45 | 0.44 | 0.34 | 0.98x | **0.76x** | +| vacuum_after_delete | 100K | 2.07 | 1.58 | 1.61 | 0.76x | **0.78x** | +| vacuum_after_delete | 1M | 12.8 | 12.4 | 13.4 | 0.97x | 1.05x | +| delete_5pct | 1M | 35.9 | 35.6 | 46.1 | 0.99x | 1.28x | + +### B7: Storage Footprint (bytes) + +| Sub-test | Scale | Baseline | Undo Off | Undo On | +|----------|-------|----------|----------|---------| +| fresh_table_size | 1M | 93,093,888 | 93,093,888 | 93,093,888 | +| fresh_total_size | 1M | 115,630,080 | 115,630,080 | 115,630,080 | +| post_update_total_size | 1M | 125,075,456 | 125,075,456 | 125,075,456 | +| undo_log_size | 1M |; |; | 340-647 MB | + +UNDO log grows ~340-647MB for 1M row workloads (varies by iteration due to +log rotation). Heap and index sizes are identical across all scenarios. + +### pgbench TPS (higher is better) + +| Clients | Scale | Baseline | Undo Off | Undo On | Off% | On% | +|---------|-------|----------|----------|---------|------|-----| +| 1 | 10 | 860 | 885 | 901 | +3.0% | +4.8% | +| 1 | 50 | 905 | 901 | 811 | -0.5% | -10.5% | +| 1 | 100 | 912 | 884 | 828 | -3.1% | -9.2% | +| 4 | 10 | 1925 | 1878 | 1877 | -2.4% | -2.5% | +| 4 | 50 | 2483 | 2203 | 2232 | -11.3% | -10.1% | +| 4 | 100 | 2592 | 1991 | 2288 | -23.2% | -11.7% | +| 8 | 10 | 3207 | 3089 | 2961 | -3.7% | -7.6% | +| 8 | 50 | 4575 | 4530 | 4063 | -1.0% | -11.2% | +| 8 | 100 | 4878 | 4211 | 3980 | -13.7% | -18.4% | + +### Concurrent Multi-Role TPS (higher is better) + +| Role | Scale | Baseline | Undo Off | Undo On | Off% | On% | +|------|-------|----------|----------|---------|------|-----| +| hot_reader | 500 | 68,793 | 67,769 | 67,996 | -1.5% | -1.2% | +| cold_reader | 500 | 67,795 | 66,879 | 67,191 | -1.4% | -0.9% | +| updater | 500 | 2,201 | 2,170 | 2,114 | -1.4% | -4.0% | +| scanner | 500 | 1,576 | 1,563 | 1,549 | -0.8% | -1.7% | +| **total** | 500 | 140,365 | 138,396 | 138,853 | -1.4% | -1.1% | + +### Zipfian Hot/Cold TPS (higher is better) + +| Clients | Scale | Baseline | Undo Off | Undo On | Off% | On% | +|---------|-------|----------|----------|---------|------|-----| +| 1 | 500 | 3,308 | 3,046 | 2,403 | -7.9% | -27.4% | +| 4 | 500 | 8,103 | 7,218 | 7,100 | -10.9% | -12.4% | +| 8 | 500 | 13,871 | 13,670 | 12,965 | -1.4% | -6.5% | + +## Key Observations + +1. **Code-presence overhead is negligible.** With UNDO disabled, the branch + shows +1% on SQL micro-benchmarks and -1.3% on concurrent workloads. + Both are within noise. + +2. **Per-row UNDO records dominate the enabled overhead.** Individual inserts + are 2.65x slower because each row requires a 48-byte UNDO header + payload + + WAL record. Bulk operations amortize this to only 12% overhead. + +3. **UNDO provides real value for rollback.** Delete rollback is 3x faster + with UNDO because it avoids re-inserting dead tuples. This is the primary + mechanism by which UNDO-based MVCC eliminates the need for VACUUM on + aborted transactions. + +4. **Concurrent workloads hide the overhead.** Under realistic multi-client + load, UNDO on shows only -1.1% to -1.8% total TPS impact. The per-row + cost is masked by I/O parallelism and lock wait time. + +5. **Zipfian (hot-key) workloads amplify overhead.** When many transactions + contend on the same rows, the per-update UNDO cost stacks (-27% at 1 + client). At higher parallelism (8 clients) it drops to -6.5%. + +6. **Storage is identical.** UNDO does not change heap or index sizes. The + UNDO log itself grows to 340-647MB for 1M-row workloads but is reclaimed + by log rotation. + +## Optimization Opportunities + +The primary bottleneck is per-row UNDO record I/O: + +- **Batch UNDO WAL records:** Group N per-row records into one WAL insert +- **Delta-encode updates:** Store only changed columns in UNDO payload +- **Larger smgr extends:** Extend base/9/ in 1MB chunks instead of per-page +- **Inline small records:** Embed UNDO pointer in tuple header for single-row ops diff --git a/src/test/benchmarks/am4way/suite/undo/lib/common.sh b/src/test/benchmarks/am4way/suite/undo/lib/common.sh new file mode 100644 index 0000000000000..cce49408ce873 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/lib/common.sh @@ -0,0 +1,811 @@ +#!/usr/bin/env bash +# +# common.sh - Shared helpers for UNDO benchmark suite +# +# Provides: build, cluster init/start/stop, psql runner, timing, +# result extraction, and configuration defaults. +# + +# Resolve paths +UNDO_BENCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "$UNDO_BENCH_DIR/../../../.." && pwd)" + +# Configuration defaults (override via environment) +BENCH_BASE="${BENCH_BASE:-/scratch/undo-bench}" +REPO_DIR="${REPO_DIR:-$REPO_ROOT}" +SHARED_BUFFERS="${SHARED_BUFFERS:-1GB}" +SCALES="${SCALES:-10000 100000 1000000}" +PGBENCH_SCALES="${PGBENCH_SCALES:-10 50 100}" +PGBENCH_CLIENTS="${PGBENCH_CLIENTS:-1 4 8}" +PGBENCH_DURATION="${PGBENCH_DURATION:-60}" +ITERATIONS="${ITERATIONS:-3}" +BENCHMARKS="${BENCHMARKS:-b1 b2 b3 b4 b5 b6 b7 b8 pgbench mixed zipfian concurrent}" +# Large-scale factor for cache-pressure workloads (working set >> shared_buffers) +PGBENCH_SCALE_LARGE="${PGBENCH_SCALE_LARGE:-500}" + +# Ports for three scenarios +PORT_BASELINE=54320 +PORT_UNDO_OFF=54321 +PORT_UNDO_ON=54322 + +# Directory layout under BENCH_BASE +SRC_DIR="$BENCH_BASE/src" +BUILD_DIR="$BENCH_BASE/build" +INSTALL_DIR="$BENCH_BASE/install" +DATA_DIR="$BENCH_BASE/data" +RESULTS_DIR="$BENCH_BASE/results" +LOGS_DIR="$BENCH_BASE/logs" +CSV_FILE="$RESULTS_DIR/undo_bench_results.csv" + +# All scenarios +SCENARIOS="baseline undo_off undo_on" + +############################################################################### +# Logging +############################################################################### + +log() { + echo "[$(date '+%H:%M:%S')] $*" +} + +die() { + echo "FATAL: $*" >&2 + exit 1 +} + +############################################################################### +# Portable helpers +############################################################################### + +# get_nproc; portable CPU count (Linux, FreeBSD, Illumos, macOS) +get_nproc() { + nproc 2>/dev/null \ + || getconf _NPROCESSORS_ONLN 2>/dev/null \ + || psrinfo 2>/dev/null | wc -l | tr -d ' ' \ + || sysctl -n hw.ncpu 2>/dev/null \ + || echo 1 +} + +# get_dir_bytes DIR; portable directory size in bytes +get_dir_bytes() { + local dir="$1" + if du -sb "$dir" >/dev/null 2>&1; then + du -sb "$dir" | awk '{print $1}' + elif du -sk "$dir" >/dev/null 2>&1; then + du -sk "$dir" | awk '{print $1 * 1024}' + else + echo 0 + fi +} + +############################################################################### +# System info +############################################################################### + +record_sysinfo() { + local outfile="$1" + { + echo "hostname: $(hostname)" + echo "date: $(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S')" + echo "kernel: $(uname -sr)" + echo "arch: $(uname -m)" + + # CPU identification; Linux, Illumos/Solaris, FreeBSD/macOS + if [ -f /proc/cpuinfo ]; then + echo "cpu: $(grep 'model name' /proc/cpuinfo | head -1 | sed 's/.*: //')" + elif command -v psrinfo >/dev/null 2>&1; then + echo "cpu: $(psrinfo -pv 2>/dev/null | grep -i 'MHz\|GHz\|SPARC\|processor' | head -1 | sed 's/^ *//')" + elif command -v sysctl >/dev/null 2>&1; then + echo "cpu: $(sysctl -n machdep.cpu.brand_string 2>/dev/null || uname -p)" + else + echo "cpu: $(uname -p 2>/dev/null || echo 'unknown')" + fi + + echo "cores: $(get_nproc)" + + # Memory; Linux, Illumos/Solaris, FreeBSD/macOS + if command -v free >/dev/null 2>&1; then + echo "ram: $(free -h 2>/dev/null | awk '/^Mem:/{print $2}')" + elif command -v prtconf >/dev/null 2>&1; then + echo "ram: $(prtconf 2>/dev/null | grep -i 'Memory size' | sed 's/.*: //')" + elif command -v sysctl >/dev/null 2>&1; then + echo "ram: $(sysctl -n hw.memsize 2>/dev/null | awk '{printf "%.0fG", $1/1073741824}')" + else + echo "ram: unknown" + fi + + echo "postgres_commit_master: $(cd "$REPO_DIR" && git rev-parse --short master 2>/dev/null || echo 'unknown')" + echo "postgres_commit_undo: $(cd "$REPO_DIR" && git rev-parse --short undo 2>/dev/null || echo 'unknown')" + } > "$outfile" + log "System info written to $outfile" +} + +############################################################################### +# Build +############################################################################### + +build_branch() { + local branch="$1" + local src="$SRC_DIR/$branch" + local build="$BUILD_DIR/$branch" + local install="$INSTALL_DIR/$branch" + + log "Building branch: $branch" + + # Create worktree if needed. If the branch is already checked out in + # the main working tree (common when running from the undo branch), + # fall back to a detached worktree from the branch's HEAD, or just + # symlink the repo directory. + if [ ! -d "$src" ]; then + log " Creating git worktree for $branch" + if (cd "$REPO_DIR" && git worktree add "$src" "$branch") \ + >>"$LOGS_DIR/build_${branch}.log" 2>&1; then + : # success + else + log " Worktree add failed (branch may be checked out); trying detached HEAD" + local branch_sha + branch_sha="$(cd "$REPO_DIR" && git rev-parse "$branch")" + if (cd "$REPO_DIR" && git worktree add --detach "$src" "$branch_sha") \ + >>"$LOGS_DIR/build_${branch}.log" 2>&1; then + : # success via detached HEAD + else + log " Detached worktree also failed; symlinking repo directory" + ln -sfn "$REPO_DIR" "$src" + fi + fi + fi + + # Meson setup + if [ ! -f "$build/build.ninja" ]; then + log " Running meson setup" + # Detect platform-specific meson options + local extra_meson_opts="" + case "$(uname -s)" in + SunOS|illumos) + # Illumos: disable LDAP (ldap_start_tls_s linking issue) + extra_meson_opts="-Dldap=disabled" + ;; + esac + meson setup "$build" "$src" \ + --prefix="$install" \ + -Dbuildtype=release \ + -Dcassert=false \ + -Dtap_tests=disabled \ + $extra_meson_opts \ + >>"$LOGS_DIR/build_${branch}.log" 2>&1 + fi + + # Build and install + log " Compiling (ninja -j$(get_nproc))" + ninja -C "$build" -j"$(get_nproc)" >>"$LOGS_DIR/build_${branch}.log" 2>&1 + log " Installing to $install" + DESTDIR= ninja -C "$build" install >>"$LOGS_DIR/build_${branch}.log" 2>&1 + + log " Build complete: $install" +} + +############################################################################### +# Scenario helpers +############################################################################### + +get_bindir() { + local scenario="$1" + case "$scenario" in + baseline) echo "$INSTALL_DIR/master/bin" ;; + undo_off|undo_on) echo "$INSTALL_DIR/undo/bin" ;; + *) die "Unknown scenario: $scenario" ;; + esac +} + +get_libdir() { + local scenario="$1" + local bindir parent + bindir="$(get_bindir "$scenario")" + parent="$(dirname "$bindir")" + for libdir in "$parent/lib64" "$parent/lib"; do + if [ -d "$libdir" ]; then + echo "$libdir" + return + fi + done + echo "$parent/lib" +} + +get_port() { + case "$1" in + baseline) echo "$PORT_BASELINE" ;; + undo_off) echo "$PORT_UNDO_OFF" ;; + undo_on) echo "$PORT_UNDO_ON" ;; + *) die "Unknown scenario: $1" ;; + esac +} + +get_pgdata() { + echo "$DATA_DIR/$1" +} + +get_create_opts() { + case "$1" in + undo_on) echo "WITH (enable_undo = on)" ;; + *) echo "" ;; + esac +} + +# Returns the scales to iterate for a given benchmark +get_bench_scales() { + local bench="$1" + case "$bench" in + b5) echo "100000" ;; # B5 manages internal sizes + pgbench|mixed) echo "$PGBENCH_SCALES" ;; + *) echo "$SCALES" ;; + esac +} + +############################################################################### +# Cluster management +############################################################################### + +init_cluster() { + local scenario="$1" + local bindir pgdata port + bindir="$(get_bindir "$scenario")" + pgdata="$(get_pgdata "$scenario")" + port="$(get_port "$scenario")" + + log "Initializing cluster: $scenario (port $port)" + + rm -rf "$pgdata" + mkdir -p "$pgdata" + "$bindir/initdb" -D "$pgdata" --no-locale -E UTF8 -A trust \ + >"$LOGS_DIR/initdb_${scenario}.log" 2>&1 + + # Common configuration + { + echo "port = $port" + echo "listen_addresses = '127.0.0.1'" + echo "unix_socket_directories = '$pgdata'" + echo "shared_buffers = $SHARED_BUFFERS" + echo "wal_level = minimal" + echo "max_wal_senders = 0" + echo "fsync = on" + echo "synchronous_commit = on" + echo "max_wal_size = 8GB" + echo "checkpoint_timeout = 30min" + echo "log_checkpoints = on" + echo "autovacuum = off" + echo "max_connections = 100" + echo "logging_collector = off" + echo "log_min_messages = warning" + } >> "$pgdata/postgresql.conf" + + # Scenario-specific configuration + case "$scenario" in + undo_off) + echo "enable_undo = off" >> "$pgdata/postgresql.conf" + ;; + undo_on) + echo "enable_undo = on" >> "$pgdata/postgresql.conf" + ;; + # baseline: no enable_undo line (master branch doesn't have it) + esac + + log " Cluster initialized: $pgdata" +} + +start_cluster() { + local scenario="$1" + local bindir pgdata port libdir + bindir="$(get_bindir "$scenario")" + pgdata="$(get_pgdata "$scenario")" + port="$(get_port "$scenario")" + libdir="$(get_libdir "$scenario")" + + log "Starting cluster: $scenario (port $port)" + + # Set library path without accumulating duplicates + export LD_LIBRARY_PATH="${libdir}" + export DYLD_LIBRARY_PATH="${libdir}" + + # Retry pg_ctl start to handle TCP TIME_WAIT on port reuse + local start_attempts=6 + local start_ok=0 + while [ "$start_attempts" -gt 0 ]; do + if "$bindir/pg_ctl" start -D "$pgdata" \ + -l "$LOGS_DIR/server_${scenario}.log" \ + -w -t 30 >/dev/null 2>&1; then + start_ok=1 + break + fi + start_attempts=$((start_attempts - 1)) + if [ "$start_attempts" -gt 0 ]; then + log " Port $port may be in TIME_WAIT, retrying in 5s ($start_attempts attempts left)" + sleep 5 + fi + done + [ "$start_ok" -eq 1 ] || die "Failed to start $scenario cluster (check $LOGS_DIR/server_${scenario}.log)" + + # Wait for ready + local retries=30 + while [ "$retries" -gt 0 ]; do + if "$bindir/pg_isready" -h 127.0.0.1 -p "$port" >/dev/null 2>&1; then + log " Server ready on port $port" + return 0 + fi + retries=$((retries - 1)) + sleep 1 + done + die "$scenario server on port $port did not become ready" +} + +stop_cluster() { + local scenario="$1" + local bindir pgdata + bindir="$(get_bindir "$scenario")" + pgdata="$(get_pgdata "$scenario")" + + log "Stopping cluster: $scenario" + "$bindir/pg_ctl" stop -D "$pgdata" -m fast -w >/dev/null 2>&1 || true + # Brief wait for TCP sockets to leave TIME_WAIT before restarting + sleep 1 +} + +stop_all_clusters() { + for s in $SCENARIOS; do + local bindir pgdata + bindir="$(get_bindir "$s" 2>/dev/null)" || continue + pgdata="$(get_pgdata "$s")" + if [ -d "$pgdata" ]; then + "$bindir/pg_ctl" stop -D "$pgdata" -m immediate >/dev/null 2>&1 || true + fi + done +} + +create_bench_db() { + local scenario="$1" + local bindir port + bindir="$(get_bindir "$scenario")" + port="$(get_port "$scenario")" + + "$bindir/psql" -h 127.0.0.1 -p "$port" -d postgres \ + -c "DROP DATABASE IF EXISTS undo_bench;" \ + -c "CREATE DATABASE undo_bench;" \ + >/dev/null 2>&1 +} + +############################################################################### +# SQL execution +############################################################################### + +# run_psql SCENARIO SQL_FILE [VAR=VALUE ...] +# Runs a SQL file via psql, returns output on stdout +run_psql() { + local scenario="$1" + local sql_file="$2" + shift 2 + + local bindir port libdir + bindir="$(get_bindir "$scenario")" + port="$(get_port "$scenario")" + libdir="$(get_libdir "$scenario")" + + export LD_LIBRARY_PATH="${libdir}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + export DYLD_LIBRARY_PATH="${libdir}${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" + + local var_args=() + for var in "$@"; do + var_args+=(-v "$var") + done + + "$bindir/psql" -h 127.0.0.1 -p "$port" -d undo_bench \ + -X --no-psqlrc \ + "${var_args[@]}" \ + -f "$sql_file" 2>&1 +} + +run_checkpoint() { + local scenario="$1" + local bindir port + bindir="$(get_bindir "$scenario")" + port="$(get_port "$scenario")" + + "$bindir/psql" -h 127.0.0.1 -p "$port" -d postgres \ + -X --no-psqlrc -c "CHECKPOINT;" >/dev/null 2>&1 +} + +############################################################################### +# Result extraction +############################################################################### + +# extract_results OUTPUT +# Parses UNDO_BENCH_RESULT lines, outputs: sub_test\tmetric\tvalue +extract_results() { + echo "$1" | grep '^UNDO_BENCH_RESULT|' | while IFS='|' read -r _marker sub_test metric value; do + # Trim whitespace + sub_test="$(echo "$sub_test" | tr -d '[:space:]')" + metric="$(echo "$metric" | tr -d '[:space:]')" + value="$(echo "$value" | tr -d '[:space:]')" + printf '%s\t%s\t%s\n' "$sub_test" "$metric" "$value" + done +} + +# median VALUE1 VALUE2 ... +# Outputs the median of numeric arguments +median() { + local n=$# + if [ "$n" -eq 0 ]; then + echo "0" + return + fi + printf '%s\n' "$@" | sort -g | awk '{a[NR]=$1} END { + if (NR % 2 == 1) + print a[int(NR/2)+1] + else + printf "%.2f", (a[NR/2] + a[NR/2+1]) / 2 + }' +} + +############################################################################### +# System metrics collection (CPU, RAM, I/O) +############################################################################### + +# _METRICS_PID tracks background vmstat/iostat processes +_METRICS_VMSTAT_PID="" +_METRICS_IOSTAT_PID="" + +# start_metrics LABEL +# Starts background vmstat + iostat sampling at 1-second intervals. +# Output goes to $LOGS_DIR/metrics_${LABEL}_{vmstat,iostat}.log +start_metrics() { + local label="$1" + local vmstat_log="$LOGS_DIR/metrics_${label}_vmstat.log" + local iostat_log="$LOGS_DIR/metrics_${label}_iostat.log" + + # vmstat: CPU (us/sy/id/wa), memory, swap, I/O; portable across Linux/FreeBSD + vmstat 1 > "$vmstat_log" 2>&1 & + _METRICS_VMSTAT_PID=$! + + # iostat: disk I/O; use different flags per platform + if [ "$(uname -s)" = "FreeBSD" ]; then + iostat -x -w 1 > "$iostat_log" 2>&1 & + elif [ "$(uname -s)" = "Linux" ]; then + iostat -x 1 > "$iostat_log" 2>&1 & + else + # Fallback: just run iostat with default flags + iostat 1 > "$iostat_log" 2>&1 & + fi + _METRICS_IOSTAT_PID=$! +} + +# stop_metrics +# Stops background metric collectors started by start_metrics. +stop_metrics() { + [ -n "$_METRICS_VMSTAT_PID" ] && kill "$_METRICS_VMSTAT_PID" 2>/dev/null && wait "$_METRICS_VMSTAT_PID" 2>/dev/null || true + [ -n "$_METRICS_IOSTAT_PID" ] && kill "$_METRICS_IOSTAT_PID" 2>/dev/null && wait "$_METRICS_IOSTAT_PID" 2>/dev/null || true + _METRICS_VMSTAT_PID="" + _METRICS_IOSTAT_PID="" +} + +# summarize_vmstat LOG_FILE +# Parses a vmstat log and outputs: avg_user_cpu avg_sys_cpu avg_idle avg_wa avg_free_mem +# Skips the first data line (boot-time avg). Handles both Linux (us/sy/id/wa) +# and FreeBSD (us/sy/id, no wa) vmstat formats. +summarize_vmstat() { + local log_file="$1" + awk ' + # Detect column layout from the header line containing "us" and "sy" + /us.*sy.*id/ { + for (i=1; i<=NF; i++) { + if ($i == "us") col_us = i + if ($i == "sy" && i > NF-5) col_sy = i # rightmost "sy" is CPU + if ($i == "id") col_id = i + if ($i == "wa") col_wa = i + if ($i == "fre") col_free = i + if ($i == "free") col_free = i + } + next + } + /^ *[0-9]/ && col_us > 0 { + lines++ + if (lines <= 1) next # skip first sample (boot-time average) + n++ + us += $col_us; sy += $col_sy; id += $col_id + if (col_wa > 0) wa += $col_wa + if (col_free > 0) free += $col_free + } + END { + if (n > 0) + printf "%.1f %.1f %.1f %.1f %.0f\n", us/n, sy/n, id/n, wa/n, free/n + else + print "0 0 0 0 0" + }' "$log_file" +} + +# summarize_iostat LOG_FILE +# Outputs: avg_read_kBs avg_write_kBs avg_busy_pct +# Works for both Linux and FreeBSD extended iostat output. +# Only considers the primary block device (nda0, sda, nvme0n1, etc.). +summarize_iostat() { + local log_file="$1" + awk ' + BEGIN { n=0; rkB=0; wkB=0; busy=0 } + # Match device lines: starts with a device name, has numeric fields + # FreeBSD: nda0 r/s w/s kr/s kw/s ms/r ms/w ms/o ms/t qlen %b (11 fields) + # Linux: sda rrqm/s wrqm/s r/s w/s rkB/s wkB/s ... %util (14+ fields) + # Only pick real disk devices (skip pass0, loop, dm-) + /^(nda|ada|da|sd|nvme|vd)/ && NF >= 5 { + n++ + if (NF >= 14) { + # Linux extended: rkB/s=$6, wkB/s=$7, %util=$NF + rkB += $6; wkB += $7; busy += $NF + } else { + # FreeBSD extended: kr/s=$4, kw/s=$5, %b=$NF + rkB += $4; wkB += $5; busy += $NF + } + } + END { + if (n > 0) + printf "%.1f %.1f %.1f\n", rkB/n, wkB/n, busy/n + else + print "0 0 0" + }' "$log_file" +} + +# record_metrics CSV_FILE SCENARIO BENCH SUB_LABEL SCALE ITER VMSTAT_LOG IOSTAT_LOG +# Parses collected metrics and writes them to the CSV. +record_metrics() { + local csv_file="$1" scenario="$2" bench="$3" sub_label="$4" + local scale="$5" iter="$6" vmstat_log="$7" iostat_log="$8" + + if [ -f "$vmstat_log" ]; then + local vm_stats + vm_stats="$(summarize_vmstat "$vmstat_log")" + local cpu_user cpu_sys cpu_idle cpu_wa mem_free + read -r cpu_user cpu_sys cpu_idle cpu_wa mem_free <<< "$vm_stats" + + csv_write "$csv_file" "$scenario" "$bench" "${sub_label}_cpu_user" "$scale" "$iter" "pct" "$cpu_user" "pct" + csv_write "$csv_file" "$scenario" "$bench" "${sub_label}_cpu_sys" "$scale" "$iter" "pct" "$cpu_sys" "pct" + csv_write "$csv_file" "$scenario" "$bench" "${sub_label}_cpu_idle" "$scale" "$iter" "pct" "$cpu_idle" "pct" + csv_write "$csv_file" "$scenario" "$bench" "${sub_label}_cpu_iowait" "$scale" "$iter" "pct" "$cpu_wa" "pct" + csv_write "$csv_file" "$scenario" "$bench" "${sub_label}_mem_free_kb" "$scale" "$iter" "kB" "$mem_free" "kB" + fi + + if [ -f "$iostat_log" ]; then + local io_stats + io_stats="$(summarize_iostat "$iostat_log")" + local io_read io_write io_busy + read -r io_read io_write io_busy <<< "$io_stats" + + csv_write "$csv_file" "$scenario" "$bench" "${sub_label}_io_read_kBs" "$scale" "$iter" "kB/s" "$io_read" "kB/s" + csv_write "$csv_file" "$scenario" "$bench" "${sub_label}_io_write_kBs" "$scale" "$iter" "kB/s" "$io_write" "kB/s" + csv_write "$csv_file" "$scenario" "$bench" "${sub_label}_io_busy" "$scale" "$iter" "pct" "$io_busy" "pct" + fi +} + +############################################################################### +# VACUUM stats collection +############################################################################### + +# get_vacuum_stats SCENARIO DB_NAME +# Outputs JSON-ish line per table: relname vacuum_count autovacuum_count +get_vacuum_stats() { + local scenario="$1" + local dbname="${2:-postgres}" + local bindir port + bindir="$(get_bindir "$scenario")" + port="$(get_port "$scenario")" + + "$bindir/psql" -h 127.0.0.1 -p "$port" -d "$dbname" -X --no-psqlrc -t -A -F$'\t' -c " + SELECT pg_stat_force_next_flush(); + SELECT relname, vacuum_count, autovacuum_count, + COALESCE(n_dead_tup, 0) AS dead_tuples, + COALESCE(n_live_tup, 0) AS live_tuples + FROM pg_stat_user_tables + ORDER BY relname; + " 2>/dev/null | grep -v '^$' | grep -v '^pg_stat_force_next_flush' +} + +# record_vacuum_delta CSV_FILE SCENARIO BENCH SCALE ITER BEFORE_FILE AFTER_FILE +# Computes the delta in vacuum_count and autovacuum_count between snapshots. +record_vacuum_delta() { + local csv_file="$1" scenario="$2" bench="$3" + local scale="$4" iter="$5" before="$6" after="$7" + + # Sum up vacuum/autovacuum counts across all tables + local vac_before autovac_before dead_before + vac_before=$(awk -F'\t' '{s+=$2} END{print s+0}' "$before") + autovac_before=$(awk -F'\t' '{s+=$3} END{print s+0}' "$before") + dead_before=$(awk -F'\t' '{s+=$4} END{print s+0}' "$before") + + local vac_after autovac_after dead_after + vac_after=$(awk -F'\t' '{s+=$2} END{print s+0}' "$after") + autovac_after=$(awk -F'\t' '{s+=$3} END{print s+0}' "$after") + dead_after=$(awk -F'\t' '{s+=$4} END{print s+0}' "$after") + + local vac_delta=$((vac_after - vac_before)) + local autovac_delta=$((autovac_after - autovac_before)) + + csv_write "$csv_file" "$scenario" "$bench" "vacuum_count" "$scale" "$iter" "count" "$vac_delta" "count" + csv_write "$csv_file" "$scenario" "$bench" "autovacuum_count" "$scale" "$iter" "count" "$autovac_delta" "count" + csv_write "$csv_file" "$scenario" "$bench" "dead_tuples_end" "$scale" "$iter" "count" "$dead_after" "count" +} + +############################################################################### +# pg_prewarm - deterministic cache state +############################################################################### + +# warm_buffers SCENARIO DB_NAME TABLE_NAMES... +# Preloads heap relations and their indexes into shared_buffers. +# Ensures 100% buffer hit ratio for in-cache workloads. +warm_buffers() { + local scenario="$1" + local dbname="${2:-postgres}" + shift 2 + local bindir port + bindir="$(get_bindir "$scenario")" + port="$(get_port "$scenario")" + + log " Warming buffers with pg_prewarm" + + # Ensure pg_prewarm extension exists + "$bindir/psql" -h 127.0.0.1 -p "$port" -d "$dbname" -X --no-psqlrc -q \ + -c "CREATE EXTENSION IF NOT EXISTS pg_prewarm;" 2>/dev/null || true + + # Prewarm each table and its indexes + for tbl in "$@"; do + "$bindir/psql" -h 127.0.0.1 -p "$port" -d "$dbname" -X --no-psqlrc -q -c " + SELECT pg_prewarm('${tbl}', 'buffer'); + " 2>/dev/null || true + # Prewarm all indexes on this table + "$bindir/psql" -h 127.0.0.1 -p "$port" -d "$dbname" -X --no-psqlrc -t -A -q -c " + SELECT indexrelid::regclass::text + FROM pg_index WHERE indrelid = '${tbl}'::regclass; + " 2>/dev/null | while read -r idx; do + [ -n "$idx" ] && "$bindir/psql" -h 127.0.0.1 -p "$port" -d "$dbname" \ + -X --no-psqlrc -q -c "SELECT pg_prewarm('${idx}', 'buffer');" 2>/dev/null || true + done + done +} + +############################################################################### +# Wait event sampling +############################################################################### + +# _WAIT_SAMPLER_PID tracks the background sampler process +_WAIT_SAMPLER_PID="" + +# start_wait_sampler SCENARIO DB_NAME OUTPUT_FILE [INTERVAL_SECS] +# Samples pg_stat_activity wait events at the given interval. +# Runs in background; call stop_wait_sampler to terminate. +start_wait_sampler() { + local scenario="$1" + local dbname="$2" + local outfile="$3" + local interval="${4:-2}" + local bindir port + bindir="$(get_bindir "$scenario")" + port="$(get_port "$scenario")" + + ( + echo "# Wait event samples: $scenario interval=${interval}s" > "$outfile" + while true; do + "$bindir/psql" -h 127.0.0.1 -p "$port" -d "$dbname" -t -A -q -c " + SELECT now()::time, wait_event_type, wait_event, count(*) + FROM pg_stat_activity + WHERE state = 'active' AND pid != pg_backend_pid() + GROUP BY 1,2,3 + ORDER BY 4 DESC; + " >> "$outfile" 2>/dev/null + sleep "$interval" + done + ) & + _WAIT_SAMPLER_PID=$! +} + +# stop_wait_sampler +# Terminates the background wait event sampler. +stop_wait_sampler() { + if [ -n "$_WAIT_SAMPLER_PID" ]; then + kill "$_WAIT_SAMPLER_PID" 2>/dev/null + wait "$_WAIT_SAMPLER_PID" 2>/dev/null || true + _WAIT_SAMPLER_PID="" + fi +} + +# summarize_wait_events OUTPUT_FILE +# Aggregates wait event samples and outputs the top events. +summarize_wait_events() { + local outfile="$1" + [ -f "$outfile" ] || return + awk -F'|' ' + /^[0-9]/ && NF>=4 { + key = $2 "|" $3 + count[key] += $4 + total += $4 + } + END { + for (k in count) + printf "%s|%d|%.1f%%\n", k, count[k], count[k]*100/total + }' "$outfile" | sort -t'|' -k2 -rn | head -10 +} + +############################################################################### +# Statistics helpers +############################################################################### + +# cv VALUES... +# Computes coefficient of variation (CV%) from a list of numeric values. +# Returns 0.0 if fewer than 2 values. +cv() { + if [ $# -lt 2 ]; then + echo "0.0" + return + fi + printf '%s\n' "$@" | awk ' + {a[NR]=$1; s+=$1} + END { + if (NR < 2) {print "0.0"; exit} + avg = s / NR + if (avg == 0) {print "0.0"; exit} + for (i=1; i<=NR; i++) ss += (a[i] - avg)^2 + sd = sqrt(ss / (NR - 1)) + printf "%.1f", (sd / avg) * 100 + }' +} + +# stdev VALUES... +# Computes sample standard deviation. +stdev() { + if [ $# -lt 2 ]; then + echo "0" + return + fi + printf '%s\n' "$@" | awk ' + {a[NR]=$1; s+=$1} + END { + if (NR < 2) {print "0"; exit} + avg = s / NR + for (i=1; i<=NR; i++) ss += (a[i] - avg)^2 + printf "%.2f", sqrt(ss / (NR - 1)) + }' +} + +# percentile P VALUES... +# Computes the P-th percentile (P in 0-100) using linear interpolation. +percentile() { + local pct="$1" + shift + printf '%s\n' "$@" | sort -g | awk -v p="$pct" ' + {a[NR]=$1} + END { + if (NR == 0) {print "0"; exit} + rank = (p / 100.0) * (NR - 1) + 1 + lo = int(rank) + hi = lo + 1 + if (lo < 1) lo = 1 + if (hi > NR) hi = NR + frac = rank - int(rank) + printf "%.2f", a[lo] + frac * (a[hi] - a[lo]) + }' +} + +############################################################################### +# Memory / RSS +############################################################################### + +# get_pg_rss SCENARIO +# Returns RSS of the postgres backend processes in kB. +get_pg_rss() { + local scenario="$1" + local pgdata + pgdata="$(get_pgdata "$scenario")" + local pid_file="$pgdata/postmaster.pid" + if [ -f "$pid_file" ]; then + local main_pid + main_pid=$(head -1 "$pid_file") + # Sum RSS of all postgres processes in this cluster + ps -o rss= -p "$main_pid" $(pgrep -P "$main_pid" 2>/dev/null) 2>/dev/null \ + | awk '{s+=$1} END{print s+0}' + else + echo "0" + fi +} diff --git a/src/test/benchmarks/am4way/suite/undo/lib/report.sh b/src/test/benchmarks/am4way/suite/undo/lib/report.sh new file mode 100644 index 0000000000000..8cec81da92fa4 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/lib/report.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# +# report.sh - CSV collection and summary report generation +# + +############################################################################### +# CSV helpers +############################################################################### + +# csv_init FILE +csv_init() { + local file="$1" + echo "timestamp,scenario,benchmark,sub_test,scale,iteration,metric,value,unit" > "$file" +} + +# csv_write FILE SCENARIO BENCHMARK SUB_TEST SCALE ITERATION METRIC VALUE UNIT +csv_write() { + local file="$1" ts + ts="$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S%z')" + echo "${ts},$2,$3,$4,$5,$6,$7,$8,$9" >> "$file" +} + +############################################################################### +# Report generation +############################################################################### + +# generate_report CSV_FILE SYSINFO_FILE OUTPUT_FILE +generate_report() { + local csv_file="$1" + local sysinfo_file="$2" + local output_file="$3" + + local cpu ram kernel hostname_val date_str + hostname_val="$(grep '^hostname:' "$sysinfo_file" | sed 's/^hostname: *//')" + cpu="$(grep '^cpu:' "$sysinfo_file" | sed 's/^cpu: *//')" + ram="$(grep '^ram:' "$sysinfo_file" | sed 's/^ram: *//')" + kernel="$(grep '^kernel:' "$sysinfo_file" | sed 's/^kernel: *//')" + date_str="$(date '+%Y-%m-%d')" + + { + echo "================================================================" + echo " UNDO Benchmark Results - ${date_str} - ${hostname_val}" + echo " CPU: ${cpu} | RAM: ${ram} | Kernel: ${kernel}" + echo "================================================================" + echo "" + + # Collect unique benchmarks in order + local benchmarks + benchmarks=$(awk -F, 'NR>1 {print $3}' "$csv_file" | awk '!seen[$0]++') + + for bench in $benchmarks; do + local bench_label + case "$bench" in + b1) bench_label="B1: Insert Throughput" ;; + b2) bench_label="B2: Update Performance" ;; + b3) bench_label="B3: Delete Performance" ;; + b4) bench_label="B4: Read Under Writes" ;; + b5) bench_label="B5: Rollback Cost" ;; + b6) bench_label="B6: VACUUM Overhead" ;; + b7) bench_label="B7: Storage Footprint" ;; + pgbench) bench_label="B8: pgbench TPS" ;; + mixed) bench_label="B9: Mixed OLTP" ;; + zipfian) bench_label="B10: Zipfian Hot/Cold" ;; + concurrent) bench_label="B11: Multi-Role Concurrent" ;; + *) bench_label="$bench" ;; + esac + + # Get scales for this benchmark + local scales + scales=$(awk -F, -v b="$bench" '$3==b && NR>1 {print $5}' "$csv_file" | sort -nu) + + for scale in $scales; do + echo " ${bench_label} (scale=${scale}, median of ${ITERATIONS:-3} iterations)" + echo " ---------------------------------------------------------------" + printf " %-24s | %10s | %10s | %10s | %8s | %8s | %5s\n" \ + "Sub-test" "Baseline" "UNDO Off" "UNDO On" "Off/Base" "On/Base" "CV%" + echo " ------------------------+------------+------------+------------+----------+----------+-------" + + # Get sub-tests for this benchmark+scale, preserving order + local sub_tests + sub_tests=$(awk -F, -v b="$bench" -v s="$scale" \ + '$3==b && $5==s && NR>1 {print $4}' "$csv_file" | awk '!seen[$0]++') + + for sub_test in $sub_tests; do + # Get unit + local unit + unit=$(awk -F, -v b="$bench" -v s="$scale" -v st="$sub_test" \ + '$3==b && $5==s && $4==st && NR>1 {print $9; exit}' "$csv_file") + + # Collect values per scenario + local base_vals off_vals on_vals + base_vals=$(awk -F, -v b="$bench" -v s="$scale" -v st="$sub_test" \ + '$3==b && $5==s && $4==st && $2=="baseline" && NR>1 {print $8}' "$csv_file") + off_vals=$(awk -F, -v b="$bench" -v s="$scale" -v st="$sub_test" \ + '$3==b && $5==s && $4==st && $2=="undo_off" && NR>1 {print $8}' "$csv_file") + on_vals=$(awk -F, -v b="$bench" -v s="$scale" -v st="$sub_test" \ + '$3==b && $5==s && $4==st && $2=="undo_on" && NR>1 {print $8}' "$csv_file") + + # Compute medians via sort + awk + local base_med off_med on_med + base_med=$(echo "$base_vals" | sort -n | awk '{a[NR]=$1} END{if(NR==0)print "N/A"; else if(NR%2==1)print a[int(NR/2)+1]; else printf "%.2f",(a[NR/2]+a[NR/2+1])/2}') + off_med=$(echo "$off_vals" | sort -n | awk '{a[NR]=$1} END{if(NR==0)print "N/A"; else if(NR%2==1)print a[int(NR/2)+1]; else printf "%.2f",(a[NR/2]+a[NR/2+1])/2}') + on_med=$(echo "$on_vals" | sort -n | awk '{a[NR]=$1} END{if(NR==0)print "N/A"; else if(NR%2==1)print a[int(NR/2)+1]; else printf "%.2f",(a[NR/2]+a[NR/2+1])/2}') + + # Compute ratios + local off_ratio on_ratio + off_ratio=$(echo "$off_med $base_med" | awk '{ + if ($1=="N/A" || $2=="N/A" || $2+0==0) print "N/A" + else printf "%.2fx", $1/$2 + }') + on_ratio=$(echo "$on_med $base_med" | awk '{ + if ($1=="N/A" || $2=="N/A" || $2+0==0) print "N/A" + else printf "%.2fx", $1/$2 + }') + + # Compute CV% for the undo_on scenario (stability indicator) + local on_cv="N/A" + if [ -n "$on_vals" ] && [ "$(echo "$on_vals" | wc -w)" -ge 2 ]; then + on_cv=$(echo "$on_vals" | awk ' + {a[NR]=$1; s+=$1} + END { + if (NR < 2) {print "N/A"; exit} + avg = s / NR + if (avg == 0) {print "0.0"; exit} + for (i=1; i<=NR; i++) ss += (a[i] - avg)^2 + sd = sqrt(ss / (NR - 1)) + printf "%.1f", (sd / avg) * 100 + }') + fi + + # Format values with unit + local base_fmt off_fmt on_fmt + if [ "$base_med" = "N/A" ]; then base_fmt="N/A" + else base_fmt=$(printf "%.1f %s" "$base_med" "$unit"); fi + if [ "$off_med" = "N/A" ]; then off_fmt="N/A" + else off_fmt=$(printf "%.1f %s" "$off_med" "$unit"); fi + if [ "$on_med" = "N/A" ]; then on_fmt="N/A" + else on_fmt=$(printf "%.1f %s" "$on_med" "$unit"); fi + + printf " %-24s | %10s | %10s | %10s | %8s | %8s | %5s\n" \ + "$sub_test" "$base_fmt" "$off_fmt" "$on_fmt" "$off_ratio" "$on_ratio" "$on_cv" + done + echo "" + done + done + + echo " ================================================================" + echo " KEY FINDINGS (review after running on target hardware):" + echo " - Code-presence overhead (UNDO Off vs Baseline): compare Off/Base columns" + echo " - Per-table UNDO overhead: compare On/Base columns for B1-B3" + echo " - Rollback cost vs cleanup benefit: B5 On/Base vs B6 On/Base" + echo " - Read stability under writes: B4 On/Base post-update" + echo " - Storage tradeoffs: B7 table sizes + UNDO log sizes" + echo " ================================================================" + echo "" + echo " Full CSV data: $csv_file" + } > "$output_file" + + cat "$output_file" +} diff --git a/src/test/benchmarks/am4way/suite/undo/pgbench/mixed_oltp.sql b/src/test/benchmarks/am4way/suite/undo/pgbench/mixed_oltp.sql new file mode 100644 index 0000000000000..bcefe6425efb7 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/pgbench/mixed_oltp.sql @@ -0,0 +1,33 @@ +-- +-- Mixed OLTP pgbench script: 40% SELECT, 30% UPDATE, 20% INSERT, 10% ROLLBACK +-- +-- The 10% rollback rate exercises UNDO's synchronous rollback path in a +-- realistic OLTP mix. Use with: pgbench -f mixed_oltp.sql +-- + +\set rnd random(1, 100) +\set aid random(1, 100000 * :scale) +\set bid random(1, 1 * :scale) +\set tid random(1, 10 * :scale) +\set delta random(-5000, 5000) + +BEGIN; + +\if :rnd <= 40 +-- SELECT (40%) +SELECT abalance FROM pgbench_accounts WHERE aid = :aid; +COMMIT; +\elif :rnd <= 70 +-- UPDATE (30%) +UPDATE pgbench_accounts SET abalance = abalance + :delta WHERE aid = :aid; +COMMIT; +\elif :rnd <= 90 +-- INSERT (20%) +INSERT INTO pgbench_history (tid, bid, aid, delta, mtime) +VALUES (:tid, :bid, :aid, :delta, CURRENT_TIMESTAMP); +COMMIT; +\else +-- ROLLBACK (10%) - exercises UNDO synchronous rollback +UPDATE pgbench_accounts SET abalance = abalance + :delta WHERE aid = :aid; +ROLLBACK; +\endif diff --git a/src/test/benchmarks/am4way/suite/undo/pgbench/role_cold_reader.sql b/src/test/benchmarks/am4way/suite/undo/pgbench/role_cold_reader.sql new file mode 100644 index 0000000000000..2eab3dddabd45 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/pgbench/role_cold_reader.sql @@ -0,0 +1,8 @@ +-- +-- Role: Cold reader; uniform random reads across the full table +-- +-- Used by the multi-role concurrent benchmark (W9). +-- Exercises buffer cache misses and I/O-bound read paths. +-- +\set aid random(1, 100000 * :scale) +SELECT abalance FROM pgbench_accounts WHERE aid = :aid; diff --git a/src/test/benchmarks/am4way/suite/undo/pgbench/role_hot_reader.sql b/src/test/benchmarks/am4way/suite/undo/pgbench/role_hot_reader.sql new file mode 100644 index 0000000000000..98d4e2be236c9 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/pgbench/role_hot_reader.sql @@ -0,0 +1,8 @@ +-- +-- Role: Hot reader; fast point lookups on frequently-accessed rows +-- +-- Used by the multi-role concurrent benchmark (W9). +-- Exercises buffer cache hits and UNDO visibility checks. +-- +\set aid random(1, 1000) +SELECT abalance FROM pgbench_accounts WHERE aid = :aid; diff --git a/src/test/benchmarks/am4way/suite/undo/pgbench/role_scanner.sql b/src/test/benchmarks/am4way/suite/undo/pgbench/role_scanner.sql new file mode 100644 index 0000000000000..7c1304b5ada5d --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/pgbench/role_scanner.sql @@ -0,0 +1,10 @@ +-- +-- Role: Scanner; range scans across moderate windows +-- +-- Used by the multi-role concurrent benchmark (W9). +-- Exercises sequential I/O, UNDO visibility across many rows, +-- and interaction with concurrent updates. +-- +\set start random(1, 100000 * :scale - 10000) +SELECT count(*), avg(abalance) FROM pgbench_accounts +WHERE aid BETWEEN :start AND :start + 9999; diff --git a/src/test/benchmarks/am4way/suite/undo/pgbench/role_updater.sql b/src/test/benchmarks/am4way/suite/undo/pgbench/role_updater.sql new file mode 100644 index 0000000000000..f012e9beac4ee --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/pgbench/role_updater.sql @@ -0,0 +1,17 @@ +-- +-- Role: Updater; uniform random updates with occasional rollback +-- +-- Used by the multi-role concurrent benchmark (W9). +-- 80% commit, 20% rollback; exercises UNDO write and rollback paths. +-- +\set rnd random(1, 100) +\set aid random(1, 100000 * :scale) +\set delta random(-5000, 5000) + +BEGIN; +UPDATE pgbench_accounts SET abalance = abalance + :delta WHERE aid = :aid; +\if :rnd <= 80 +COMMIT; +\else +ROLLBACK; +\endif diff --git a/src/test/benchmarks/am4way/suite/undo/pgbench/zipfian_hot_cold.sql b/src/test/benchmarks/am4way/suite/undo/pgbench/zipfian_hot_cold.sql new file mode 100644 index 0000000000000..81b2f7abcba60 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/pgbench/zipfian_hot_cold.sql @@ -0,0 +1,42 @@ +-- +-- Zipfian hot/cold pgbench script: skewed access pattern +-- +-- Uses random_zipfian() to create realistic hot/cold access patterns. +-- A small set of "hot" rows receives the majority of operations, while +-- the rest of the table is cold. This stresses buffer management and +-- UNDO's per-row overhead on hot rows while cold rows may need disk I/O. +-- +-- Mix: 50% hot reads, 20% hot updates, 15% cold reads, 10% cold updates, +-- 5% rollback (exercises UNDO on hot rows). +-- +-- Use with: pgbench -f zipfian_hot_cold.sql +-- + +\set rnd random(1, 100) +\set hot_aid random_zipfian(1, 100000 * :scale, 1.2) +\set cold_aid random(1, 100000 * :scale) +\set delta random(-5000, 5000) + +BEGIN; + +\if :rnd <= 50 +-- Hot read (50%): Zipfian-distributed point lookup +SELECT abalance FROM pgbench_accounts WHERE aid = :hot_aid; +COMMIT; +\elif :rnd <= 70 +-- Hot update (20%): Zipfian-distributed update +UPDATE pgbench_accounts SET abalance = abalance + :delta WHERE aid = :hot_aid; +COMMIT; +\elif :rnd <= 85 +-- Cold read (15%): uniform random point lookup (may cause cache miss) +SELECT abalance FROM pgbench_accounts WHERE aid = :cold_aid; +COMMIT; +\elif :rnd <= 95 +-- Cold update (10%): uniform random update +UPDATE pgbench_accounts SET abalance = abalance + :delta WHERE aid = :cold_aid; +COMMIT; +\else +-- Rollback (5%): Zipfian hot row update + rollback +UPDATE pgbench_accounts SET abalance = abalance + :delta WHERE aid = :hot_aid; +ROLLBACK; +\endif diff --git a/src/test/benchmarks/am4way/suite/undo/run_undo_bench.sh b/src/test/benchmarks/am4way/suite/undo/run_undo_bench.sh new file mode 100755 index 0000000000000..855f43cc8472e --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/run_undo_bench.sh @@ -0,0 +1,553 @@ +#!/usr/bin/env bash +# +# run_undo_bench.sh - UNDO Benchmark Suite Orchestrator +# +# Compares three scenarios: +# baseline - pristine master branch (no UNDO code) +# undo_off - undo branch with enable_undo=off (code-presence overhead) +# undo_on - undo branch with enable_undo=on (full UNDO overhead/benefits) +# +# Usage: +# ./run_undo_bench.sh +# +# Configuration (environment variables): +# BENCH_BASE - Working directory (default: /scratch/undo-bench) +# REPO_DIR - Source repo (default: auto-detect) +# SHARED_BUFFERS - PG shared_buffers (default: 1GB) +# SCALES - Row counts for SQL benchmarks (default: 10000 100000 1000000) +# PGBENCH_SCALES - pgbench scale factors (default: 10 50 100) +# PGBENCH_CLIENTS - Client counts (default: 1 4 8) +# PGBENCH_DURATION - Seconds per pgbench run (default: 60) +# ITERATIONS - Measurement iterations (default: 3, warmup=1 always) +# BENCHMARKS - Which to run (default: b1 b2 b3 b4 b5 b6 b7 b8 pgbench mixed) +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +source "$SCRIPT_DIR/lib/report.sh" + +############################################################################### +# Cleanup trap +############################################################################### + +cleanup() { + echo "" + log "Cleaning up..." + stop_metrics 2>/dev/null || true + stop_all_clusters + # Clean up git worktrees (skip symlinks) + for _branch in master undo; do + if [ -d "$SRC_DIR/$_branch" ] && [ ! -L "$SRC_DIR/$_branch" ]; then + (cd "$REPO_DIR" && git worktree remove "$SRC_DIR/$_branch" --force 2>/dev/null) || true + fi + done + log "Done. Results in $RESULTS_DIR/" +} + +trap cleanup EXIT + +############################################################################### +# Banner +############################################################################### + +echo "" +echo "============================================================" +echo " UNDO Benchmark Suite" +echo " Scenarios: baseline | undo_off | undo_on" +echo " Benchmarks: $BENCHMARKS" +echo " SQL scales: $SCALES" +echo " pgbench scales: $PGBENCH_SCALES" +echo " Iterations: $ITERATIONS (+ 1 warmup)" +echo " BENCH_BASE: $BENCH_BASE" +echo "============================================================" +echo "" + +############################################################################### +# Phase 0: Setup directories +############################################################################### + +log "Phase 0: Setting up directories" +mkdir -p "$SRC_DIR" "$BUILD_DIR" "$INSTALL_DIR" "$DATA_DIR" "$RESULTS_DIR" "$LOGS_DIR" + +record_sysinfo "$RESULTS_DIR/sysinfo.txt" +csv_init "$CSV_FILE" + +############################################################################### +# Phase 1: Build both branches +############################################################################### + +log "Phase 1: Building branches" +build_branch master +build_branch undo + +############################################################################### +# Phase 2: Initialize clusters +############################################################################### + +log "Phase 2: Initializing clusters" +for scenario in $SCENARIOS; do + init_cluster "$scenario" +done + +############################################################################### +# Phase 3: Run benchmarks +############################################################################### + +log "Phase 3: Running benchmarks" + +# run_sql_benchmark BENCH_NAME SQL_FILE +# Runs a SQL benchmark across all scenarios and scales +run_sql_benchmark() { + local bench="$1" + local sql_file="$2" + local scales + scales="$(get_bench_scales "$bench")" + + for scenario in $SCENARIOS; do + start_cluster "$scenario" + create_bench_db "$scenario" + + local create_opts + create_opts="$(get_create_opts "$scenario")" + + for scale in $scales; do + log " $bench / $scenario / scale=$scale" + + # Warmup iteration (discarded) + log " Warmup..." + run_psql "$scenario" "$sql_file" \ + "scenario=$scenario" "row_count=$scale" "create_opts=$create_opts" \ + >/dev/null 2>&1 || true + run_checkpoint "$scenario" + + # Measurement iterations + for iter in $(seq 1 "$ITERATIONS"); do + log " Iteration $iter/$ITERATIONS" + local output + output="$(run_psql "$scenario" "$sql_file" \ + "scenario=$scenario" "row_count=$scale" "create_opts=$create_opts")" + + # Extract and record results + extract_results "$output" | while IFS=$'\t' read -r sub_test metric value; do + # Determine unit from metric name + local unit + case "$metric" in + time_ms) unit="ms" ;; + bytes) unit="bytes" ;; + count) unit="count" ;; + *) unit="$metric" ;; + esac + csv_write "$CSV_FILE" "$scenario" "$bench" "$sub_test" \ + "$scale" "$iter" "$metric" "$value" "$unit" + done + + run_checkpoint "$scenario" + done + done + + stop_cluster "$scenario" + done +} + +# run_pgbench_benchmark BENCH_NAME [CUSTOM_SCRIPT] +# Runs pgbench across all scenarios, scales, and client counts. +# Collects system metrics (CPU, RAM, I/O) and VACUUM stats per run. +run_pgbench_benchmark() { + local bench="$1" + local custom_script="${2:-}" + + for scenario in $SCENARIOS; do + start_cluster "$scenario" + + local bindir port libdir create_opts + bindir="$(get_bindir "$scenario")" + port="$(get_port "$scenario")" + libdir="$(get_libdir "$scenario")" + create_opts="$(get_create_opts "$scenario")" + + export LD_LIBRARY_PATH="${libdir}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + export DYLD_LIBRARY_PATH="${libdir}${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" + + # Enable autovacuum for pgbench runs so we can track VACUUM frequency + "$bindir/psql" -h 127.0.0.1 -p "$port" -d postgres -X --no-psqlrc \ + -c "ALTER SYSTEM SET autovacuum = on;" \ + -c "ALTER SYSTEM SET autovacuum_naptime = '15s';" \ + -c "SELECT pg_reload_conf();" \ + >/dev/null 2>&1 + + for scale in $PGBENCH_SCALES; do + log " $bench / $scenario / scale=$scale: initializing pgbench tables" + + # Initialize pgbench tables + "$bindir/pgbench" -i -s "$scale" -h 127.0.0.1 -p "$port" postgres \ + >"$LOGS_DIR/pgbench_init_${scenario}_${scale}.log" 2>&1 + + # Enable UNDO on pgbench tables for undo_on scenario. + if [ "$scenario" = "undo_on" ]; then + "$bindir/psql" -h 127.0.0.1 -p "$port" -d postgres -X --no-psqlrc \ + -c "ALTER TABLE pgbench_accounts SET (enable_undo = on);" \ + -c "ALTER TABLE pgbench_tellers SET (enable_undo = on);" \ + -c "ALTER TABLE pgbench_branches SET (enable_undo = on);" \ + -c "ALTER TABLE pgbench_history SET (enable_undo = on);" \ + >/dev/null 2>&1 + fi + + for clients in $PGBENCH_CLIENTS; do + log " $bench / $scenario / scale=$scale / clients=$clients" + + # Build pgbench command + local pgbench_args=(-h 127.0.0.1 -p "$port" + -c "$clients" -j "$clients" + -T "$PGBENCH_DURATION" --no-vacuum postgres) + + if [ -n "$custom_script" ]; then + pgbench_args+=(-f "$custom_script") + fi + + # Prewarm buffers for in-cache benchmarks (scale <= 100) + if [ "$scale" -le 100 ] 2>/dev/null; then + warm_buffers "$scenario" postgres \ + pgbench_accounts pgbench_branches pgbench_tellers + fi + + # Warmup + log " Warmup..." + "$bindir/pgbench" "${pgbench_args[@]}" \ + >"$LOGS_DIR/pgbench_warmup_${scenario}_${scale}_${clients}.log" 2>&1 || true + + # Measurement iterations + for iter in $(seq 1 "$ITERATIONS"); do + log " Iteration $iter/$ITERATIONS" + + local metrics_label="${bench}_${scenario}_s${scale}_c${clients}_i${iter}" + + # Snapshot VACUUM stats before run + local vac_before="$LOGS_DIR/vacstats_before_${metrics_label}.tsv" + get_vacuum_stats "$scenario" postgres > "$vac_before" + + # Record RSS before + local rss_before + rss_before="$(get_pg_rss "$scenario")" + + # Start system metrics collection + start_metrics "$metrics_label" + + # Start wait event sampler + start_wait_sampler "$scenario" postgres \ + "$LOGS_DIR/waits_${metrics_label}.txt" 2 + + # Run pgbench + local output + output=$("$bindir/pgbench" "${pgbench_args[@]}" 2>&1) || true + + # Stop wait sampler and system metrics + stop_wait_sampler + stop_metrics + + # Record RSS after + local rss_after + rss_after="$(get_pg_rss "$scenario")" + + # Parse TPS (PG19: "without initial connection time") + local tps lat + tps=$(echo "$output" | grep -iE "without initial connection|excluding connections" \ + | sed 's/.*= *//' | sed 's/ .*//' || echo "") + lat=$(echo "$output" | grep -i "latency average" \ + | sed 's/.*= *//' | sed 's/ .*//' || echo "") + + [ -z "$tps" ] && tps="0" + [ -z "$lat" ] && lat="0" + + # Record TPS and latency + csv_write "$CSV_FILE" "$scenario" "$bench" \ + "tps_c${clients}" "$scale" "$iter" "tps" "$tps" "tps" + csv_write "$CSV_FILE" "$scenario" "$bench" \ + "lat_c${clients}" "$scale" "$iter" "latency_ms" "$lat" "ms" + + # Record system metrics (CPU, I/O) + record_metrics "$CSV_FILE" "$scenario" "$bench" "c${clients}" \ + "$scale" "$iter" \ + "$LOGS_DIR/metrics_${metrics_label}_vmstat.log" \ + "$LOGS_DIR/metrics_${metrics_label}_iostat.log" + + # Record RAM (RSS in kB) + csv_write "$CSV_FILE" "$scenario" "$bench" \ + "c${clients}_rss_before_kb" "$scale" "$iter" "kB" "$rss_before" "kB" + csv_write "$CSV_FILE" "$scenario" "$bench" \ + "c${clients}_rss_after_kb" "$scale" "$iter" "kB" "$rss_after" "kB" + + # Snapshot VACUUM stats after run and record deltas + local vac_after="$LOGS_DIR/vacstats_after_${metrics_label}.tsv" + get_vacuum_stats "$scenario" postgres > "$vac_after" + record_vacuum_delta "$CSV_FILE" "$scenario" "$bench" \ + "$scale" "$iter" "$vac_before" "$vac_after" + done + + run_checkpoint "$scenario" + done + done + + # Disable autovacuum again before stopping + "$bindir/psql" -h 127.0.0.1 -p "$port" -d postgres -X --no-psqlrc \ + -c "ALTER SYSTEM RESET autovacuum;" \ + -c "ALTER SYSTEM RESET autovacuum_naptime;" \ + >/dev/null 2>&1 || true + + stop_cluster "$scenario" + done +} + +# Measure UNDO log directory size for undo_on scenario +measure_undo_log_size() { + local scenario="$1" + local bench="$2" + local scale="$3" + local iter="$4" + + if [ "$scenario" = "undo_on" ]; then + local pgdata undo_dir size + pgdata="$(get_pgdata "$scenario")" + undo_dir="$pgdata/base/undo" + if [ -d "$undo_dir" ]; then + size=$(get_dir_bytes "$undo_dir") + [ -n "$size" ] && csv_write "$CSV_FILE" "$scenario" "$bench" \ + "undo_log_size" "$scale" "$iter" "bytes" "$size" "bytes" + fi + fi +} + +# ── Run each benchmark ──────────────────────────────────────────────────────── + +for bench in $BENCHMARKS; do + log "=== Benchmark: $bench ===" + + # Re-initialize clusters between benchmarks to avoid stale UNDO log files + for scenario in $SCENARIOS; do + init_cluster "$scenario" + done + + case "$bench" in + b1) + run_sql_benchmark b1 "$SCRIPT_DIR/sql/b1_insert_throughput.sql" + ;; + b2) + run_sql_benchmark b2 "$SCRIPT_DIR/sql/b2_update_performance.sql" + ;; + b3) + run_sql_benchmark b3 "$SCRIPT_DIR/sql/b3_delete_performance.sql" + ;; + b4) + run_sql_benchmark b4 "$SCRIPT_DIR/sql/b4_read_under_writes.sql" + ;; + b5) + run_sql_benchmark b5 "$SCRIPT_DIR/sql/b5_rollback_cost.sql" + ;; + b6) + run_sql_benchmark b6 "$SCRIPT_DIR/sql/b6_vacuum_overhead.sql" + ;; + b7) + # B7 needs UNDO log size measurement after SQL run + _run_b7() { + local b7_scales + b7_scales="$(get_bench_scales b7)" + for scenario in $SCENARIOS; do + start_cluster "$scenario" + create_bench_db "$scenario" + local create_opts + create_opts="$(get_create_opts "$scenario")" + for scale in $b7_scales; do + log " b7 / $scenario / scale=$scale" + # Warmup + run_psql "$scenario" "$SCRIPT_DIR/sql/b7_storage_footprint.sql" \ + "scenario=$scenario" "row_count=$scale" "create_opts=$create_opts" \ + >/dev/null 2>&1 || true + run_checkpoint "$scenario" + for iter in $(seq 1 "$ITERATIONS"); do + log " Iteration $iter/$ITERATIONS" + local output + output="$(run_psql "$scenario" "$SCRIPT_DIR/sql/b7_storage_footprint.sql" \ + "scenario=$scenario" "row_count=$scale" "create_opts=$create_opts")" + local sub_test metric value unit + extract_results "$output" | while IFS=$'\t' read -r sub_test metric value; do + case "$metric" in + time_ms) unit="ms" ;; + bytes) unit="bytes" ;; + count) unit="count" ;; + *) unit="$metric" ;; + esac + csv_write "$CSV_FILE" "$scenario" "b7" "$sub_test" \ + "$scale" "$iter" "$metric" "$value" "$unit" + done + # Measure UNDO log directory size + measure_undo_log_size "$scenario" "b7" "$scale" "$iter" + run_checkpoint "$scenario" + done + done + stop_cluster "$scenario" + done + } + _run_b7 + ;; + b8) + run_sql_benchmark b8 "$SCRIPT_DIR/sql/b8_large_transaction.sql" + ;; + pgbench) + run_pgbench_benchmark pgbench + ;; + mixed) + run_pgbench_benchmark mixed "$SCRIPT_DIR/pgbench/mixed_oltp.sql" + ;; + zipfian) + # Zipfian hot/cold workload: skewed access pattern. + # Uses larger scale factor to create realistic cache-pressure. + PGBENCH_SCALES="$PGBENCH_SCALE_LARGE" \ + run_pgbench_benchmark zipfian "$SCRIPT_DIR/pgbench/zipfian_hot_cold.sql" + ;; + concurrent) + # Multi-role concurrent workload (W9-style): + # 4 concurrent pgbench instances with different behaviors, + # all hitting the same database simultaneously. + _run_concurrent() { + for scenario in $SCENARIOS; do + start_cluster "$scenario" + + local bindir port libdir create_opts + bindir="$(get_bindir "$scenario")" + port="$(get_port "$scenario")" + libdir="$(get_libdir "$scenario")" + create_opts="$(get_create_opts "$scenario")" + + export LD_LIBRARY_PATH="${libdir}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + export DYLD_LIBRARY_PATH="${libdir}${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" + + local scale="$PGBENCH_SCALE_LARGE" + log " concurrent / $scenario / scale=$scale: initializing" + + # Initialize pgbench tables at large scale + "$bindir/pgbench" -i -s "$scale" -h 127.0.0.1 -p "$port" postgres \ + >"$LOGS_DIR/pgbench_init_concurrent_${scenario}.log" 2>&1 + + # Enable UNDO on pgbench tables for undo_on + if [ "$scenario" = "undo_on" ]; then + "$bindir/psql" -h 127.0.0.1 -p "$port" -d postgres -X --no-psqlrc \ + -c "ALTER TABLE pgbench_accounts SET (enable_undo = on);" \ + -c "ALTER TABLE pgbench_tellers SET (enable_undo = on);" \ + -c "ALTER TABLE pgbench_branches SET (enable_undo = on);" \ + -c "ALTER TABLE pgbench_history SET (enable_undo = on);" \ + >/dev/null 2>&1 + fi + + # Divide clients across 4 roles: 30% hot read, 30% cold read, + # 20% updater, 20% scanner + local total_clients=16 + local hot_c=5 cold_c=5 upd_c=3 scan_c=3 + + for iter in $(seq 1 "$ITERATIONS"); do + log " concurrent / $scenario / iteration=$iter" + local label="concurrent_${scenario}_i${iter}" + + # Start wait event sampler + start_wait_sampler "$scenario" postgres \ + "$LOGS_DIR/waits_${label}.txt" 2 + + # Start system metrics + start_metrics "$label" + + # Launch 4 roles in parallel + "$bindir/pgbench" -h 127.0.0.1 -p "$port" \ + -c "$hot_c" -j "$hot_c" \ + -T "$PGBENCH_DURATION" --no-vacuum \ + -f "$SCRIPT_DIR/pgbench/role_hot_reader.sql" \ + postgres >"$LOGS_DIR/pgb_hot_${label}.log" 2>&1 & + local pid_hot=$! + + "$bindir/pgbench" -h 127.0.0.1 -p "$port" \ + -c "$cold_c" -j "$cold_c" \ + -T "$PGBENCH_DURATION" --no-vacuum \ + -f "$SCRIPT_DIR/pgbench/role_cold_reader.sql" \ + postgres >"$LOGS_DIR/pgb_cold_${label}.log" 2>&1 & + local pid_cold=$! + + "$bindir/pgbench" -h 127.0.0.1 -p "$port" \ + -c "$upd_c" -j "$upd_c" \ + -T "$PGBENCH_DURATION" --no-vacuum \ + -f "$SCRIPT_DIR/pgbench/role_updater.sql" \ + postgres >"$LOGS_DIR/pgb_upd_${label}.log" 2>&1 & + local pid_upd=$! + + "$bindir/pgbench" -h 127.0.0.1 -p "$port" \ + -c "$scan_c" -j "$scan_c" \ + -T "$PGBENCH_DURATION" --no-vacuum \ + -f "$SCRIPT_DIR/pgbench/role_scanner.sql" \ + postgres >"$LOGS_DIR/pgb_scan_${label}.log" 2>&1 & + local pid_scan=$! + + # Wait for all roles to finish + wait $pid_hot $pid_cold $pid_upd $pid_scan 2>/dev/null || true + + # Stop metrics and sampler + stop_metrics + stop_wait_sampler + + # Extract TPS from each role + for role in hot cold upd scan; do + local logf="$LOGS_DIR/pgb_${role}_${label}.log" + local tps_val + tps_val=$(grep -iE "without initial connection|excluding connections" "$logf" \ + | sed 's/.*= *//' | sed 's/ .*//' 2>/dev/null || echo "0") + [ -z "$tps_val" ] && tps_val="0" + csv_write "$CSV_FILE" "$scenario" "concurrent" \ + "tps_${role}" "$scale" "$iter" "tps" "$tps_val" "tps" + done + + # Aggregate total TPS across all roles + local total_tps=0 + for role in hot cold upd scan; do + local logf="$LOGS_DIR/pgb_${role}_${label}.log" + local t + t=$(grep -iE "without initial connection|excluding connections" "$logf" \ + | sed 's/.*= *//' | sed 's/ .*//' 2>/dev/null || echo "0") + [ -z "$t" ] && t="0" + total_tps=$(echo "$total_tps $t" | awk '{printf "%.1f", $1+$2}') + done + csv_write "$CSV_FILE" "$scenario" "concurrent" \ + "tps_total" "$scale" "$iter" "tps" "$total_tps" "tps" + + # Record wait event summary + local wait_summary + wait_summary="$(summarize_wait_events "$LOGS_DIR/waits_${label}.txt")" + if [ -n "$wait_summary" ]; then + echo "$wait_summary" | while IFS='|' read -r wtype wevent wcount wpct; do + csv_write "$CSV_FILE" "$scenario" "concurrent" \ + "wait_${wtype}_${wevent}" "$scale" "$iter" "samples" "$wcount" "samples" + done + fi + + run_checkpoint "$scenario" + done + + stop_cluster "$scenario" + done + } + _run_concurrent + ;; + *) + log "WARNING: Unknown benchmark '$bench', skipping" + ;; + esac +done + +############################################################################### +# Phase 4: Generate report +############################################################################### + +log "Phase 4: Generating report" +generate_report "$CSV_FILE" "$RESULTS_DIR/sysinfo.txt" "$RESULTS_DIR/summary.txt" + +echo "" +log "Benchmark complete." +log "CSV: $CSV_FILE" +log "Report: $RESULTS_DIR/summary.txt" +log "Logs: $LOGS_DIR/" diff --git a/src/test/benchmarks/am4way/suite/undo/sql/b1_insert_throughput.sql b/src/test/benchmarks/am4way/suite/undo/sql/b1_insert_throughput.sql new file mode 100644 index 0000000000000..d66e0da6c1886 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/sql/b1_insert_throughput.sql @@ -0,0 +1,70 @@ +-- +-- B1: Insert Throughput +-- +-- Measures UNDO record generation cost on INSERTs. +-- Each INSERT with UNDO writes a 48-byte header (no old-tuple payload). +-- +-- Variables: :scenario, :row_count, :create_opts +-- + +-- ================================================================ +-- Setup +-- ================================================================ +DROP TABLE IF EXISTS bench_insert; +CREATE TABLE bench_insert ( + id integer, + value integer, + data text +) :create_opts; + +-- ================================================================ +-- B1a: Bulk INSERT via generate_series +-- ================================================================ +\echo '--- Bulk INSERT :row_count rows ---' + +SELECT clock_timestamp()::text AS _t0 \gset + +INSERT INTO bench_insert (id, value, data) +SELECT i, i, md5(i::text) FROM generate_series(1, :row_count) i; + +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|bulk_insert|time_ms|:_elapsed + +SELECT pg_relation_size('bench_insert') AS _size \gset +\echo UNDO_BENCH_RESULT|bulk_insert_size|bytes|:_size + +-- ================================================================ +-- B1b: Individual INSERT (PL/pgSQL loop, capped at 10000) +-- ================================================================ +TRUNCATE bench_insert; + +SELECT LEAST(:row_count, 10000) AS _ind_count \gset + +\echo '--- Individual INSERT :_ind_count rows ---' + +-- Use a temp function so the loop limit is passed as a parameter +-- (psql variables are not expanded inside $$ string constants) +CREATE FUNCTION pg_temp.bench_individual_insert(n integer) +RETURNS void LANGUAGE plpgsql AS $fn$ +BEGIN + FOR i IN 1..n LOOP + INSERT INTO bench_insert (id, value, data) + VALUES (i, i, md5(i::text)); + END LOOP; +END +$fn$; + +SELECT clock_timestamp()::text AS _t0 \gset + +SELECT pg_temp.bench_individual_insert(:_ind_count); + +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|individual_insert|time_ms|:_elapsed + +SELECT pg_relation_size('bench_insert') AS _size \gset +\echo UNDO_BENCH_RESULT|individual_insert_size|bytes|:_size + +-- ================================================================ +-- Cleanup +-- ================================================================ +DROP TABLE bench_insert; diff --git a/src/test/benchmarks/am4way/suite/undo/sql/b2_update_performance.sql b/src/test/benchmarks/am4way/suite/undo/sql/b2_update_performance.sql new file mode 100644 index 0000000000000..e02b46dd7ac04 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/sql/b2_update_performance.sql @@ -0,0 +1,147 @@ +-- +-- B2: Update Performance (OLTP-style targeted operations) +-- +-- Measures UNDO overhead for realistic update patterns: single-row PK +-- lookups, small batches, cross-table operations, and one full-table pass. +-- Avoids repeated full-table scans that dominate runtime on small systems. +-- +-- Variables: :scenario, :row_count, :create_opts +-- + +-- ================================================================ +-- Setup: two related tables (orders + items) +-- ================================================================ +DROP TABLE IF EXISTS bench_items; +DROP TABLE IF EXISTS bench_orders; + +CREATE TABLE bench_orders ( + id integer PRIMARY KEY, + customer_id integer, + status integer DEFAULT 0, + total numeric DEFAULT 0, + updated_at timestamp DEFAULT now() +) :create_opts; + +CREATE TABLE bench_items ( + id integer PRIMARY KEY, + order_id integer, + quantity integer DEFAULT 1, + price numeric DEFAULT 9.99 +) :create_opts; + +INSERT INTO bench_orders (id, customer_id) +SELECT i, (i % 1000) + 1 FROM generate_series(1, :row_count) i; + +INSERT INTO bench_items (id, order_id, quantity, price) +SELECT i, ((i - 1) % :row_count) + 1, (i % 10) + 1, round((random() * 100)::numeric, 2) +FROM generate_series(1, :row_count) i; + +CREATE INDEX bench_items_order_idx ON bench_items (order_id); + +-- Record initial sizes +SELECT pg_relation_size('bench_orders') AS _size \gset +\echo UNDO_BENCH_RESULT|initial_orders_size|bytes|:_size +SELECT pg_relation_size('bench_items') AS _size \gset +\echo UNDO_BENCH_RESULT|initial_items_size|bytes|:_size + +-- ================================================================ +-- B2a: Single-row UPDATE by PK (1000 individual updates) +-- ================================================================ +\echo '--- Single-row UPDATE by PK (1000 rows) ---' + +CREATE FUNCTION pg_temp.bench_single_updates(n integer) +RETURNS void LANGUAGE plpgsql AS $fn$ +BEGIN + FOR i IN 1..n LOOP + UPDATE bench_orders SET status = status + 1 WHERE id = i; + END LOOP; +END +$fn$; + +SELECT LEAST(:row_count, 1000) AS _n \gset + +SELECT clock_timestamp()::text AS _t0 \gset +SELECT pg_temp.bench_single_updates(:_n); +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|single_row_update|time_ms|:_elapsed + +-- ================================================================ +-- B2b: Small batch UPDATE (10 rows per batch, 100 batches) +-- ================================================================ +\echo '--- Batch UPDATE (10-row batches, 100 batches) ---' + +CREATE FUNCTION pg_temp.bench_batch_updates(batches integer, batch_sz integer, max_id integer) +RETURNS void LANGUAGE plpgsql AS $fn$ +DECLARE + start_id integer; +BEGIN + FOR b IN 1..batches LOOP + start_id := ((b - 1) * batch_sz) % max_id + 1; + UPDATE bench_orders SET status = status + 1 + WHERE id >= start_id AND id < start_id + batch_sz; + END LOOP; +END +$fn$; + +SELECT clock_timestamp()::text AS _t0 \gset +SELECT pg_temp.bench_batch_updates(100, 10, :row_count); +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|batch_update_10x100|time_ms|:_elapsed + +-- ================================================================ +-- B2c: Cross-table UPDATE (update order + recalculate from items) +-- ================================================================ +\echo '--- Cross-table UPDATE (100 orders with item aggregation) ---' + +CREATE FUNCTION pg_temp.bench_cross_table_updates(n integer) +RETURNS void LANGUAGE plpgsql AS $fn$ +BEGIN + FOR i IN 1..n LOOP + UPDATE bench_items SET quantity = quantity + 1 + WHERE order_id = i AND id = i; + + UPDATE bench_orders SET total = ( + SELECT COALESCE(sum(quantity * price), 0) + FROM bench_items WHERE order_id = i + ), updated_at = now() + WHERE id = i; + END LOOP; +END +$fn$; + +SELECT clock_timestamp()::text AS _t0 \gset +SELECT pg_temp.bench_cross_table_updates(LEAST(:row_count, 100)); +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|cross_table_update|time_ms|:_elapsed + +-- ================================================================ +-- B2d: 1% targeted UPDATE +-- ================================================================ +\echo '--- 1% targeted UPDATE ---' + +SELECT clock_timestamp()::text AS _t0 \gset +UPDATE bench_orders SET status = status + 1 WHERE id % 100 = 0; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|targeted_1pct_update|time_ms|:_elapsed + +-- ================================================================ +-- B2e: One full-table pass (for overhead comparison, single round) +-- ================================================================ +\echo '--- Single full-table UPDATE ---' + +SELECT clock_timestamp()::text AS _t0 \gset +UPDATE bench_orders SET status = status + 1; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|full_table_update_1r|time_ms|:_elapsed + +-- Final sizes +SELECT pg_relation_size('bench_orders') AS _size \gset +\echo UNDO_BENCH_RESULT|final_orders_size|bytes|:_size +SELECT pg_relation_size('bench_items') AS _size \gset +\echo UNDO_BENCH_RESULT|final_items_size|bytes|:_size + +-- ================================================================ +-- Cleanup +-- ================================================================ +DROP TABLE bench_items; +DROP TABLE bench_orders; diff --git a/src/test/benchmarks/am4way/suite/undo/sql/b3_delete_performance.sql b/src/test/benchmarks/am4way/suite/undo/sql/b3_delete_performance.sql new file mode 100644 index 0000000000000..f98a145725555 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/sql/b3_delete_performance.sql @@ -0,0 +1,103 @@ +-- +-- B3: Delete Performance (OLTP-style targeted operations) +-- +-- Measures UNDO cost for realistic delete patterns: single-row PK +-- lookups, small batches, targeted percentage, and dead tuple tracking. +-- +-- Variables: :scenario, :row_count, :create_opts +-- + +-- ================================================================ +-- Setup +-- ================================================================ +DROP TABLE IF EXISTS bench_delete; +CREATE TABLE bench_delete ( + id integer PRIMARY KEY, + value integer, + data text +) :create_opts; + +INSERT INTO bench_delete +SELECT i, i, md5(i::text) FROM generate_series(1, :row_count) i; + +SELECT pg_relation_size('bench_delete') AS _size \gset +\echo UNDO_BENCH_RESULT|initial_size|bytes|:_size + +-- ================================================================ +-- B3a: Single-row DELETE by PK (500 rows) +-- ================================================================ +\echo '--- Single-row DELETE by PK (500 rows) ---' + +CREATE FUNCTION pg_temp.bench_single_deletes(n integer, max_id integer) +RETURNS void LANGUAGE plpgsql AS $fn$ +BEGIN + -- Delete from the end so we don't affect later tests + FOR i IN REVERSE max_id..(max_id - n + 1) LOOP + DELETE FROM bench_delete WHERE id = i; + END LOOP; +END +$fn$; + +SELECT LEAST(:row_count / 2, 500) AS _del_n \gset + +SELECT clock_timestamp()::text AS _t0 \gset +SELECT pg_temp.bench_single_deletes(:_del_n, :row_count); +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|single_row_delete|time_ms|:_elapsed + +SELECT count(*) AS _cnt FROM bench_delete \gset +\echo UNDO_BENCH_RESULT|rows_after_single_delete|count|:_cnt + +-- ================================================================ +-- B3b: Small batch DELETE (10-row batches, 50 batches) +-- ================================================================ +\echo '--- Batch DELETE (10-row batches, 50 batches) ---' + +SELECT clock_timestamp()::text AS _t0 \gset + +CREATE FUNCTION pg_temp.bench_batch_deletes(batches integer, batch_sz integer, max_id integer) +RETURNS void LANGUAGE plpgsql AS $fn$ +DECLARE + start_id integer; +BEGIN + FOR b IN 1..batches LOOP + start_id := ((b - 1) * batch_sz) % max_id + 1; + DELETE FROM bench_delete + WHERE id >= start_id AND id < start_id + batch_sz; + END LOOP; +END +$fn$; + +SELECT pg_temp.bench_batch_deletes(50, 10, :row_count); +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|batch_delete_10x50|time_ms|:_elapsed + +SELECT count(*) AS _cnt FROM bench_delete \gset +\echo UNDO_BENCH_RESULT|rows_after_batch_delete|count|:_cnt + +-- ================================================================ +-- B3c: 5% targeted DELETE +-- ================================================================ +\echo '--- 5% targeted DELETE ---' + +SELECT clock_timestamp()::text AS _t0 \gset +DELETE FROM bench_delete WHERE id % 20 = 0; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|targeted_5pct_delete|time_ms|:_elapsed + +SELECT count(*) AS _cnt FROM bench_delete \gset +\echo UNDO_BENCH_RESULT|rows_after_targeted_delete|count|:_cnt + +-- Dead tuple check +SELECT pg_stat_force_next_flush(); +SELECT COALESCE(n_dead_tup, 0) AS _dead + FROM pg_stat_user_tables WHERE relname = 'bench_delete' \gset +\echo UNDO_BENCH_RESULT|dead_after_deletes|count|:_dead + +SELECT pg_relation_size('bench_delete') AS _size \gset +\echo UNDO_BENCH_RESULT|final_size|bytes|:_size + +-- ================================================================ +-- Cleanup +-- ================================================================ +DROP TABLE bench_delete; diff --git a/src/test/benchmarks/am4way/suite/undo/sql/b4_read_under_writes.sql b/src/test/benchmarks/am4way/suite/undo/sql/b4_read_under_writes.sql new file mode 100644 index 0000000000000..450813425da7c --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/sql/b4_read_under_writes.sql @@ -0,0 +1,125 @@ +-- +-- B4: Read Under Writes (OLTP-style targeted operations) +-- +-- Tests read stability after targeted writes rather than full-table scans. +-- UNDO-enabled tables should maintain consistent read performance since +-- there are no dead tuples to skip. +-- +-- Variables: :scenario, :row_count, :create_opts +-- + +-- ================================================================ +-- Setup +-- ================================================================ +DROP TABLE IF EXISTS bench_ruw; +CREATE TABLE bench_ruw ( + id integer PRIMARY KEY, + value integer, + data text +) :create_opts; + +INSERT INTO bench_ruw +SELECT i, i % 1000, md5(i::text) +FROM generate_series(1, :row_count) i; + +CREATE INDEX bench_ruw_value_idx ON bench_ruw (value); + +-- ================================================================ +-- Baseline reads on clean table +-- ================================================================ +\echo '--- Baseline sequential scan ---' + +SELECT clock_timestamp()::text AS _t0 \gset +SELECT count(*), sum(value) FROM bench_ruw; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|baseline_seqscan|time_ms|:_elapsed + +\echo '--- Baseline index scan ---' +SET enable_seqscan = off; +SELECT clock_timestamp()::text AS _t0 \gset +SELECT count(*) FROM bench_ruw WHERE value BETWEEN 100 AND 200; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|baseline_idxscan|time_ms|:_elapsed +RESET enable_seqscan; + +-- ================================================================ +-- Phase 1: Targeted single-row updates (1000 rows, no VACUUM) +-- ================================================================ +\echo '--- 1000 single-row updates (no VACUUM) ---' + +CREATE FUNCTION pg_temp.bench_targeted_updates(n integer) +RETURNS void LANGUAGE plpgsql AS $fn$ +BEGIN + FOR i IN 1..n LOOP + UPDATE bench_ruw SET value = value + 1 WHERE id = i; + END LOOP; +END +$fn$; + +SELECT pg_temp.bench_targeted_updates(LEAST(:row_count, 1000)); + +-- Post-targeted-update reads +\echo '--- Post-targeted-update sequential scan ---' +SELECT clock_timestamp()::text AS _t0 \gset +SELECT count(*), sum(value) FROM bench_ruw; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|post_targeted_seqscan|time_ms|:_elapsed + +\echo '--- Post-targeted-update index scan ---' +SET enable_seqscan = off; +SELECT clock_timestamp()::text AS _t0 \gset +SELECT count(*) FROM bench_ruw WHERE value BETWEEN 100 AND 200; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|post_targeted_idxscan|time_ms|:_elapsed +RESET enable_seqscan; + +-- ================================================================ +-- Phase 2: Batch updates (10% of rows in one statement) +-- ================================================================ +\echo '--- 10% batch update (no VACUUM) ---' + +UPDATE bench_ruw SET value = value + 1 WHERE id % 10 = 0; + +\echo '--- Post-batch-update sequential scan ---' +SELECT clock_timestamp()::text AS _t0 \gset +SELECT count(*), sum(value) FROM bench_ruw; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|post_batch_seqscan|time_ms|:_elapsed + +-- ================================================================ +-- Phase 3: Interleaved single-row write + PK read (100 cycles) +-- ================================================================ +\echo '--- Interleaved write+read: 100 cycles ---' + +CREATE FUNCTION pg_temp.bench_interleaved_rw(n integer) +RETURNS void LANGUAGE plpgsql AS $fn$ +DECLARE + _val integer; +BEGIN + FOR i IN 1..n LOOP + UPDATE bench_ruw SET value = value + 1 WHERE id = i; + SELECT value INTO _val FROM bench_ruw WHERE id = i; + END LOOP; +END +$fn$; + +SELECT clock_timestamp()::text AS _t0 \gset +SELECT pg_temp.bench_interleaved_rw(LEAST(:row_count, 100)); +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|interleaved_rw_100|time_ms|:_elapsed + +-- ================================================================ +-- Phase 4: Read after VACUUM (heap should recover) +-- ================================================================ +VACUUM bench_ruw; + +\echo '--- Post-VACUUM sequential scan ---' +SELECT clock_timestamp()::text AS _t0 \gset +SELECT count(*), sum(value) FROM bench_ruw; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|post_vacuum_seqscan|time_ms|:_elapsed + +-- ================================================================ +-- Cleanup +-- ================================================================ +DROP TABLE bench_ruw; diff --git a/src/test/benchmarks/am4way/suite/undo/sql/b5_rollback_cost.sql b/src/test/benchmarks/am4way/suite/undo/sql/b5_rollback_cost.sql new file mode 100644 index 0000000000000..fe66b46705367 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/sql/b5_rollback_cost.sql @@ -0,0 +1,141 @@ +-- +-- B5: Rollback Cost +-- +-- The key UNDO differentiator. Standard PG rollback is near-instant (marks +-- xact aborted, leaves dead tuples). UNDO rollback synchronously walks the +-- chain and physically reverses each operation. +-- +-- Tests INSERT rollback at 100, 1000, 10000, 100000 rows; UPDATE and DELETE +-- rollback at 10000 rows. Captures WAL volume and dead tuple counts. +-- +-- Variables: :scenario, :row_count (unused, B5 uses internal sizes), :create_opts +-- + +-- ================================================================ +-- Setup +-- ================================================================ +DROP TABLE IF EXISTS bench_rollback; +CREATE TABLE bench_rollback (id integer, data text) :create_opts; + +-- ================================================================ +-- INSERT Rollback: 100 rows +-- ================================================================ +\echo '--- INSERT rollback: 100 rows ---' + +BEGIN; +INSERT INTO bench_rollback SELECT i, md5(i::text) FROM generate_series(1, 100) i; +SELECT clock_timestamp()::text AS _t1 \gset +ROLLBACK; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t1'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|ins_rollback_100|time_ms|:_elapsed + +SELECT count(*) AS _cnt FROM bench_rollback \gset +\echo UNDO_BENCH_RESULT|ins_rollback_100_rows|count|:_cnt + +-- ================================================================ +-- INSERT Rollback: 1,000 rows +-- ================================================================ +\echo '--- INSERT rollback: 1,000 rows ---' + +BEGIN; +INSERT INTO bench_rollback SELECT i, md5(i::text) FROM generate_series(1, 1000) i; +SELECT clock_timestamp()::text AS _t1 \gset +ROLLBACK; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t1'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|ins_rollback_1k|time_ms|:_elapsed + +SELECT count(*) AS _cnt FROM bench_rollback \gset +\echo UNDO_BENCH_RESULT|ins_rollback_1k_rows|count|:_cnt + +-- ================================================================ +-- INSERT Rollback: 10,000 rows +-- ================================================================ +\echo '--- INSERT rollback: 10,000 rows ---' + +BEGIN; +INSERT INTO bench_rollback SELECT i, md5(i::text) FROM generate_series(1, 10000) i; +SELECT clock_timestamp()::text AS _t1 \gset +ROLLBACK; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t1'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|ins_rollback_10k|time_ms|:_elapsed + +SELECT count(*) AS _cnt FROM bench_rollback \gset +\echo UNDO_BENCH_RESULT|ins_rollback_10k_rows|count|:_cnt + +-- ================================================================ +-- INSERT Rollback: 100,000 rows +-- ================================================================ +\echo '--- INSERT rollback: 100,000 rows ---' + +BEGIN; +INSERT INTO bench_rollback SELECT i, md5(i::text) FROM generate_series(1, 100000) i; +SELECT clock_timestamp()::text AS _t1 \gset +ROLLBACK; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t1'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|ins_rollback_100k|time_ms|:_elapsed + +SELECT count(*) AS _cnt FROM bench_rollback \gset +\echo UNDO_BENCH_RESULT|ins_rollback_100k_rows|count|:_cnt + +-- ================================================================ +-- UPDATE Rollback: 10,000 rows +-- ================================================================ +\echo '--- UPDATE rollback: 10,000 rows ---' + +-- Insert baseline data for update test +INSERT INTO bench_rollback SELECT i, 'baseline_' || i FROM generate_series(1, 10000) i; + +BEGIN; +UPDATE bench_rollback SET data = 'modified'; +SELECT clock_timestamp()::text AS _t1 \gset +ROLLBACK; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t1'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|upd_rollback_10k|time_ms|:_elapsed + +-- Verify original data preserved +SELECT count(*) AS _cnt FROM bench_rollback WHERE data LIKE 'baseline_%' \gset +\echo UNDO_BENCH_RESULT|upd_rollback_preserved|count|:_cnt + +-- ================================================================ +-- DELETE Rollback: 10,000 rows +-- ================================================================ +\echo '--- DELETE rollback: 10,000 rows ---' + +BEGIN; +DELETE FROM bench_rollback; +SELECT clock_timestamp()::text AS _t1 \gset +ROLLBACK; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t1'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|del_rollback_10k|time_ms|:_elapsed + +-- Verify rows restored +SELECT count(*) AS _cnt FROM bench_rollback \gset +\echo UNDO_BENCH_RESULT|del_rollback_restored|count|:_cnt + +-- ================================================================ +-- WAL Volume: INSERT + ROLLBACK of 10,000 rows +-- ================================================================ +\echo '--- WAL volume: 10K row insert+rollback ---' + +TRUNCATE bench_rollback; +SELECT pg_current_wal_lsn()::text AS _wal0 \gset + +BEGIN; +INSERT INTO bench_rollback SELECT i, md5(i::text) FROM generate_series(1, 10000) i; +ROLLBACK; + +SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), :'_wal0'::pg_lsn)::bigint AS _wal_bytes \gset +\echo UNDO_BENCH_RESULT|wal_10k_ins_rollback|bytes|:_wal_bytes + +-- ================================================================ +-- Dead tuple check after all rollbacks +-- ================================================================ +SELECT pg_stat_force_next_flush(); +SELECT COALESCE(n_dead_tup, 0) AS _dead + FROM pg_stat_user_tables WHERE relname = 'bench_rollback' \gset +\echo UNDO_BENCH_RESULT|dead_after_rollbacks|count|:_dead + +-- ================================================================ +-- Cleanup +-- ================================================================ +DROP TABLE bench_rollback; diff --git a/src/test/benchmarks/am4way/suite/undo/sql/b6_vacuum_overhead.sql b/src/test/benchmarks/am4way/suite/undo/sql/b6_vacuum_overhead.sql new file mode 100644 index 0000000000000..959786e1b59ce --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/sql/b6_vacuum_overhead.sql @@ -0,0 +1,93 @@ +-- +-- B6: VACUUM Overhead (OLTP-style targeted operations) +-- +-- Generates dead tuples via targeted updates and small deletes, then +-- measures VACUUM cost. With UNDO, committed operations leave zero +-- dead tuples, so VACUUM should be nearly instant. +-- +-- Variables: :scenario, :row_count, :create_opts +-- + +-- ================================================================ +-- Setup +-- ================================================================ +DROP TABLE IF EXISTS bench_vacuum; +CREATE TABLE bench_vacuum ( + id integer PRIMARY KEY, + value integer, + data text +) :create_opts; + +INSERT INTO bench_vacuum +SELECT i, i, md5(i::text) FROM generate_series(1, :row_count) i; + +SELECT pg_relation_size('bench_vacuum') AS _size \gset +\echo UNDO_BENCH_RESULT|initial_size|bytes|:_size + +-- ================================================================ +-- Phase 1: Generate dead tuples via targeted updates +-- - 1000 single-row updates + one 10% batch update +-- ================================================================ +\echo '--- Generating dead tuples via targeted updates ---' + +CREATE FUNCTION pg_temp.bench_targeted_updates(n integer) +RETURNS void LANGUAGE plpgsql AS $fn$ +BEGIN + FOR i IN 1..n LOOP + UPDATE bench_vacuum SET value = value + 1 WHERE id = i; + END LOOP; +END +$fn$; + +SELECT pg_temp.bench_targeted_updates(LEAST(:row_count, 1000)); +UPDATE bench_vacuum SET value = value + 1 WHERE id % 10 = 0; + +-- Pre-VACUUM metrics +SELECT pg_relation_size('bench_vacuum') AS _size \gset +\echo UNDO_BENCH_RESULT|pre_vacuum_size|bytes|:_size + +SELECT pg_stat_force_next_flush(); +SELECT COALESCE(n_dead_tup, 0) AS _dead + FROM pg_stat_user_tables WHERE relname = 'bench_vacuum' \gset +\echo UNDO_BENCH_RESULT|dead_pre_vacuum|count|:_dead + +-- ================================================================ +-- Phase 2: VACUUM timing +-- ================================================================ +\echo '--- VACUUM ---' + +SELECT clock_timestamp()::text AS _t0 \gset +VACUUM bench_vacuum; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|vacuum_time|time_ms|:_elapsed + +SELECT pg_relation_size('bench_vacuum') AS _size \gset +\echo UNDO_BENCH_RESULT|post_vacuum_size|bytes|:_size + +SELECT pg_stat_force_next_flush(); +SELECT COALESCE(n_dead_tup, 0) AS _dead + FROM pg_stat_user_tables WHERE relname = 'bench_vacuum' \gset +\echo UNDO_BENCH_RESULT|dead_post_vacuum|count|:_dead + +-- ================================================================ +-- Phase 3: DELETE 5% + VACUUM cycle +-- ================================================================ +\echo '--- DELETE 5% + VACUUM ---' + +SELECT clock_timestamp()::text AS _t0 \gset +DELETE FROM bench_vacuum WHERE id % 20 = 0; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|delete_5pct|time_ms|:_elapsed + +SELECT clock_timestamp()::text AS _t0 \gset +VACUUM bench_vacuum; +SELECT round(extract(epoch FROM (clock_timestamp() - :'_t0'::timestamptz)) * 1000, 2) AS _elapsed \gset +\echo UNDO_BENCH_RESULT|vacuum_after_delete|time_ms|:_elapsed + +SELECT pg_relation_size('bench_vacuum') AS _size \gset +\echo UNDO_BENCH_RESULT|final_size|bytes|:_size + +-- ================================================================ +-- Cleanup +-- ================================================================ +DROP TABLE bench_vacuum; diff --git a/src/test/benchmarks/am4way/suite/undo/sql/b7_storage_footprint.sql b/src/test/benchmarks/am4way/suite/undo/sql/b7_storage_footprint.sql new file mode 100644 index 0000000000000..a36b8a925036b --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/sql/b7_storage_footprint.sql @@ -0,0 +1,74 @@ +-- +-- B7: Storage Footprint (OLTP-style targeted operations) +-- +-- Compares table sizes after targeted updates rather than repeated +-- full-table passes. Measures fresh load, post-targeted-update, +-- and post-VACUUM sizes. UNDO log size measured by orchestrator. +-- +-- Variables: :scenario, :row_count, :create_opts +-- + +-- ================================================================ +-- Test 1: Fresh load size +-- ================================================================ +\echo '--- Fresh load: :row_count rows ---' + +DROP TABLE IF EXISTS bench_storage; +CREATE TABLE bench_storage ( + id integer PRIMARY KEY, + counter integer DEFAULT 0, + payload text DEFAULT repeat('x', 50) +) :create_opts; + +INSERT INTO bench_storage (id) +SELECT i FROM generate_series(1, :row_count) i; + +SELECT pg_relation_size('bench_storage') AS _size \gset +\echo UNDO_BENCH_RESULT|fresh_table_size|bytes|:_size + +SELECT pg_total_relation_size('bench_storage') AS _size \gset +\echo UNDO_BENCH_RESULT|fresh_total_size|bytes|:_size + +SELECT pg_indexes_size('bench_storage') AS _size \gset +\echo UNDO_BENCH_RESULT|fresh_index_size|bytes|:_size + +-- ================================================================ +-- Test 2: Post targeted-update sizes (1000 single + 10% batch) +-- ================================================================ +\echo '--- Targeted updates (1000 single-row + 10% batch) ---' + +CREATE FUNCTION pg_temp.bench_targeted_updates(n integer) +RETURNS void LANGUAGE plpgsql AS $fn$ +BEGIN + FOR i IN 1..n LOOP + UPDATE bench_storage SET counter = counter + 1 WHERE id = i; + END LOOP; +END +$fn$; + +SELECT pg_temp.bench_targeted_updates(LEAST(:row_count, 1000)); +UPDATE bench_storage SET counter = counter + 1 WHERE id % 10 = 0; + +SELECT pg_relation_size('bench_storage') AS _size \gset +\echo UNDO_BENCH_RESULT|post_update_table_size|bytes|:_size + +SELECT pg_total_relation_size('bench_storage') AS _size \gset +\echo UNDO_BENCH_RESULT|post_update_total_size|bytes|:_size + +-- ================================================================ +-- Test 3: Post-VACUUM sizes +-- ================================================================ +\echo '--- After VACUUM ---' + +VACUUM bench_storage; + +SELECT pg_relation_size('bench_storage') AS _size \gset +\echo UNDO_BENCH_RESULT|post_vacuum_table_size|bytes|:_size + +SELECT pg_total_relation_size('bench_storage') AS _size \gset +\echo UNDO_BENCH_RESULT|post_vacuum_total_size|bytes|:_size + +-- ================================================================ +-- Cleanup +-- ================================================================ +DROP TABLE bench_storage; diff --git a/src/test/benchmarks/am4way/suite/undo/sql/b8_large_transaction.sql b/src/test/benchmarks/am4way/suite/undo/sql/b8_large_transaction.sql new file mode 100644 index 0000000000000..cf2a57fa10a18 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/undo/sql/b8_large_transaction.sql @@ -0,0 +1,116 @@ +-- +-- b8_large_transaction.sql +-- +-- Benchmark: Large-transaction rollback cost characterization +-- +-- Measures INSERT, UPDATE, and DELETE rollback time at multiple transaction +-- sizes. This quantifies the O(N) rollback cost of UNDO vs. the O(1) CLOG +-- rollback of standard heap (which defers the O(N) cost to VACUUM). +-- +-- Variables: :scenario, :row_count, :create_opts +-- + +-- ================================================================ +-- Setup +-- ================================================================ +DROP TABLE IF EXISTS bench_large_txn; +CREATE TABLE bench_large_txn (id integer, data text) :create_opts; + +-- Pre-populate for UPDATE/DELETE tests +INSERT INTO bench_large_txn +SELECT i, md5(i::text) +FROM generate_series(1, :row_count) i; + +-- ================================================================ +-- Test 1: INSERT + ROLLBACK +-- ================================================================ +\echo '--- INSERT + ROLLBACK ---' + +TRUNCATE bench_large_txn; + +SELECT clock_timestamp()::text AS _t0 \gset +BEGIN; +INSERT INTO bench_large_txn +SELECT i, md5(i::text) FROM generate_series(1, :row_count) i; +SELECT clock_timestamp()::text AS _t_insert \gset +ROLLBACK; +SELECT clock_timestamp()::text AS _t_rollback \gset + +SELECT round(extract(epoch FROM + (:'_t_insert'::timestamptz - :'_t0'::timestamptz)) * 1000, 2) AS _ins_ms \gset +SELECT round(extract(epoch FROM + (:'_t_rollback'::timestamptz - :'_t_insert'::timestamptz)) * 1000, 2) AS _rb_ms \gset + +\echo UNDO_BENCH_RESULT|insert_ms|time_ms|:_ins_ms +\echo UNDO_BENCH_RESULT|insert_rollback_ms|time_ms|:_rb_ms + +SELECT count(*) AS _cnt FROM bench_large_txn \gset +\echo UNDO_BENCH_RESULT|post_insert_rollback_rows|count|:_cnt + +-- ================================================================ +-- Test 2: UPDATE (half rows, constant string) + ROLLBACK +-- +-- Uses a constant assignment rather than md5(data) to avoid confounding +-- measurement with CPU overhead; DML time reflects I/O + WAL cost. +-- ================================================================ +\echo '--- UPDATE + ROLLBACK ---' + +-- Re-populate +TRUNCATE bench_large_txn; +INSERT INTO bench_large_txn +SELECT i, md5(i::text) FROM generate_series(1, :row_count) i; + +SELECT clock_timestamp()::text AS _t0 \gset +BEGIN; +UPDATE bench_large_txn SET data = lpad('', 50, 'u') WHERE id <= :row_count / 2; +SELECT clock_timestamp()::text AS _t_update \gset +ROLLBACK; +SELECT clock_timestamp()::text AS _t_rollback \gset + +SELECT round(extract(epoch FROM + (:'_t_update'::timestamptz - :'_t0'::timestamptz)) * 1000, 2) AS _upd_ms \gset +SELECT round(extract(epoch FROM + (:'_t_rollback'::timestamptz - :'_t_update'::timestamptz)) * 1000, 2) AS _rb_ms \gset + +\echo UNDO_BENCH_RESULT|update_ms|time_ms|:_upd_ms +\echo UNDO_BENCH_RESULT|update_rollback_ms|time_ms|:_rb_ms + +-- ================================================================ +-- Test 3: DELETE + ROLLBACK +-- ================================================================ +\echo '--- DELETE + ROLLBACK ---' + +SELECT clock_timestamp()::text AS _t0 \gset +BEGIN; +DELETE FROM bench_large_txn; +SELECT clock_timestamp()::text AS _t_delete \gset +ROLLBACK; +SELECT clock_timestamp()::text AS _t_rollback \gset + +SELECT round(extract(epoch FROM + (:'_t_delete'::timestamptz - :'_t0'::timestamptz)) * 1000, 2) AS _del_ms \gset +SELECT round(extract(epoch FROM + (:'_t_rollback'::timestamptz - :'_t_delete'::timestamptz)) * 1000, 2) AS _rb_ms \gset + +\echo UNDO_BENCH_RESULT|delete_ms|time_ms|:_del_ms +\echo UNDO_BENCH_RESULT|delete_rollback_ms|time_ms|:_rb_ms + +SELECT count(*) AS _cnt FROM bench_large_txn \gset +\echo UNDO_BENCH_RESULT|post_delete_rollback_rows|count|:_cnt + +-- ================================================================ +-- Note: Cold-WAL rollback scenario +-- +-- The tests above measure warm-cache rollback (UNDO WAL records may be +-- in the OS buffer cache). To measure cold-WAL rollback (WAL reads from +-- disk), use the crash-recovery path: run a large uncommitted transaction, +-- stop postgres with pg_ctl stop -m immediate, then restart and measure +-- the recovery time via pg_stat_recovery_prefetch or server logs. +-- The run_undo_bench.sh harness can be extended with a crash-recovery +-- benchmark variant for this purpose. +-- ================================================================ + +-- ================================================================ +-- Cleanup +-- ================================================================ +DROP TABLE bench_large_txn; diff --git a/src/test/benchmarks/am4way/suite/visualizer.py b/src/test/benchmarks/am4way/suite/visualizer.py new file mode 100644 index 0000000000000..682cb8f50cc73 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/visualizer.py @@ -0,0 +1,585 @@ +""" +Visualization: generates matplotlib charts and an HTML dashboard +from benchmark analysis results. +""" + +import html +import json +import logging +import os +from typing import Any, Dict, List, Optional + +from .result_analyzer import AnalysisReport, ComparisonResult, StorageComparison + +logger = logging.getLogger(__name__) + +# Try importing matplotlib; gracefully degrade if missing +try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import matplotlib.ticker as ticker + HAS_MATPLOTLIB = True +except ImportError: + HAS_MATPLOTLIB = False + logger.info("matplotlib not available; chart generation will be skipped") + + +def _human_bytes(n: int) -> str: + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(n) < 1024: + return f"{n:.1f} {unit}" + n /= 1024 # type: ignore + return f"{n:.1f} PB" + + +class Visualizer: + """Generates charts and HTML dashboard from benchmark results.""" + + def __init__(self, output_dir: str): + self.output_dir = output_dir + os.makedirs(output_dir, exist_ok=True) + + # ------------------------------------------------------------------ + # Chart generation (requires matplotlib) + # ------------------------------------------------------------------ + + def _save_fig(self, fig, name: str) -> str: + path = os.path.join(self.output_dir, name) + fig.savefig(path, dpi=120, bbox_inches="tight") + plt.close(fig) + logger.info("Saved chart: %s", path) + return name + + def generate_speedup_chart( + self, comparisons: List[ComparisonResult] + ) -> Optional[str]: + """Bar chart of speedup ratios by query pattern.""" + if not HAS_MATPLOTLIB or not comparisons: + return None + + patterns = sorted(set(c.query_pattern for c in comparisons)) + # Average speedup per pattern across all schemas/distributions + avg_speedups = [] + for p in patterns: + vals = [c.speedup for c in comparisons if c.query_pattern == p and c.speedup != float("inf")] + avg_speedups.append(sum(vals) / len(vals) if vals else 1.0) + + fig, ax = plt.subplots(figsize=(10, 6)) + colors = ["#2ecc71" if s > 1.0 else "#e74c3c" for s in avg_speedups] + bars = ax.barh(patterns, avg_speedups, color=colors) + ax.axvline(x=1.0, color="black", linestyle="--", linewidth=0.8, label="HEAP baseline") + ax.set_xlabel("Speedup (Noxu / HEAP)") + ax.set_title("Query Performance: Noxu vs HEAP") + + for bar, val in zip(bars, avg_speedups): + ax.text( + bar.get_width() + 0.05, + bar.get_y() + bar.get_height() / 2, + f"{val:.2f}x", + va="center", + fontsize=9, + ) + + ax.legend() + fig.tight_layout() + return self._save_fig(fig, "speedup_by_pattern.png") + + def generate_storage_chart( + self, storage_comps: List[StorageComparison] + ) -> Optional[str]: + """Grouped bar chart comparing HEAP and Noxu storage sizes.""" + if not HAS_MATPLOTLIB or not storage_comps: + return None + + labels = [ + f"{sc.schema_name}\n{sc.row_count:,} rows\n{sc.distribution}" + for sc in storage_comps + ] + heap_sizes = [sc.heap_total_bytes / (1024 * 1024) for sc in storage_comps] + noxu_sizes = [sc.noxu_total_bytes / (1024 * 1024) for sc in storage_comps] + + fig, ax = plt.subplots(figsize=(max(8, len(labels) * 2), 6)) + x = range(len(labels)) + width = 0.35 + ax.bar([i - width / 2 for i in x], heap_sizes, width, label="HEAP", color="#3498db") + ax.bar([i + width / 2 for i in x], noxu_sizes, width, label="Noxu", color="#2ecc71") + + ax.set_ylabel("Total Size (MB)") + ax.set_title("Storage Comparison: HEAP vs Noxu") + ax.set_xticks(list(x)) + ax.set_xticklabels(labels, fontsize=8) + ax.legend() + + # Annotate compression ratio + for i, sc in enumerate(storage_comps): + ax.text( + i, max(heap_sizes[i], noxu_sizes[i]) + 0.5, + f"{sc.compression_ratio:.1f}x", + ha="center", fontsize=9, fontweight="bold", + ) + + fig.tight_layout() + return self._save_fig(fig, "storage_comparison.png") + + def generate_latency_heatmap( + self, comparisons: List[ComparisonResult] + ) -> Optional[str]: + """Heatmap of median latencies across schemas and query patterns.""" + if not HAS_MATPLOTLIB or not comparisons: + return None + + schemas = sorted(set(c.schema_name for c in comparisons)) + patterns = sorted(set(c.query_pattern for c in comparisons)) + + data = [] + for schema in schemas: + row = [] + for pattern in patterns: + vals = [ + c.speedup + for c in comparisons + if c.schema_name == schema and c.query_pattern == pattern + and c.speedup != float("inf") + ] + row.append(sum(vals) / len(vals) if vals else 1.0) + data.append(row) + + fig, ax = plt.subplots(figsize=(max(8, len(patterns) * 1.5), max(4, len(schemas) * 1.5))) + im = ax.imshow(data, cmap="RdYlGn", aspect="auto", vmin=0.5, vmax=3.0) + ax.set_xticks(range(len(patterns))) + ax.set_xticklabels(patterns, rotation=45, ha="right", fontsize=8) + ax.set_yticks(range(len(schemas))) + ax.set_yticklabels(schemas, fontsize=9) + ax.set_title("Speedup Heatmap (green = Noxu faster)") + + for i in range(len(schemas)): + for j in range(len(patterns)): + ax.text(j, i, f"{data[i][j]:.2f}x", ha="center", va="center", fontsize=8) + + fig.colorbar(im, ax=ax, label="Speedup (Noxu/HEAP)") + fig.tight_layout() + return self._save_fig(fig, "speedup_heatmap.png") + + def generate_compression_chart( + self, report: AnalysisReport + ) -> Optional[str]: + """Bar chart of per-column compression width ratios.""" + if not HAS_MATPLOTLIB or not report.per_column_compression: + return None + + # Take the first config's per-column data + first_key = next(iter(report.per_column_compression)) + col_data = report.per_column_compression[first_key] + + cols = sorted(col_data.keys()) + heap_widths = [col_data[c].get("heap_avg_width", 0) for c in cols] + noxu_widths = [col_data[c].get("noxu_avg_width", 0) for c in cols] + + fig, ax = plt.subplots(figsize=(max(8, len(cols)), 6)) + x = range(len(cols)) + width = 0.35 + ax.bar([i - width / 2 for i in x], heap_widths, width, label="HEAP avg_width", color="#3498db") + ax.bar([i + width / 2 for i in x], noxu_widths, width, label="Noxu avg_width", color="#2ecc71") + + ax.set_ylabel("Average Width (bytes)") + ax.set_title(f"Per-Column Average Width: {first_key}") + ax.set_xticks(list(x)) + ax.set_xticklabels(cols, rotation=45, ha="right", fontsize=8) + ax.legend() + fig.tight_layout() + return self._save_fig(fig, "column_compression.png") + + # ------------------------------------------------------------------ + # CSV export + # ------------------------------------------------------------------ + + def export_csv(self, report: AnalysisReport) -> str: + """Export benchmark results to CSV files. Returns path to main CSV.""" + import csv + + # Query timing comparisons + timing_path = os.path.join(self.output_dir, "timing_results.csv") + with open(timing_path, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow([ + "schema", "row_count", "distribution", "query_pattern", + "heap_median_s", "noxu_median_s", "speedup", + "heap_p95_s", "noxu_p95_s", + "heap_mean_s", "noxu_mean_s", + ]) + for c in report.comparisons: + writer.writerow([ + c.schema_name, c.row_count, c.distribution, c.query_pattern, + f"{c.heap_timing.median:.6f}", + f"{c.noxu_timing.median:.6f}", + f"{c.speedup:.4f}", + f"{c.heap_timing.p95:.6f}", + f"{c.noxu_timing.p95:.6f}", + f"{c.heap_timing.mean:.6f}", + f"{c.noxu_timing.mean:.6f}", + ]) + + # Storage comparisons + storage_path = os.path.join(self.output_dir, "storage_results.csv") + with open(storage_path, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow([ + "schema", "row_count", "distribution", + "heap_table_bytes", "heap_index_bytes", "heap_total_bytes", + "noxu_table_bytes", "noxu_index_bytes", "noxu_total_bytes", + "compression_ratio", "space_savings_pct", + ]) + for sc in report.storage_comparisons: + writer.writerow([ + sc.schema_name, sc.row_count, sc.distribution, + sc.heap_table_bytes, sc.heap_index_bytes, sc.heap_total_bytes, + sc.noxu_table_bytes, sc.noxu_index_bytes, sc.noxu_total_bytes, + f"{sc.compression_ratio:.4f}", + f"{sc.space_savings_pct:.2f}", + ]) + + # Per-column compression + col_path = os.path.join(self.output_dir, "column_compression.csv") + with open(col_path, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow([ + "config", "column", "type", + "heap_avg_width", "noxu_avg_width", "width_ratio", + "heap_n_distinct", "noxu_n_distinct", + ]) + for config_key, cols in report.per_column_compression.items(): + for col_name, stats in cols.items(): + writer.writerow([ + config_key, col_name, + stats.get("column_type", ""), + stats.get("heap_avg_width", ""), + stats.get("noxu_avg_width", ""), + f"{stats.get('width_ratio', 0):.4f}" if stats.get("width_ratio") else "", + stats.get("heap_n_distinct", ""), + stats.get("noxu_n_distinct", ""), + ]) + + logger.info("CSV files written to %s", self.output_dir) + return timing_path + + # ------------------------------------------------------------------ + # HTML dashboard + # ------------------------------------------------------------------ + + def generate_recommendations(self, report: AnalysisReport) -> list: + """Generate optimization recommendations based on benchmark results.""" + recs = [] + summary = report.summary + + # Recommendation 1: Column projection performance + per_pattern = summary.get("per_pattern_avg_speedup", {}) + proj_speedup = per_pattern.get("column_projection", 1.0) + if proj_speedup < 1.2: + recs.append({ + "priority": "HIGH", + "area": "Column Projection", + "finding": f"Column projection speedup is only {proj_speedup:.2f}x over HEAP.", + "recommendation": ( + "Investigate column-skip efficiency. Noxu should show large " + "gains for narrow projections on wide tables. Check that " + "non-projected columns are truly not read from disk." + ), + }) + elif proj_speedup > 2.0: + recs.append({ + "priority": "INFO", + "area": "Column Projection", + "finding": f"Column projection shows strong {proj_speedup:.2f}x speedup.", + "recommendation": "This is a key Noxu advantage. Highlight in documentation.", + }) + + # Recommendation 2: Aggregation performance + agg_speedup = per_pattern.get("aggregation", 1.0) + if agg_speedup < 1.0: + recs.append({ + "priority": "HIGH", + "area": "Aggregation", + "finding": f"Aggregation is {agg_speedup:.2f}x vs HEAP (slower).", + "recommendation": ( + "Columnar storage should excel at aggregations. Check for " + "unnecessary tuple reconstruction and decompression overhead " + "in the aggregation path." + ), + }) + + # Recommendation 3: Compression ratio + avg_comp = summary.get("avg_compression_ratio", 1.0) + if avg_comp < 1.5: + recs.append({ + "priority": "MEDIUM", + "area": "Compression", + "finding": f"Average compression ratio is only {avg_comp:.2f}x.", + "recommendation": ( + "Consider implementing additional compression strategies: " + "dictionary encoding for low-cardinality text, RLE for " + "clustered data, and delta encoding for sorted integers." + ), + }) + + # Recommendation 4: Full scan overhead + full_scan_speedup = per_pattern.get("full_scan", 1.0) + if full_scan_speedup < 0.8: + recs.append({ + "priority": "MEDIUM", + "area": "Full Table Scan", + "finding": f"Full scan is {full_scan_speedup:.2f}x vs HEAP (regression).", + "recommendation": ( + "Full scans that read all columns should be close to HEAP " + "performance. The overhead suggests tuple reconstruction cost " + "is significant. Consider optimizing the column-to-tuple " + "assembly path." + ), + }) + + # Recommendation 5: Index scan performance + idx_speedup = per_pattern.get("index_scan", 1.0) + if idx_speedup < 0.9: + recs.append({ + "priority": "MEDIUM", + "area": "Index Scan", + "finding": f"Index scan is {idx_speedup:.2f}x vs HEAP (regression).", + "recommendation": ( + "Point lookups via index should not regress. Check that " + "TID-to-column-page mapping is efficient and does not " + "require scanning through column pages sequentially." + ), + }) + + # Recommendation 6: Storage efficiency per data type + for config_key, col_data in report.per_column_compression.items(): + for col_name, stats in col_data.items(): + ratio = stats.get("width_ratio", 0) + col_type = stats.get("column_type", "") + if ratio > 0 and ratio < 1.0: + recs.append({ + "priority": "LOW", + "area": f"Column Storage ({col_name})", + "finding": ( + f"Column '{col_name}' ({col_type}) has width ratio " + f"{ratio:.2f} (Noxu wider than HEAP)." + ), + "recommendation": ( + f"Investigate per-column overhead for {col_type} type. " + "The columnar format should not be wider than HEAP." + ), + }) + break # Only check first configuration + + # If no issues found, add a positive recommendation + if not recs: + recs.append({ + "priority": "INFO", + "area": "Overall", + "finding": "Benchmark results look good across all patterns.", + "recommendation": ( + "Continue with larger dataset sizes to identify scaling behavior." + ), + }) + + return recs + + def generate_dashboard(self, report: AnalysisReport) -> str: + """Generate a self-contained HTML dashboard. Returns path to HTML file.""" + charts = {} + if HAS_MATPLOTLIB: + charts["speedup"] = self.generate_speedup_chart(report.comparisons) + charts["storage"] = self.generate_storage_chart(report.storage_comparisons) + charts["heatmap"] = self.generate_latency_heatmap(report.comparisons) + charts["compression"] = self.generate_compression_chart(report) + + recommendations = self.generate_recommendations(report) + html_content = self._render_html(report, charts, recommendations) + path = os.path.join(self.output_dir, "dashboard.html") + with open(path, "w") as f: + f.write(html_content) + logger.info("Dashboard written to %s", path) + return path + + def _render_html( + self, report: AnalysisReport, charts: Dict[str, Optional[str]], + recommendations: Optional[list] = None, + ) -> str: + summary = report.summary + + # Build timing table + timing_rows = "" + for c in report.comparisons: + color = "#2ecc71" if c.speedup > 1.0 else "#e74c3c" + timing_rows += f""" + + {html.escape(c.schema_name)} + {c.row_count:,} + {html.escape(c.distribution)} + {html.escape(c.query_pattern)} + {c.heap_timing.median * 1000:.2f} + {c.noxu_timing.median * 1000:.2f} + {c.speedup:.2f}x + """ + + # Build storage table + storage_rows = "" + for sc in report.storage_comparisons: + storage_rows += f""" + + {html.escape(sc.schema_name)} + {sc.row_count:,} + {html.escape(sc.distribution)} + {_human_bytes(sc.heap_total_bytes)} + {_human_bytes(sc.noxu_total_bytes)} + {sc.compression_ratio:.2f}x + {sc.space_savings_pct:.1f}% + """ + + # Chart image tags + def img_tag(name: Optional[str]) -> str: + if name: + return f'' + return '

Chart not available (matplotlib not installed)

' + + summary_json = html.escape(json.dumps(summary, indent=2, default=str)) + + # Build recommendations HTML + rec_rows = "" + if recommendations: + priority_colors = { + "HIGH": "#e74c3c", + "MEDIUM": "#f39c12", + "LOW": "#3498db", + "INFO": "#2ecc71", + } + for rec in recommendations: + color = priority_colors.get(rec["priority"], "#999") + rec_rows += f""" + + {html.escape(rec['priority'])} + {html.escape(rec['area'])} + {html.escape(rec['finding'])} + {html.escape(rec['recommendation'])} + """ + + return f""" + + + + +Noxu Benchmark Dashboard + + + +

Noxu Benchmark Dashboard

+ +
+

Summary

+
+
+
{summary.get('median_speedup', 0):.2f}x
+
Median Query Speedup
+
+
+
{summary.get('max_speedup', 0):.2f}x
+
Best Speedup
+
+
+
{summary.get('avg_compression_ratio', 0):.2f}x
+
Avg Compression Ratio
+
+
+
{summary.get('avg_space_savings_pct', 0):.1f}%
+
Avg Space Savings
+
+
+
+ +
+

Charts

+
+
{img_tag(charts.get("speedup"))}
+
{img_tag(charts.get("storage"))}
+
{img_tag(charts.get("heatmap"))}
+
{img_tag(charts.get("compression"))}
+
+
+ +
+

Query Timing Comparison

+ + + + + + + + +{timing_rows} + +
SchemaRowsDistributionPatternHEAP (ms)Noxu (ms)Speedup
+
+ +
+

Storage Comparison

+ + + + + + + + +{storage_rows} + +
SchemaRowsDistributionHEAP TotalNoxu TotalCompressionSavings
+
+ +
+

Optimization Recommendations

+ + + + + + + + +{rec_rows} + +
PriorityAreaFindingRecommendation
+
+ +
+

Raw Summary Data

+
{summary_json}
+
+ + + +""" diff --git a/src/test/benchmarks/am4way/suite/workload_runner.py b/src/test/benchmarks/am4way/suite/workload_runner.py new file mode 100644 index 0000000000000..03c08ba542917 --- /dev/null +++ b/src/test/benchmarks/am4way/suite/workload_runner.py @@ -0,0 +1,261 @@ +""" +Workload runner: executes query patterns against HEAP and Noxu tables, +collecting timing and EXPLAIN ANALYZE data. +""" + +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from .config import ColumnType, QueryPattern, TableSchema +from .database import DatabaseManager + +logger = logging.getLogger(__name__) + + +@dataclass +class QueryResult: + """Result of a single query execution.""" + query_pattern: str + table_name: str + storage_method: str # "heap" or "noxu" + query_sql: str + elapsed_seconds: float + row_count: int = 0 + explain_plan: Optional[Dict[str, Any]] = None + + +@dataclass +class WorkloadResult: + """Aggregated results for a complete workload run.""" + schema_name: str + row_count: int + distribution: str + storage_method: str + results: List[QueryResult] = field(default_factory=list) + + def add(self, result: QueryResult): + self.results.append(result) + + +class WorkloadRunner: + """Generates and executes query workloads against benchmark tables.""" + + def __init__( + self, + db: DatabaseManager, + warmup_iterations: int = 2, + measure_iterations: int = 5, + ): + self.db = db + self.warmup_iterations = warmup_iterations + self.measure_iterations = measure_iterations + + # ------------------------------------------------------------------ + # Query generators per pattern + # ------------------------------------------------------------------ + + def _full_scan_query(self, table_name: str, schema: TableSchema) -> str: + return f"SELECT * FROM {table_name}" + + def _column_projection_query(self, table_name: str, schema: TableSchema) -> str: + # Select first 2 non-id columns (or all if < 2) + cols = [c[0] for c in schema.columns if c[0] != "id"][:2] + if not cols: + cols = [schema.columns[0][0]] + return f"SELECT {', '.join(cols)} FROM {table_name}" + + def _filtered_scan_query(self, table_name: str, schema: TableSchema) -> str: + # Find a suitable filter column + for col_name, col_type in schema.columns: + if col_type == ColumnType.INT and col_name != "id": + return f"SELECT * FROM {table_name} WHERE {col_name} > 0" + if col_type == ColumnType.BOOLEAN: + return f"SELECT * FROM {table_name} WHERE {col_name} = TRUE" + # Fallback: filter on id + return f"SELECT * FROM {table_name} WHERE id > 0 AND id <= 1000" + + def _aggregation_query(self, table_name: str, schema: TableSchema) -> str: + agg_exprs = [] + for col_name, col_type in schema.columns: + if col_type in (ColumnType.INT, ColumnType.BIGINT, ColumnType.FLOAT, ColumnType.NUMERIC): + agg_exprs.append(f"SUM({col_name})") + agg_exprs.append(f"AVG({col_name})") + if len(agg_exprs) >= 6: + break + if not agg_exprs: + agg_exprs = ["COUNT(*)"] + return f"SELECT COUNT(*), {', '.join(agg_exprs)} FROM {table_name}" + + def _group_by_query(self, table_name: str, schema: TableSchema) -> str: + # Find a good GROUP BY column (low-ish cardinality integer or boolean) + group_col = None + agg_col = None + for col_name, col_type in schema.columns: + if col_name == "id": + continue + if col_type in (ColumnType.INT, ColumnType.BOOLEAN) and group_col is None: + group_col = col_name + if col_type in (ColumnType.FLOAT, ColumnType.NUMERIC, ColumnType.INT, ColumnType.BIGINT) and agg_col is None: + agg_col = col_name + + if group_col is None: + group_col = schema.columns[0][0] + if agg_col is None: + agg_col = "id" + + return ( + f"SELECT {group_col}, COUNT(*), SUM({agg_col}), AVG({agg_col}) " + f"FROM {table_name} GROUP BY {group_col}" + ) + + def _index_scan_query(self, table_name: str, schema: TableSchema) -> str: + return f"SELECT * FROM {table_name} WHERE id = 42" + + def _get_query( + self, pattern: QueryPattern, table_name: str, schema: TableSchema + ) -> str: + generators = { + QueryPattern.FULL_SCAN: self._full_scan_query, + QueryPattern.COLUMN_PROJECTION: self._column_projection_query, + QueryPattern.FILTERED_SCAN: self._filtered_scan_query, + QueryPattern.AGGREGATION: self._aggregation_query, + QueryPattern.GROUP_BY: self._group_by_query, + QueryPattern.INDEX_SCAN: self._index_scan_query, + } + gen = generators.get(pattern) + if gen is None: + raise ValueError(f"Unknown query pattern: {pattern}") + return gen(table_name, schema) + + # ------------------------------------------------------------------ + # Execution + # ------------------------------------------------------------------ + + async def _run_single( + self, + query: str, + pattern: QueryPattern, + table_name: str, + storage_method: str, + collect_explain: bool = True, + ) -> QueryResult: + """Run a single query, returning timing and optional EXPLAIN data.""" + # Warm up + for _ in range(self.warmup_iterations): + await self.db.fetch(query) + + # Measure + timings = [] + row_count = 0 + for _ in range(self.measure_iterations): + rows, elapsed = await self.db.fetch_timed(query) + timings.append(elapsed) + row_count = len(rows) + + median_time = sorted(timings)[len(timings) // 2] + + # Collect EXPLAIN ANALYZE on one run + explain_plan = None + if collect_explain: + try: + explain_plan = await self.db.explain_analyze(query) + except Exception as e: + logger.warning("EXPLAIN ANALYZE failed for %s: %s", table_name, e) + + return QueryResult( + query_pattern=pattern.value, + table_name=table_name, + storage_method=storage_method, + query_sql=query, + elapsed_seconds=median_time, + row_count=row_count, + explain_plan=explain_plan, + ) + + async def run_workload( + self, + schema: TableSchema, + heap_table: str, + noxu_table: str, + row_count: int, + distribution: str, + patterns: Optional[List[QueryPattern]] = None, + collect_explain: bool = True, + ) -> tuple: + """Run a full workload against both HEAP and Noxu tables. + + Returns (heap_workload_result, noxu_workload_result). + """ + if patterns is None: + patterns = list(QueryPattern) + + heap_result = WorkloadResult( + schema_name=schema.name, + row_count=row_count, + distribution=distribution, + storage_method="heap", + ) + noxu_result = WorkloadResult( + schema_name=schema.name, + row_count=row_count, + distribution=distribution, + storage_method="noxu", + ) + + for pattern in patterns: + logger.info( + "Running %s on %s/%s (rows=%d, dist=%s)", + pattern.value, + heap_table, + noxu_table, + row_count, + distribution, + ) + + # HEAP + heap_query = self._get_query(pattern, heap_table, schema) + heap_qr = await self._run_single( + heap_query, pattern, heap_table, "heap", collect_explain + ) + heap_result.add(heap_qr) + + # Noxu + noxu_query = self._get_query(pattern, noxu_table, schema) + noxu_qr = await self._run_single( + noxu_query, pattern, noxu_table, "noxu", collect_explain + ) + noxu_result.add(noxu_qr) + + speedup = ( + heap_qr.elapsed_seconds / noxu_qr.elapsed_seconds + if noxu_qr.elapsed_seconds > 0 + else float("inf") + ) + logger.info( + " %s: heap=%.4fs noxu=%.4fs speedup=%.2fx", + pattern.value, + heap_qr.elapsed_seconds, + noxu_qr.elapsed_seconds, + speedup, + ) + + return heap_result, noxu_result + + async def run_custom_query( + self, + query: str, + table_name: str, + storage_method: str, + label: str = "custom", + collect_explain: bool = True, + ) -> QueryResult: + """Run an arbitrary query with benchmarking instrumentation.""" + return await self._run_single( + query, + QueryPattern.FULL_SCAN, # placeholder + table_name, + storage_method, + collect_explain, + )