diff --git a/.github/workflows/fork-ocr-model-check.yml b/.github/workflows/fork-ocr-model-check.yml
new file mode 100644
index 0000000000000..b85a990c66fd7
--- /dev/null
+++ b/.github/workflows/fork-ocr-model-check.yml
@@ -0,0 +1,18 @@
+# Weekly check for a newer Claude Opus on Bedrock; opens/updates one issue here
+# when OCR_BEDROCK_MODEL is behind. Notify-only (GITHUB_TOKEN cannot write
+# Actions variables). Logic lives in gburd/ci-workflows.
+
+name: OCR model self-check
+
+on:
+ schedule:
+ - cron: '0 12 * * 1' # Mondays 12:00 UTC
+ workflow_dispatch:
+
+jobs:
+ check:
+ uses: gburd/ci-workflows/.github/workflows/ocr-model-check.yml@v1
+ with:
+ aws_role_arn: ${{ vars.AWS_ROLE_ARN }}
+ aws_region: ${{ vars.AWS_REGION }}
+ bedrock_model: ${{ vars.OCR_BEDROCK_MODEL }}
diff --git a/.github/workflows/fork-ocr-review.yml b/.github/workflows/fork-ocr-review.yml
new file mode 100644
index 0000000000000..ae938754339b4
--- /dev/null
+++ b/.github/workflows/fork-ocr-review.yml
@@ -0,0 +1,43 @@
+# AI PR review (Claude Opus on Bedrock). Logic and review rules live in
+# gburd/ci-workflows so this fork's master carries a stub instead of ~1000
+# lines that have to be rebased onto upstream forever.
+#
+# Needs repo variables AWS_ROLE_ARN, AWS_REGION, OCR_BEDROCK_MODEL. Auth is
+# GitHub OIDC; no static AWS keys. See the ci-workflows README.
+
+name: OCR AI Review
+
+on:
+ pull_request:
+ # Note: no draft filter — drafts are reviewed too.
+ types: [opened, synchronize, reopened, ready_for_review]
+ issue_comment:
+ types: [created]
+ workflow_dispatch:
+ inputs:
+ pr_number:
+ description: 'PR number to review'
+ required: true
+ type: number
+
+# One review per PR; cancel superseded runs to save Bedrock spend.
+concurrency:
+ group: ocr-review-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
+ cancel-in-progress: true
+
+jobs:
+ review:
+ # Comment events only when the comment is on a PR and starts with the
+ # trigger keyword; PR events and manual dispatch always.
+ if: |
+ github.event_name == 'pull_request' ||
+ github.event_name == 'workflow_dispatch' ||
+ (github.event_name == 'issue_comment' && github.event.issue.pull_request &&
+ (startsWith(github.event.comment.body, '/open-code-review') ||
+ startsWith(github.event.comment.body, '@open-code-review') ||
+ startsWith(github.event.comment.body, '/pg-history')))
+ uses: gburd/ci-workflows/.github/workflows/ocr-review.yml@v1
+ with:
+ aws_role_arn: ${{ vars.AWS_ROLE_ARN }}
+ aws_region: ${{ vars.AWS_REGION }}
+ bedrock_model: ${{ vars.OCR_BEDROCK_MODEL }}
diff --git a/.github/workflows/fork-sync-upstream.yml b/.github/workflows/fork-sync-upstream.yml
new file mode 100644
index 0000000000000..022b3101b5a9d
--- /dev/null
+++ b/.github/workflows/fork-sync-upstream.yml
@@ -0,0 +1,48 @@
+# Keep this fork's master rebased on postgres/postgres master, carrying only
+# the fork-owned CI stubs (.github/workflows/fork-*.yml). The workflows they
+# call, and the OCR review rules, live in gburd/ci-workflows.
+#
+# Requires the SYNC_TOKEN secret: a PAT with repo+workflow scope. The default
+# GITHUB_TOKEN is not allowed to push commits that touch .github/workflows/,
+# and every commit we carry does.
+
+name: Sync from upstream
+
+on:
+ schedule:
+ - cron: '17 */3 * * *'
+ workflow_dispatch:
+
+concurrency:
+ group: fork-sync-upstream
+
+permissions:
+ contents: read
+
+jobs:
+ sync:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.SYNC_TOKEN }}
+
+ - name: Rebase master onto upstream
+ run: |
+ set -euo pipefail
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git remote add upstream https://github.com/postgres/postgres.git
+ git fetch --no-tags upstream master
+
+ # Master carries fork CI only. Anything else means work landed here
+ # that belongs on a branch; force-rebasing it on a timer would be wrong.
+ if git diff --name-only upstream/master...HEAD |
+ grep -v '^\.github/workflows/fork-'; then
+ echo "::error::master carries non-CI changes (listed above); move them to a branch"
+ exit 1
+ fi
+
+ git rebase upstream/master
+ git push --force-with-lease
diff --git a/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/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/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/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/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/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.
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/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/doc/src/sgml/undo.sgml b/doc/src/sgml/undo.sgml
new file mode 100644
index 0000000000000..18a0b1cb0cb5f
--- /dev/null
+++ b/doc/src/sgml/undo.sgml
@@ -0,0 +1,624 @@
+
+
+
+ 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. 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:
+
+
+
+-- 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_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_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.
+
+
+
+
+ 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/meson.build b/meson.build
index d05634fef0d8c..29e9891b12393 100644
--- a/meson.build
+++ b/meson.build
@@ -1585,6 +1585,28 @@ 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
+###############################################################
+
+# 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..518fea3d7b263 100644
--- a/src/Makefile.global.in
+++ b/src/Makefile.global.in
@@ -256,6 +256,16 @@ 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.
+USE_FILEOPS = 1
+override CPPFLAGS += -DUSE_FILEOPS
+
ifdef PGXS
override CPPFLAGS := -I$(includedir_server) -I$(includedir_internal) $(CPPFLAGS)
else # not PGXS
diff --git a/src/backend/access/Makefile b/src/backend/access/Makefile
index e88d72ea0397d..88f863ae3946c 100644
--- a/src/backend/access/Makefile
+++ b/src/backend/access/Makefile
@@ -22,6 +22,8 @@ SUBDIRS = \
sequence \
table \
tablesample \
- transam
+ transam \
+ undo \
+ flux
include $(top_srcdir)/src/backend/common.mk
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/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/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/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/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..11210dd2bcab6 100644
--- a/src/backend/access/index/indexam.c
+++ b/src/backend/access/index/indexam.c
@@ -238,6 +238,86 @@ 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, 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);
+}
+
/* -------------------------
* index_insert_cleanup - clean up after all index inserts are done
* -------------------------
diff --git a/src/backend/access/meson.build b/src/backend/access/meson.build
index 5fd18de74f92b..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')
@@ -14,3 +15,4 @@ subdir('spgist')
subdir('table')
subdir('tablesample')
subdir('transam')
+subdir('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/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/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/nbtdedup.c b/src/backend/access/nbtree/nbtdedup.c
index af7affdf409d3..40c92c9a2b3af 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 d5d964301c1b4..889a0c2fd1640 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,11 +1304,22 @@ _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));
+ /*
+ * 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 +1505,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);
@@ -1480,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,
@@ -1530,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);
@@ -1956,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 */
/*
@@ -2004,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);
@@ -2057,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)));
@@ -2083,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);
@@ -2246,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);
@@ -2455,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;
@@ -2820,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));
}
@@ -2888,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..c1892a2a72b1e 100644
--- a/src/backend/access/nbtree/nbtreadpage.c
+++ b/src/backend/access/nbtree/nbtreadpage.c
@@ -1036,7 +1036,23 @@ _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 +1084,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 +1121,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..cb1c8bf8cc7c0 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"
@@ -31,10 +34,12 @@
#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"
#include "utils/index_selfuncs.h"
+#include "utils/injection_point.h"
#include "utils/memutils.h"
#include "utils/wait_event.h"
@@ -163,6 +168,8 @@ bthandler(PG_FUNCTION_ARGS)
.amrescan = btrescan,
.amgettuple = btgettuple,
.amgetbitmap = btgetbitmap,
+ .amdeletemark = btdeletemark,
+ .amundomark = btundomark,
.amendscan = btendscan,
.ammarkpos = btmarkpos,
.amrestrpos = btrestrpos,
@@ -304,7 +311,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 +328,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 +429,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 +446,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 +1538,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 +1590,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 +1726,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 +1875,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 +1907,476 @@ 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.
+ *
+ * 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 probe)
+{
+ 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 (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))
+ {
+ 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/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/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..d53a115597388 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,29 @@ _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 +773,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 +857,8 @@ _bt_truncate(Relation rel, IndexTuple lastleft, IndexTuple firstright,
BTreeTupleGetHeapTID(firstright)) < 0);
#endif
+ if (firstright_plain != NULL)
+ pfree(firstright_plain);
return tidpivot;
}
@@ -1110,6 +1150,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 +1164,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 +1191,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 +1200,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/Makefile b/src/backend/access/rmgrdesc/Makefile
index cd95eec37f148..7cd57928336be 100644
--- a/src/backend/access/rmgrdesc/Makefile
+++ b/src/backend/access/rmgrdesc/Makefile
@@ -9,10 +9,13 @@ top_builddir = ../../../..
include $(top_builddir)/src/Makefile.global
OBJS = \
+ atmdesc.o \
brindesc.o \
clogdesc.o \
committsdesc.o \
dbasedesc.o \
+ fileopsdesc.o \
+ fluxdesc.o \
genericdesc.o \
gindesc.o \
gistdesc.o \
@@ -29,6 +32,9 @@ 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/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
new file mode 100644
index 0000000000000..ba4cb111c372e
--- /dev/null
+++ b/src/backend/access/rmgrdesc/fileopsdesc.c
@@ -0,0 +1,220 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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%s",
+ path, (unsigned int) xlrec->mode,
+ (xlrec->flags & FILEOPS_MKDIR_TREE) ? " tree" : "");
+ }
+ 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/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 d9000ccd9fd10..4144dad0bbc33 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',
@@ -22,8 +23,22 @@ rmgr_desc_sources = files(
'spgdesc.c',
'standbydesc.c',
'tblspcdesc.c',
+ 'pbu_undoactiondesc.c',
+ 'pbu_undologdesc.c',
+ 'undodesc.c',
'xactdesc.c',
'xlogdesc.c',
)
+
+# 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
+# pg_waldump fails to link. The autoconf build picks this up via its
+# *desc*.c wildcard; meson needs it listed explicitly.
+rmgr_desc_sources += files('fileopsdesc.c')
+
backend_sources += rmgr_desc_sources
diff --git a/src/backend/access/rmgrdesc/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/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/rmgrdesc/undodesc.c b/src/backend/access/rmgrdesc/undodesc.c
new file mode 100644
index 0000000000000..feff7e13462c3
--- /dev/null
+++ b/src/backend/access/rmgrdesc/undodesc.c
@@ -0,0 +1,151 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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_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_INFLIGHT:
+ {
+ xl_undo_inflight *xlrec = (xl_undo_inflight *) rec;
+
+ 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;
+
+ case XLOG_UNDO_BATCH:
+ {
+ xl_undo_batch *xlrec = (xl_undo_batch *) rec;
+
+ appendStringInfo(buf,
+ "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),
+ xlrec->primary_reloid,
+ xlrec->persistence);
+ }
+ 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_APPLY_RECORD:
+ id = "APPLY_RECORD";
+ 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/table/tableam.c b/src/backend/access/table/tableam.c
index 7aaa8d8715059..edd2a64dc2c91 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"
@@ -670,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,
@@ -677,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;
@@ -756,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;
@@ -780,3 +808,133 @@ 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 delete-marking table AM (FLUX) is refused; see the comment in the
+ * body.
+ */
+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;
+
+ /*
+ * 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;
+
+ /* 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;
+}
+
+/*
+ * 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 72d2c662bf2be..57ff7e6a3c3bc 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
@@ -55,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/access/transam/rmgr.c b/src/backend/access/transam/rmgr.c
index 4fda03a3cfcc6..688f5ba397f47 100644
--- a/src/backend/access/transam/rmgr.c
+++ b/src/backend/access/transam/rmgr.c
@@ -6,6 +6,7 @@
* src/backend/access/transam/rmgr.c
*/
#include "postgres.h"
+#include "storage/fileops.h"
#include "access/rmgr.h"
#include "access/xlog_internal.h"
@@ -40,6 +41,13 @@
#include "replication/origin.h"
#include "storage/standby.h"
#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 48e478a4ecbd8..2b6f4597bb651 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/atm.h"
+#include "access/xactundo.h"
#include "access/htup_details.h"
#include "access/subtrans.h"
#include "access/transam.h"
@@ -97,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"
@@ -147,6 +150,23 @@ 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);
+
+/*
+ * 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 */
@@ -162,6 +182,23 @@ 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.
+ *
+ * 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 */
@@ -227,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);
@@ -492,6 +529,9 @@ MarkAsPreparingGuts(GlobalTransaction gxact, FullTransactionId fxid,
gxact->locking_backend = MyProcNumber;
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);
/*
@@ -978,8 +1018,22 @@ TwoPhaseFilePath(char *path, FullTransactionId fxid)
/*
* Header for a 2PC state file
+ *
+ * TWOPHASE_MAGIC must be bumped whenever xl_xact_prepare changes layout.
+ * The struct gained last_batch_lsn[NUndoPersistenceLevels] (24 bytes) for
+ * UNDO chain tracking across 2PC boundaries, requiring a bump from
+ * 0x57F94534 to 0x57F94535 to prevent old servers from silently misreading
+ * the variable-length arrays that follow the fixed header at the wrong offsets.
+ *
+ * Bumped again to 0x57F94536: FILEOPS now registers TWOPHASE_RM_FILEOPS_ID
+ * records so deferred commit-time file operations (RENAME/DELETE/RMDIR/RMTREE)
+ * and abort-time filesystem undo survive PREPARE. This adds a new 2PC record
+ * type (not a header field), but a bump still keeps a mixed-version cluster
+ * from replaying a state file whose record stream it does not understand.
+ *
+ * Bumped to 0x57F94537: the header gained undo_first_batch_lsn.
*/
-#define TWOPHASE_MAGIC 0x57F94534 /* format identifier */
+#define TWOPHASE_MAGIC 0x57F94537 /* format identifier */
typedef xl_xact_prepare TwoPhaseFileHeader;
@@ -1101,6 +1155,12 @@ 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);
+ hdr.undo_first_batch_lsn =
+ GetCurrentXactFirstBatchLSN(UNDOPERSISTENCE_PERMANENT);
+
save_state_data(&hdr, sizeof(TwoPhaseFileHeader));
save_state_data(gxact->gid, hdr.gidlen);
@@ -1154,6 +1214,25 @@ 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
+ * 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);
@@ -1498,6 +1577,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
*/
@@ -1577,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,
@@ -1584,13 +1717,84 @@ FinishPreparedTransaction(const char *gid, bool isCommit)
commitstats,
hdr->ninvalmsgs, invalmsgs,
hdr->initfileinval, gid);
+ }
else
+ {
+ char *fileops = NULL;
+ uint32 fileops_len = 0;
+ char *scan = bufptr;
+
+ /*
+ * 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; 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;
+ * this matches the crash-recovery UNDO phase in undo_xlog.c.
+ */
+ {
+ 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))
+ 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);
+ gid, fileops, fileops_len);
+
+ INJECTION_POINT("rollback-prepared-after-abort-record", NULL);
+ }
ProcArrayRemove(proc, latestXid);
@@ -2145,6 +2349,15 @@ 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];
+ gxact->undo_first_batch_lsn = hdr->undo_first_batch_lsn;
+
/* recovered, so reset the flag for entries generated by redo */
gxact->inredo = false;
@@ -2444,7 +2657,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;
@@ -2464,6 +2677,8 @@ RecordTransactionAbortPrepared(TransactionId xid,
elog(PANIC, "cannot abort transaction %u, it was already committed",
xid);
+ if (fileops_len)
+ FileOpsBeginAbort();
START_CRIT_SECTION();
/*
@@ -2476,7 +2691,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 */
@@ -2493,6 +2708,19 @@ RecordTransactionAbortPrepared(TransactionId xid,
TransactionIdAbortTree(xid, nchildren, children);
END_CRIT_SECTION();
+ if (fileops_len)
+ {
+ PG_TRY();
+ {
+ INJECTION_POINT("fileops-abort-after-flush", NULL);
+ FileOpsApplyAbort(fileops, fileops_len);
+ }
+ PG_FINALLY();
+ {
+ FileOpsEndAbort();
+ }
+ PG_END_TRY();
+ }
/*
* Wait for synchronous replication, if required.
@@ -2597,6 +2825,8 @@ 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];
+ gxact->undo_first_batch_lsn = hdr->undo_first_batch_lsn;
strlcpy(gxact->gid, gid, GIDSIZE);
/* And insert it into the active array */
@@ -2878,3 +3108,37 @@ TwoPhaseGetOldestXidInCommit(void)
return oldestRunningXid;
}
+
+/*
+ * TwoPhaseGetOldestUndoBatchLSN
+ * 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
+ * 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_first_batch_lsn;
+
+ if (XLogRecPtrIsValid(lsn) &&
+ (!XLogRecPtrIsValid(oldest) || lsn < oldest))
+ oldest = lsn;
+ }
+
+ LWLockRelease(TwoPhaseStateLock);
+
+ return oldest;
+}
diff --git a/src/backend/access/transam/twophase_rmgr.c b/src/backend/access/transam/twophase_rmgr.c
index fae254c6e2364..4d9263142617f 100644
--- a/src/backend/access/transam/twophase_rmgr.c
+++ b/src/backend/access/transam/twophase_rmgr.c
@@ -15,10 +15,30 @@
#include "postgres.h"
#include "access/multixact.h"
+#include "access/flux.h"
#include "access/twophase_rmgr.h"
#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.
+ */
+
+/*
+ * 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] =
@@ -27,7 +47,10 @@ 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) */
+ NULL, /* per-backend UNDO (postabort-only) */
+ flux_twophase_recover /* FLUX */
};
const TwoPhaseCallback twophase_postcommit_callbacks[TWOPHASE_RM_MAX_ID + 1] =
@@ -36,7 +59,10 @@ 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 */
+ pbu_twophase_postcommit, /* per-backend UNDO */
+ flux_twophase_postcommit /* FLUX */
};
const TwoPhaseCallback twophase_postabort_callbacks[TWOPHASE_RM_MAX_ID + 1] =
@@ -45,7 +71,10 @@ 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 */
+ pbu_twophase_postabort, /* per-backend UNDO */
+ flux_twophase_postabort /* FLUX */
};
const TwoPhaseCallback twophase_standby_recover_callbacks[TWOPHASE_RM_MAX_ID + 1] =
@@ -54,5 +83,8 @@ 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 */
+ 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 cab99f25e1ed4..dad1110005014 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -21,11 +21,16 @@
#include
#include "access/commit_ts.h"
+#include "access/flux.h"
#include "access/multixact.h"
#include "access/parallel.h"
#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"
@@ -55,6 +60,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"
@@ -218,6 +224,15 @@ 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 */
+
+ /*
+ * 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;
@@ -317,6 +332,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
*/
@@ -358,6 +383,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);
@@ -419,6 +447,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 +1183,191 @@ 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;
+}
+
+/*
+ * 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.
+ *
+ * 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'
+ * 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 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 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.
+ */
+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
*/
@@ -1814,6 +2058,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
@@ -1832,9 +2078,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.
*/
/*
@@ -1856,6 +2102,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();
@@ -1872,7 +2122,7 @@ RecordTransactionAbort(bool isSubXact)
nrels, rels,
ndroppedstats, droppedstats,
MyXactFlags, InvalidTransactionId,
- NULL);
+ NULL, fileops, fileops_len);
if (replorigin)
/* Move LSNs forward for this replication origin */
@@ -1901,8 +2151,26 @@ RecordTransactionAbort(bool isSubXact)
*/
TransactionIdAbortTree(xid, nchildren, children);
+ if (fileops_len)
+ XLogFlush(XactLastRecEnd);
END_CRIT_SECTION();
+ if (fileops_len)
+ {
+ PG_TRY();
+ {
+ INJECTION_POINT("fileops-abort-after-flush", NULL);
+ FileOpsApplyAbort(fileops, fileops_len);
+ }
+ PG_FINALLY();
+ {
+ FileOpsEndAbort();
+ }
+ PG_END_TRY();
+ }
+ if (fileops)
+ pfree(fileops);
+
/* Compute latestXid while we have the child XIDs handy */
latestXid = TransactionIdLatest(xid, nchildren, children);
@@ -2152,6 +2420,10 @@ StartTransaction(void)
s->childXids = NULL;
s->nChildXids = 0;
s->maxChildXids = 0;
+ s->undoRecPtr = 0; /* no UNDO records yet */
+ /* 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,
@@ -2328,6 +2600,8 @@ CommitTransaction(void)
* the transaction-abort path.
*/
+ PbuAtPreCommit_Undo();
+
CallXactCallbacks(is_parallel_worker ? XACT_EVENT_PARALLEL_PRE_COMMIT
: XACT_EVENT_PRE_COMMIT);
@@ -2389,6 +2663,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();
@@ -2458,6 +2747,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 +2794,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
@@ -2606,6 +2900,8 @@ PrepareTransaction(void)
break;
}
+ PbuAtPreCommit_Undo();
+
CallXactCallbacks(XACT_EVENT_PRE_PREPARE);
/*
@@ -2678,6 +2974,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
+ * 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,
+ (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();
@@ -2725,6 +3055,7 @@ PrepareTransaction(void)
AtPrepare_PgStat();
AtPrepare_MultiXact();
AtPrepare_RelationMap();
+ AtPrepare_Flux();
/*
* Here is where we really truly prepare.
@@ -2790,6 +3121,8 @@ PrepareTransaction(void)
PostPrepare_Inval();
PostPrepare_smgr();
+ if (PendingPhysOpsPostPrepare_hook)
+ PendingPhysOpsPostPrepare_hook();
PostPrepare_MultiXact(fxid);
@@ -2828,6 +3161,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 +3280,13 @@ AbortTransaction(void)
TransStateAsString(s->state));
Assert(s->parent == NULL);
+ /*
+ * 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;
+
/*
* set the current transaction state information appropriately during the
* abort processing
@@ -2972,6 +3322,40 @@ AbortTransaction(void)
s->parallelModeLevel = 0;
s->parallelChildXact = false; /* should be false already */
+ /* 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 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
*/
@@ -3039,7 +3423,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);
@@ -3139,6 +3526,7 @@ StartTransactionCommand(void)
* transaction.
*/
case TBLOCK_DEFAULT:
+ PbuApplyPendingUndo();
StartTransaction();
s->blockState = TBLOCK_STARTED;
break;
@@ -5218,6 +5606,7 @@ CommitSubTransaction(void)
/* Post-commit cleanup */
if (FullTransactionIdIsValid(s->fullTransactionId))
AtSubCommit_childXids();
+ AtSubCommit_PbuUndo();
AfterTriggerEndSubXact(true);
AtSubCommit_Portals(s->subTransactionId,
s->parent->subTransactionId,
@@ -5238,6 +5627,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.
@@ -5403,6 +5794,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);
@@ -5413,6 +5817,9 @@ AbortSubTransaction(void)
CallSubXactCallbacks(SUBXACT_EVENT_ABORT_SUB, s->subTransactionId,
s->parent->subTransactionId);
+ /* The apply above marked its per-backend UNDO. */
+ AtSubAbort_PbuUndo();
+
ResourceOwnerRelease(s->curTransactionOwner,
RESOURCE_RELEASE_BEFORE_LOCKS,
false, false);
@@ -5422,6 +5829,7 @@ AbortSubTransaction(void)
s->parent->subTransactionId);
AtEOSubXact_TypeCache();
AtEOSubXact_Inval(false);
+ AtSubAbort_PbuRetryUndo();
ResourceOwnerRelease(s->curTransactionOwner,
RESOURCE_RELEASE_LOCKS,
false, false);
@@ -5429,6 +5837,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);
@@ -6068,7 +6478,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;
@@ -6083,7 +6494,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))
@@ -6196,6 +6607,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);
@@ -6418,6 +6835,13 @@ xact_redo_abort(xl_xact_parsed_abort *parsed, TransactionId xid,
false /* backward */ , false /* WAL */ );
}
+ if (parsed->fileops_len)
+ {
+ XLogFlush(lsn);
+ INJECTION_POINT("fileops-abort-after-flush", NULL);
+ FileOpsApplyAbort(parsed->fileops, parsed->fileops_len);
+ }
+
/* Make sure files supposed to be dropped are dropped */
if (parsed->nrels > 0)
{
@@ -6455,6 +6879,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 +6898,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 +6910,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 +6931,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 +6955,22 @@ 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->database,
+ 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..cdfbfe0c66964 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"
@@ -55,7 +56,10 @@
#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/xactundo.h"
#include "access/xlog_internal.h"
#include "access/xlogarchive.h"
#include "access/xloginsert.h"
@@ -753,6 +757,18 @@ static XLogRecPtr CreateOverwriteContrecordRecord(XLogRecPtr aborted_lsn,
XLogRecPtr pagePtr,
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,
@@ -773,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);
@@ -6323,6 +6339,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
@@ -6373,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,
@@ -6620,8 +6661,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
@@ -6834,7 +6880,25 @@ 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
+ * 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();
/*
* If any of the critical GUCs have changed, log them before we allow
@@ -7051,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;
@@ -7083,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;
@@ -7697,6 +7765,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 +7805,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 +8083,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 +8103,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 +8124,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
@@ -8049,6 +8139,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();
/*
@@ -8060,7 +8158,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 +8196,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 +8250,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 +8282,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 +8315,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 +8336,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 +8527,22 @@ CheckPointGuts(XLogRecPtr checkPointRedo, int flags)
CheckPointRelationMap();
CheckPointReplicationOrigin();
+ /* 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();
@@ -8426,6 +8572,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();
}
/*
@@ -8965,6 +9118,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..eceb49a30afca 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"
@@ -73,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
*/
@@ -207,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.
@@ -1570,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;
}
@@ -1869,6 +1882,45 @@ 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")));
+ fileopsUndoApplied = PerformUndoRecovery();
+ ereport(LOG,
+ (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
+ * 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
new file mode 100644
index 0000000000000..30ee65c20f53f
--- /dev/null
+++ b/src/backend/access/undo/Makefile
@@ -0,0 +1,44 @@
+#-------------------------------------------------------------------------
+#
+# 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 = \
+ atm.o \
+ logical_revert_worker.o \
+ slog.o \
+ slog_tuple.o \
+ undo.o \
+ undo_bufmgr.o \
+ undo_xlog.o \
+ undoapply.o \
+ undoinsert.o \
+ undolog.o \
+ undorecord.o \
+ undormgr.o \
+ undostats.o \
+ undoworker.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
new file mode 100644
index 0000000000000..7ffbe2f4cf589
--- /dev/null
+++ b/src/backend/access/undo/README
@@ -0,0 +1,1052 @@
+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.
+
+### 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.
+
+The FLUX table access method (src/backend/access/flux) writes its table
+UNDO to the per-backend engine.
+
+
+## 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.
+
+## 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_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
+ 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.
+
+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).
+
+## 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_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:
+ - 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 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
+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.
+
+### 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
+ (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 SQL function is implemented (undostats.c):
+
+ 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
+
+ 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.
+
+ 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_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
+ 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;
+
+pg_stat_get_undo_retention() shows what holds UNDO WAL back.
+
+### 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/atm.c b/src/backend/access/undo/atm.c
new file mode 100644
index 0000000000000..9f9d93d0e5ea9
--- /dev/null
+++ b/src/backend/access/undo/atm.c
@@ -0,0 +1,638 @@
+/*-------------------------------------------------------------------------
+ *
+ * atm.c
+ * 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:
+ *
+ * 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
+ * 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
+ * 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 0x41544D34 /* "ATM4" */
+
+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;
+ 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, bool fileops_owned);
+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() */
+}
+
+/*
+ * 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.
+ *
+ * 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.
+ *
+ * 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, bool fileops_owned)
+{
+ return SLogTxnInsert(xid, reloid, dboid, last_batch_lsn, stop_lsn,
+ first_batch_lsn, fileops_owned, false);
+}
+
+/*
+ * 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.
+ *
+ * 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, bool fileops_owned)
+{
+ return ATMAddAbortedBounded(xid, dboid, last_batch_lsn, InvalidXLogRecPtr,
+ first_batch_lsn, fileops_owned);
+}
+
+/*
+ * 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, bool fileops_owned)
+{
+ xl_atm_abort xlrec;
+
+ /*
+ * 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;
+
+ XLogBeginInsert();
+ XLogRegisterData((char *) &xlrec, SizeOfXlAtmAbort);
+ XLogInsert(RM_ATM_ID, XLOG_ATM_ABORT);
+
+ return true;
+}
+
+/*
+ * 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. 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, bool fileops_owned)
+{
+ (void) ATMAddAbortedInternal(xid, dboid, InvalidOid, last_batch_lsn,
+ InvalidXLogRecPtr, first_batch_lsn, fileops_owned);
+}
+
+/*
+ * 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, xlrec->fileops_owned);
+ }
+ 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 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.
+ */
+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.fileops_owned = entries[i].fileops_owned;
+ 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, records[i].fileops_owned);
+ 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..78b1a13bbc091
--- /dev/null
+++ b/src/backend/access/undo/logical_revert_worker.c
@@ -0,0 +1,845 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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/slog.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/fileops.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;
+ SLogTxnEntry entry;
+ 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();
+ {
+ 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
+ * remove it from the ATM entirely.
+ */
+ if (done)
+ {
+ ATMMarkReverted(xid);
+ ATMForget(xid);
+ }
+ }
+ PG_CATCH();
+ {
+ FileOpsSkipUndo = false;
+ 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
new file mode 100644
index 0000000000000..46e6c8c382199
--- /dev/null
+++ b/src/backend/access/undo/meson.build
@@ -0,0 +1,31 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+backend_sources += files(
+ 'atm.c',
+ 'logical_revert_worker.c',
+ 'slog.c',
+ 'slog_tuple.c',
+ 'undo.c',
+ 'undo_bufmgr.c',
+ 'undo_xlog.c',
+ 'undoapply.c',
+ 'undoinsert.c',
+ 'undolog.c',
+ 'undorecord.c',
+ 'undormgr.c',
+ '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..0e3f2374a0173
--- /dev/null
+++ b/src/backend/access/undo/perbackend/pbu_undolog.c
@@ -0,0 +1,3196 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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;
+
+/*
+ * 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);
+}
+
+/*
+ * 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
+pg_stat_get_undo_logs(PG_FUNCTION_ARGS)
+{
+#define PG_STAT_GET_UNDO_LOGS_COLS 8
+ ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+ UndoLogNumber low_logno;
+ UndoLogNumber high_logno;
+ UndoLogSharedData *shared = MyUndoLogState.shared;
+ char *tablespace_name = NULL;
+ Oid last_tablespace = InvalidOid;
+
+ InitMaterializedSRF(fcinfo, 0);
+
+ /* 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 (UndoLogNumber 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" :
+ "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(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);
+ else
+ nulls[2] = true;
+
+ tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, 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..c5636160038fe
--- /dev/null
+++ b/src/backend/access/undo/perbackend/pbu_undoworker.c
@@ -0,0 +1,912 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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/injection_point.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);
+
+ /* Lets tests hold a rollback request back from the worker. */
+ INJECTION_POINT("pbu-undo-worker-before-apply", NULL);
+
+ 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/slog.c b/src/backend/access/undo/slog.c
new file mode 100644
index 0000000000000..9b8a6238809d6
--- /dev/null
+++ b/src/backend/access/undo/slog.c
@@ -0,0 +1,907 @@
+/*-------------------------------------------------------------------------
+ *
+ * slog.c
+ * Secondary Log (sLog) -- the transaction sLog, which holds the
+ * Aborted Transaction Map (ATM)
+ *
+ * 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 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).
+ *
+ * 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/freepage.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;
+ bool fileops_owned;
+} 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);
+}
+
+/*
+ * 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 */
+
+/* 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 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) */
+static slog_atm_radix_tree * slog_atm_tree_local = NULL;
+
+
+/*
+ * slog_atm_tree
+ * Return this backend's attached ATM radix tree, attaching lazily.
+ *
+ * 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(bool create)
+{
+ MemoryContext oldcxt;
+ bool created = false;
+
+ if (slog_atm_tree_local != NULL)
+ return slog_atm_tree_local;
+
+ LWLockAcquire(&SLogState->txn_lock.lock, create ? LW_EXCLUSIVE : LW_SHARED);
+ if (!DsaPointerIsValid(SLogState->atm_handle) && !create)
+ {
+ LWLockRelease(&SLogState->txn_lock.lock);
+ return NULL;
+ }
+
+ /*
+ * 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);
+ 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
+ * ----------------------------------------------------------------
+ */
+
+/*
+ * SLogShmemSize
+ * Calculate shared memory needed for the sLog.
+ *
+ * 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 MAXALIGN(sizeof(SLogSharedState));
+}
+
+/*
+ * SLogShmemRequest
+ * Register shared memory needs for the sLog.
+ *
+ * Registers the shared state struct.
+ */
+void
+SLogShmemRequest(void)
+{
+ /* Register the shared state structure */
+ ShmemRequestStruct(.name = "Secondary Log State",
+ .size = sizeof(SLogSharedState),
+ .ptr = (void **) &SLogState,
+ );
+}
+
+/*
+ * SLogShmemInit
+ * Initialize sLog shared memory contents.
+ *
+ * Called during the init_fn phase. The framework has already allocated
+ * SLogState. The ATM's DSA area and tree do not exist yet.
+ */
+void
+SLogShmemInit(void)
+{
+ LWLockInitialize(&SLogState->txn_lock.lock, LWTRANCHE_SLOG);
+ SLogState->area = DSA_HANDLE_INVALID;
+ SLogState->atm_handle = InvalidDsaPointer;
+}
+
+/*
+ * 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,
+};
+
+/* ================================================================
+ * Transaction sLog functions
+ * ================================================================
+ */
+
+/*
+ * 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, 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, bool fileops_owned,
+ bool check_space)
+{
+ 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;
+ value.fileops_owned = fileops_owned;
+ value.dboid = dboid;
+ value.abort_time = GetCurrentTimestamp();
+ value.revert_complete = false;
+
+ LWLockAcquire(&SLogState->txn_lock.lock, LW_EXCLUSIVE);
+
+ /*
+ * 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.
+ */
+ 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;
+}
+
+/*
+ * 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(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);
+
+ 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->fileops_owned = found->fileops_owned;
+ 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(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);
+ 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(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);
+
+ 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(false);
+ slog_atm_iter *iter;
+ slog_atm_value_t *val;
+ uint64 k;
+ uint64 *to_remove;
+ int nremove = 0;
+ 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);
+
+ /* 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(false);
+ 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;
+
+ /* 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));
+
+ 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(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);
+ 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(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);
+ 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(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);
+ 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(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);
+ 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(false);
+ slog_atm_iter *iter;
+ slog_atm_value_t *val;
+ uint64 k;
+ SLogTxnEntry *arr = NULL;
+ int count = 0;
+ int capacity = 64;
+
+ *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);
+
+ 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].fileops_owned = val->fileops_owned;
+ 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_tuple.c b/src/backend/access/undo/slog_tuple.c
new file mode 100644
index 0000000000000..6057516549f74
--- /dev/null
+++ b/src/backend/access/undo/slog_tuple.c
@@ -0,0 +1,1623 @@
+/*-------------------------------------------------------------------------
+ *
+ * slog_tuple.c
+ * 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
+ *
+ * IDENTIFICATION
+ * src/backend/access/undo/slog_tuple.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/slog.h"
+#include "access/twophase.h"
+#include "access/xact.h"
+#include "common/hashfn.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/spin.h"
+#include "utils/dsa.h"
+#include "utils/injection_point.h"
+#include "utils/memutils.h"
+
+static void slog_tl_forget_storage(Oid relid, RelFileNumber relnumber);
+
+/* ----------------------------------------------------------------
+ * 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;
+ SLogOpType op_type; /* INSERT, or ABORTED once its savepoint is */
+
+ struct SLogTrackedKey *next;
+} SLogTrackedKey;
+
+static SLogTrackedKey *slog_tracked_keys = NULL;
+
+/* ----------------------------------------------------------------
+ * 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;
+ 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, relnumber, encoded_tid) */
+ char status; /* required by simplehash */
+} SLogInsertTidEntry;
+
+static inline uint32
+slog_insert_tid_hash(const SLogInsertTidKey *key)
+{
+ 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));
+}
+
+#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))
+#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
+#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;
+}
+
+/*
+ * SLogTupleTrackLocalOnly
+ * Lightweight local-only tracking (INSERTs only).
+ *
+ * Records (relid, relnumber, tid, xid, subxid) in backend-local memory.
+ *
+ * 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.
+ */
+void
+SLogTupleTrackLocalOnly(Oid relid, RelFileNumber relnumber, 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.relnumber = relnumber;
+ 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;
+ key.relnumber = relnumber;
+ ItemPointerCopy(tid, &key.tid);
+
+ 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);
+ }
+}
+
+/*
+ * 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
+SLogTupleRemoveBySubXid(TransactionId xid, TransactionId subxid)
+{
+ 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;
+ }
+}
+
+/*
+ * SLogTupleUpdateSubXid
+ * Re-parent a committed subtransaction's inserts to its parent, so a
+ * later rollback of the parent finds them.
+ */
+void
+SLogTupleUpdateSubXid(TransactionId xid,
+ TransactionId old_subxid,
+ TransactionId new_subxid)
+{
+ SLogTrackedKey *tk;
+
+ for (tk = slog_tracked_keys; tk != NULL; tk = tk->next)
+ {
+ if (TransactionIdEquals(tk->xid, xid) && tk->subxid == old_subxid)
+ tk->subxid = new_subxid;
+ }
+}
+
+/*
+ * 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;
+}
+
+/*
+ * 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;
+ 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++;
+ }
+ }
+
+ /* 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].relnumber = tk->relnumber;
+ arr[count].xid = tk->xid;
+ arr[count].subxid = tk->subxid;
+ arr[count].op_type = tk->op_type;
+ count++;
+ }
+
+ *out_keys = arr;
+ return count;
+}
+
+/*
+ * 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.)
+ */
+void
+SLogTupleForgetStorage(Oid relid, RelFileNumber relnumber)
+{
+ SLogTrackedKey **link = &slog_tracked_keys;
+
+ slog_tl_forget_storage(relid, relnumber);
+
+ while (*link != NULL)
+ {
+ SLogTrackedKey *tk = *link;
+
+ if (tk->key.relid == relid && tk->key.relnumber == relnumber)
+ {
+ *link = tk->next;
+ pfree(tk);
+ }
+ else
+ link = &tk->next;
+ }
+ 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 (ie->key.relid == relid && ie->key.relnumber == relnumber)
+ sloginsert_delete(slog_insert_tids, ie->key);
+ }
+ }
+}
+
+
+/* ----------------------------------------------------------------
+ * Row locks (see the file header)
+ * ----------------------------------------------------------------
+ */
+/* one entry per strength at most; strengths strictly increase with depth */
+#define SLOG_TL_MAX_DEPTH 4
+
+#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))
+
+/*
+ * 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.
+ */
+typedef struct SLogTupleLockEntry
+{
+ uint64 key;
+ uint64 val;
+} SLogTupleLockEntry;
+
+/*
+ * 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;
+
+/* a table is grown before it is more than 3/4 full */
+#define SLOG_TL_MIN_ENTRIES 16
+
+/*
+ * 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
+
+/*
+ * 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.
+ */
+typedef struct SLogTupleLockInline
+{
+ Oid relid;
+ RelFileNumber relnumber;
+ BlockNumber blkno;
+ OffsetNumber offnum;
+ uint16 nlev;
+ uint64 lev[SLOG_TL_MAX_DEPTH];
+} SLogTupleLockInline;
+
+/*
+ * TIDs a slot keeps inline; the transaction's further TIDs go to its tables.
+ *
+ * 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.
+ */
+#define SLOG_TL_NINLINE 8
+
+typedef struct SLogTupleLockSlot
+{
+ 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)
+
+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);
+}
+
+/* The holder-index bucket of a TID */
+static inline uint32
+slog_tl_bucket(Oid relid, RelFileNumber relnumber, ItemPointer tid)
+{
+ uint64 h;
+
+ h = hash_combine64(murmurhash64(((uint64) relid << 32) | relnumber),
+ murmurhash64(((uint64) ItemPointerGetBlockNumber(tid) << 16) |
+ ItemPointerGetOffsetNumber(tid)));
+ return (uint32) h & (SLOG_TL_INDEX_NBUCKETS - 1);
+}
+
+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);
+}
+
+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);
+}
+
+/*
+ * 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.
+ */
+static inline void
+slog_tl_change_begin(SLogTupleLockSlot *slot)
+{
+ pg_atomic_fetch_add_u32(&slot->changecount, 1);
+}
+
+static inline void
+slog_tl_change_end(SLogTupleLockSlot *slot)
+{
+ pg_atomic_fetch_add_u32(&slot->changecount, 1);
+}
+
+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++)
+ {
+ 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;
+ }
+}
+
+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;
+
+ 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;
+}
+
+static dsa_area *
+slog_tl_dsa(void)
+{
+ if (slog_tl_area == NULL)
+ {
+ bool found;
+
+ slog_tl_area = GetNamedDSA("slog_tuple_lock_tables", &found);
+ }
+ return slog_tl_area;
+}
+
+/*
+ * 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.
+ */
+static void
+slog_tl_index_set(SLogTupleLockShared *dir, int slotno, uint32 bucket)
+{
+ pg_atomic_uint64 *word = slog_tl_index_word(dir, bucket, slotno);
+ uint64 bit = slog_tl_index_bit(slotno);
+
+ 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);
+}
+
+/* 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);
+
+ 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;
+}
+
+/* Clear slot slotno from every bucket. */
+static void
+slog_tl_index_clear_all(SLogTupleLockShared *dir, int slotno)
+{
+ uint64 bit = slog_tl_index_bit(slotno);
+
+ for (uint32 b = 0; b < SLOG_TL_INDEX_NBUCKETS; b++)
+ {
+ pg_atomic_uint64 *word = slog_tl_index_word(dir, b, slotno);
+
+ if (pg_atomic_read_u64(word) & bit)
+ pg_atomic_fetch_and_u64(word, ~bit);
+ }
+}
+
+/*
+ * The inline record of a TID in a slot, or -1. For the slot's writer, which
+ * needs no protection against itself.
+ */
+static int
+slog_tl_find_inline(SLogTupleLockSlot *slot, Oid relid,
+ RelFileNumber relnumber, ItemPointer tid)
+{
+ BlockNumber blkno = ItemPointerGetBlockNumber(tid);
+ OffsetNumber offnum = ItemPointerGetOffsetNumber(tid);
+
+ for (int r = 0; r < slot->ninline; r++)
+ {
+ SLogTupleLockInline *in = &slot->inl[r];
+
+ if (in->relid == relid && in->relnumber == relnumber &&
+ in->blkno == blkno && in->offnum == offnum)
+ return r;
+ }
+ return -1;
+}
+
+/* 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;
+}
+
+/* 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);
+}
+
+/*
+ * 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).
+ */
+static int
+slog_tl_read_inline(SLogTupleLockSlot *slot, Oid relid,
+ RelFileNumber relnumber, ItemPointer tid,
+ LOCKMODE lockmode, uint64 *cand, bool *has_tables)
+{
+ volatile SLogTupleLockSlot *vslot = slot;
+ BlockNumber blkno = ItemPointerGetBlockNumber(tid);
+ OffsetNumber offnum = ItemPointerGetOffsetNumber(tid);
+ SpinDelayStatus delay;
+ int n;
+
+ init_local_spin_delay(&delay);
+ for (;;)
+ {
+ uint32 before = pg_atomic_read_u32(&slot->changecount);
+ int ninline;
+ bool found = false;
+
+ if (before & 1)
+ {
+ perform_spin_delay(&delay);
+ continue;
+ }
+ pg_read_barrier();
+
+ n = 0;
+ ninline = Min(vslot->ninline, SLOG_TL_NINLINE);
+ for (int r = 0; r < ninline; r++)
+ {
+ volatile SLogTupleLockInline *in = &vslot->inl[r];
+ int nlev;
+
+ 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++)
+ {
+ 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;
+ }
+ 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);
+ }
+}
+
+/*
+ * 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.
+ */
+static dsa_pointer
+slog_tl_alloc(Size size, bool table)
+{
+ 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);
+}
+
+/* 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);
+}
+
+/* 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;
+}
+
+static inline SLogTupleLockEntry *
+slog_tl_table(SLogTupleLockRel *rel)
+{
+ return (SLogTupleLockEntry *) dsa_get_address(slog_tl_dsa(), rel->table);
+}
+
+/*
+ * 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.
+ */
+static inline SLogTupleLockEntry *
+slog_tl_entry(SLogTupleLockEntry *tab, uint64 size, uint64 key)
+{
+ uint64 i = murmurhash64(key) & (size - 1);
+
+ while (tab[i].key != key && tab[i].key != 0)
+ i = (i + 1) & (size - 1);
+ return &tab[i];
+}
+
+/* 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);
+
+ return e->key == key ? e->val : 0;
+}
+
+/*
+ * 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.
+ */
+static SLogTupleLockRel *
+slog_tl_add_rel(int slotno, Oid relid, RelFileNumber relnumber)
+{
+ SLogTupleLockSlot *slot = slog_tl_slot(slog_tl_dir, slotno);
+ SLogTupleLockRel *rels;
+ SLogTupleLockRel *rel;
+
+ rel = slog_tl_find_rel(slot, relid, relnumber);
+ if (rel != NULL)
+ return rel;
+
+ rels = slog_tl_rels(slot);
+ if (slot->nrels == slot->maxrels)
+ {
+ 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);
+ }
+
+ 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;
+}
+
+/*
+ * 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)
+ {
+ *slog_tl_entry(tab, size, old[i].key) = old[i];
+ used++;
+ }
+ }
+ LWLockAcquire(&slot->lock, LW_EXCLUSIVE);
+ rel->table = newtable;
+ rel->size = size;
+ rel->used = used;
+ LWLockRelease(&slot->lock);
+ dsa_free(slog_tl_dsa(), oldtable);
+}
+
+/*
+ * 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.
+ */
+static void
+slog_tl_table_write(int slotno, Oid relid, RelFileNumber relnumber,
+ ItemPointer tid, int n, const uint64 *lev, int oldn)
+{
+ SLogTupleLockSlot *slot = slog_tl_slot(slog_tl_dir, slotno);
+ SLogTupleLockRel *rel = slog_tl_add_rel(slotno, relid, relnumber);
+ SLogTupleLockEntry *tab;
+
+ if (rel->used + n > rel->size / 4 * 3)
+ slog_tl_grow_table(slot, rel, rel->used + n);
+ tab = slog_tl_table(rel);
+
+ LWLockAcquire(&slot->lock, LW_EXCLUSIVE);
+ for (int d = 0; d < n; d++)
+ {
+ uint64 key = slog_tl_key(tid, d);
+ SLogTupleLockEntry *e = slog_tl_entry(tab, rel->size, key);
+
+ if (e->key == 0)
+ {
+ 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);
+}
+
+/*
+ * 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.
+ */
+static void
+slog_tl_free_slot(int slotno, bool mine)
+{
+ SLogTupleLockShared *dir = slog_tl_directory();
+ SLogTupleLockSlot *slot = slog_tl_slot(dir, slotno);
+
+ if (DsaPointerIsValid(slot->rels))
+ {
+ 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++)
+ {
+ SLogTupleLockRel *rel = &rels[i];
+
+ if (!DsaPointerIsValid(rel->table))
+ continue;
+ if (keep && rel->size <= SLOG_TL_KEEP_ENTRIES)
+ {
+ /* (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;
+ }
+ if (!keep)
+ {
+ dsa_free(slog_tl_dsa(), slot->rels);
+ slot->rels = InvalidDsaPointer;
+ slot->maxrels = 0;
+ }
+ LWLockRelease(&slot->lock);
+ }
+ if (slot->ninline > 0)
+ {
+ slog_tl_change_begin(slot);
+ slot->ninline = 0;
+ slog_tl_change_end(slot);
+ }
+ slot->xid = InvalidTransactionId;
+
+ /*
+ * 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;
+}
+
+/* The first row lock of this backend's transaction: take its slot. */
+static SLogTupleLockSlot *
+slog_tl_my_slot(void)
+{
+ SLogTupleLockShared *dir = slog_tl_directory();
+ SLogTupleLockSlot *slot = slog_tl_slot(dir, MyProcNumber);
+
+ if (!slog_tl_used)
+ {
+ if (slog_tl_listed == NULL)
+ slog_tl_listed = MemoryContextAlloc(TopMemoryContext,
+ sizeof(uint16) * SLOG_TL_INDEX_NBUCKETS);
+
+ /*
+ * 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;
+ }
+ return slot;
+}
+
+/*
+ * SLogTupleLockAcquire -- record that the current subtransaction holds a row
+ * lock of the given strength on a tuple.
+ *
+ * Call it under the tuple's buffer lock, before changing the page: running
+ * out of shared memory ERRORs here, with nothing changed.
+ *
+ * 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.
+ */
+void
+SLogTupleLockAcquire(Oid relid, RelFileNumber relnumber, ItemPointer tid,
+ LockTupleMode mode)
+{
+ 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)
+ {
+ 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 (v == 0)
+ break;
+ old[oldn++] = v;
+ }
+ }
+
+ /*
+ * 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 (int d = 0; d < oldn; d++)
+ {
+ if (!TransactionIdIsCurrentTransactionId(SLOG_TL_XID(old[d])))
+ continue;
+ if (SLOG_TL_MODE(old[d]) >= mode)
+ return;
+ if (TransactionIdEquals(SLOG_TL_XID(old[d]), cur))
+ continue;
+ stack[n++] = old[d];
+ }
+ 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);
+}
+
+/*
+ * 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
+SLogTupleLockHeldByMe(Oid relid, RelFileNumber relnumber, ItemPointer tid,
+ LockTupleMode *mode)
+{
+ SLogTupleLockSlot *slot;
+ SLogTupleLockRel *rel;
+ int best = -1;
+ int r;
+
+ if (!slog_tl_used)
+ return false;
+ slot = slog_tl_slot(slog_tl_dir, MyProcNumber);
+ r = slog_tl_find_inline(slot, relid, relnumber, tid);
+ if (r >= 0)
+ {
+ for (int d = 0; d < slot->inl[r].nlev; d++)
+ {
+ uint64 v = slot->inl[r].lev[d];
+
+ if (TransactionIdIsCurrentTransactionId(SLOG_TL_XID(v)))
+ best = Max(best, (int) SLOG_TL_MODE(v));
+ }
+ }
+ 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 (v == 0)
+ break;
+ if (TransactionIdIsCurrentTransactionId(SLOG_TL_XID(v)))
+ best = Max(best, (int) SLOG_TL_MODE(v));
+ }
+ }
+ if (best < 0)
+ return false;
+ if (mode)
+ *mode = (LockTupleMode) best;
+ return true;
+}
+
+/*
+ * One holder slot of SLogTupleLockConflictXid: the xid to wait for, or
+ * InvalidTransactionId.
+ */
+static TransactionId
+slog_tl_probe_slot(SLogTupleLockShared *dir, int i, Oid relid,
+ RelFileNumber relnumber, ItemPointer tid, LOCKMODE lockmode)
+{
+ SLogTupleLockSlot *slot;
+ uint64 cand[SLOG_TL_MAX_DEPTH];
+ int n;
+ bool has_tables;
+
+ slot = slog_tl_slot(dir, i);
+ n = slog_tl_read_inline(slot, relid, relnumber, tid, lockmode, cand,
+ &has_tables);
+ if (n == 0 && has_tables)
+ {
+ SLogTupleLockRel *rel;
+
+ (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++)
+ {
+ uint64 v = slog_tl_lookup(rel, slog_tl_key(tid, d));
+
+ if (v == 0)
+ break;
+ if (DoLockModesConflict(lockmode,
+ slog_tl_lockmode[SLOG_TL_MODE(v)]))
+ cand[n++] = v;
+ }
+ LWLockRelease(&slot->lock);
+ }
+
+ /* (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;
+}
+
+/*
+ * 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.
+ *
+ * Within one holder the outermost conflicting lock is returned, since it
+ * lives longest.
+ *
+ * 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
+SLogTupleLockConflictXid(Oid relid, RelFileNumber relnumber, ItemPointer tid,
+ LockTupleMode mode)
+{
+ SLogTupleLockShared *dir = slog_tl_directory();
+ uint32 bucket = slog_tl_bucket(relid, relnumber, tid);
+ LOCKMODE lockmode = slog_tl_lockmode[mode];
+
+ for (int w = 0; w < dir->nwords; w++)
+ {
+ pg_atomic_uint64 *word = slog_tl_index_word(dir, bucket, w * 64);
+ uint64 bits = pg_atomic_read_u64(word);
+
+ while (bits != 0)
+ {
+ int j = pg_rightmost_one_pos64(bits);
+ int i = w * 64 + j;
+ TransactionId xwait;
+
+ 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);
+ }
+ }
+ return InvalidTransactionId;
+}
+
+
+/* Top-level commit or abort: release every row lock of this backend. */
+void
+SLogTupleLockReleaseAll(void)
+{
+ if (!slog_tl_used)
+ return;
+ slog_tl_free_slot(MyProcNumber, true);
+ slog_tl_used = false;
+}
+
+/* Row locks of SLogTupleForgetStorage */
+static void
+slog_tl_forget_storage(Oid relid, RelFileNumber relnumber)
+{
+ SLogTupleLockSlot *slot;
+ SLogTupleLockRel *rel;
+ bool changing = false;
+
+ if (!slog_tl_used)
+ return;
+ slot = slog_tl_slot(slog_tl_dir, MyProcNumber);
+ for (int r = 0; r < slot->ninline; r++)
+ {
+ if (slot->inl[r].relid != relid || slot->inl[r].relnumber != relnumber)
+ continue;
+ if (!changing)
+ slog_tl_change_begin(slot);
+ changing = true;
+ slot->inl[r].relid = InvalidOid;
+ }
+ if (changing)
+ slog_tl_change_end(slot);
+
+ /*
+ * Empty its table and move it past the ones in use, where the slot's
+ * release finds it.
+ */
+ rel = slog_tl_find_rel(slot, relid, relnumber);
+ if (rel != NULL)
+ {
+ 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);
+ }
+}
+
+/*
+ * 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
+SLogTupleLockForEachMine(SLogTupleLockCallback cb, void *arg)
+{
+ SLogTupleLockSlot *slot;
+ SLogTupleLockRel *rels;
+
+ if (!slog_tl_used)
+ return;
+ slot = slog_tl_slot(slog_tl_dir, MyProcNumber);
+ for (int r = 0; r < slot->ninline; r++)
+ {
+ SLogTupleLockInline *in = &slot->inl[r];
+ ItemPointerData tid;
+
+ 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);
+ }
+
+ rels = slog_tl_rels(slot);
+ for (int i = 0; i < slot->nrels; i++)
+ {
+ SLogTupleLockEntry *tab = slog_tl_table(&rels[i]);
+
+ for (uint64 k = 0; k < rels[i].size; k++)
+ {
+ 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);
+ }
+ }
+}
+
+/*
+ * 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
+SLogTupleLockAtPrepare(FullTransactionId fxid, bool any_live)
+{
+ SLogTupleLockShared *dir;
+ SLogTupleLockSlot *src;
+ SLogTupleLockSlot *dst;
+ int dstno;
+ uint64 dstbit;
+
+ if (!slog_tl_used)
+ return;
+ if (!any_live)
+ {
+ SLogTupleLockReleaseAll();
+ return;
+ }
+
+ dir = slog_tl_dir;
+ src = slog_tl_slot(dir, MyProcNumber);
+ dstno = TwoPhaseGetDummyProcNumber(fxid, false);
+ dst = slog_tl_slot(dir, dstno);
+ Assert(dstno > MyProcNumber);
+
+ /* 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);
+
+ /*
+ * 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.
+ */
+ 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;
+}
+
+/*
+ * 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
+SLogTupleLockRestore(FullTransactionId fxid, Oid relid, RelFileNumber relnumber,
+ ItemPointer tid, LockTupleMode mode)
+{
+ 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)
+ {
+ if (SLOG_TL_MODE(slot->inl[r].lev[0]) < mode)
+ slog_tl_write_inline(slot, r, relid, relnumber, tid, 1, &val);
+ return;
+ }
+
+ 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);
+}
+
+/*
+ * 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
+SLogTupleLockReleasePrepared(FullTransactionId fxid)
+{
+ int slotno = TwoPhaseGetDummyProcNumber(fxid, true);
+ SLogTupleLockShared *dir = slog_tl_directory();
+
+ 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
new file mode 100644
index 0000000000000..e28cb767169b0
--- /dev/null
+++ b/src/backend/access/undo/undo.c
@@ -0,0 +1,355 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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/atm.h"
+#include "access/logical_revert_worker.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;
+
+/*
+ * 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);
+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());
+ size = add_size(size, LogicalRevertShmemSize());
+ size = add_size(size, ATMShmemSize());
+
+ /* Per-backend UNDO engine. */
+ size = add_size(size, PbuUndoLogShmemSize());
+ size = add_size(size, PendingUndoShmemSize());
+ size = add_size(size, UndoLauncherShmemSize());
+
+ 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();
+
+ /*
+ * 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;
+ }
+ }
+
+ /*
+ * 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();
+}
+
+/*
+ * 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();
+ LogicalRevertShmemInit();
+ ATMShmemInit();
+
+ /*
+ * 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.
+ */
+ 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).
+ *
+ * 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
+ * generic UNDO sub-modules listed above.
+ */
+static void
+UndoShmemAttach_internal(void *arg)
+{
+ UndoLogShmemInit();
+ XactUndoShmemInit();
+ UndoWorkerShmemInit();
+ LogicalRevertShmemInit();
+
+ /* Per-backend UNDO engine shmem (see comment above about self-attach). */
+ PbuUndoLogShmemInit();
+ PendingUndoShmemInit();
+ UndoLauncherShmemInit();
+
+ 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();
+
+ /*
+ * 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);
+}
+
+/*
+ * 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..36ff401584b53
--- /dev/null
+++ b/src/backend/access/undo/undo_xlog.c
@@ -0,0 +1,1116 @@
+/*-------------------------------------------------------------------------
+ *
+ * undo_xlog.c
+ * UNDO resource manager WAL redo routines
+ *
+ * This module implements the WAL redo callback for the RM_UNDO_ID resource
+ * 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_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 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
+ *
+ * IDENTIFICATION
+ * src/backend/access/undo/undo_xlog.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#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"
+#include "access/transam.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 "access/nbtree.h"
+#include "access/nbtxlog.h"
+#include "miscadmin.h"
+#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; see
+ * the file header for what each one does.
+ */
+void
+undo_redo(XLogReaderState *record)
+{
+ uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK;
+
+ switch (info)
+ {
+
+
+
+ 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_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, xlrec->dboid,
+ 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_INFLIGHT:
+ {
+ xl_undo_inflight *xlrec = (xl_undo_inflight *) XLogRecGetData(record);
+
+ 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);
+ }
+}
+
+/* ----------------------------------------------------------------
+ * 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 */
+ 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;
+
+/* 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
+
+/*
+ * 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
+ *
+ * Called from the XLOG_UNDO_BATCH redo handler to track which
+ * transactions have UNDO data that may need rollback.
+ */
+void
+UndoRecoveryTrackBatch(TransactionId xid, Oid dboid, XLogRecPtr batch_lsn,
+ XLogRecPtr chain_prev,
+ UndoPersistenceLevel persistence)
+{
+ UndoRecoveryEntry *entry;
+
+ if (!TransactionIdIsValid(xid))
+ return;
+
+ 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)
+ {
+ 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;
+
+ entry = undo_recovery_entry(x->xid, x->dboid);
+ if (entry == NULL)
+ return;
+
+ for (int i = 0; i < NUndoPersistenceLevels; i++)
+ {
+ 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];
+ }
+}
+
+/*
+ * 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;
+
+ for (int i = 0; i < undo_recovery_nentries; i++)
+ {
+ UndoRecoveryEntry *e = &undo_recovery_entries[i];
+
+ 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 (XLogRecPtrIsValid(lsn) &&
+ (!XLogRecPtrIsValid(oldest) || lsn < oldest))
+ oldest = lsn;
+ }
+ }
+ UndoSetRecoveryBatchLSN(oldest);
+}
+
+/*
+ * 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. 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)
+{
+ 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;
+ XLogRecPtr first_batch_lsn; /* oldest batch: WAL to retain */
+ bool fileops_owned; /* startup completed the FILEOPS inverses */
+ 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.
+ *
+ * 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.
+ */
+bool
+PerformUndoRecovery(void)
+{
+ int i,
+ j;
+ int total_xacts = 0;
+ 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++)
+ {
+ 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;
+ bool fileops_owned = true;
+ XLogRecPtr perm_first = InvalidXLogRecPtr;
+
+ 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;
+
+ if (XLogRecPtrIsValid(batch_lsn))
+ {
+ UndoChainWalkResult res;
+
+ /*
+ * 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;
+ 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. 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.complete || res.skipped > 0 || res.failed > 0)
+ any_skipped = true;
+
+ /*
+ * 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)
+ {
+ 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;
+ }
+ }
+ }
+
+ /*
+ * If any UNDO records were skipped (e.g., due to syscache not being
+ * initialized during early recovery), track this transaction for
+ * 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.
+ */
+ if (any_skipped)
+ {
+ XLogRecPtr perm_lsn = entry->last_batch_lsn[UNDOPERSISTENCE_PERMANENT];
+
+ if (XLogRecPtrIsValid(perm_lsn))
+ {
+ 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 = 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, fileops_owned);
+
+ 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;
+
+ return fileops_applied;
+}
+
+/*
+ * FlushDeferredUndoXacts - WAL-log the transactions deferred to the ATM
+ *
+ * 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, 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.
+ *
+ * This must be called after InRedo is set to false because ATMAddAborted()
+ * writes WAL, which is not allowed during recovery.
+ */
+void
+FlushDeferredUndoXacts(void)
+{
+ DeferredUndoXact *deferred;
+ int count = 0;
+
+ if (deferred_undo_xacts == NULL)
+ return;
+
+ 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, deferred->fileops_owned);
+ count++;
+
+ pfree(deferred);
+ }
+
+ if (count > 0)
+ ereport(LOG,
+ (errmsg("added %d deferred transaction(s) to ATM for async UNDO processing",
+ 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. 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.
+ * 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, bool *malformed)
+{
+ XLogRecord *record_hdr;
+ char *errormsg = NULL;
+ UndoBatchData *result;
+ xl_undo_batch *xlrec;
+ char *record_data;
+ 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
+ * 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)));
+ *malformed = true;
+ 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)));
+ *malformed = true;
+ 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)));
+ *malformed = true;
+ 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;
+ }
+}
+
+/*
+ * 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), and
+ * FLUX pages have an 8-byte one.
+ */
+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);
+#ifdef USE_FLUX
+ else if (special == MAXALIGN(sizeof(FluxPageOpaqueData)))
+ flux_mask(pagedata, blkno);
+#endif
+ 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
new file mode 100644
index 0000000000000..c4991861d6fa9
--- /dev/null
+++ b/src/backend/access/undo/undoapply.c
@@ -0,0 +1,426 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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 "storage/fileops.h"
+#include "utils/injection_point.h"
+#include "utils/memutils.h"
+
+/* See undormgr.h */
+bool UndoApplyInline = false;
+
+/*
+ * ApplyOneUndoRecord - Apply a single UNDO record via RM dispatch
+ *
+ * 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 UndoApplyResult
+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, 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. 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.
+ */
+
+ /*
+ * 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 UNDO_APPLY_ERROR;
+ }
+
+ /*
+ * 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)));
+ else if (result == UNDO_APPLY_SKIPPED)
+ ereport(DEBUG2,
+ (errmsg("UNDO rollback: skipped %s record at %llu",
+ rmgr->rm_name,
+ (unsigned long long) urec_ptr)));
+ else
+ ereport(WARNING,
+ (errmsg("UNDO rollback: error applying %s record at %llu",
+ rmgr->rm_name,
+ (unsigned long long) urec_ptr)));
+
+ return result;
+}
+
+/*
+ * 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).
+ *
+ * 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)
+{
+ 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)",
+ 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, &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;
+ }
+
+ res->batches++;
+ res->oldest_batch = batch_lsn;
+
+ /*
+ * 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))));
+ res->complete = false;
+ res->malformed = true;
+ res->unread_batch = 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))));
+ res->complete = false;
+ res->malformed = true;
+ res->unread_batch = 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;
+ UndoApplyResult result;
+ char *payload = NULL;
+
+ memcpy(&header, record_starts[i], SizeOfUndoRecordHeader);
+
+ if (header.urec_payload_len > 0)
+ payload = record_starts[i] + SizeOfUndoRecordHeader;
+
+ 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++;
+ break;
+ case UNDO_APPLY_SKIPPED:
+ res->skipped++;
+ break;
+ case UNDO_APPLY_ERROR:
+ res->failed++;
+ break;
+ }
+ }
+
+ MemoryContextSwitchTo(oldctx);
+ MemoryContextDelete(batchctx);
+ }
+
+ INJECTION_POINT("undo-apply-after-batch", NULL);
+
+ /*
+ * 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;
+
+ 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;
+ }
+ }
+
+ 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
new file mode 100644
index 0000000000000..d15f9dbe2883d
--- /dev/null
+++ b/src/backend/access/undo/undoinsert.c
@@ -0,0 +1,168 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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 "miscadmin.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.dboid = MyDatabaseId;
+ 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..da4b2ee6dbfde
--- /dev/null
+++ b/src/backend/access/undo/undolog.c
@@ -0,0 +1,475 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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/atm.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);
+ pg_atomic_init_u64(&UndoLogShared->recovery_undo_lsn,
+ 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);
+}
+
+/*
+ * 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.
+ *
+ * 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;
+ XLogRecPtr atm_oldest;
+ 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;
+ }
+ }
+
+ /* 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. 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();
+
+ if (XLogRecPtrIsValid(prep_oldest) &&
+ (!XLogRecPtrIsValid(oldest) || prep_oldest < oldest))
+ oldest = prep_oldest;
+ }
+
+ return oldest;
+}
+
+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..2c4cb9c5a0377
--- /dev/null
+++ b/src/backend/access/undo/undostats.c
@@ -0,0 +1,119 @@
+/*-------------------------------------------------------------------------
+ *
+ * undostats.c
+ * UNDO log statistics collection and reporting
+ *
+ * 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
+ *
+ * IDENTIFICATION
+ * src/backend/access/undo/undostats.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/slog.h"
+#include "access/twophase.h"
+#include "access/undolog.h"
+#include "access/undostats.h"
+#include "access/xlog.h"
+#include "access/xlogrecovery.h"
+#include "funcapi.h"
+#include "miscadmin.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_retention);
+
+#define UNDO_RETENTION_COLS 6
+
+static void
+undo_retention_row(ReturnSetInfo *rsinfo, XLogRecPtr cur, const char *holder,
+ int pid, TransactionId xid, Oid dboid, XLogRecPtr lsn)
+{
+ 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_stat_get_undo_retention - What is keeping UNDO WAL from being recycled
+ *
+ * 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
+ *
+ * The minimum oldest_lsn over all rows is what UndoGetOldestBatchLSN() feeds
+ * into KeepLogSeg().
+ */
+Datum
+pg_stat_get_undo_retention(PG_FUNCTION_ARGS)
+{
+ ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+ XLogRecPtr cur;
+ XLogRecPtr lsn;
+ SLogTxnEntry *entries;
+ int n;
+
+ InitMaterializedSRF(fcinfo, 0);
+
+ if (UndoLogShared == NULL)
+ return (Datum) 0;
+
+ cur = RecoveryInProgress() ? GetXLogReplayRecPtr(NULL) :
+ GetXLogInsertRecPtr();
+
+ for (int i = 0; i < MaxBackends; i++)
+ {
+ PGPROC *proc;
+
+ lsn = (XLogRecPtr)
+ pg_atomic_read_u64(&UndoLogShared->backend_undo_lsns[i]);
+ if (!XLogRecPtrIsValid(lsn))
+ continue;
+ proc = GetPGProcByNumber(i);
+ undo_retention_row(rsinfo, cur, "backend", proc->pid, proc->xid,
+ proc->databaseId, lsn);
+ }
+
+ n = SLogTxnSnapshotForCheckpoint(&entries);
+ for (int i = 0; i < n; i++)
+ {
+ if (entries[i].revert_complete)
+ continue;
+ 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);
+ }
+
+ lsn = TwoPhaseGetOldestUndoBatchLSN();
+ if (XLogRecPtrIsValid(lsn))
+ undo_retention_row(rsinfo, cur, "prepared", 0, InvalidTransactionId,
+ InvalidOid, lsn);
+
+ return (Datum) 0;
+}
diff --git a/src/backend/access/undo/undoworker.c b/src/backend/access/undo/undoworker.c
new file mode 100644
index 0000000000000..efa6b7d615b5a
--- /dev/null
+++ b/src/backend/access/undo/undoworker.c
@@ -0,0 +1,507 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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
+ *
+ * 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;
+ TimestampTz current_time;
+ int i;
+
+ /* 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)));
+ }
+ }
+
+ /*
+ * 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();
+
+ /* Also clean up a failed transaction already in TRANS_ABORT. */
+ AbortOutOfAnyTransaction();
+
+ /* 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..e2309c1be7cea
--- /dev/null
+++ b/src/backend/access/undo/xactundo.c
@@ -0,0 +1,1781 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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/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/fileops.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
+ * 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];
+
+ /* 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 */
+
+ /* 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];
+
+ /*
+ * 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
+ * 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;
+
+/*
+ * 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
+ * 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?
+ *
+ * One XactUndoInflightSlot per backend.
+ */
+Size
+XactUndoShmemSize(void)
+{
+ return mul_size(MaxBackends, sizeof(XactUndoInflightSlot));
+}
+
+/*
+ * XactUndoShmemInit
+ * Initialize shared memory for transaction undo state.
+ */
+void
+XactUndoShmemInit(void)
+{
+ 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);
+}
+
+/*
+ * 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. 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;
+
+ /* 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];
+ cur->chain_bytes[i] = XactUndo.chain_bytes[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. 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;
+
+ /* 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];
+ cur->chain_bytes[i] = XactUndo.chain_bytes[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;
+ 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
+ * 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;
+ 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)
+ 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;
+ }
+
+ if (MyProc != NULL)
+ MyProc->delayChkptFlags = saved_delay;
+}
+
+/*
+ * 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];
+}
+
+/*
+ * 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.
+ *
+ * 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() 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
+ * 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.
+ *
+ * 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.
+ */
+void
+AtCommit_XactUndo(void)
+{
+ int i;
+
+ /*
+ * 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.)
+ */
+ Assert(!XactUndoHasPendingData());
+
+ 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 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
+ * 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.
+ *
+ * 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)
+{
+ 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: 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 -
+ * 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))
+ {
+ /*
+ * 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
+ * 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);
+
+ if (XactUndo.needs_inline_abort ||
+ (undo_instant_abort_threshold > 0 &&
+ total_undo_bytes < (Size) undo_instant_abort_threshold))
+ {
+ /*
+ * Small transaction: apply UNDO inline. Use a dedicated
+ * AllocSetContext to avoid BumpContext pfree issues.
+ */
+ MemoryContext undo_ctx;
+ MemoryContext old_ctx;
+ int saved_trans_state;
+ uint32 saved_holdoff;
+
+ undo_ctx = AllocSetContextCreate(TopMemoryContext,
+ "Inline UNDO Apply",
+ ALLOCSET_DEFAULT_SIZES);
+ old_ctx = MemoryContextSwitchTo(undo_ctx);
+
+ {
+ volatile bool undo_applied = false;
+
+ /*
+ * 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, deferring to ATM",
+ 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.
+ *
+ * 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();
+ saved_trans_state = EnterInlineUndoApplyState();
+ UndoApplyInline = true;
+ 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();
+ UndoApplyInline = false;
+ 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), and the
+ * worker leaves index entries to VACUUM.
+ */
+ elog(DEBUG1, "inline UNDO failed for xid %u, "
+ "deferring to ATM",
+ GetCurrentTransactionId());
+
+ if (ATMAddAborted(GetCurrentTransactionId(),
+ MyDatabaseId, perm_lsn,
+ 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());
+
+
+
+
+ }
+ 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], true))
+ lsn_safely_held = true;
+ else
+ elog(WARNING,
+ "ATM full: could not record aborted transaction %u",
+ GetCurrentTransactionId());
+ }
+ }
+ }
+
+ INJECTION_POINT("undo-xact-abort-after-atm", NULL);
+
+ /* Free all per-persistence-level record sets. */
+ for (i = 0; i < NUndoPersistenceLevels; i++)
+ {
+ if (XactUndo.record_set[i] != NULL)
+ {
+ UndoRecordSetFree(XactUndo.record_set[i]);
+ XactUndo.record_set[i] = NULL;
+ }
+ }
+
+ /*
+ * Release the WAL retention hold if 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, 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.
+ */
+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, 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];
+ volatile bool applied = false;
+
+ /*
+ * 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;
+ uint32 saved_holdoff;
+
+ 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();
+ UndoApplyInline = true;
+ saved_holdoff = InterruptHoldoffCount;
+ PG_TRY();
+ {
+ 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, and hold interrupts again as the
+ * subtransaction abort did (see AtAbort_XactUndo).
+ */
+ InterruptHoldoffCount = saved_holdoff;
+ BufferLockReleaseAll();
+ LWLockReleaseAll();
+ XLogResetInsertion();
+ ConditionVariableCancelSleep();
+ pgaio_error_cleanup();
+ FlushErrorState();
+ applied = false;
+ }
+ PG_END_TRY();
+ UndoApplyInline = false;
+ LeaveInlineUndoApplyState(saved_trans_state);
+
+ MemoryContextSwitchTo(old_ctx);
+ MemoryContextDelete(undo_ctx);
+ }
+ else
+ elog(DEBUG1, "subxact UNDO: head %X/%X is not a valid UNDO "
+ "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, true))
+ 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());
+ }
+
+ /*
+ * Reset the last_location to what it was before this subtransaction,
+ * so that if the parent transaction continues and then aborts, only
+ * the parent's records are applied (the subtransaction's records have
+ * already been applied).
+ */
+ if (UndoRecPtrIsValid(parent->start_location[i]))
+ XactUndo.last_location[i] = parent->start_location[i];
+ }
+
+ XactUndo.subxact_depth--;
+}
+
+/*
+ * AtProcExit_XactUndo
+ * Process exit cleanup for transaction undo.
+ */
+void
+AtProcExit_XactUndo(void)
+{
+ int i;
+
+ /* Free any lingering record sets. */
+ for (i = 0; i < NUndoPersistenceLevels; i++)
+ {
+ if (XactUndo.record_set[i] != NULL)
+ {
+ UndoRecordSetFree(XactUndo.record_set[i]);
+ XactUndo.record_set[i] = NULL;
+ }
+ }
+
+ /* Release any WAL retention hold (in case process exits mid-transaction). */
+ UndoClearBatchLSN();
+
+ ResetXactUndo();
+}
+
+/*
+ * XactUndo_SubXactCallback
+ * Subtransaction callback to manage UNDO subtransaction state.
+ *
+ * This ensures the UNDO subsystem properly tracks all subtransactions,
+ * including those created by ROLLBACK TO SAVEPOINT.
+ *
+ * The subtransaction state is stored in a dynamically-grown array in
+ * TopMemoryContext. In the common case (depth < capacity), no allocation
+ * occurs. Growth via repalloc is safe here because it operates on a
+ * TopMemoryContext allocation, not a per-subtransaction context.
+ */
+static void
+XactUndo_SubXactCallback(SubXactEvent event, SubTransactionId mySubid,
+ SubTransactionId parentSubid, void *arg)
+{
+ int i;
+
+ /* These parameters are mandated by the callback signature. */
+ (void) parentSubid;
+ (void) arg;
+
+ switch (event)
+ {
+ case SUBXACT_EVENT_START_SUB:
+
+ /*
+ * Write out any deferred records before this subtransaction
+ * begins.
+ *
+ * A pending batch must never span a subtransaction boundary. The
+ * records in it belong to the OUTER level, but the flush that
+ * will eventually write them stamps them all with one batch LSN;
+ * if that flush happened after this point, the outer level's
+ * records would land above the boundary we are about to save and
+ * a later ROLLBACK TO SAVEPOINT would revert them along with this
+ * subtransaction's own -- discarding work that was never rolled
+ * back. (Concretely: savepoint s1, insert, release s1, savepoint
+ * s2, insert, rollback to s2 would also undo the s1 insert.)
+ *
+ * This must run BEFORE the boundary is saved below, so the
+ * flushed batch sits strictly below it.
+ */
+ XactUndoFlushPending();
+
+ /*
+ * A new subtransaction is starting. Push an entry onto the
+ * dynamically-grown stack, extending it if needed.
+ */
+ XactUndo.subxact_depth++;
+ EnsureSubxactStackCapacity();
+ {
+ XactUndoSubTransactionState *s = CURRENT_SUBXACT();
+
+ s->nestingLevel = mySubid;
+ for (i = 0; i < NUndoPersistenceLevels; ++i)
+ {
+ s->start_location[i] = InvalidUndoRecPtr;
+ /* Save parent's last_batch_lsn for restore on abort */
+ s->last_batch_lsn[i] = XactUndo.last_batch_lsn[i];
+ s->chain_bytes[i] = XactUndo.chain_bytes[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:
+ /* AbortSubTransaction applies undo before logging the abort. */
+ 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.needs_inline_abort = 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;
+ XactUndo.subxact_stack[0].chain_bytes[i] = 0;
+ }
+ }
+
+ for (i = 0; i < NUndoPersistenceLevels; i++)
+ {
+ 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);
+}
+
+/*
+ * 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/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/commands/dbcommands.c b/src/backend/commands/dbcommands.c
index 7e3fc59eafdda..ca16ce8bd73e3 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"
@@ -219,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);
@@ -472,56 +475,95 @@ CreateDirAndVersionFile(char *dbpath, Oid dbid, Oid tsid, bool isRedo)
sprintf(buf, "%s\n", PG_MAJORVERSION);
nbytes = strlen(PG_MAJORVERSION) + 1;
- /* Create database directory. */
- if (MakePGDirectory(dbpath) < 0)
+ /*
+ * Create database directory. The copy fills it through smgr, so an abort
+ * or crash before commit must remove the whole tree.
+ */
+ 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)));
+ FileOpsMkdirTree(dbpath, pg_dir_create_mode, dbid);
+ }
+ 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 +675,13 @@ 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);
+
+ INJECTION_POINT("createdb-file-copy-after-copydir", NULL);
/* Record the filesystem change in XLOG */
{
@@ -2221,8 +2267,17 @@ 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 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",
@@ -2245,9 +2300,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 +3466,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/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/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 93ad49e59fdcd..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);
@@ -960,6 +961,7 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
break;
default:
(void) heap_reloptions(relkind, reloptions, true);
+ break;
}
if (stmt->ofTypename)
@@ -1074,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,
@@ -17160,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
*
@@ -17187,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;
@@ -17397,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/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/commands/trigger.c b/src/backend/commands/trigger.c
index 3f0fb68eaeea4..f84bd385724b9 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,
@@ -3728,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;
@@ -3774,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
{
@@ -3783,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 ? \
@@ -3792,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
@@ -3805,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;
@@ -3934,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 */
};
@@ -4111,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)
{
@@ -4181,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)
@@ -4231,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));
+ }
}
/* ----------
@@ -4269,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 */
@@ -4435,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
@@ -4619,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);
@@ -4692,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);
+ }
}
}
@@ -4811,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);
+ }
}
/*
@@ -4858,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;
@@ -5270,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;
@@ -5716,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;
@@ -6222,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
@@ -6400,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)
{
@@ -6596,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/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/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/backend/executor/execReplication.c b/src/backend/executor/execReplication.c
index 18da4fe08f633..b18e3ab9c0433 100644
--- a/src/backend/executor/execReplication.c
+++ b/src/backend/executor/execReplication.c
@@ -27,10 +27,13 @@
#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 "storage/procarray.h"
#include "utils/builtins.h"
+#include "utils/datum.h"
#include "utils/lsyscache.h"
#include "utils/rel.h"
#include "utils/snapmgr.h"
@@ -478,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,
@@ -486,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);
+
+ /*
+ * 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_UNLOCK);
+ LockBuffer(buf, BUFFER_LOCK_UNLOCK);
- if (!recently_dead)
- return;
+ if (!recently_dead)
+ return;
- xmax = HeapTupleHeaderGetUpdateXid(tuple->t_data);
- if (!TransactionIdIsValid(xmax))
- 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) &&
@@ -623,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);
@@ -688,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);
@@ -783,6 +818,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 +1050,49 @@ 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.
+ */
+ if (RelationUpdatesInPlace(rel))
+ ExecMaterializeSlot(searchslot);
+
simple_table_tuple_update(rel, tid, slot, estate->es_snapshot,
&update_indexes);
@@ -1000,7 +1124,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 558ceece9bb87..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);
/*
@@ -1755,7 +1756,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,
@@ -2356,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;
@@ -2376,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 :
@@ -2465,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);
}
/* ----------------------------------------------------------------
@@ -2583,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);
@@ -2660,7 +2682,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,
@@ -2747,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)
@@ -3434,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);
@@ -3458,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/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c
index 22e0e8b11f8d5..c11579d92f125 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/backend/postmaster/bgworker.c b/src/backend/postmaster/bgworker.c
index 3da2a90417fc6..785a29e8dca60 100644
--- a/src/backend/postmaster/bgworker.c
+++ b/src/backend/postmaster/bgworker.c
@@ -13,6 +13,10 @@
#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"
#include "commands/repack.h"
#include "libpq/pqsignal.h"
#include "miscadmin.h"
@@ -166,6 +170,31 @@ 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
+ },
+ /* 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/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/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/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/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..109c7c116dc23
--- /dev/null
+++ b/src/backend/storage/file/README
@@ -0,0 +1,199 @@
+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
+ 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
+ FileOpsSymlink -- symlink() now; UNDO unlink on abort
+ FileOpsLink -- link() now; UNDO unlink on abort
+ 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,
+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
+
+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.
+
+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
+ 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).
+ at_commit = true removes on commit (dropping state the transaction is
+ logically deleting); at_commit = false removes only on abort.
+
+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
+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/copydir.c b/src/backend/storage/file/copydir.c
index ee42c796f773a..4eb46d3498a41 100644
--- a/src/backend/storage/file/copydir.c
+++ b/src/backend/storage/file/copydir.c
@@ -24,11 +24,14 @@
#include
#include
+#include "access/xlog.h"
+#include "common/file_perm.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,16 +47,24 @@ 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 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)
+copydir(const char *fromdir, const char *todir, bool recurse,
+ bool register_for_abort_cleanup)
{
DIR *xldir;
struct dirent *xlde;
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)));
@@ -78,9 +89,13 @@ 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: removing the top-level destination
+ * tree removes them too.
+ */
if (recurse)
- copydir(fromfile, tofile, true);
+ copydir(fromfile, tofile, true, false);
}
else if (xlde_type == PGFILETYPE_REG)
{
diff --git a/src/backend/storage/file/fileops.c b/src/backend/storage/file/fileops.c
new file mode 100644
index 0000000000000..b5972c44a5985
--- /dev/null
+++ b/src/backend/storage/file/fileops.c
@@ -0,0 +1,2459 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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.
+ *
+ * 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
+ * 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 "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);
+
+/*
+ * 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);
+ }
+}
+
+/*
+ * 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
+ *
+ * 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);
+}
+
+/* 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)
+{
+ 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)
+ 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; failing to create the file is an ERROR.
+ */
+int
+FileOpsCreate(const char *path, int flags, mode_t mode, bool register_delete)
+{
+ int fd;
+ int pathlen = fileops_pathlen(path);
+
+ Assert(!IsInParallelMode());
+
+ 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;
+
+ 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);
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsCreate);
+ XLogRegisterData(path, pathlen);
+ fileops_log(XLOG_FILEOPS_CREATE, &undo_ctx);
+
+ INJECTION_POINT("fileops-create-after-wal-flush", NULL);
+ }
+
+ 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;
+}
+
+/* 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.
+ *
+ * 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;
+ 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());
+
+ 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)));
+
+ 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);
+
+ /* 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,
+ (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 (CloseTransientFile(fd) != 0)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not close file \"%s\": %m", path)));
+
+ if (XLogIsNeeded())
+ {
+ xl_fileops_write xlrec;
+
+ 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;
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsWrite);
+ XLogRegisterData(path, pathlen);
+ XLogRegisterData(data, len);
+ fileops_log(XLOG_FILEOPS_WRITE, &undo_ctx);
+ }
+
+ pfree(before);
+ pfree(payload);
+ 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. The
+ * original size is restored if the transaction aborts.
+ */
+void
+FileOpsTruncate(const char *path, off_t length)
+{
+ int fd;
+ struct stat st;
+ off_t orig_length = 0;
+ int pathlen = fileops_pathlen(path);
+ char *before;
+ uint32 before_len;
+
+ Assert(!IsInParallelMode());
+
+ /* Capture original length for UNDO before truncation */
+ if (stat(path, &st) == 0)
+ orig_length = st.st_size;
+
+ 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;
+
+ 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)));
+ }
+
+ /*
+ * 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;
+
+ 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 (CloseTransientFile(fd) != 0)
+ ereport(WARNING,
+ (errcode_for_file_access(),
+ errmsg("could not close file \"%s\": %m", path)));
+
+ if (XLogIsNeeded())
+ {
+ xl_fileops_truncate xlrec;
+ XactUndoContext undo_ctx;
+ char *payload = palloc0(sizeof(FileopsUndoTruncate) + pathlen + before_len);
+ FileopsUndoTruncate *hdr = (FileopsUndoTruncate *) payload;
+
+ xlrec.length = 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);
+ fileops_log(XLOG_FILEOPS_TRUNCATE, &undo_ctx);
+ pfree(payload);
+ }
+ pfree(before);
+}
+
+/*
+ * 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 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;
+ int pathlen = fileops_pathlen(path);
+
+ Assert(!IsInParallelMode());
+
+ /* 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;
+
+ 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 (XLogIsNeeded())
+ {
+ xl_fileops_chmod xlrec;
+ XactUndoContext undo_ctx;
+ char payload[sizeof(FileopsUndoChmod) + MAXPGPATH];
+ FileopsUndoChmod *hdr = (FileopsUndoChmod *) payload;
+
+ xlrec.mode = mode;
+ xlrec.orig_mode = orig_mode;
+
+ hdr->orig_mode = orig_mode;
+ hdr->new_mode = mode & 07777;
+ 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);
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsChmod);
+ XLogRegisterData(path, pathlen);
+ fileops_log(XLOG_FILEOPS_CHMOD, &undo_ctx);
+
+ INJECTION_POINT("fileops-chmod-after-wal-flush", NULL);
+ }
+
+ 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; a failed chown() is an ERROR.
+ */
+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;
+ int pathlen = fileops_pathlen(path);
+
+ Assert(!IsInParallelMode());
+
+ /* Capture original ownership for UNDO before chown */
+ if (stat(path, &st) == 0)
+ {
+ orig_uid = st.st_uid;
+ orig_gid = st.st_gid;
+ }
+
+#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;
+ XactUndoContext undo_ctx;
+ char payload[sizeof(FileopsUndoChown) + MAXPGPATH];
+ FileopsUndoChown *hdr = (FileopsUndoChown *) payload;
+
+ xlrec.uid = uid;
+ xlrec.gid = gid;
+ xlrec.orig_uid = orig_uid;
+ xlrec.orig_gid = orig_gid;
+
+ 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);
+ fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT,
+ UNDO_RMID_FILEOPS, FILEOPS_UNDO_CHOWN,
+ InvalidOid, payload,
+ sizeof(FileopsUndoChown) + pathlen);
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsChown);
+ XLogRegisterData(path, pathlen);
+ fileops_log(XLOG_FILEOPS_CHOWN, &undo_ctx);
+ }
+
+ return 0;
+}
+
+void
+FileOpsSync(const char *path)
+{
+ fsync_fname(path, false);
+}
+
+/*
+ * fileops_mkdir - Create a directory within a transaction
+ *
+ * Shared by FileOpsMkdir() and FileOpsMkdirTree(); see there.
+ */
+static int
+fileops_mkdir(const char *path, mode_t mode, uint16 flags, Oid dboid)
+{
+ int pathlen = fileops_pathlen(path);
+
+ Assert(!IsInParallelMode());
+
+ 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;
+ XactUndoContext undo_ctx;
+ char payload[sizeof(FileopsUndoMkdir) + MAXPGPATH];
+ FileopsUndoMkdir *hdr = (FileopsUndoMkdir *) payload;
+ struct stat st;
+
+ xlrec.mode = mode;
+ 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);
+ fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT,
+ UNDO_RMID_FILEOPS, FILEOPS_UNDO_MKDIR,
+ InvalidOid, payload,
+ sizeof(FileopsUndoMkdir) + pathlen);
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsMkdir);
+ XLogRegisterData(path, pathlen);
+ fileops_log(XLOG_FILEOPS_MKDIR, &undo_ctx);
+ }
+
+ return 0;
+}
+
+/*
+ * 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);
+}
+
+/*
+ * 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);
+}
+
+/*
+ * 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);
+}
+
+/*
+ * 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; a failed symlink() is an ERROR.
+ */
+int
+FileOpsSymlink(const char *target, const char *linkpath)
+{
+ int targetlen = fileops_pathlen(target);
+ int linkpathlen = fileops_pathlen(linkpath);
+
+ Assert(!IsInParallelMode());
+
+ 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;
+ XactUndoContext undo_ctx;
+ char payload[sizeof(FileopsUndoSymlink) + 2 * MAXPGPATH];
+ FileopsUndoSymlink *hdr = (FileopsUndoSymlink *) payload;
+
+ 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);
+ 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);
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsSymlink);
+ XLogRegisterData(target, targetlen);
+ XLogRegisterData(linkpath, linkpathlen);
+ fileops_log(XLOG_FILEOPS_SYMLINK, &undo_ctx);
+ }
+
+ 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; a failed link() is an ERROR.
+ */
+int
+FileOpsLink(const char *oldpath, const char *newpath)
+{
+ int oldpathlen = fileops_pathlen(oldpath);
+ int newpathlen = fileops_pathlen(newpath);
+ struct stat st;
+
+ Assert(!IsInParallelMode());
+
+ /*
+ * The new link will be this inode; the UNDO removes newpath only while it
+ * still is.
+ */
+ 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;
+ XactUndoContext undo_ctx;
+ char payload[sizeof(FileopsUndoLink) + MAXPGPATH];
+ FileopsUndoLink *hdr = (FileopsUndoLink *) payload;
+
+ 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);
+ fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT,
+ UNDO_RMID_FILEOPS, FILEOPS_UNDO_LINK,
+ InvalidOid, payload,
+ sizeof(FileopsUndoLink) + newpathlen);
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsLink);
+ XLogRegisterData(oldpath, oldpathlen);
+ XLogRegisterData(newpath, newpathlen);
+ fileops_log(XLOG_FILEOPS_LINK, &undo_ctx);
+ }
+
+ 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. 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,
+ const void *value, size_t len)
+{
+ bool had_value = false;
+ char *orig_value = NULL;
+ ssize_t orig_value_len = 0;
+ int pathlen = fileops_pathlen(path);
+ ssize_t vlen;
+
+ Assert(!IsInParallelMode());
+
+ /*
+ * 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)
+ {
+ 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;
+ }
+
+ if (pg_setxattr(path, name, value, len) < 0)
+ {
+ int save_errno = errno;
+
+ 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)));
+ }
+
+ /*
+ * Register abort-time pending op to restore/remove xattr on rollback. If
+ * had_value: restore original value. If !had_value: remove the xattr.
+ */
+ 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 namelen = (int) strlen(name) + 1;
+ XactUndoContext undo_ctx;
+ Size payload_len;
+ char *payload;
+ FileopsUndoSetxattr *hdr;
+
+ 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) + 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);
+ 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);
+ if (len > 0)
+ memcpy(payload + sizeof(FileopsUndoSetxattr) + pathlen + namelen +
+ hdr->orig_value_len, value, len);
+
+ fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT,
+ UNDO_RMID_FILEOPS, FILEOPS_UNDO_SETXATTR,
+ InvalidOid, payload, payload_len);
+
+ 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);
+ fileops_log(XLOG_FILEOPS_SETXATTR, &undo_ctx);
+ pfree(payload);
+ }
+
+ 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. 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); 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;
+ int pathlen = fileops_pathlen(path);
+ ssize_t vlen;
+
+ Assert(!IsInParallelMode());
+
+ /*
+ * 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)
+ {
+ 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 */
+ }
+
+ if (pg_removexattr(path, name) < 0 && errno != PG_ENOATTR)
+ {
+ int save_errno = errno;
+
+ 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)));
+ }
+
+ /*
+ * 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 (!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 namelen = (int) strlen(name) + 1;
+ XactUndoContext undo_ctx;
+ char *payload = NULL;
+ bool had_value = (saved_value != NULL);
+
+ 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 (had_value)
+ {
+ Size payload_len;
+ FileopsUndoRemovexattr *hdr;
+
+ payload_len = sizeof(FileopsUndoRemovexattr) + pathlen + namelen +
+ saved_value_len;
+ 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);
+
+ fileops_prepare_undo(&undo_ctx, RELPERSISTENCE_PERMANENT,
+ UNDO_RMID_FILEOPS, FILEOPS_UNDO_REMOVEXATTR,
+ InvalidOid, payload, payload_len);
+ }
+
+ XLogBeginInsert();
+ XLogRegisterData(&xlrec, SizeOfFileOpsRemovexattr);
+ XLogRegisterData(path, pathlen);
+ XLogRegisterData(name, namelen);
+ if (had_value && saved_value_len > 0)
+ XLogRegisterData(saved_value, (uint32) saved_value_len);
+ fileops_log(XLOG_FILEOPS_REMOVEXATTR, had_value ? &undo_ctx : NULL);
+ if (payload)
+ pfree(payload);
+ }
+
+ 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_INVERSE:
+ break;
+ 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.
+ */
+ 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:
+
+ /*
+ * 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\"",
+ 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) 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.
+ *
+ * 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)
+ {
+ FileOpsTwoPhaseRecord rec;
+ char *buf;
+ uint32 path_len;
+ uint32 newpath_len;
+ uint32 data_len;
+ uint32 total;
+ char *p;
+
+ /* Immediate inverses were serialized as one blob above. */
+ 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)
+{
+ if (info != FILEOPS_TWOPHASE_INVERSES)
+ fileops_twophase_do(true, recdata, len);
+}
+
+/*
+ * fileops_twophase_postabort - ROLLBACK PREPARED callback
+ *
+ * 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)
+{
+ /* Inverses were applied with the durable abort decision, before unlock. */
+ if (info != FILEOPS_TWOPHASE_INVERSES)
+ 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..6d95fbb0dbe07
--- /dev/null
+++ b/src/backend/storage/file/fileops_undo.c
@@ -0,0 +1,812 @@
+/*-------------------------------------------------------------------------
+ *
+ * fileops_undo.c
+ * FILEOPS UNDO resource manager
+ *
+ * This module implements the UNDO apply callbacks for transactional file
+ * 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, if the object still
+ * shows that change (see fileops_undo_apply):
+ * - CREATE: unlink the created file
+ * - 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, 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, 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
+ *
+ * IDENTIFICATION
+ * src/backend/storage/file/fileops_undo.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include
+#include
+
+#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"
+
+/*
+ * UNDO subtype constants and payload structures are defined in fileops.h,
+ * shared between this file (apply side) and fileops.c (insert side).
+ */
+
+static bool applyingAbort = 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,
+ 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_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, 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 && FileOpsSkipUndo)
+ return UNDO_APPLY_SUCCESS;
+
+ 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);
+
+ switch (fileops_undo_lstat("CREATE", path, &st))
+ {
+ 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;
+ }
+ }
+ break;
+
+ case FILEOPS_UNDO_WRITE:
+ {
+ FileopsUndoWrite hdr;
+ const char *path;
+ const char *before;
+ const char *after;
+ char *current;
+ int fd;
+
+ if (payload_len < sizeof(hdr))
+ return UNDO_APPLY_ERROR;
+ 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 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;
+ }
+ }
+ pfree(current);
+ close(fd);
+ }
+ 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);
+
+ switch (fileops_undo_lstat("TRUNCATE", path, &st))
+ {
+ case 0:
+ break;
+ case -1:
+ result = UNDO_APPLY_ERROR;
+ 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;
+
+ 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);
+
+ switch (fileops_undo_lstat("CHMOD", path, &st))
+ {
+ 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;
+
+ 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);
+
+ switch (fileops_undo_lstat("CHOWN", path, &st))
+ {
+ 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
+ }
+ 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);
+
+ switch (fileops_undo_lstat("MKDIR", path, &st))
+ {
+ 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;
+
+ case FILEOPS_UNDO_SYMLINK:
+ {
+ 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;
+
+ 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(),
+ 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);
+
+ switch (fileops_undo_lstat("LINK", newpath, &st))
+ {
+ 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;
+
+ case FILEOPS_UNDO_SETXATTR:
+ {
+ 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;
+
+ switch (fileops_undo_lstat("SETXATTR", path, &st))
+ {
+ 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;
+
+ 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;
+
+ 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;
+
+ default:
+ ereport(WARNING,
+ (errmsg("FILEOPS UNDO: unknown subtype %u", info)));
+ return UNDO_APPLY_ERROR;
+ }
+
+ INJECTION_POINT("fileops-undo-apply-end", NULL);
+
+ /* 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));
+
+ return result;
+}
+
+/*
+ * 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.
+ *
+ * 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;
+
+ 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(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, elevel);
+ 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, elevel);
+ }
+}
+
+/*
+ * 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_TRUNCATE:
+ opname = "TRUNCATE";
+ break;
+ case FILEOPS_UNDO_CHMOD:
+ opname = "CHMOD";
+ break;
+ case FILEOPS_UNDO_CHOWN:
+ opname = "CHOWN";
+ break;
+ case FILEOPS_UNDO_MKDIR:
+ opname = "MKDIR";
+ break;
+ case FILEOPS_UNDO_SYMLINK:
+ opname = "SYMLINK";
+ break;
+ case FILEOPS_UNDO_LINK:
+ opname = "LINK";
+ break;
+ case FILEOPS_UNDO_SETXATTR:
+ opname = "SETXATTR";
+ break;
+ case FILEOPS_UNDO_REMOVEXATTR:
+ opname = "REMOVEXATTR";
+ break;
+ default:
+ opname = "UNKNOWN";
+ break;
+ }
+
+ appendStringInfo(buf, "fileops %s", opname);
+}
diff --git a/src/backend/storage/file/meson.build b/src/backend/storage/file/meson.build
index 795402589b0b9..7e487324c5f3e 100644
--- a/src/backend/storage/file/meson.build
+++ b/src/backend/storage/file/meson.build
@@ -4,6 +4,8 @@ backend_sources += files(
'buffile.c',
'copydir.c',
'fd.c',
+ 'fileops.c',
+ 'fileops_undo.c',
'fileset.c',
'reinit.c',
'sharedfileset.c',
diff --git a/src/backend/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/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..b179c3e38e01a 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,11 @@ 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."
+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/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/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..043ea9fe6c481 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -1097,6 +1097,13 @@
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.',
long_desc => 'The planner will merge subqueries into upper queries if the resulting FROM list would have no more than this many items.',
@@ -1945,6 +1952,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.',
@@ -2038,6 +2055,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',
@@ -2206,7 +2232,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',
},
@@ -2758,6 +2784,15 @@
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 => 'ssl', type => 'bool', context => 'PGC_SIGHUP', group => 'CONN_AUTH_SSL',
short_desc => 'Enables SSL connections.',
variable => 'EnableSSL',
@@ -3295,6 +3330,66 @@
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_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.',
+ flags => 'GUC_UNIT_MB',
+ variable => 'undo_max_wal_retention_size',
+ boot_val => '0',
+ min => '0',
+ 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.',
+ 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..0dc61771478f6 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -30,10 +30,15 @@
#endif
#include "access/commit_ts.h"
+#include "access/flux.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"
+#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..01431970204cf 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
@@ -914,6 +915,19 @@
# (change requires restart)
#recovery_init_sync_method = fsync # fsync, syncfs (Linux 5.8+)
+# - 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)
+#max_logical_revert_workers = 2 # 0 disables the logical revert launcher
+#logical_revert_naptime = 1s # time between ATM scan cycles
+
#------------------------------------------------------------------------------
# CONFIG FILE INCLUDES
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_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_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 931ab8b979e23..d5b6134bb9f87 100644
--- a/src/bin/pg_waldump/rmgrdesc.c
+++ b/src/bin/pg_waldump/rmgrdesc.c
@@ -17,9 +17,13 @@
#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"
#include "access/spgxlog.h"
+#include "access/atm_xlog.h"
+#include "access/undo_xlog.h"
#include "access/xact.h"
#include "access/xlog_internal.h"
#include "catalog/storage_xlog.h"
@@ -29,6 +33,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 7b33efc62999f..7a75e37120406 100644
--- a/src/bin/pg_waldump/t/001_basic.pl
+++ b/src/bin/pg_waldump/t/001_basic.pl
@@ -96,7 +96,13 @@
ReplicationOrigin
Generic
LogicalMessage
-XLOG2$/,
+XLOG2
+Undo
+ATM
+FLUX
+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/amapi.h b/src/include/access/amapi.h
index 7924033353031..e50f2ce056752 100644
--- a/src/include/access/amapi.h
+++ b/src/include/access/amapi.h
@@ -203,6 +203,43 @@ 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; 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).
+ */
+typedef bool (*amundomark_function) (Relation indexRelation,
+ Relation heapRelation,
+ Datum *values,
+ bool *isnull,
+ ItemPointer heap_t_ctid,
+ bool clear_mark, bool probe);
+
/* end index scan */
typedef void (*amendscan_function) (IndexScanDesc scan);
@@ -311,6 +348,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/atm.h b/src/include/access/atm.h
new file mode 100644
index 0000000000000..5fe31e6a9e2ff
--- /dev/null
+++ b/src/include/access/atm.h
@@ -0,0 +1,74 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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 ATMEnsureCreated(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, bool fileops_owned);
+extern bool ATMAddAbortedBounded(TransactionId xid, Oid dboid,
+ XLogRecPtr last_batch_lsn,
+ XLogRecPtr stop_lsn,
+ XLogRecPtr first_batch_lsn, bool fileops_owned);
+extern void ATMAddAbortedNoWAL(TransactionId xid, Oid dboid,
+ XLogRecPtr last_batch_lsn,
+ XLogRecPtr first_batch_lsn, bool fileops_owned);
+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..3bcfca914241b
--- /dev/null
+++ b/src/include/access/atm_xlog.h
@@ -0,0 +1,55 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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) */
+ bool fileops_owned; /* FILEOPS completed or owned by abort WAL */
+} xl_atm_abort;
+
+#define SizeOfXlAtmAbort (offsetof(xl_atm_abort, fileops_owned) + sizeof(bool))
+
+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/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/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 1bfdc1ef6b1eb..51cf8823650fc 100644
--- a/src/include/access/genam.h
+++ b/src/include/access/genam.h
@@ -154,6 +154,17 @@ 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_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,
+ Relation heapRelation,
+ bool clear_mark);
extern IndexScanDesc index_beginscan(Relation heapRelation,
Relation indexRelation,
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/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/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/nbtree.h b/src/include/access/nbtree.h
index 3097e9bb1af9b..a5df997849000 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, bool probe);
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,
@@ -1331,4 +1466,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/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/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/relscan.h b/src/include/access/relscan.h
index d7614104730fb..6834ddfeab7d8 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/rmgrlist.h b/src/include/access/rmgrlist.h
index ae32ef16d67b6..7505883c12da4 100644
--- a/src/include/access/rmgrlist.h
+++ b/src/include/access/rmgrlist.h
@@ -48,3 +48,13 @@ 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, 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
+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/slog.h b/src/include/access/slog.h
new file mode 100644
index 0000000000000..03f4ea6eff4ed
--- /dev/null
+++ b/src/include/access/slog.h
@@ -0,0 +1,188 @@
+/*-------------------------------------------------------------------------
+ *
+ * slog.h
+ * Secondary Log (sLog): shared state of the UNDO subsystem that is not WAL
+ *
+ * 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).
+ *
+ * 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.
+ *
+ * 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"
+
+/*
+ * What an UNDO-based table AM's transaction did to a tuple, as tracked
+ * backend-locally (SLogTupleTrackLocalOnly).
+ */
+typedef enum SLogOpType
+{
+ SLOG_OP_INSERT = 1,
+ SLOG_OP_ABORTED = 6, /* its subtransaction rolled back */
+} 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? */
+ bool fileops_owned; /* FILEOPS completed or owned by abort WAL */
+} SLogTxnEntry;
+
+/*
+ * SLogTupleKey - a tuple: relation, its storage, and TID.
+ */
+typedef struct SLogTupleKey
+{
+ Oid relid;
+ RelFileNumber relnumber;
+ ItemPointerData tid;
+} SLogTupleKey;
+
+/* ----------------------------------------------------------------
+ * 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, bool fileops_owned,
+ bool check_space);
+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);
+
+/* 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 (slog_tuple.c)
+ * ----------------------------------------------------------------
+ */
+
+/* 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 SLogTupleUpdateSubXid(TransactionId xid,
+ TransactionId old_subxid,
+ TransactionId new_subxid);
+extern void SLogTupleForgetStorage(Oid relid, RelFileNumber relnumber);
+extern void SLogTupleResetTracking(void);
+
+/*
+ * 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;
+ SLogOpType op_type;
+} SLogTrackedKeyInfo;
+
+extern int SLogTupleCollectTrackedKeys(TransactionId xid,
+ SLogTrackedKeyInfo **out_keys);
+
+/*
+ * 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_internal.h b/src/include/access/slog_internal.h
new file mode 100644
index 0000000000000..fa62c46ea46cc
--- /dev/null
+++ b/src/include/access/slog_internal.h
@@ -0,0 +1,38 @@
+/*-------------------------------------------------------------------------
+ *
+ * slog_internal.h
+ * Shared-state definitions private to the sLog implementation
+ *
+ * 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
+ *
+ * src/include/access/slog_internal.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef SLOG_INTERNAL_H
+#define SLOG_INTERNAL_H
+
+#include "storage/lwlock.h"
+#include "utils/dsa.h"
+
+/*
+ * 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
+{
+ LWLockPadded txn_lock; /* single LWLock serializing ATM access */
+ 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;
+
+#endif /* SLOG_INTERNAL_H */
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index ea3f2a6be9907..1eccb91351d20 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;
/*
@@ -308,6 +316,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
@@ -324,6 +352,101 @@ 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;
+
+ /*
+ * 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 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.
+ *
+ * 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.
+ *
+ * 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
+ * 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;
+
+ /*
+ * 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;
+
+ /*
+ * 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;
+
+ /*
+ * 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.
@@ -501,6 +624,13 @@ 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
@@ -536,6 +666,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.
@@ -1350,6 +1488,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
@@ -1432,6 +1634,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.
@@ -2147,6 +2368,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
@@ -2162,4 +2391,23 @@ 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
+ * ----------------------------------------------------------------------------
+ */
+
+extern bool RelationUsesIndexUndo(Relation indexrel, Relation heaprel);
+extern UndoEngine RelationUndoEngine(Relation rel);
+extern bool RelationSupportsDeleteMarking(Relation rel);
+
#endif /* TABLEAM_H */
diff --git a/src/include/access/twophase.h b/src/include/access/twophase.h
index 1d2ff42c9b72f..3714ea62b3385 100644
--- a/src/include/access/twophase.h
+++ b/src/include/access/twophase.h
@@ -48,6 +48,7 @@ extern GlobalTransaction MarkAsPreparing(FullTransactionId fxid, const char *gid
extern void StartPrepare(GlobalTransaction gxact);
extern void EndPrepare(GlobalTransaction gxact);
extern bool StandbyTransactionIdIsPrepared(TransactionId xid);
+extern bool RecoveryTransactionIdIsPrepared(TransactionId xid);
extern TransactionId PrescanPreparedTransactions(TransactionId **xids_p,
int *nxids_p);
@@ -72,4 +73,6 @@ extern bool LookupGXactBySubid(Oid subid);
extern TransactionId TwoPhaseGetOldestXidInCommit(void);
+extern XLogRecPtr TwoPhaseGetOldestUndoBatchLSN(void);
+
#endif /* TWOPHASE_H */
diff --git a/src/include/access/twophase_rmgr.h b/src/include/access/twophase_rmgr.h
index 8927f369c39b5..96f03a054e5d0 100644
--- a/src/include/access/twophase_rmgr.h
+++ b/src/include/access/twophase_rmgr.h
@@ -28,7 +28,10 @@ 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_PERBACKEND_ID 6
+#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[];
@@ -39,4 +42,10 @@ extern PGDLLIMPORT const TwoPhaseCallback twophase_standby_recover_callbacks[];
extern void RegisterTwoPhaseRecord(TwoPhaseRmgrId rmid, uint16 info,
const void *data, uint32 len);
+/* FILEOPS two-phase commit callbacks (defined in storage/file/fileops.c) */
+extern void fileops_twophase_postcommit(FullTransactionId fxid, uint16 info,
+ void *recdata, uint32 len);
+extern void fileops_twophase_postabort(FullTransactionId fxid, uint16 info,
+ void *recdata, uint32 len);
+
#endif /* TWOPHASE_RMGR_H */
diff --git a/src/include/access/undo.h b/src/include/access/undo.h
new file mode 100644
index 0000000000000..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..2d4c60eca5700
--- /dev/null
+++ b/src/include/access/undo_bufmgr.h
@@ -0,0 +1,289 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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 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..ef573f6924e8c
--- /dev/null
+++ b/src/include/access/undo_xlog.h
@@ -0,0 +1,219 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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_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 upper 4 bits identify
+ * the operation; the low 4 bits are reserved for generic WAL flags.
+ */
+#define XLOG_UNDO_APPLY_RECORD 0x30 /* CLR: UNDO applied to page */
+#define XLOG_UNDO_BATCH 0x60 /* Batched UNDO records in WAL */
+#define XLOG_UNDO_INFLIGHT 0x70 /* UNDO chains in flight at a
+ * checkpoint */
+
+/*
+ * 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_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 */
+ 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 */
+ 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_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
+ *
+ * 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);
+extern void undo_mask(char *pagedata, BlockNumber blkno);
+
+/* 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,
+ bool *malformed);
+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, 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 bool 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..4e2a89e2eb855
--- /dev/null
+++ b/src/include/access/undolog.h
@@ -0,0 +1,178 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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)))
+
+/* 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;
+
+ /*
+ * 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.
+ *
+ * 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 */
+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);
+extern void UndoSetRecoveryBatchLSN(XLogRecPtr lsn);
+
+/*
+ * Utility functions
+ */
+extern UndoRecPtr UndoLogGetInsertPtr(uint32 log_number);
+extern UndoRecPtr UndoLogGetDiscardPtr(uint32 log_number);
+#endif /* UNDOLOG_H */
diff --git a/src/include/access/undorecord.h b/src/include/access/undorecord.h
new file mode 100644
index 0000000000000..c0d9d6171d7c5
--- /dev/null
+++ b/src/include/access/undorecord.h
@@ -0,0 +1,266 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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 */
+
+/*
+ * 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 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 */
+ 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
new file mode 100644
index 0000000000000..df020fb283304
--- /dev/null
+++ b/src/include/access/undormgr.h
@@ -0,0 +1,129 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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
+/* In-tree consumers define FILEOPS (2) and FLUX (5) in 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];
+
+/*
+ * 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);
+
+/* 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..d506def55945e
--- /dev/null
+++ b/src/include/access/undormgrlist.h
@@ -0,0 +1,43 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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 */
+
+/* built-in index AM UNDO resource managers */
+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/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..ece7f8349ba7e
--- /dev/null
+++ b/src/include/access/undostats.h
@@ -0,0 +1,20 @@
+/*-------------------------------------------------------------------------
+ *
+ * undostats.h
+ * UNDO monitoring functions
+ *
+ * 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 "fmgr.h"
+
+extern Datum pg_stat_get_undo_retention(PG_FUNCTION_ARGS);
+
+#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..c18a808a596e1 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,
@@ -194,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)
/*
@@ -368,8 +387,35 @@ 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];
+
+ /*
+ * 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)
+
+/*
+ * 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
@@ -430,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;
@@ -439,6 +487,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);
@@ -519,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 */
@@ -535,4 +586,31 @@ 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);
+
+/*
+ * 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/access/xactundo.h b/src/include/access/xactundo.h
new file mode 100644
index 0000000000000..4dd4c718fc1c8
--- /dev/null
+++ b/src/include/access/xactundo.h
@@ -0,0 +1,146 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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);
+
+/* 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/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/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/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 f46427258e3db..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',
@@ -12778,4 +12782,22 @@
proname => 'hashoid8extended', prorettype => 'int8',
proargtypes => 'oid8 int8', prosrc => 'hashoid8extended' },
+# 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 => '{text,int4,xid,oid,pg_lsn,int8}',
+ proargmodes => '{o,o,o,o,o,o}',
+ 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 => '{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/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,
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);
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/port/pg_xattr.h b/src/include/port/pg_xattr.h
new file mode 100644
index 0000000000000..7159fc27a26a1
--- /dev/null
+++ b/src/include/port/pg_xattr.h
@@ -0,0 +1,40 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_xattr.h
+ * Cross-platform extended attribute abstraction
+ *
+ * Provides pg_setxattr() and pg_removexattr() that work across:
+ * - Linux: setxattr/removexattr
+ * - macOS: setxattr/removexattr (extra options param)
+ * - FreeBSD: extattr_set_file/extattr_delete_file
+ * - Windows: NTFS Alternate Data Streams
+ * - Fallback: returns ENOTSUP with WARNING
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/include/port/pg_xattr.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_XATTR_H
+#define PG_XATTR_H
+
+/*
+ * ENODATA is Linux-specific. FreeBSD/macOS use ENOATTR for "attribute not
+ * found". Provide a portable PG_ENOATTR so callers don't need #ifdefs.
+ */
+#if defined(ENOATTR)
+#define PG_ENOATTR ENOATTR
+#elif defined(ENODATA)
+#define PG_ENOATTR ENODATA
+#else
+#define PG_ENOATTR ENOENT /* last-resort fallback */
+#endif
+
+extern int pg_setxattr(const char *path, const char *name,
+ const void *value, size_t size);
+extern ssize_t pg_getxattr(const char *path, const char *name,
+ void *value, size_t size);
+extern int pg_removexattr(const char *path, const char *name);
+
+#endif /* PG_XATTR_H */
diff --git a/src/include/storage/bufmgr.h b/src/include/storage/bufmgr.h
index 6837b35fc6d0b..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/copydir.h b/src/include/storage/copydir.h
index 40849c2ef9947..b979ee4258b7d 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 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.
+ */
+extern void copydir(const char *fromdir, const char *todir, bool recurse,
+ bool register_for_abort_cleanup);
extern void copy_file(const char *fromfile, const char *tofile);
#endif /* COPYDIR_H */
diff --git a/src/include/storage/fileops.h b/src/include/storage/fileops.h
new file mode 100644
index 0000000000000..2f1608f3a186d
--- /dev/null
+++ b/src/include/storage/fileops.h
@@ -0,0 +1,510 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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, /* abort: remove a FileOpsMkdirTree() tree */
+ PENDING_FILEOP_SYMLINK,
+ PENDING_FILEOP_LINK,
+ PENDING_FILEOP_SETXATTR,
+ PENDING_FILEOP_REMOVEXATTR,
+ PENDING_FILEOP_INVERSE
+} 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);
+
+/* 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);
+
+/* 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);
+
+/* 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 */
+ /* variable-length path follows */
+} xl_fileops_truncate;
+
+#define SizeOfFileOpsTruncate (offsetof(xl_fileops_truncate, 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 */
+ uint16 flags; /* FILEOPS_MKDIR_* */
+ /* variable-length path follows */
+} xl_fileops_mkdir;
+
+#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
+ */
+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 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);
+
+/*
+ * 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_WRITE 0x0002
+#define FILEOPS_UNDO_CREATE 0x0001
+#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.
+ *
+ * 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: the path to unlink, if it is still a regular file */
+typedef struct FileopsUndoCreate
+{
+ uint16 path_len; /* including NUL */
+ /* followed by path */
+} 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;
+
+/* 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 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 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: 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: the linkpath to unlink, if it still points to target */
+typedef struct FileopsUndoSymlink
+{
+ uint16 linkpath_len;
+ uint16 target_len;
+ /* followed by linkpath, then target */
+} FileopsUndoSymlink;
+
+/* 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 (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), new value */
+} FileopsUndoSetxattr;
+
+/* REMOVEXATTR undo payload: path + name + removed value */
+typedef struct FileopsUndoRemovexattr
+{
+ uint16 path_len;
+ uint16 name_len;
+ uint32 value_len;
+ /* followed by path, name, value */
+} FileopsUndoRemovexattr;
+
+/* Recursive directory removal */
+extern void FileOpsRmdirRecursive(const char *path, bool at_commit);
+
+#endif /* FILEOPS_H */
diff --git a/src/include/storage/lwlocklist.h b/src/include/storage/lwlocklist.h
index 8d858be9927f6..59c46be80f75f 100644
--- a/src/include/storage/lwlocklist.h
+++ b/src/include/storage/lwlocklist.h
@@ -141,3 +141,8 @@ 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)
+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 9ad619080be22..538d38490735d 100644
--- a/src/include/storage/subsystemlist.h
+++ b/src/include/storage/subsystemlist.h
@@ -88,3 +88,18 @@ PG_SHMEM_SUBSYSTEM(DataChecksumsShmemCallbacks)
/* AIO subsystem. This delegates to the method-specific callbacks */
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)
+
+/* FLUX table access method subsystems */
+PG_SHMEM_SUBSYSTEM(FluxMvccShmemCallbacks)
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/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/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/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"""
+
+
+
+
+"""
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,
+ )
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;